My first attempt at controlling a ship was direct: arrow up means position += 1 in the facing direction, release means stop. It works, but it feels like a typewriter carriage. The ship has no mass, no inertia, no drift. Three small pieces are all it takes to turn that into proper 2D movement.
Acceleration instead of direct velocity
Instead of vx = TARGET_SPEED, I add a small slice per frame:
CONST ACCEL = 0.25
IF IsKeyPressed(KEY_UP) THEN
vx = vx + COS(RAD(angle)) * ACCEL
vy = vy + SIN(RAD(angle)) * ACCEL
END IF
vx and vy build up over time while the key is held. A second of thrust gets you further than a quick tap. Trivially simple physics, but exactly the gap between “cursor position” and “object with inertia”.
Friction instead of a hard stop
Releasing the key shouldn’t yank the ship to a halt, and you also don’t want to drift forever. Friction handles both in a single line per frame:
CONST FRICTION = 0.98
vx = vx * FRICTION
vy = vy * FRICTION
Speed shrinks by two percent each frame. With no input you slow down gracefully and eventually come to rest. While thrusting you fight friction, so the top speed stabilises by itself. 0.98 is taste; higher means slipperier, lower means stickier.
Speed clamp by vector normalisation
To stop the ship going arbitrarily fast you want a maximum speed. The naive version clamps each component:
' Naive: clamp each component
IF vx > MAX_SPEED THEN vx = MAX_SPEED
IF vx < -MAX_SPEED THEN vx = -MAX_SPEED
IF vy > MAX_SPEED THEN vy = MAX_SPEED
IF vy < -MAX_SPEED THEN vy = -MAX_SPEED
That feels wrong on the diagonal, because the diagonal speed can hit SQR(MAX² + MAX²), roughly 1.41 × MAX. The fix is to look at the vector as a whole and only constrain its length:
CONST MAX_SPEED = 5
speed = SQR(vx * vx + vy * vy)
IF speed > MAX_SPEED THEN
vx = (vx / speed) * MAX_SPEED
vy = (vy / speed) * MAX_SPEED
END IF
That’s vector normalisation followed by scaling. Direction stays put, only length is squashed down to MAX_SPEED. The ship now travels equally fast diagonally as horizontally, which feels right.
Put it together
Eight lines per frame, that’s all it takes for a ship with proper 2D movement:
IF IsKeyPressed(KEY_UP) THEN
vx = vx + COS(RAD(angle)) * ACCEL
vy = vy + SIN(RAD(angle)) * ACCEL
END IF
speed = SQR(vx * vx + vy * vy)
IF speed > MAX_SPEED THEN
vx = (vx / speed) * MAX_SPEED
vy = (vy / speed) * MAX_SPEED
END IF
vx = vx * FRICTION
vy = vy * FRICTION
x = x + vx
y = y + vy
The pattern carries one-to-one to anything moving in 2D: characters in a top-down game, mouse-pointer smoothing, particles in a simulation, cameras following a target. The three pieces stay the same, only the constants change.