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.vyThe 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 + accelerationThat 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 += GRAVITYevery frame, andy += vyevery 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 speedWith 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 - 1The 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.5if 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 fuelThe 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.0PAD_X = 60PAD_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.05THRUST = 0.12PAD_X = 60PAD_W = 40SAFE_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
Rafter landing or crashing starts over.
Game rules to implement:
- the lander starts at
(80.0, 10.0)with velocity(0.0, 0.0), fuel100.0, and state"flying", - gravity adds
0.05to vertical velocity each frame, - upward thrust subtracts
0.12from vertical velocity and costs1fuel, - left thrust subtracts
0.06from horizontal velocity and costs0.5fuel, - right thrust adds
0.06to horizontal velocity and costs0.5fuel, - thrust has no effect after fuel reaches
0, and fuel is clamped so it never goes below0, - after gravity and thrust, velocity is added to position,
- the landing pad starts at x-coordinate
60, is40pixels wide, and sits on ground y-coordinate110, - 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:
| Class | Contract |
|---|---|
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 |
App | owns one LunarLanderGame, handles Pyxel input, restart, and drawing |
Pyxel integration requirements:
App.update()passes held thrust keys intogame.step(...),- when
game.lander.stateis"landed"or"crashed",App.update()listens forpyxel.KEY_Rand resets the model, App.draw()draws the ground and pad fromgame.pad,App.draw()draws the lander, fuel, speed, and state messages fromgame.lander,- do not call
pyxelfromLander,LandingPad, orLunarLanderGame, - do not start the app when the file is imported,
- and guard the launch with
if __name__ == "__main__": App().
Check Your Understanding
- What is the relationship between position, velocity, and acceleration? How is each one updated?
- How is releasing the lander’s thruster different from releasing a movement key in the earlier games, and what role does acceleration play?
- Once the lander has a nonnegative downward velocity, why does
THRUSTneed to be larger thanGRAVITYto reduce or reverse that velocity? - Landing requires two conditions to both be true. What are they, and why is checking only position not enough?
- 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.