Arthis.Land

Building a Complete Browser Game in One HTML File

Every game on arthis.space is one HTML file with no build step — here is the skeleton, the loop, the touch layer, and the honest price of that decision.

There are over a hundred games on arthis.space and not one of them has a bundler. Chess, ludo, carrom, 8-ball pool in 3D, hand cricket, hill climb rally, air hockey, sumo fighter, a voxel sandbox, an interactive globe — each is a single .html file containing its own markup, styles, logic and assets. You can save one to a USB stick, open it from a file:// URL on a laptop with no network, and it plays.

That constraint is not nostalgia. It is the cheapest way I have found to make a large library of small games survive.

Why one file wins for this shape of project

A build step is a liability that scales with the number of things you build. A hundred games with a hundred package.json files is a hundred dependency trees quietly rotting. A hundred games as a hundred HTML files is a directory.

The practical wins compound:

The size envelope in practice

Across the library the range is narrower than you would expect. The smallest thing worth calling a game is around 23 kB. The largest — a 3D globe with geography data and camera work — is about 150 kB. A typical 3D game lands between 60 and 110 kB.

GameWhat it isApproximate size
flappy-peacock2D canvas, one mechanic, procedural art~23 kB
air hockey2D canvas, physics, two-player local30–45 kB
chess2D board, move generation, search40–60 kB
carrom / ludo / hand cricket2D board games with rules engines40–70 kB
hill climb rally2D physics, terrain, vehicle model60–90 kB
8-ball pool 3DWebGL, cue physics, camera control60–110 kB
sumo fighter3D arena, ragdoll-ish push physics60–110 kB
atlas-globe3D globe, geography dataset~150 kB

Those numbers are the source file, not the dependency. A 3D game pulls its renderer from a CDN and that is a separate, shared, already-cached request. What surprises people is how little handwritten code a complete game needs once you stop paying for a framework you are not using. A board game is mostly rules, and rules are cheap.

The skeleton every one of them shares

One canvas. One loop. One array of entities. One state machine. Every game in the library is a variation on that.

The state machine is four states — menu, play, pause, gameover — held in a single string variable, switched on at the top of update and again at the top of render. It sounds trivially small to bother naming, and that is the point: without it you end up with five booleans that can contradict each other, and a game that is somehow both paused and over.

The entity array is plain objects pushed and spliced. No classes, no ECS, no inheritance chain. An entity is {x, y, vx, vy, r, alive, kind} and a few fields specific to its kind. Systems iterate the array and switch on kind. The moment you reach for an abstraction here you have started writing an engine instead of a game.

Fixed timestep, and why the delta must be clamped

requestAnimationFrame hands you a timestamp, not a guarantee. Switch tabs for thirty seconds and the next callback arrives with a delta of thirty thousand milliseconds. Feed that straight into your integrator and every object teleports — through walls, off the map, into a collision test that returns nonsense. The same thing happens on a laggy first frame, on a phone that throttles, and any time the device sleeps.

Fix the simulation step and clamp the accumulator:

const STEP = 1 / 60;          // simulate at a fixed 60 Hz
const MAX_FRAME = 0.25;       // never swallow more than 250ms at once
let acc = 0, last = performance.now();

function frame(now) {
  requestAnimationFrame(frame);

  // A backgrounded tab returns a delta of many seconds. Clamping here is
  // what stops everything teleporting on the first frame after a tab switch.
  let dt = (now - last) / 1000;
  last = now;
  if (dt > MAX_FRAME) dt = MAX_FRAME;

  if (state === 'play') {
    acc += dt;
    // Spiral-of-death guard: if we are this far behind, drop the backlog
    // rather than run 400 steps and fall further behind.
    if (acc > MAX_FRAME) acc = MAX_FRAME;
    while (acc >= STEP) { update(STEP); acc -= STEP; }
  }

  render(acc / STEP);         // fractional leftover, for interpolation
}
requestAnimationFrame(frame);

A fixed step also makes physics reproducible. Variable-delta integration means a 144 Hz monitor and a 60 Hz phone produce different trajectories, and in a pool game that difference is the shot going in or not.

devicePixelRatio, or the game looks blurry on every phone

A canvas has two sizes: its CSS box and its backing store. Set only the CSS size and the browser stretches a 400×700 bitmap over a 3× display. Text goes soft, thin lines smear, and the game reads as cheap before anyone has touched it.

