DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building Lunar Lander from scratch: two forces, one thrust vector, and a three-part touchdown test

Lunar Lander is the oldest kind of arcade game — beat gravity with a short tank of fuel and set down gently — and it looks like it would need a physics engine. It doesn't. When I built it in vanilla JavaScript on a canvas, the whole spacecraft turned out to be six numbers, and the entire game is two forces acting on them. Here is how it fits together.

The lander is just numbers

There is no sprite and no library. The whole craft is one small object: where it is, how fast it moves, which way it tilts, and how much fuel is left.

let L = {
  x: 360, y: 50,     // position, pixels
  vx: 0,   vy: 0,    // velocity, px/second
  angle: 0,          // tilt in radians, 0 = nose up
  fuel: 100,         // burns only while thrusting
  thrusting: false
};
Enter fullscreen mode Exit fullscreen mode

Everything you see on screen — the HUD, the flame, the crash — is read from these six values each frame.

Two forces, one of them conditional

Only two things ever push the lander. Gravity is unconditional: every frame it adds a little downward speed. Thrust is its mirror image — it exists only while you hold the key and the tank is not empty, so it is set well above gravity to be able to cancel it and then lift.

const GRAVITY = 30;   // px/s², always down (+y)
const THRUST  = 78;   // px/s² along the nose
L.thrusting = keys.thrust && L.fuel > 0;
Enter fullscreen mode Exit fullscreen mode

Aiming the push with sin and cos

The engine fires out of the belly, so the push always goes wherever the nose points. Upright, it is pure lift. Tilt, and that single push splits into a sideways part (sin) and an upward part (cos). This one bit of trigonometry is the whole steering model — lean into your drift and the sideways component cancels it.

let ax = 0, ay = GRAVITY;                 // gravity on the y axis
if (L.thrusting){
  ax += Math.sin(L.angle) * THRUST;       // sideways part
  ay -= Math.cos(L.angle) * THRUST;       // upward part (−y = up)
  L.fuel -= BURN * dt;
}
Enter fullscreen mode Exit fullscreen mode

The chain that moves everything: accel → velocity → position

This is the heart of every physics sim. Acceleration is the rate velocity changes; velocity is the rate position changes. So each frame you add acceleration onto velocity, then velocity onto position, each scaled by dt (seconds since the last frame). Updating velocity before position is the stable semi-implicit Euler order.

L.vx += ax * dt;   // accel changes velocity
L.vy += ay * dt;
L.x  += L.vx * dt; // velocity changes position
L.y  += L.vy * dt;
Enter fullscreen mode Exit fullscreen mode

The terrain is a list of points with one flat pad

The ground looks elaborate but it is only points at random heights joined by lines. The landing pad is the simplest trick imaginable: force two neighbouring points to the same height and the line between them draws as a flat ledge. To know when a foot touches — and to show altitude — I read the terrain height under any x by linear interpolation between the two points the lander sits between.

function groundY(x){
  for (let i = 0; i < terrain.length - 1; i++){
    const a = terrain[i], b = terrain[i + 1];
    if (x >= a.x && x <= b.x){
      const t = (x - a.x) / (b.x - a.x);   // 0..1 across the segment
      return a.y + (b.y - a.y) * t;        // straight-line interpolation
    }
  }
  return terrain[terrain.length - 1].y;
}
Enter fullscreen mode Exit fullscreen mode

Touchdown or crash: three tolerances

When the feet reach the ground, one test decides the whole game. You must be over the pad, slow enough in both directions, and near-upright. Miss any one and it is a crash — and because each condition is a separate boolean, the game can even tell you why you crashed.

const SAFE_VY = 28, SAFE_VX = 20, SAFE_ANG = 0.14;   // ~8°
function land(){
  const onPad   = L.x >= pad.x1 && L.x <= pad.x2;
  const gentle  = Math.abs(L.vy) <= SAFE_VY && Math.abs(L.vx) <= SAFE_VX;
  const upright = Math.abs(L.angle) <= SAFE_ANG;
  state = (onPad && gentle && upright) ? "landed" : "crash";
}
Enter fullscreen mode Exit fullscreen mode

Those three numbers are the entire difficulty of the game. Loosen them and it is trivial; tighten them and it is brutal.

One clamped loop ties it together

A single requestAnimationFrame loop drives everything, and it clamps dt to a small maximum. Without the clamp, a lag spike or a backgrounded tab would hand the physics a huge step and the lander would teleport straight through the floor. Drawing a rotating ship is easy too — instead of rotating the ship, you translate the canvas to its position, rotate by its angle, and draw the body upright at the origin. The HUD only ever reads the state numbers, so the display can never disagree with the physics.

The lesson underneath it all: keep the smallest possible source of truth — six numbers and a list of points — and let the flame, the altitude, the crash reason, and the win all fall out as pure reads over that state.

Fire the thruster and try to set the Eagle down gently:
https://dev48v.infy.uk/game/day36-lunar-lander.html

Top comments (0)