Drift is the fourth game I’ve built, and the first that isn’t a puzzle: an arcade space shooter for the iPhone and the web, played in portrait with one thumb, all in neon vector. Waves of enemies, bosses with attack patterns, power-ups drifting through space in capsules, and at the busiest moments several hundred things moving on screen at once.

It’s drawn with the plain HTML <canvas> 2D API, no game engine, and runs in a web view on the phone. A shooter that stutters is unplayable, so 60 frames per second wasn’t a goal, it was the requirement. Four decisions got it there.

1. A fixed-step simulation, interpolated on screen

The simulation never looks at how long a frame took. It advances in whole ticks of 1/60 s, and the screen draws between the last two ticks. Real time goes into an accumulator; the loop spends it in ticks:

frame(now: number): number {
  if (this.last !== null) this.backlog += Math.max(0, now - this.last);
  this.last = now;
  let steps = 0;
  while (this.backlog >= this.stepMs && steps < this.maxSteps) {
    this.tick();
    this.backlog -= this.stepMs;
    steps++;
  }
  if (this.backlog >= this.stepMs) this.backlog = 0;
  this.draw(this.backlog / this.stepMs);
  return steps;
}

Three things follow:

  • The game plays the same at any frame rate. A 120 Hz display draws twice per tick (desktop browsers, or Safari with its 60 fps preference turned off; iOS web views currently cap requestAnimationFrame at 60 Hz, so the iPhone build gets one tick a frame), and a phone that drops a frame runs two ticks in the next one. Even at a steady 60 Hz, timer jitter makes some frames run none and some two; interpolation hides that, at the cost of drawing up to one tick behind. Physics, collisions and bullet patterns never change with the device.
  • Rendering is smooth anyway. alpha (the leftover fraction of a tick) interpolates every entity between its previous and current position, so motion doesn’t judder at high refresh rates.
  • A stall doesn’t make the game race. After a tab switch or a debugger pause, at most four ticks (maxSteps) run in one frame and the rest of the backlog is dropped, instead of fast-forwarding the player into a wall of bullets.

As a bonus, a fixed step makes the simulation reproducible: with a seeded random generator and inputs recorded per tick, a run replays exactly on the same engine, which the tests do. Across engines, Math.sin and friends may differ in the last bit, so a replay recorded in one browser isn’t guaranteed in another.

2. Pools: allocate nothing per frame

Garbage collection pauses are the classic cause of stutter in JavaScript games: a few milliseconds every so often, at the worst moment. Drift avoids them by not creating garbage during play. Enemies, bullets, the player’s shots, pick-ups and particles live in preallocated pools: arrays of objects created once, reused when an entity dies and a new one spawns.

The renderer follows the same rule. Paths are built once as Path2D objects; colours and looks are made once, never per frame; the HUD (score, hull, the bomb button) is DOM over the canvas, and it touches the page only when a number actually changes.

Collisions are circle against circle, and swept over the tick: testing only the positions at the end of each tick misses grazing hits at any speed, and once a bolt and an enemy close in faster than twice the sum of their radii per tick, it misses head-on hits too. So the test sweeps their relative motion over the tick. With fewer than 400 live entities expected, a brute-force check is cheap enough; a spatial grid is planned only if a profile ever shows the need.

Advertisement

3. Glow is baked once, never drawn per frame

Neon needs glow, and the canvas has an obvious way to draw it: shadowBlur. It’s also one of the slowest things you can do in a frame. Blur cost grows with the radius and the area, and a shooter would pay it for every ship and bullet, on every frame drawn.

So Drift draws glow once per sprite, into its own canvas, and every frame only stamps that canvas with drawImage, under additive blending:

// Each pass is the shadow alone: the drawing itself is traced two canvas widths to the left,
// off the canvas, and its shadow thrown back onto it. This is the only shadowBlur there is.
const away = side * 2;
for (const pass of passes) {
  at(away);
  c.shadowOffsetX = away;
  c.shadowBlur = pass.blur * px;
  c.shadowColor = look.colour;
  c.globalAlpha = Math.min(1, pass.alpha * look.strength * this.glowScale);
  traceArt(c, id, look, scale, look.colour, false);
}

The trick in the comment is worth stealing: to get a glow without the shape, draw the shape off the canvas and throw its shadow back onto it. Three blurred passes make the halo; then the crisp stroke and its bright core go on top. One catch: shadow offsets and blur are in device pixels and ignore the canvas transform (hence pass.blur * px), so do the off-canvas move in device pixels too, or the shape and its shadow drift apart under a scale().

The cache is keyed by drawing, look and size (rounded to half a unit so it stays small) and rebuilt only when the screen’s scale changes or the player turns on reduced motion, which softens the glow. One more lesson came from playtesting: bake everything a run can need up front. Baking lazily meant the first boss, the first hit flash or the first kill stalled a frame while its sprite was made. Now the renderer bakes the whole set when the cache empties, and the first explosion is as smooth as the hundredth. Watch the total, though: WebKit on iOS has a fixed budget for canvas memory, and past it canvases silently draw blank, so release old ones (width = height = 0) before baking a new set. The project’s rules file says it in one line: never draw glow with shadowBlur per frame.

Additive blending (globalCompositeOperation = 'lighter') does the rest of the neon look almost for free: where glows overlap, they brighten, like light (keep the glow’s alpha modest, or dense overlaps clip to white).

4. A performance budget that is a test

“It feels fast on my phone” doesn’t survive the next feature. Drift writes its budget down and tests it on every run of the test suite:

engine tick under 1 ms median and under 3 ms p99, with 300 live entities.

The test builds a deliberate worst case: 120 parked drones that can’t die, 180 slow enemy bullets, and the busiest fire the player can have (the strongest homing weapon, wingmen and overdrive together), refilled every tick so the crowd never thins. It runs 300 warm-up ticks, measures 3,000, and checks that nearly every measured tick really had 300 entities or more before it checks the times. On my development container it measures:

median 0.041 ms · p99 0.099 ms · 2,991 of 3,000 ticks at 300+ entities

That leaves the simulation a tiny slice of the 16.7 ms a frame has (8.3 ms at 120 Hz), and the rest for drawing. Those numbers are from V8 on a desktop-class CPU; a phone’s JavaScriptCore is several times slower, and the worst frame runs four ticks, which is why the budget leaves so much headroom. The other half of the budget is human: 60 fps on an iPhone 12 through TestFlight, checked by playing the busiest phases.

The juice, without breaking any of it

Screen shake, hit flashes, hit-stop (a few frozen frames on a big hit), particles (a pool of 600), the boss kill’s zoom and the sector’s hue shift all live in a separate effects layer. It reads each tick’s events after the tick, runs on its own seeded random stream, and never feeds back into the simulation, so replays stay exact. A hit-stop is a frame that draws without ticking and without banking the time, so the game doesn’t race to catch up afterwards. And all of it respects the player’s reduced-motion setting, which softens the glow and turns off the shake.

What I’d tell anyone drawing games on canvas

  • Fix the timestep and interpolate the drawing. Variable steps make physics, tests and fairness depend on the device.
  • Allocate nothing per frame. Pools for entities, objects built once for paths and looks.
  • Never blur per frame. Bake glow and other expensive effects into cached canvases, and bake them all before play starts.
  • Write the budget down and test it, with a scenario harsher than real play, so a regression fails a build instead of a review on a phone.

The canvas is plenty for a fast 2D game, even on a phone, as long as each frame only does what has to happen in that frame.