๐ผ Screenshots & video โ How it works
01 Overview
A self-hosted studio-plus-arcade that turns a sentence into a playable browser game. It industrializes something I'd been doing by hand โ building small multiplayer games for my family โ into a push-button pipeline, with three engine pathways sharing one flow: describe โ scaffold โ AI-code โ build โ dress with assets โ deploy.
Phaser 3
A Phaser + Express/Socket.io template filled in by an AI coding agent, with rooms, scores, and music wired in.
Three.js
Three.js with a WebGPUโWebGL2 fallback, Rapier physics, and Colyseus multiplayer, in TypeScript.
Unity 6 WebGL
The LLM writes one C# class; it's compiled to WebGL on a headless Unity on a Windows GPU PC, retried on errors, and deployed โ no editor, no hands.
02 Why I built it
I kept hand-building one-off games for the family arcade, and each one was the same dance: scaffold, wire up multiplayer, add sound, deploy. The studio is that dance made repeatable โ so a new game is a description and a few minutes, not an evening. The Unity pathway was the ambitious part: I wanted to prove an AI could drive a real engine end to end, with no human ever opening the editor.
03 What I built & how it works
A studio, a headless build daemon, and an API-key broker in between.
Fig. 1 โ the studio orchestrates; the GPU PC compiles Unity; deployment spins up a sibling nginx container over the Docker socket.
- Describe โ a name, a description, a pathway, and (for Unity) one of 14 genre templates.
- Scaffold & code โ copy a template, then run a headless coding agent through a serialized queue to fill in the game logic.
- Build โ for Unity, generate a C# class, ship it to the daemon, poll the compiler logs, and repair compile errors with the model.
- Dress โ gather and generate the art, sound, and music the code referenced, then auto-patch it in.
- Deploy โ promote to a container, register it in the arcade, open the firewall, add a homepage tile.
04 ๐ Skills & tech used
05 Notable challenges & decisions
The interesting engineering wasn't the AI โ it was making the AI's output survive contact with real systems.
Reverse-proxied WebSockets are where multiplayer goes to die
"Create Room does nothing" had three distinct root causes fixed over three days: proxy middleware that never binds server upgrade events; stale per-draft proxies whose leftover upgrade handlers destroyed live sockets; and a global JSON body-parser eating the matchmaking request before it reached the game. Each looked identical from the browser.
Coding agents need guardrails, not trust
Agents sometimes reported "done" without writing any game logic, so a skeleton detector counts real lines and re-runs a focused prompt when it finds only placeholders. The patch path abandoned agent tooling for a direct ===FILE:=== streaming protocol with a per-chunk silence watchdog โ more reliable than trusting tool-call schemas.
Compile errors are trainable
One session turned ten recurring C# errors (CS0507, CS0115, CS1061โฆ) into permanent, per-genre prompt rules. On failure the builder extracts the error CS#### lines and asks the model to fix exactly those, up to twice.
A third-party script that silently disabled the whole wizard
A day of flailing until the real cause surfaced: a CDN script was blocking JavaScript execution so no click handler ever attached. The fix was serving that one file locally โ "nothing happens when I click" is rarely about the click.
Saying no kept it shippable. Deliberate scope cuts: Unity games are standalone containers with no multiplayer and no arcade integration; build success is deployment, with no promote step.
06 Results
Sources: studio SQLite database, build-daemon artifacts, and session logs.
07 Screenshots & video
The whole thesis fits in one screen recording โ and the arcade is playable above.
wizard โ live build log โ the deployed game playing in an iframe


