Off the radar  /  №03  /  06.28.26

A server in the static arcade.

Every game in the arcade so far was single-player and purely static: a file the browser runs alone. Combat needs two people in the same fight at the same time, which means a server. Here is how I added one without giving up the rule.

The arcade inherits the rule the rest of this system runs on: everything is static. Asteroids and Lander are each one WebAssembly file the browser runs by itself, sitting on disk next to a page, served like a photo. No server, no session, no state that outlives your tab. That rule is the reason I can forget my own infrastructure.

Combat breaks it. It is the old Atari tank duel: two players, one walled arena, banking shots off the walls at each other in real time. Two people in the same fight need a single source of truth about where the tanks are and who just got hit, and that truth has to live somewhere neither player owns. That somewhere is a server, the first one this arcade has ever needed.

So the interesting work was not the game. It was adding a server without handing back the things the rule buys me.

The server is one file, and it is the whole game

I did not stand up a game server in the way that phrase usually means: a process I run, watch, patch, and pay for by the hour. I used a database you publish your game logic into. The tables, the twenty-times-a-second physics, the bot, the play-the-winner queue all live in a single Rust file that compiles to WebAssembly and gets pushed up with one command. The database runs it as the authority. There is no second program that is "the server"; the module is the server.

The client does not trust itself

Each browser just sends its input, up and down and fire, and draws whatever the database streams back. It never decides that it got a kill or moved through a wall; it asks, and the authority answers. That is the whole reason the server exists, so I refused to smear the rules across both sides. The client is the same flavor of WebAssembly as the other two cabinets, wearing the same CRT shader I carried over without changing a line of it.

I talk to it in its own language, by hand

The database speaks a plain websocket protocol with a text dialect, and there is an official kit for talking to it that wants a package manager and a build step I keep out of this house. I did not use it. I read the actual messages on the wire once, wrote the handful I needed by hand, and parsed the replies with what the browser already ships. Combat depends on exactly the three Rust crates Asteroids does. The dependency list did not grow to add multiplayer.

An idle cabinet spends nothing

The carousel runs each game live on the face of its cabinet, an attract loop playing on the little screen while you browse. For a networked game the naive version of that is a disaster: every passer-by silently opens a connection to the server just by scrolling past. So the preview is a lie, on purpose. It is a local bot-versus-bot duel computed entirely in your browser, touching nothing. The real server only wakes when a human presses start, and it puts its own physics loop back to sleep the instant the last tank leaves the room. A quiet arcade costs nothing.

The infrastructure, briefly

  • Server: one Rust module on SpacetimeDB. Tables, 20 Hz physics, the bot, the queue; compiled to WebAssembly, published with one command.
  • Client: Rust to the same WebAssembly the other cabinets use, the CRT shader carried over untouched.
  • Wire: the database's JSON websocket, hand-rolled. No SDK, no npm; the same three crates Asteroids leans on.
  • Toolchain: the SpacetimeDB binary pinned by version into the repo, like the site generator. Never a curl piped into a shell.
  • Hosting: the game is static files on a bucket behind a CDN, exactly like a photo. Only a live match reaches the server.
  • Invite: a room code in the page URL. The link is the invitation; add one word and the link only watches.
  • Bot: a single-player opponent driven by the server, not a second fake browser. Press B.
  • Cost when nobody plays: zero. No connection from the preview, and the loop halts itself on an empty room.

The honest part: Combat is the first thing in this arcade that can fall over on its own. A file cannot go down; a server can. So I kept the blast radius to one cabinet. If the database is unreachable the worst case is that one game says "waiting" while the other two, still just files, keep running like nothing happened. The dynamic behavior is a single lit island in water I keep calm by default, which was the whole point of leaving a labeled empty slot and trusting myself to fill it on purpose.

It is the third cabinet. Spin to Combat, send a friend the link, and let someone play the winner.