Asteroids — Implementing Torus Space and Asteroid Splitting
Torus topology where exiting one edge wraps to the opposite, three-stage asteroid splitting — a record of implementing the core mechanics of a classic physics game
The Beginning — A Game of Inertia
Asteroids (1979) derives its brilliance from simplicity. Three inputs — thrust, rotation, fire — create the physical sensation of navigating outer space. That feeling of continuing to drift when thrust cuts out was unlike anything else in games at the time.
I focused on faithfully reproducing that physical sensation in Canvas.
Torus Space — wrapX / wrapY
Asteroids' most iconic property is its screen boundary behavior. Exit left and reappear on the right; exit top and reappear at the bottom. Mathematically, this is a torus — a donut-shaped topological space.
The implementation itself is straightforward.
function wrapX(x: number): number {
if (x < 0) return x + GAME_W;
if (x > GAME_W) return x - GAME_W;
return x;
}
function wrapY(y: number): number {
if (y < 0) return y + GAME_H;
if (y > GAME_H) return y - GAME_H;
return y;
}
But rendering introduces a problem. When an asteroid straddles the boundary — half at the left edge, half at the right — it needs to be drawn correctly. The solution is to draw boundary-straddling objects at both positions.
function drawWithWrap(ctx: CanvasRenderingContext2D, obj: PhysicsObject) {
const positions = getWrappedPositions(obj.x, obj.y, obj.radius);
positions.forEach(([x, y]) => drawAt(ctx, obj, x, y));
}
Asteroid Splitting — Three-Stage Split Physics
Asteroids split in two when hit. Size follows three stages: large → medium → small. Small asteroids are destroyed without splitting.
function splitAsteroid(a: Asteroid): Asteroid[] {
if (a.size === 'small') return []; // destroyed
const nextSize = a.size === 'large' ? 'medium' : 'small';
const speed = Math.hypot(a.vx, a.vy) * 1.4; // 1.4× speed increase on split
const baseAngle = Math.atan2(a.vy, a.vx);
// Two fragments diverge at ±30 degrees
return [+30, -30].map(offset => {
const angle = baseAngle + (offset * Math.PI) / 180;
return createAsteroid({
x: a.x,
y: a.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: nextSize,
});
});
}
The split angle and speed multiplier were tuned against the original arcade version. The ±30 degree, 1.4× combination produced the most natural explosion feel.
Inertial Flight — Frictionless Space
The player ship has no friction. Applying thrust accumulates velocity in that direction, and cutting thrust does not decelerate the ship.
function updateShip(ship: Ship, input: Input) {
if (input.thrust) {
const ax = Math.cos(ship.angle) * THRUST_FORCE;
const ay = Math.sin(ship.angle) * THRUST_FORCE;
ship.vx += ax;
ship.vy += ay;
// Maximum speed clamp
const speed = Math.hypot(ship.vx, ship.vy);
if (speed > MAX_SPEED) {
ship.vx = (ship.vx / speed) * MAX_SPEED;
ship.vy = (ship.vy / speed) * MAX_SPEED;
}
}
// No velocity damping — outer space
ship.x = wrapX(ship.x + ship.vx);
ship.y = wrapY(ship.y + ship.vy);
}
This zero-damping physics is what creates Asteroids' unique tension. The core skill is navigating between asteroids while managing accumulated momentum.
Hyperspace
The original arcade's hyperspace feature is also implemented. Pressing the button instantly teleports the ship to a random position — but it might materialize inside an asteroid.
function hyperspace(ship: Ship, asteroids: Asteroid[]) {
ship.x = Math.random() * GAME_W;
ship.y = Math.random() * GAME_H;
ship.vx = 0;
ship.vy = 0;
// Instant death if overlapping an asteroid
const overlap = asteroids.some(a => Math.hypot(a.x - ship.x, a.y - ship.y) < a.radius + ship.radius);
if (overlap) explodeShip(ship);
}
The gambling element adds exquisite tension. The decision of whether to use hyperspace in a desperate situation is compelling in itself.
Stage Clear Audio
The stage clear fanfare is implemented using Web Audio scheduling. Timing is based on AudioContext.currentTime, not setTimeout.
function playStageClear(ctx: AudioContext, gainNode: GainNode) {
const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6
const t = ctx.currentTime;
freqs.forEach((freq, i) => {
const startTime = t + i * 0.12;
const osc = ctx.createOscillator();
osc.frequency.value = freq;
osc.connect(gainNode);
osc.start(startTime);
osc.stop(startTime + 0.1);
});
}
Closing Thoughts
What surprised me most while building Asteroids was how much tension inertia alone can generate. The absence of friction — that single physical fact — demands constant velocity management from the player, and that's where the distinctive fun comes from.
Whether the Atari team in 1979 knew this consciously or stumbled onto it, I can't say. But the result is still enjoyable forty years later.