Skip to content
~nguyen-huu-trung
← Back to projects

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

Browser

Babylon.js client

PWA, touch, adaptive quality

AudioWorklet

16kHz → 4-bit ADPCM

Protocol

Protobuf / WebSocket

one schema, generated Go + TS

Go server

room loop 20Hz

fully authoritative

interest index

128m chunks, 90m radius

bot runner

fills rooms, stores nothing

Data

PostgreSQL

sqlc, migrations on boot

shared/data

shared balance JSON

Operations

Caddy

automatic HTTPS

Docker Compose

one VPS, one-command deploy

admin + analytics

D1/D7, funnels, config

The browser sends intent and draws the world; every rule lives in the Go server’s room loop.

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.