Kieran Crown
Blog

I built the whole game in one HTML file before touching React Native

428 lines of canvas in a single file answered the only question that mattered, and the port that followed was mechanical because of it.

4 min read
Game DevReact NativePrototypingOverbloom

Before Overbloom was an Expo app it was a file called heatrunner.html. One file, 428 lines, no build step, no dependencies. Open it in a browser and you can play the game.

It is still in the repo. Parts of the engine still refer back to it, and there is a comment in the current state module that reads Mirrors reset() in the HTML, because that is exactly what it does.

The only question worth answering first

An arcade runner lives or dies on whether the moment-to-moment movement feels good. Nothing else is recoverable. If steering the orb is not satisfying then better art will not save it, and neither will power-ups or a progression system or a daily challenge.

That question has nothing to do with React Native. It is a question about numbers: how fast the orb accelerates toward your finger, how much it overshoots, how quickly obstacles close in, how much slack you get before a collision counts.

Answering it inside a React Native project means paying for a native rebuild every time you want to try a different acceleration constant. Answering it in a single HTML file means saving and hitting refresh. On a bad tuning day I would do that a few hundred times.

What the file contains

The structure is as unglamorous as it sounds. Config at the top:

heatrunner.html
const CFG = {
  // steering, gaps, spawn rates, timings
};

A pile of small functions for the sim, all operating on module-level state:

heatrunner.html
function burst(x, y, color, n) {
  for (let i = 0; i < n; i++) {
    const a = Math.random() * 6.283, s = 60 + Math.random() * 220;
    // ...
  }
}
function popup(x, y, text, color, size) { popups.push({ x, y, text, color, size: size || 18, life: 1 }); }
function ring(x, y, color, maxR) { rings.push({ x, y, r: 6, maxR, color, life: 1 }); }

Collision helpers that are pure maths and survived the port untouched:

heatrunner.html
function circleRect(cx, cy, r, rx, ry, rw, rh) {
  const nx = clamp(cx, rx, rx + rw), ny = clamp(cy, ry, ry + rh);
  const dx = cx - nx, dy = cy - ny;
  return dx * dx + dy * dy < r * r;
}
 
function circleOBB(cx, cy, r, px, py, hl, hw, ang) {
  const dx = cx - px, dy = cy - py;
  const cos = Math.cos(-ang), sin = Math.sin(-ang);
  // ...
}

And an update(dt) that runs the world forward, plus a draw pass, plus a requestAnimationFrame loop. Even the haptics have a stand-in:

heatrunner.html
function vibrate(ms) { if (navigator.vibrate) navigator.vibrate(ms); }

That is the whole thing. No module system. No types. Global mutable state everywhere, which is precisely what you want in a file whose entire purpose is to be rewritten twenty times in an afternoon.

Why the port was easy

Here is the part I did not plan and would now do deliberately.

Canvas 2D and Skia have close to the same drawing model. You have a canvas, you set up a paint or a fill style, you draw a shape, you transform and restore. Porting ctx.arc(...) to canvas.drawCircle(...) is a lookup, not a redesign.

More importantly, writing plain canvas code forces the architecture that a 120fps React Native game needs anyway. There is no component tree, so state is already one big mutable object. Draw order is explicit, so there is no reconciler deciding when things happen. The sim is already a function of state and delta time.

When I moved it into React Native with Skia, the shape of the code barely changed. update() became a worklet. The draw pass became a worklet that records a Picture. The globals became fields on a GameState object held in a shared value. What had been throwaway prototype code turned out to be a reasonable first draft of the engine, because the constraints of a browser canvas loop and the constraints of a UI-thread worklet loop point the same way.

The things that did have to be rewritten were the things that could not have been prototyped in a browser regardless. Audio went from nothing to a real engine with crossfading music. Haptics went from navigator.vibrate to per-event patterns. Particles went from an unbounded array to a fixed pool, because Hermes garbage collection matters in a way browser GC did not at prototype scale.

What I would keep and what I would change

Keep: the refusal to add a build step. The entire value of the prototype was the save-and-refresh loop, and every tool that promises to speed you up costs you some of that.

Keep: throwing away the code without ceremony. The prototype is not a foundation. It is an experiment that happens to leave behind some maths you can copy.

Change: I would have taken notes on the tuning values as I went. By the time I ported, I had a CFG object full of numbers that felt right and no record of what I had tried and rejected. When the same questions came up again on device, I re-ran experiments I had already run.

Change: I would have tested on a phone browser much earlier. Steering with a mouse cursor and steering with a thumb are different problems, and I tuned against the wrong one for longer than I should have.

Is it still useful?

The prototype has one job left. It is the fastest way to try a mechanic that would be invasive to add to the real game. If I want to know whether a new obstacle type is fun, I would rather spend twenty minutes in a file with no types and no tests than a day threading it through spawn tables and a store.

The finished version is on the App Store, and the movement in it is still recognisably the movement I tuned in that file.