Beben Arcade
Twelve small games at one address, installable to a home screen, every one of them still opening with the phone in airplane mode. The games took a weekend. The boundary that keeps them working took the discipline.
A request that fails on a plane is a broken app
An offline arcade has exactly one failure mode that matters, and it is not a bug in a game. It is a request. Any single reference that leaves the arcade folder, a shared stylesheet, a hosted font, an icon set, an analytics snippet, becomes a fetch that resolves fine at your desk and fails in the only moment the arcade exists to serve.
So the constraint was written as a boundary rather than a goal: nothing under the games directory may reference anything outside it. That reads as pedantry until you apply it, because the first things it rules out are ours. The site's own index.css, the nav partial copied verbatim into every other page, the Sprite widget, the theme toggle. The arcade had to become a deliberate exception to our own design system.
The commercial constraint is that this is a design studio's own product, not a game company's output. Twelve games do not pay for themselves and were never meant to. They exist because offline-first is easy to claim in a pitch and hard to demonstrate, and a thing a prospective client can install, unplug and keep using is an argument that does not need making twice.
The versions that did not ship
Each of these is the conventional answer to a real problem here, and each lost to the boundary rather than to taste.
Unify the arcade with the main site's design system
The obvious efficiency, and the one that breaks the product. Sharing index.css, the nav and the theme toggle means sharing their availability: unified means coupled, and coupled means the arcade goes down when the site does. The arcade instead ships its own manifest, its own service worker scoped to the games directory, and its own visual language, and pays for that in duplication on purpose.
Build on a game engine or a framework
Twelve independent games would be twelve copies of the same three hundred lines, and an engine is the standard fix. It is the wrong fix at this scale for two reasons: an engine wants each game modelled its way, and it arrives with a build step attached. The build step is precisely the thing being refused, because a build step is a dependency that must keep working for the arcade to keep shipping.
Network-first caching, so players always get the newest build
Network-first is the safer default everywhere except here, where the network is assumed absent. Cache-first is the only correct strategy for an offline arcade, and it carries one sharp edge: ship a fix without changing the cache name and returning visitors keep the bug forever, not for a while, until they clear site data. The mitigation is a written rule rather than a clever mechanism.
Generate the game registry from a folder listing
Adding a game touches four separate lists: the precache array, a hub card, the hub's inline icon map, and the slug array the achievements read. A registry that derived all four from the filesystem is the obvious deduplication and needs a build step to run. The four hand-maintained lists are the cheaper trade, because the cost is paid once per new game and the build step would be paid on every deploy forever.
Load the display face from a font host
A hosted font is a cross-boundary request, so it fails offline, and the full face is too heavy to self-host casually. Subsetting to uppercase glyphs only brings it to 5,212 bytes, which is small enough to precache without thinking about it. The cost is real and permanent: every use of the face must be paired with an uppercase transform, because the lowercase glyphs are simply not in the file.
Keep the 168KB cover image as the hub hero
It was the single largest asset in a project whose whole argument is weight. Redrawing the hero in CSS and SVG costs nothing to transfer, scales to any screen without variants, and removes a file from the precache list that every installed player would have carried forever.
Put achievements and high scores on a server
A leaderboard is the expected feature and it imports an entire back end: accounts, a database, moderation of what people name themselves, and a bill that scales with players. All eight achievements evaluate against device storage instead. Nobody can see your streak but you, which is not a limitation to apologise for but the same position the studio takes on its tools: if the feature does not need a server, it does not get one.
What is running now
Twelve games at one address, installable as a PWA, with a service worker scoped to the arcade and a precache list of 20 entries. Put the device in airplane mode after the first visit and every game still opens.
The shared runtime is arcade.js, 35,673 bytes of plain JavaScript that does the boring things every game needed and nothing more: a fixed-timestep loop so a 144Hz phone and a 60Hz laptop run the same physics, a canvas fitter that handles device pixel ratio so no game ships its own blurry-canvas bug, an 8-bit Web Audio synth so sounds are generated rather than downloaded, score and settings stores wrapped in try/catch, and top-bar chrome injected at load so the back button sits in the same pixel in all twelve games.
Each game is then one self-contained HTML file that loads the runtime and owns everything else. You can open any of them, read the whole game top to bottom, and change it without knowing how the other eleven work. At this scale that property is worth more than deduplication.
The identity is a constraint rather than a decoration. The arcade is dark only, never reads the main site's theme key, and assigns each game one neon accent that colours its card, its in-game chrome, its canvas art and its game-over jingle. You learn which game you are in from the colour before you read a word.
- Twelve games, 227,558 bytes of static files in total, installable to a home screen
- A shared runtime in arcade.js and arcade.css, 46,905 bytes combined, no engine and no bundler
- A cache-first service worker scoped to the arcade, precaching 20 entries under a versioned cache name
- A 5,212 byte subset of Press Start 2P, uppercase glyphs only, self-hosted
- Seven neon accent tokens over a near-black ground, one assigned to each of the twelve games
- Eight achievements, a CRT scanline mode and a rare launch flourish, all evaluated on the device
- A written rule in sw.js that every commit touching the arcade bumps the cache name
What can actually be counted
This site runs no analytics, so none of what follows is a play count. Every figure below was read off disk with a shell command before it was written down.
222.2 KB for twelve games, a shared runtime, a font, icons and a service worker.
Twelve folders, each one self-contained HTML that reads top to bottom.
arcade.js, plus 11,232 bytes of arcade.css. No engine, no bundler, no package manifest.
Press Start 2P subset to uppercase glyphs only, which is why every use is paired with an uppercase transform.
The list a new game must be added to by hand. There is no generated registry, on purpose.
Seven tokens across twelve games, so five neons are shared by two games each.
All evaluated against device storage. No account, no leaderboard, no database of who played what at 2am.
Two absolute URLs exist under the arcade: a canonical link tag and one link back to the main site. Neither is a fetch, so nothing crosses the boundary at runtime.
There are no play counts, no install counts and no retention figures on this page, because the arcade collects none of them and this site runs no analytics at all. That is a direct consequence of the design rather than an oversight: an analytics snippet is a cross-boundary request, so measuring the thing would have broken the property being demonstrated. What can be verified is the part that matters to a client, and it can be verified without trusting us: install it, turn the wifi off, and see whether it still opens.
A rule in a comment is not a check
The service worker is cache-first. That is what makes airplane mode work, and it is also what makes a stale file permanent. The whole defence is one line at the top of sw.js telling whoever edits the arcade to bump the cache name on every commit that touches it. It is a comment. Nothing enforces it and no test fails without it, and a missed bump serves the old game to everyone who already has the arcade installed until they go and clear storage.
The precache list has the same shape. Twenty paths, typed by hand, and adding a thirteenth game means remembering to add its folder or shipping a game that quietly only works online. This page is the evidence. It described that list as twenty-five entries. The list has held sixteen, then eighteen, then twenty, and never twenty-five. The figure was corrected the moment somebody recounted it, which is what writing numbers down is for, but a list a person maintains and a number a person types will drift again.
And the boundary costs what it was always going to cost. The arcade shares nothing with the main site, so a fix made once out there has to be made again in here, and the two drift apart in small ways forever. That was the trade rather than an oversight, and it is still the right one, but it is a bill that arrives every month rather than a win that arrived once.
Turning two hand-kept lists into checks
Nothing here is a promise with a date on it. It is the shortlist, in the order the risk actually sits.
- Make the cache-name rule a check that fails the build, rather than a sentence at the top of a file
- Generate the precache list from the folders on disk, so adding a game cannot leave one out
- Publish the list and the cache name on this page from the source, the way the homepage figures are
- The hub interface is the oldest part of the arcade and the part a first visit is judged on