Night Nabbers is a free 3D stealth game that runs in a browser tab: you play a raccoon on a night street, sneak past guard dogs and searchlights, steal eggs and hatch odd little buddies. You can play solo against bots or with up to 16 players in the same street. There is no download and no sign-up. This post is a tour of how it is built and a few decisions that paid off. You can try it at nightnabbers.com while you read.
The stack
- TypeScript (strict) and Vite for the client
- three.js on WebGL2 for rendering, with a post-processing chain
- Preact and signals for the UI layer (menus, HUD, shop), kept out of the render loop
- glTF models, compressed with meshoptimizer, so the first load stays small on phones
- Cloudflare Workers and Durable Objects for online rooms, D1 for persistence
- 12 languages, with the strings loaded per locale
One simulation, two places
The most useful decision was to write the game rules once. A deterministic simulation that runs at a fixed 30 Hz step lives in a shared/ folder. The browser runs it for single player and for client-side prediction. The server runs the very same code inside a Durable Object for online rooms.
That gives you three things at once:
- Single player works with no server at all.
- Prediction is exact, because the client and the server step the same function with the same fixed timestep. When the server's answer arrives, there is rarely anything to correct.
- The server can re-validate every input. Clients are a view, not an authority.
Rooms on Durable Objects
Each street is a room. A Durable Object holds several streets and ticks them from a single loop at 10 Hz, with the simulation stepping three 30 Hz substeps per tick. Packing many streets into one object matters because an object that is ticking can never hibernate, and you are billed for its wall-clock time. When no street has a human for 30 seconds it is dropped, no timer is left pending, and the object is evicted and stops costing anything. A matchmaker object places players into streets, and a server-side check marks an object "hot" when its tick lag grows, so no new players are sent to it.
CPU turned out not to be the limit. One full street costs on the order of tens of microseconds per tick in Node, so the real levers are how many streets share an object and how often you wake it up.
A tiny binary protocol
Traffic is WebSocket frames with hand-packed bytes rather than JSON:
- A client input is 5 bytes: a message type, a 16-bit sequence number, a packed stick (16 direction sectors, 4 magnitude levels, the gait) and a flags byte. It is sent only when the quantised value changes, at most 10 times a second. Prediction uses exactly the bytes that were sent, which is what keeps it exact.
- Server updates are deltas, one per tick per client and only if something changed. Positions are quantised over the world size.
- Each client only receives entities within an area of interest (32 m, with a wider exit radius of 38 m so something walking along the edge does not flicker in and out).
- The street-wide part of a delta is encoded once per tick. Each client's message splices in the pre-encoded records it can see, so the per-client cost is a distance check and a memcpy. A fuzz test checks that the spliced output is byte-identical to the reference encoder.
The protocol has a version number, and a client that meets a different version simply stays offline instead of misreading frames.
Staying alive when things break
Rooms snapshot their state to storage, so a crashed or redeployed object rebuilds the street on the first reconnect. We killed the local worker process on purpose to measure it: players were back in a few seconds with their coins intact. Inputs are strictly decoded and rate limited, there is a per-address socket cap, and the tests throw 50 thousand random frames, every truncated server frame, and 16 simulated players mashing random input for two simulated minutes at the protocol. The rule is that a bad frame produces an error or a decode, never a hang.
What I would do again
- Keep the simulation free of any platform types, so it runs in the browser, in Node tests and in a Worker unchanged.
- Spend effort on bytes early, because bandwidth and object time are what the bill is made of.
- Test the failure paths on purpose: restore from a snapshot, protocol fuzzing, long soak runs in real browsers.
Honest note on how it was made
The game is made by a small team with the help of AI tools. Some 3D models and early promotional art were generated with AI tools and then edited, rigged and tuned by hand. Code and text were written with AI assistants under human direction and review. All sound and music is synthesised by the game's own code, and every screenshot and video of the game is a real capture.
Try it
Free in the browser, desktop or phone, no account: nightnabbers.com. Trailer: youtu.be/SnSh6b3PVSs. Press kit: nightnabbers.com/press. Questions about the netcode or the tooling are welcome in the comments.
Top comments (0)