Games and Programming in the Wild

A Lunar Lander


Learning Objectives

  • You can distinguish position, velocity, and acceleration, and explain how each updates the next.
  • You can simulate gravity and thrust by adding to velocity each frame.
  • You can model a depletable resource (fuel) and stop an action when it runs out.
  • You can judge a landing by checking position and speed together.

The player controls in the earlier games changed position directly or produced movement at a fixed speed: holding a movement key moved the player-controlled object at a steady rate, and releasing it stopped that input-driven movement. Real flight does not feel like that. A spacecraft does not stop the moment you cut the thrust — it keeps drifting, and gravity keeps pulling it down. In this game, the controls change velocity gradually through acceleration.

The Lunar Lander is the classic game for this. You pilot a craft falling toward the surface of the moon. Gravity pulls you down, a little faster every second. Your thruster pushes back, but it burns fuel, and you have only so much. Land gently on the pad and you win; come down too fast or miss the pad and you crash. It is a game about managing a falling object, and the whole feel of it comes from acceleration.

Three Layers: Position, Velocity, Acceleration

You already know two of these. Position (x, y) is where the ship is. Velocity (vx, vy) is how fast the position changes each frame — you have updated position from velocity since the Asteroids chapter:

self.y = self.y + self.vy

The new layer is acceleration: how fast the velocity changes each frame. Just as position is updated by adding velocity, velocity is updated by adding acceleration:

self.vy = self.vy + acceleration

That is the entire idea, and it is a beautiful repetition: each layer is updated by adding the layer below it. Acceleration changes velocity; velocity changes position. Gravity is simply a constant downward acceleration — every frame, the downward velocity gets a little larger, so the ship falls faster and faster.

Think of dropping a ball. In the first instant it is barely moving; a second later it is falling fast; a second after that, faster still. The speed keeps growing because gravity keeps adding to it. In code: vy += GRAVITY every frame, and y += vy every frame. The first line makes the fall speed up; the second turns that speed into motion.

Gravity

Gravity is one line in the update step — a small constant added to the downward velocity every frame:

GRAVITY = 0.05
def update(self):
self.vy = self.vy + GRAVITY # fall a little faster each frame
# ...
self.y = self.y + self.vy # and move according to current speed

With nothing else, the ship falls, accelerating the whole way, and slams into the ground. The player’s job is to fight this.

Thrust and Fuel

The thruster pushes the ship up — a negative acceleration, opposing gravity. The player fires it by holding the up arrow, and each frame of thrust burns a unit of fuel. When the fuel is gone, the thruster stops working:

THRUST = 0.12
if pyxel.btn(pyxel.KEY_UP) and self.fuel > 0:
self.vy = self.vy - THRUST # push up, against gravity
self.fuel = self.fuel - 1

The condition self.fuel > 0 is the same guard-before-acting pattern you have used throughout — you cannot spend a resource you do not have, just as the clicker would not let you buy an upgrade you could not afford. Notice that THRUST (0.12) is larger than GRAVITY (0.05). In a frame with upward thrust, gravity first adds to vy and thrust then subtracts from it, so the net change is upward. This lets thrust reduce a downward velocity and eventually reverse it. If THRUST is less than or equal to GRAVITY, upward thrust cannot reduce or reverse a nonnegative downward velocity; whether a particular game is still winnable also depends on its initial state, landing rules, and level geometry.

Side thrusters work the same way, nudging the horizontal velocity left or right at a smaller fuel cost, so the player can steer toward the pad:

if pyxel.btn(pyxel.KEY_LEFT) and self.fuel > 0:
self.vx = self.vx - THRUST / 2
self.fuel = self.fuel - 0.5
if pyxel.btn(pyxel.KEY_RIGHT) and self.fuel > 0:
self.vx = self.vx + THRUST / 2
self.fuel = self.fuel - 0.5
self.fuel = max(0, self.fuel) # never show negative fuel

The max(0, self.fuel) clamp — the same idiom as Breakout’s paddle — keeps the displayed fuel from dipping below zero on the frame it runs out.

Judging the Landing

