Game Rig

Home/Blog/Fake browser

Testing a game in a fake browser

Every game boots, plays two minutes and gets restarted inside a hand-written window object, and the run is worth having precisely because it cannot see the screen.

Fifteen of the sixteen games on this site are played for two simulated minutes every time the test suite runs: 7,200 frames each at 60 Hz, driven by a scripted sequence of key presses, with no browser, no window and no display involved. The last timed run of the whole suite took 13.2 seconds on this laptop.

The harness is scripts/smoke-game.js, and it is not jsdom, not Playwright and not a headless Chrome download. It is a plain JavaScript object holding the pieces a game touches, handed to vm.createContext, with a queue standing in for the frame callback. Each game is a single file with its logic inline, so the harness pulls the script bodies out of the markup and evaluates them against that object.

It earns its place by catching one class of failure well: an exception thrown on boot, mid-frame, on pause, on restart, on resize, or when the audio system is missing. Everything else it reports is a hint rather than a verdict.

The three passes the harness makes over each game
PassConfigurationFrames driven
1Desktop, devicePixelRatio 2, keyboard input7,200
2No AudioContext at all, devicePixelRatio 11,800
3Coarse pointer, devicePixelRatio 1, pointer events on the touch bar1,800

Three passes over fifteen games is 162,000 frames, and dividing that by 13.2 seconds is my arithmetic rather than a benchmark. A two-minute run has stopped being expensive enough to skip.

The window object is a plain object with a document on it

Nothing in the sandbox is a browser class. document.getElementById looks an id up in a Map and manufactures an element if nobody has asked for it before, so a game that queries an element missing from its own HTML gets a working stub rather than a null dereference. Each element records its textContent and its listeners, which the harness fires by hand.

localStorage is a Map with the four methods a game actually calls and no quota at all. That is the right trade here, because the question is whether a record was written and whether it parses, not what happens at five megabytes — production quota behaviour is covered in what localStorage can and cannot do.

Time is faked too. performance.now() returns (i + 1) * 1000 / 60 during frame i, so a game measuring elapsed time sees exactly 16.67 ms per frame with no drift. requestAnimationFrame pushes its callback onto an array the harness drains by hand, which is what makes 7,200 frames take a fraction of a second and what makes the run inspectable between frames.

A canvas context that agrees with everything

The 2D context is a Proxy, which is the trick that makes this worth doing. Reading an unknown property returns a function that does nothing, so fillRect, setLineDash and the rest can be called without the harness having heard of them. A few methods are special-cased: measureText returns a width of 12 whatever the string is, and a gradient returns an object with an addColorStop.

Three calls are counted instead of ignored: save, restore and setTransform. When the first two disagree at the end of a run, a game has leaked transform state frame after frame, which in a browser appears as a scene that slowly drifts with no code that looks wrong. That assertion has found more bugs here than anything else in the file.

What the stub answers, and what the browser would

The middle column is the honest inventory of the gap, and every entry in it is a question the harness has decided not to answer.

What the fake window returns for calls the games make
CallIn the sandboxIn a real browser
getBoundingClientRect()A fixed 960 by 600 box at 0, 0Whatever the stylesheet and the loaded fonts worked out
ctx.measureText(text){ width: 12 } for any stringThe real advance width at the current font and size
getComputedStyle(el)getPropertyValue returns an empty stringEvery declared and inherited property
getContext("webgl")nullA GL context, or null on a blocklisted driver
localStorageA Map with no quotaA string store of a few megabytes per origin
requestAnimationFrameA queue only the harness drainsThe compositor, at the display's own rate

Two rows there have consequences. The fixed rectangle makes every sizing bug invisible: a game that lays out its HUD from getBoundingClientRect gets the same numbers whatever the page does. The null GL context is why Highway Rush 3D carries data-smoke="skip" and says so in the output, rather than failing a test that was never able to run it.

Holding a key down for twenty-four frames

The first input driver pressed and released a key in the same frame, so by the next animation frame the keyboard state said nothing was held. Games with an automatic throttle never noticed. The driving games noticed by sitting on the start line for two minutes while the harness reported a healthy run.

The fix was to hold each key for 24 frames, which is 400 ms, and to cycle through nine keys so a car or a runner has something to steer against. A scripted key sequence is a model of a player, and one that presses faster than any hand can is testing a player who does not exist.

A crash worth chasing has to happen twice

Random input proves that a game does not crash. It cannot prove that a puzzle is solvable, and it cannot reproduce the failure worth fixing, because the next run draws different numbers. Time comes from the faked clock and the key sequence is fixed, so Neon Snake will fail at the same frame on every machine.

Games that use randomness need a way out of it. Road Dodge keeps its own generator, so gamerigTest.seed(7) restarts a run from a known state: the default is 0x1d4b9a3, the state goes through a mulberry32-style integer hash, and the traffic, the fuel cans and the spawn distances come out in the same order every time. Screen shake still calls Math.random, so a cosmetic jitter cannot consume numbers the traffic ordering depends on — worth reading alongside seeded randomness.

The same hook lets a game be driven deliberately rather than mashed at. Three games here expose one, and each can be asked for its state or told to start a specific level. That beats any assertion the harness could write for itself, because the person writing the test knows which level is awkward.

What a stub will never see

The gaps are not accidents of this implementation; they are what a stub is. jsdom, the serious version of the same idea, says in its own documentation that it does no layout and no rendering and returns zeros for many layout properties. Canvas support exists there only when the optional canvas package is installed, and without it a canvas element behaves like a div.

Real pixels need something that rasterises. node-canvas is a Cairo-backed implementation of the web canvas API for Node, with prebuilt binaries for macOS, Linux and Windows, and it would let a test compare a frame with a stored image. That image changes every time a colour is touched, and it is not what this site needs.

Real layout needs a real engine. Playwright drives Chromium, Firefox and WebKit headless, in parallel, by default, with genuine layout, fonts, WebGL and input events, and it answers the questions this harness cannot ask. It is also a few hundred megabytes of browser binaries and a much slower run, so it is saved for the cases the cheap falsifier cannot reach.

Pros and cons

Pros

  • Fifteen games are played to a result in about thirteen seconds, so the suite runs on every edit rather than before a release.
  • Nothing has to be downloaded or installed, so a fresh checkout can run the whole thing immediately.
  • Counting save against restore catches a leaked transform that no screenshot would reveal.
  • A faked clock and a fixed key sequence make the failing frame identical on every machine that runs it.

Cons

  • No layout engine and no rasteriser, so nothing here can tell whether a game looks right.
  • The canvas stub accepts every call, which means a typo in a method name is silently absorbed instead of throwing like a real context would.
  • Games with no test hook and no score element only produce notes, and a note nobody reads is a note that stops being written.
  • It cannot judge the thing that matters most, which is whether any of the sixteen games is enjoyable.

Sources

  • jsdom — the caveats that set the ceiling for this whole approach: no layout, no rendering, and canvas only when the optional peer package is present.
  • node-canvas — the Cairo-backed canvas for Node, and what it would cost to assert on real pixels.
  • Playwright: installation — headless Chromium, Firefox and WebKit in parallel, and the list of things a stub cannot reach.