A LAN arcade you cycle by dropping folders
Most of what runs in this lab is load-bearing: DNS, notifications, backups, the monitoring stack. Every so often it’s worth building something whose entire justification is that it’s fun. This one is a LAN-only arcade — a single page of static, single-player browser games that anyone on the home network can open and play, no accounts, no internet, no cloud.
The design goal that made it interesting wasn’t “host some games.” It was: make adding a game trivial. Drop a folder, add a card, done. Everything else — the container, the web server, the observability — is the boring part that should never need touching once it’s up. Getting the games themselves to behave turned out to be the whole job.
The shape of it
The arcade is a container, not a VM — an unprivileged Debian LXC with 2 vCPU, 4 GB of RAM, and a small disk on the SSD pool. It answers at arcade.solar-hawk.cc, resolved by the lab’s DNS to a LAN address. No Cloudflare tunnel, no external exposure: this is deliberately something you can only reach from inside the house. A kid’s tablet on the home Wi-Fi gets to it; the internet does not.
Serving is Caddy, installed as a native package rather than in Docker. There’s no reverse-proxy tier, no PHP, no database — it’s a static file server and nothing more. The root holds a hub index.html, and each game lives in its own subfolder:
/srv/arcade/
├── index.html # the hub — one card per game
├── 2048/
├── solitaire/
├── sudoku/
├── oregontrail/
└── ...
The Caddyfile is about as short as a Caddyfile gets: listen on port 80, serve that directory, and fall back to {path}/index.html so a bare /sudoku/ loads the game’s index without a trailing-slash dance. Adding a game means putting its built static files in a new folder and adding one card to the hub. Caddy picks it up instantly — no restart, no reload, no config change. That’s the entire “cycle games” workflow, and it’s the whole point: the lineup is meant to rotate as the family gets bored of things.
Why 4 GB for a static file server
The container serves static files, which needs a couple hundred megabytes at most. It has 4 GB anyway, and the reason is a good example of how homelab sizing gets decided by the worst single moment rather than the steady state.
Several of these games aren’t shipped as ready-to-serve HTML. They’re source projects that have to be built — an Angular app here, a Vite + React app there. One of them, an idle game, runs its production build (ng build) inside the container as part of the deploy script. At 2 GB of RAM that build gets OOM-killed partway through. Bump the container to 4 GB and it completes. So the container is sized for the fifteen minutes a year it compiles an Angular bundle, not for the 99.99% of the time it’s handing out cached HTML. RAM is cheap in this lab; a mysterious half-built game is not.
Every game is a small integration project
Here’s the thing nobody tells you about a “just static games” page: almost no open-source browser game is actually ready to drop into a subfolder on an offline network. Each one needed work, and the problems clustered into three recurring shapes.
They phone home for assets. A depressing number of otherwise-offline games pull jQuery from a CDN, load Google Fonts from fonts.googleapis.com, or fire a Google Analytics beacon on load. On a network with the WAN unplugged — or just on principle — those are broken dependencies and privacy leaks. So each game gets de-CDN’d: jQuery repointed to a bundled copy, web fonts downloaded and vendored locally with the @font-face URLs rewritten, analytics snippets stripped out entirely. I wrote one small shared script to localize Google Fonts and reused it across every game that needed it.
They assume they own the root of the domain. This is the subtle one. A game built with default settings bakes in absolute asset paths — /assets/app.js, /main.css. That works when the game is the whole site. It breaks the instant you serve it from /sudoku/, because the browser requests /assets/app.js at the domain root, where nothing lives. The fix depends on the toolchain: Vite needs base: './', Create React App needs "homepage": ".", an Angular build needs --base-href /sudoku/. Get it wrong and you get a blank page and a console full of 404s — a failure mode I got very familiar with.
PWAs try to hijack the whole origin. The nastiest offenders are games shipped as Progressive Web Apps. Their service worker registers with scope: "/", which means once you’ve loaded one PWA game, its service worker tries to intercept requests for every game on the origin. One game’s cache starts serving another game’s requests. The clean fixes are either to strip the PWA plugin out of the build (which is what I did for the sudoku game — rebuilt it with the service worker removed) or, if a game really needs to be a PWA, give it its own subdomain so its scope can’t reach anything else.
None of these are hard once you know the pattern. But every single game hit at least one of them, and a few hit all three.
The game that couldn’t be included
The lineup went through some churn, and one drop is worth a paragraph because it’s a good lesson in verifying claims before committing to them.
I wanted a text-based life-simulation game in the mix — the “you are born, make choices, see how your life turns out” genre. There’s a popular open-source one, and several forks advertise English support. I got fairly far into integrating it before actually reading the localization files, and discovered the problem: in every fork I found, the “English” support is UI strings only. The actual content — the thousands of life events, talents, and outcomes that are the entire game — stays in the original Chinese. The forks that genuinely play in English aren’t self-hostable static sites at all; they’re LLM-backed remakes that need a live backend to generate the text. There is, as far as I could find, no genuinely-English, genuinely-static, genuinely-self-hostable version of that game.
So it got dropped, and replaced with a dark, atmospheric text-narrative game that scratches a similar itch and actually ships as English static files. The lesson is the ordinary one that keeps re-teaching itself: “supports English” on a repo’s README is a claim, not a fact, and the cost of not checking it is measured in wasted integration work.
The lineup, and why the number is fluid
At launch it’s seven games — a mix of the classics (2048, solitaire, sudoku) and some richer things (a wagon-trail survival game, a kingdom-building idle game, the dark-room narrative game). Several candidates got cut during the build for not being fun enough, or for being more trouble to integrate than they were worth.
But the count doesn’t really matter, because the entire architecture exists to make it change. That’s the difference between this and just bookmarking some game sites. The lineup is a directory. Bored of a game? Delete the folder, remove the card. Found a new one? Build it, drop it in, add a card. The infrastructure never moves. I can hand that workflow to future-me — or write it down for anyone else in the house who wants to curate — and it’s a five-minute operation with no server access required beyond copying files.
The boring parts that make it a homelab service and not a toy
Because it lives in the lab, it gets the same treatment every other service does, even though it’s “just games.” It’s provisioned as code, so the container can be rebuilt from scratch. It runs the standard observability trio — a metrics exporter, a log shipper, and the security agent — so it shows up green on the same dashboards as everything else and I’d know if it fell over. Its runbook is a README in the infrastructure repo rather than a separate wiki page, which for a service this simple is the right amount of documentation.
That’s arguably overkill for an arcade. But the marginal cost of doing it the standard way — the way every other service in the lab is done — is nearly zero, because the patterns are already built. The alternative, a special snowflake service with no monitoring and no reproducible build, is the thing that bites you eighteen months later when it breaks and you’ve forgotten how it works. Even the fun stuff gets the boring treatment. Especially the fun stuff, because the fun stuff is exactly what you forget the details of.
What it’s actually for
There’s a real point buried under the engineering. A house full of screens usually means a house full of things reaching out to the internet — telemetry, ads, accounts, “engagement.” This is a small deliberate counterexample: a page of genuinely offline games that don’t track anyone, don’t need a login, don’t work without the local network and don’t care that they don’t work off it. It runs on hardware I own, in a container I can rebuild, serving files I’ve read and stripped of their CDN calls.
It’s a toy. It’s also a tidy demonstration of the whole lab’s thesis — that you can own the thing instead of renting it — applied to something as low-stakes as a rainy-afternoon game of 2048.
Comments
No comments yet — be the first.