When the ship reaches the ground, the game decides: a soft landing on the pad, or a crash. This is the one moment where two conditions combine. The ship must be over the pad and moving slowly enough:

SAFE_SPEED = 1.0
PAD_X = 60
PAD_W = 40
if self.y >= 110: # reached the ground
self.y = 110
on_pad = PAD_X <= self.x <= PAD_X + PAD_W
slow_enough = self.vy <= SAFE_SPEED
if on_pad and slow_enough:
self.state = "landed"
else:
self.state = "crashed"

on_pad uses the chained comparison to check the ship is horizontally within the pad. slow_enough checks the downward speed is below the safe threshold. Both must be true to land; either one failing is a crash. This is the difference between this game and the others: success is not “reach the goal”, it is “reach the goal in the right state”. A ship can be perfectly over the pad and still crash because it was falling too fast — which is exactly the tension that makes the game work.

The state here is a string — "flying", "landed", or "crashed" — rather than a boolean. With only two outcomes (alive or dead) the earlier games used a boolean flag, but three outcomes is a natural place for a small set of named states. A string with a handful of fixed values is a perfectly good way to track which of several states a game is in, and it reads clearly in the conditions: if self.state != "flying". Larger games sometimes formalise this into a “state machine”, but a string is a good starting point.

The Complete Game

import pyxel
GRAVITY = 0.05
THRUST = 0.12
PAD_X = 60
PAD_W = 40
SAFE_SPEED = 1.0
class App:
def __init__(self):
self.reset()
pyxel.init(160, 120, title="Lunar Lander")
pyxel.run(self.update, self.draw)
def reset(self):
self.x = 80.0
self.y = 10.0
self.vx = 0.0
self.vy = 0.0
self.fuel = 100.0
self.state = "flying"
def update(self):
if self.state != "flying":
if pyxel.btnp(pyxel.KEY_R):
self.reset()
return
# gravity always pulls down
self.vy = self.vy + GRAVITY
# thrusters, while there is fuel
if pyxel.btn(pyxel.KEY_UP) and self.fuel > 0:
self.vy = self.vy - THRUST
self.fuel = self.fuel - 1
if pyxel.btn(pyxel.KEY_LEFT) and self.fuel > 0:
self.vx = self.vx - THRUST / 2
self.fuel = self.fuel - 0.5
if pyxel.btn(pyxel.KEY_RIGHT) and self.fuel > 0:
self.vx = self.vx + THRUST / 2
self.fuel = self.fuel - 0.5
self.fuel = max(0, self.fuel)
# move according to velocity
self.x = self.x + self.vx
self.y = self.y + self.vy
# touchdown
if self.y >= 110:
self.y = 110
on_pad = PAD_X <= self.x <= PAD_X + PAD_W
slow_enough = self.vy <= SAFE_SPEED
if on_pad and slow_enough:
self.state = "landed"
else:
self.state = "crashed"
def draw(self):
pyxel.cls(0)
pyxel.rect(0, 116, 160, 4, 4) # ground
pyxel.rect(PAD_X, 114, PAD_W, 2, 11) # landing pad
pyxel.rect(self.x - 3, self.y - 3, 6, 6, 7) # the lander
pyxel.text(4, 4, f"Fuel: {int(self.fuel)}", 10)
pyxel.text(4, 12, f"Speed: {self.vy:.1f}", 7)
if self.state == "landed":
pyxel.text(50, 60, "THE EAGLE HAS LANDED", 11)
pyxel.text(60, 70, "press R", 7)
elif self.state == "crashed":
pyxel.text(60, 60, "CRASHED", 8)
pyxel.text(60, 70, "press R", 7)
if __name__ == "__main__":
App()

Play it and feel the difference acceleration makes. The ship is hard to control at first — it keeps drifting after you let go, and a moment of inattention turns into a fast fall you cannot arrest in time. That stubborn momentum, the sense that the ship has weight, is entirely the result of one extra layer: velocity that is itself changed by acceleration rather than set directly. It is a small change in the code and a large change in the feel.

The Same Physics in Other Games

