Personal project · Playable on the web
Deep-sea spearfishing — a multiplayer 3D game in the browser
A Go server owning every rule at a 20Hz tick, a Babylon.js client, one protobuf schema generating both sides — and voice chat riding that same WebSocket instead of WebRTC.
- Role
- Sole author — server, client, protocol, infrastructure
- Period
- 2026
- Stack
- GoWebSocketTypeScriptBabylon.jsVitePostgreSQLPWADocker
20Hz
server-authoritative simulation tick
100
players per room, tick under 50ms
~270KB
gzip for the 3D bundle, a 9KB lobby
64kbps
voice over WebSocket, no WebRTC
1
protobuf schema generating Go and TS
90m
interest radius — only what is visible
133
species across 5 rarity tiers
1
VPS running all of it, TLS automated
The problem
I wanted to settle a question for myself: can a real-time multiplayer 3D game run straight in a phone browser — no install, no app store, no WebRTC or TURN — and still fit on a small VPS?
Two constraints pull against each other. The server has to own every decision so that everyone in a room sees the same world and nobody can rewrite the outcome locally; at the same time it all has to stay smooth on 4G and on a mid-range phone, where neither bandwidth nor GPU has room to spare.
And one problem that is not technical at all: a multiplayer game with nobody in it is an empty sea. A newcomer opens it, sees no one, and closes the tab before finding out what the game is.
The approach
- 01
The server decides, the client only sends intent
A room is a Go loop running twenty times a second: fish, oxygen, collisions, whether a harpoon hit or missed — all computed on the server. The client sends intent, not outcomes. That turns performance into a number you have to hold: a benchmark of a full 100-player room spread across the map must stay under 50ms per tick, encoding and per-player delivery included, or the 20Hz budget is gone.
- 02
Send only what a player can actually see
The 1km² map is split into 128m chunks; on each send the server builds a per-chunk index and packs only what falls inside a 90m radius, drawing at most the 16 nearest players. The client likewise loads only the chunks around its camera, and the server simulates fish only in chunks that hold someone. A room nobody is watching drops to a sparser simulation rate — which is what makes always-on rooms nearly free.
- 03
One schema across two languages
The protocol is binary Protocol Buffers over WebSocket, generated for Go and TypeScript from one .proto file. No serialization is hand-written twice, so the wire format cannot drift between server and client — a new field is added in one place, and both sides learn about it at build time.
- 04
A seeded world, implemented twice, locked by a golden test
Terrain, rocks and wrecks are generated from a worldSeed, and the algorithm exists twice: in Go for simulation, in TypeScript for rendering. A small divergence means a player swims through a rock on one machine and is blocked on another. So the output is frozen into a shared golden file: change the algorithm and the Go tests must match it again — that file is the contract between the two languages.
- 05
Voice chat without WebRTC
The mic runs through an AudioWorklet, downsamples to 16kHz and is compressed with 4-bit IMA ADPCM to about 64kbps, then rides the WebSocket that is already open — no TURN, no extra ports, no third-party service. The server only bounds size, rate-limits and fans out to the room. When one listener’s queue congests, voice frames are dropped while snapshots are not, and the connection is never closed: choppy audio beats being thrown out of the room.
- 06
The phone is the primary device, not an edge case
The lobby weighs about 9KB and appears almost instantly, while the 3D bundle (Babylon.js, ~270KB gzip) downloads in parallel as the player picks a room. Graphics quality is chosen per device and steps down automatically when FPS drops below 26, remembering the reduced level for next time. Per-chunk props are merged into one or two vertex-coloured meshes, touch joysticks are the default interface in landscape, and the game installs to the home screen as a PWA.
- 07
Bots so the sea is never empty
The server keeps three public rooms open at all times and fills them with bots up to a target each room picks between 6 and 20, re-rolled every 12 minutes so the room list looks unevenly busy, the way a real one does. Bots hunt, surface to breathe, return to the boat when their bag is full and join the boss fight; when real players arrive the bots quietly withdraw. They hold no account and write nothing to the database — they solve the cold-start problem without polluting data or leaderboards.
- 08
Operated like a product, not a demo
The admin panel carries daily active players, D1/D7 return rates, the tutorial funnel, account and room management, shop pricing and event toggles — stored in the database and applied to every live room at once. Players’ JavaScript errors are shipped back to the server and logged alongside a warning when a room ticks slowly. Deployment is one command: Docker Compose brings up Caddy with automatic HTTPS, the Go server and PostgreSQL on a single VPS, with a nightly database backup.
Architecture
Babylon.js client
PWA, touch, adaptive quality
AudioWorklet
16kHz → 4-bit ADPCM
Protobuf / WebSocket
one schema, generated Go + TS
room loop 20Hz
fully authoritative
interest index
128m chunks, 90m radius
bot runner
fills rooms, stores nothing
PostgreSQL
sqlc, migrations on boot
shared/data
shared balance JSON
Caddy
automatic HTTPS
Docker Compose
one VPS, one-command deploy
admin + analytics
D1/D7, funnels, config
Outcome
- The game runs in production and is playable on a phone from a single link — no install, no store, no plugin.
- Built the way I usually work: specification first, AI-assisted implementation, then tightened with tests, golden files and a benchmark.
- A single room holds up to 100 players on a small VPS, because it only ever sends what is visible and slows its simulation when nobody is watching.
- Room-wide voice chat runs over the game’s own WebSocket — no added infrastructure, no added cost, and it drops audio frames rather than players when the network congests.
- There is enough game to actually play rather than a technical demo: 133 species across five rarity tiers, a day/night cycle, a night-time boss, delivery orders, quests, a weekly leaderboard and crews.