08 Honest status
The studio and pipeline were built and used heavily in March 2026, then went quiet โ the Unity build daemon has been offline since July, and Unity builds need the GPU PC awake. The arcade is playable here as a self-hosted, de-identified clone: create a player and avatar, play the multiplayer mini-games, and try the chat, music, and presence. Authentication is username-only by design โ a friendly home-arcade choice, not production security.
game-arcade โ Build Recipe
Take a bare machine to a running copy of GameHub โ a self-hosted family game arcade: a hub with username accounts, an avatar creator, a lobby with live chat / presence, and 11 games (8 in a shared multiplayer service plus standalone UNO, Boggle, and Pop the Lock). One Caddy reverse proxy fronts everything; each game lives on its own subdomain so it owns its origin. Node + Socket.IO + better-sqlite3, no build step for the clients.
Status: โ Verified 2026-08-01 โ built from this clone with
docker compose up -d --build(6 services) and driven end-to-end in a real browser: all 12 subdomains route (hub+ 11 games), a new player + avatar is created from scratch, lobby chat + online presence work on first entry, a game launches across the subdomain boundary carrying the auth token, and two isolated browsers join the same blackjack table and see each other. Audio works out of the box (synthesized sound effects + a seeded background-music loop). Three bugs surfaced during verification and were fixed in the clone (see What was fixed). All three tiers are covered; the prebuilt tarball (docker saveof caddy + the 5 game images) is ~1.0 GB, verified loadable 2026-08-01. The clean clone lives inclone/.Sensitive data: none shipped. No family accounts, avatars, chat history, saved games, or music โ the databases start empty and are seeded with three synthetic demo players. All hardcoded secrets (a shared
INTERNAL_API_KEY, twoJWT_SECRETs) were replaced with dev placeholders. Auth is username-only by design โ a friendly home-arcade choice, not production security.
What it is
A set of small Node/Socket.IO servers behind one Caddy reverse proxy. There is no build, no ML, no
dataset, and no GPU โ "reproducing" it is just running the services. The only architectural trick is
one origin per game: the hub is hub.arcade.localhost, blackjack is blackjack.arcade.localhost,
and so on. The browser derives every URL from location.host, so the whole arcade rehosts under a
different base domain by editing one file (Caddyfile) โ no code changes.
| Service | Games | Internal |
|---|---|---|
game-hub |
the hub (accounts, avatars, lobby, chat, presence) | :3003 |
mini-games (pm2) |
Royal 21 ยท Tetris ยท Block Blast ยท Snake ยท Bubble Burst ยท Bomber ยท Card Clash ยท Tower Siege | :3001โ3009 |
uno |
UNO | :3010 |
boggle |
Boggle | :3011 |
pop-lock |
Pop the Lock | :3011 |
caddy |
reverse proxy (the only published port) | :80 |
Prerequisites
New machine? Install the base tools first โ see ../SETUP.md. Then, per tier:
| Tier | You need |
|---|---|
| 1 โ prebuilt containers | Docker (see ../SETUP.md). Nothing else. |
| 2 โ build from source | Same as Tier 1, plus this source bundle. |
| 3 โ bare-metal | Node 20+ and the Caddy binary. better-sqlite3 compiles on install, so a C toolchain (build-essential / Xcode CLT / VS Build Tools). |
A modern browser resolves *.localhost to 127.0.0.1 automatically (Chrome, Edge, Firefox,
Safari) โ so http://hub.arcade.localhost/ just works with no hosts-file edits. That is the whole
reason the demo uses arcade.localhost.
Configuration โ nothing required
The arcade runs key-free. Every secret has a working dev default, so docker compose up needs no
.env. To set your own, copy clone/.env.example to clone/.env:
JWT_SECRET=change_me # signs the username-only session tokens
INTERNAL_API_KEY=change_me # gameโgame stats calls + the admin music manager
To host under your own domain (e.g. arcade.example.com): point a wildcard *.arcade.example.com
at the box and change arcade.localhost โ arcade.example.com in Caddyfile (12 lines). No app code
changes โ the client reads the base off location.host. For real internet exposure, switch the
Caddyfile blocks from http:// to plain hostnames so Caddy provisions TLS automatically.
Tier 1 โ Run the prebuilt containers (lowest friction)
Availability: the prebuilt image bundle is available on request โ it is not published or linked anywhere. Ask Kevin for it, or build everything from source via Tier 2/3 below.
Build once, save the images to a tarball, and load them anywhere โ no rebuild, no source needed:
# on a machine that has built the images (see Tier 2):
docker save caddy:2-alpine clone-game-hub clone-mini-games clone-uno clone-boggle clone-pop-lock \
-o gamehub-images.tar # ~1.0 GB (caddy is the pulled caddy:2-alpine; the 5 game
# images are locally built as clone-<service>)
# on the target machine:
docker load -i gamehub-images.tar
cd clone
docker compose up -d # images already present โ no build step
# โ http://hub.arcade.localhost/
Tier 2 โ Build from source (recommended; this is the verified path)
cd clone
docker compose up -d --build # builds 5 Node images + pulls caddy, starts all 6 services
# โ http://hub.arcade.localhost/
docker-compose.yml + the per-service Dockerfiles + Caddyfile are the build documentation โ all
clean, no LAN/host config. Only caddy publishes a host port (80); everything else talks over the
internal bridge network by service name.
Tier 3 โ Bare-metal (no Docker)
Run each service in its own terminal (or a process manager) and Caddy in front. From clone/:
# 1) game-hub
cd game-hub && npm install && DB_PATH=./data/gamehub.db PORT=3003 node server.js
# 2) the 8-in-1 mini-games service (uses pm2)
cd mini-games && npm install && npx pm2-runtime ecosystem.config.js # serves :3001โ3009
# 3) the three standalone games
cd uno && npm install && PORT=3010 node server.js
cd boggle && npm install && PORT=3011 node server.js
cd pop-lock && npm install && PORT=3011 node server.js # (use a different port if 3011 is taken)
# 4) Caddy out front โ the Caddyfile already maps every subdomain to a service:port.
# Bare-metal has no Docker DNS, so point the upstreams at 127.0.0.1 first:
sed 's/game-hub:3003/127.0.0.1:3003/; s/mini-games:/127.0.0.1:/; s/uno:/127.0.0.1:/; \
s/boggle:/127.0.0.1:/; s/pop-lock:/127.0.0.1:/' Caddyfile > Caddyfile.local
caddy run --config Caddyfile.local --adapter caddyfile
Give boggle and pop-lock distinct ports here (both default to 3011 โ fine as separate containers,
but they collide on one host); update the two ports in Caddyfile.local to match.
Verify
# every subdomain answers through Caddy (Host header bypasses DNS):
for h in hub blackjack tetris snake bomber cardclash tower bubble blockgame uno boggle poplock; do
printf "%-12s %s\n" "$h" "$(curl -s -o /dev/null -w '%{http_code}' -H "Host: $h.arcade.localhost" http://127.0.0.1/)"
done
# โ all 200
Then in a browser at http://hub.arcade.localhost/:
1. Click New Player โ Quick, hit ๐ฒ Randomize, type a callsign, CREATE โ you land in the lobby.
2. Type in lobby chat and Send โ your message appears; the online counter shows you.
3. Click โถ Play Now on Royal 21 โ you're on blackjack.arcade.localhost with your name pre-filled
(the token rode across on the URL). Create Table โ note the table code.
4. In a second browser / private window, join as a different player with that code โ both windows
show both players at the table. (That's the multiplayer path.)
5. Click the ๐ toggle to cycle off โ sfx โ all; on all you get the background-music loop.
Notes & gotchas
*.arcade.localhostneeds no hosts file โ modern browsers special-case.localhostto loopback. Command-line tools (curl,node) do not, which is why the CLI checks above use aHost:header.- One origin per game, not one port per game. The original addressed games by
host:port; a port is part of the origin, so that already crossed origins. The clone swaps ports for subdomains โ same origin-crossing, but every game now owns its own root path, so zero in-game code changed to rehost. - The auth token crosses origins via the URL.
localStorageis per-origin, so the hub can't share its token with a game subdomain โ the hub appends?user=&token=when it launches a game, and the game reads it there. This is by design (it matched the original port-based behavior). - Username-only auth. Anyone can log in as any name; tokens are signed but there's no password. It's a home arcade, not a hardened service โ don't expose it to the open internet as-is.
- Audio: all sound effects are synthesized in the browser (Web Audio oscillators) โ no files.
Background music loads from
/audio/music.mp3; the clone seeds a short, self-authored, royalty-free ambient loop on first boot (game-hub/scripts/make_default_music.py), and admins can replace it through the in-lobby ๐ต music manager (which is gated byINTERNAL_API_KEY). - Client assets are cached for a day (
express.static(..., { maxAge: '1d' })). If you edit client JS/HTML and don't see the change, hard-refresh (or bump the cache). - Empty tables rotate. A blackjack table with no second player is recycled after its idle countdown, so share the code and join promptly.
What was fixed (bugs surfaced during verification)
- All four SQLite services crash-looped (
SQLITE_CANTOPEN). Each Dockerfile declaredVOLUME ["/app/data"]beforechown; Docker discards build changes to a volume path, so the data dir stayed root-owned and the non-root runtime user couldn't open its.db. โ create +chown/app/databefore theVOLUMEline. - Chat and presence were dead for a freshly-created player.
connectSocket()(which registers the chat/presence handlers) was only called on the page-reload auto-login path, never fromenterLobby(). โ call it fromenterLobby(), made idempotent. - Multiplayer was broken โ table codes churned and joins failed. The blackjack server called
broadcastQuip(...)in its join handler, but the function was undefined โ it crashed on every join; pm2 restarted it and the in-memory tables vanished. โ definedbroadcastQuipusing the file's own quip idiom.
What was stripped / changed from the family version (recipe minimalism)
- The real LAN addressing โ games hardcoded the home server's LAN hostname
(
http://<home-server>:<port>) โ โ subdomain-per-game behind one Caddy (*.arcade.localhost), derived fromlocation.host. - Family data โ real accounts, avatars, chat history, and saved games โ databases start empty; three
synthetic demo players (
demo_ace,demo_nova,demo_pixel) are seeded on boot. - The external avatar service โ the original proxied a private avatar generator (and named DiceBear styles) โ a self-contained, deterministic geometric identicon generator (no network).
- Hardcoded secrets โ a shared
INTERNAL_API_KEYand twoJWT_SECRETs โ dev placeholders that all services share via Compose defaults. - Google Fonts (CDN
<link>/@import) โ system font stacks (fully offline). - Real music tracks โ one self-authored royalty-free default loop, seeded on boot.
- Two never-shipped games (
brainrot,fortnite_2d) trimmed from the launch/validation lists.
Provenance (no data, no model, no GPU)
Nothing is trained and nothing is downloaded at runtime. Avatars are generated locally (a hashed 5ร5 symmetric identicon); the background-music loop is synthesized by the included Python script. No datasets, no weights, no external services.