The position-velocity-acceleration chain is not specific to lunar landers. It is how essentially every game with a sense of weight works. A platformer character that jumps and falls uses exactly this: gravity adds to downward velocity, a jump sets velocity sharply upward, and landing on a platform zeroes it. A racing game’s car accelerates and coasts the same way. Once you have built the lander, you have built the core of all of them — a platformer is mostly this physics plus the tile-collision idea from the Maze chapter. If you want a challenge for your own game later, that combination is a rich place to start.

Lunar Lander with classes

0 / 40 points

Refactor the Lunar Lander game from the chapter into a class-based Pyxel program.

Keep the chapter mechanics:

  • gravity accelerates the lander downward,
  • thrust changes velocity while fuel remains,
  • horizontal thrust steers the lander,
  • fuel never displays as negative,
  • touching down on the pad slowly enough lands successfully,
  • touching down too fast or away from the pad crashes,
  • and pressing R after landing or crashing starts over.

Game rules to implement:

  • the lander starts at (80.0, 10.0) with velocity (0.0, 0.0), fuel 100.0, and state "flying",
  • gravity adds 0.05 to vertical velocity each frame,
  • upward thrust subtracts 0.12 from vertical velocity and costs 1 fuel,
  • left thrust subtracts 0.06 from horizontal velocity and costs 0.5 fuel,
  • right thrust adds 0.06 to horizontal velocity and costs 0.5 fuel,
  • thrust has no effect after fuel reaches 0, and fuel is clamped so it never goes below 0,
  • after gravity and thrust, velocity is added to position,
  • the landing pad starts at x-coordinate 60, is 40 pixels wide, and sits on ground y-coordinate 110,
  • touchdown happens when the lander’s y-coordinate reaches the ground y-coordinate,
  • on touchdown, the lander’s y-coordinate is clamped to exactly the ground y-coordinate,
  • a touchdown lands successfully only when the lander’s x-coordinate is over the pad and vertical speed is at most 1.0,
  • otherwise touchdown crashes,
  • and after landing or crashing, the model should stop updating until the player restarts.

The mechanics must live in model classes that can be tested without Pyxel. The Pyxel App should only translate input into method calls and draw the current model.

Required classes:

ClassContract
Lander(x=80.0, y=10.0, vx=0.0, vy=0.0, fuel=100.0)stores position, velocity, fuel, and state
Lander.apply_gravity(self, gravity=0.05)adds gravity to vertical velocity
Lander.apply_thrust(self, up=False, left=False, right=False, thrust=0.12)changes velocity while fuel remains and clamps fuel to zero
Lander.move(self)adds velocity to position
LandingPad(x=60, width=40, ground_y=110, safe_speed=1.0)stores landing-pad rules
LandingPad.contains(self, x)returns whether an x-coordinate is over the pad
LandingPad.judge(self, lander)returns "landed" when the lander is over the pad and slow enough; otherwise returns "crashed"
LunarLanderGame(lander=None, pad=None)owns lander and pad
LunarLanderGame.step(self, thrust_up=False, thrust_left=False, thrust_right=False)updates one frame while flying and judges touchdown
Appowns one LunarLanderGame, handles Pyxel input, restart, and drawing

Pyxel integration requirements:

  • App.update() passes held thrust keys into game.step(...),
  • when game.lander.state is "landed" or "crashed", App.update() listens for pyxel.KEY_R and resets the model,
  • App.draw() draws the ground and pad from game.pad,
  • App.draw() draws the lander, fuel, speed, and state messages from game.lander,
  • do not call pyxel from Lander, LandingPad, or LunarLanderGame,
  • do not start the app when the file is imported,
  • and guard the launch with if __name__ == "__main__": App().

Check Your Understanding

  1. What is the relationship between position, velocity, and acceleration? How is each one updated?
  2. How is releasing the lander’s thruster different from releasing a movement key in the earlier games, and what role does acceleration play?
  3. Once the lander has a nonnegative downward velocity, why does THRUST need to be larger than GRAVITY to reduce or reverse that velocity?
  4. Landing requires two conditions to both be true. What are they, and why is checking only position not enough?
  5. The game uses a string ("flying", "landed", "crashed") for its state instead of a boolean. Why is a string a reasonable choice here?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0