function resize(canvas, ctx) {
  const dpr = Math.min(window.devicePixelRatio || 1, 2); // cap: 3x costs 2.25x fill
  const rect = canvas.getBoundingClientRect();

  canvas.width  = Math.round(rect.width  * dpr);
  canvas.height = Math.round(rect.height * dpr);

  // Draw in CSS pixels; the transform maps them onto the real backing store.
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

  view.w = rect.width;   // game logic stays in CSS pixels, never device pixels
  view.h = rect.height;
}
window.addEventListener('resize', () => resize(canvas, ctx));

The cap at 2 is deliberate. A 3× phone means 9 device pixels per CSS pixel, and on a fullscreen canvas that is the difference between a steady 60fps and a warm phone at 40. Two is sharp enough that nobody notices the ceiling.

Touch is the primary input, not a port

Most of these games are played on a phone held in one hand. Designing for a keyboard and bolting on touch afterwards produces controls that sit where the desktop UI happened to leave room — usually the top of the screen, which is exactly where a thumb cannot reach.

So controls are placed in the thumb zone first: the bottom third, hugging the left and right edges, with targets no smaller than about 48 CSS pixels. Hill climb rally is two pedals in the bottom corners. Sumo fighter is a left-hand stick and a right-hand button. The desktop keyboard mapping is the afterthought, and it costs four lines.

Use pointer events rather than separate mouse and touch paths, capture the pointer so a finger that slides off the button still counts, and kill the browser's default gestures or the page will scroll and zoom while somebody is trying to steer:

// CSS does half the work: touch-action stops scroll/zoom before JS sees it.
//   .pad { touch-action: none; user-select: none; -webkit-user-select: none; }

function bindHold(el, key) {
  el.addEventListener('pointerdown', e => {
    e.preventDefault();
    el.setPointerCapture(e.pointerId);  // finger sliding off the pad keeps the input held
    input[key] = true;
  });

  const release = e => {
    input[key] = false;
    if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
  };
  el.addEventListener('pointerup', release);
  el.addEventListener('pointercancel', release);  // an incoming call fires this, not pointerup
}

bindHold(document.querySelector('#gas'),   'accel');
bindHold(document.querySelector('#brake'), 'brake');

pointercancel is the one people miss. The system steals the pointer for a notification or a gesture, pointerup never fires, and the car drives into a wall at full throttle. There is more on the general shape of this in pointer events and phone input.

Audio only after a gesture

Every browser blocks audio until the user has interacted with the page, and a blocked AudioContext starts suspended and stays that way silently. The failure mode is a game that is mute for one person in five with no error anywhere.

Create the context lazily on the first real input — the tap on "Play", not on load — and resume it explicitly. Keep a single context for the whole game and build effects from oscillators and short buffers rather than shipping audio files; a kick, a click and a whistle synthesised in a dozen lines cost nothing and keep the file one file.

State in plain objects, DOM out of the loop

Game state is one object graph: game, entities, input, view. Nothing in the update path reads the DOM, and nothing in the render path writes to it. Reading getBoundingClientRect() or offsetWidth inside a loop forces synchronous layout sixty times a second, and a canvas game that does this drops frames for reasons no profiler flags as "your fault".

HTML overlays — score, lives, menus — are updated only when the underlying value changes, not every frame, and they are absolutely positioned so a change cannot reflow the canvas. When a game genuinely needs a DOM-heavy interface the rules are different and stricter; that is its own discipline, covered in keeping a DOM-heavy UI at 60fps.

CDN dependencies, pinned exactly

Single-file does not mean zero-dependency. The 3D games load their renderer from a CDN, which keeps the game file small and means every 3D game on the site shares one cached copy of the library.

Pin the exact version. Not a range, not @latest, not a major-version alias that silently follows minor releases. A game written eighteen months ago against one renderer version and left untouched will break the day that alias moves, and you will find out from a player rather than a test — because there are no tests. An exact URL is the only version lock a file with no lockfile has.

A single-file game has no package.json, so the script tag is the manifest. Treat the version number in that URL with the seriousness you would give a lockfile, because it is doing the same job alone.

What this costs you

The approach is not free, and pretending otherwise would be dishonest.

That last one is the real tax. Over a hundred games it means the library is not a codebase, it is a hundred codebases that happen to look alike. I accept it because the alternative — a shared engine everything depends on — makes every game hostage to one file's version history, and the whole reason a game from three years ago still opens is that it depends on nothing I control.

If you are shipping one ambitious game, use a real toolchain. If you are shipping many small ones and you want them to still work in a decade, the file is the architecture.

Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.