This document explains the high-level design of Pongo, how the pieces fit together, and provides links to the code for deep dives.
Pongo is a real-time multiplayer game built on a shared-code architecture: the game logic is written in Rust and compiled to WebAssembly, and the same code runs on the authoritative server (a Cloudflare Durable Object) and in the browser's offline VS-AI game. The server steps the simulation at 60Hz and broadcasts a state snapshot every third tick (20Hz); the client renders at display rate, interpolating between snapshots.
Source: docs/diagrams/system-overview.dot. All diagrams (and how they render) are in docs/diagrams/.
Note
Durable Objects run "at the edge", but each match runs in a single location — wherever Cloudflare first instantiates the DO, typically near whoever created the match. Players far from that location still see light-speed latency; region-aware placement is a possible future improvement (see docs/BACKLOG.md).
Pongo is small, but it leans on a consistent set of patterns. Knowing these explains most of the code, and new code should fit one of them rather than inventing a parallel mechanism.
- One deterministic core, two hosts. All gameplay lives in
Simulation::step— a pure, deterministic function (seeded RNG, fixed timestep, no I/O). It is run by the authoritative server (GameState::step) and the offline VS-AI game (LocalGame::step). The multiplayer client does not run it — it moves its own paddle locally and interpolates the opponent + ball from server snapshots. Never fork gameplay into a host; add it togame_coreso both stay identical. - Plain structs, not an ECS. The entity set is tiny and fixed (one
Ball, up to twoPaddles —components.rs), soSimulationholds them as plain fields. Behaviour lives in ordered systems (systems/); the pipeline order (ingest inputs → move ball → move paddles → collisions → scoring) is defined once, instep. - The
Simulationaggregate (parameter object). The entities (ball,paddles) plus the resources (Score, Events, NetQueue, GameRng, RespawnState) are bundled into oneSimulationwithnew(seed)andstep(&mut self). Each host embeds oneSimulationrather than threading the pieces individually. - Fixed timestep, host-owned accumulator.
Simulation::stepadvances exactly oneParams::FIXED_DTtick — the single source for the timestep. Hosts feed real elapsed time into their own accumulator and callstepthe right number of times (the server alarm; the client's offline-game loop), so physics is frame-rate independent and reproducible. - Command queue for input. Every input source — keyboard, touch, the AI, the network — funnels through
NetQueue::push_input(player_id, y)as an absolute target Y, andingest_inputsdrains it onto the matching paddle'starget_y. The simulation never knows where input came from. - Strongly-typed ids.
PlayerId(u8)(components.rs) names a side (PlayerId::LEFT/RIGHT) so it can't be confused with a score or tick. The domain (game_core and the server's match logic) usesPlayerId; the wire protocol staysu8, converting at the boundary. - Config over a constants layer.
Paramsholds theconsttuning values;Config(config.rs) is the cloneable runtime struct seeded from them and threaded through systems. Tune gameplay in one place.
- Authoritative server, local own-paddle + interpolation. The server owns the truth. The client applies its own paddle input locally for a zero-latency feel and interpolates the opponent + ball from 20Hz snapshots (
state.rs). There's no prediction/reconciliation layer — the client isn't authoritative over anything that affects the other player. - One snapshot DTO.
GameStateSnapshotis the single shape used both on the wire and for rendering; the client interpolates and exponentially smooths remote entities from it (state.rs). - Tagged-enum binary protocol, append-only.
proto::{C2S, S2C}arepostcard-serialised enums withto_bytes/from_byteshelpers. Postcard encodes the variant index positionally, so add new variants at the end to stay compatible with clients connected across a deploy. - FSM: logic in Rust, effects in JS. Valid states and transitions live in
fsm.rs; side effects (DOM, sockets, timers) live in the JS wrapper. Full detail in docs/STATE_MACHINE.md. - wasm-bindgen facade.
WasmClient(lib.rs) is the thin, JS-callable surface over the internalClient; JS holds no game state of its own.
- Modular vanilla front-end, no bundler.
index.htmlloads focused ES modules directly:script.js(the FSM driver) importswasm.js(WASM init + bindings),audio.js(Web Audio),overlays.js(DOM overlays), andinput.js(keyboard/touch/mouse). There is intentionally no front-end build step. - Installable PWA with an offline app shell. A service worker (
sw.js) precaches the shell so the menu and the offline VS-AI game load without a network;pwa.jsregisters it and shows an in-app "update available" prompt. The cache version is stamped per deploy at build time (scripts/stamp-sw.mjs) — that byte change is what the browser picks up as an update. Dynamic routes (/create,/ws/,/join/) are never cached.
- Ports & adapters for testability. The DO logic depends on two traits —
GameClient(a sendable socket) andEnvironment(time + logging) — not on concrete Workers types, sogame_state.rsis unit-tested natively withMockGameClient/MockEnv(tests.rs). Keep new server logic inGameState(testable), not in the thin#[durable_object]shell. - Interior mutability, borrow dropped before await. The DO holds
RefCell<GameState>; handlers mustdropthe borrow before any.await(see the alarm loop inlib.rs). - Identify sockets by attachment. Each socket is tagged with its player id via
serialize_attachment; the close/ping handlers recover it withdeserialize_attachment. Never guess the player from map order. - Fire-and-forget broadcast. Sends to clients ignore per-socket errors (
let _ = …send); a dead socket is reaped by the close handler, not by send failures.
The project is structured as a Cargo workspace with shared crates.
| Crate | Path | Description | Key Files |
|---|---|---|---|
| game_core | game_core/ |
The Heart. Shared struct-based simulation, physics, and config. | simulation.rs (Simulation::step)config.rs (constants) |
| client_wasm | client_wasm/ |
The Frontend. Interpolation and Canvas2D rendering. | lib.rs (entry)canvas2d.rs (renderer) |
| server_do | server_do/ |
The Backend. Durable Object implementation. | game_state.rs (server logic) |
| proto | proto/ |
The Glue. Network messages and serialization. | lib.rs (structs) |
| lobby_worker | lobby_worker/ |
The Lobby. HTTP routing, match-code generation, and the static front-end (vanilla ES modules + a service-worker PWA). Re-exports MatchDO. |
src/lib.rs (router)script.js (FSM driver) + wasm/audio/overlays/input/pwa.js modulessw.js, manifest.webmanifest, icons/ |
The worker/ directory holds the wasm-pack build output (pkg/, gitignored) plus index.js, the Worker entry shim that initialises the WASM and wires the Durable Object lifecycle.
Source: docs/diagrams/netcode-loop.dot.
- Browser captures key press in
on_key_down. - Client updates local paddle immediately.
- Client sends
C2S::Inputto server. - Server validates input (enforcing speed limits) and updates the paddle's target.
- Server includes new paddle position in next broadcast.
requestAnimationFramecallsrender.- In a local game, the simulation steps; in a match, remote entities are interpolated toward the latest snapshot.
Renderer::drawpaints the arena, paddles, and ball onto the canvas.
| Constant | Value | Unit |
|---|---|---|
| Arena | 32 × 24 | units |
| Paddle | 0.8 × 4.0 | units |
| Paddle speed | 18 | units/sec |
| Ball radius | 0.5 | units |
| Ball speed | 12 → 24 | units/sec |
| Speed multiplier | 1.05× | per hit |
| Win score | 5 | points |
Constants defined in game_core/src/config.rs
The authoritative definitions live in proto/src/lib.rs; variants are appended, never reordered (postcard encodes the variant index positionally).
Client → Server (C2S): Join { code } · Input { player_id, y, seq } · Ping { t_ms } · Restart
Server → Client (S2C): Welcome { player_id } · MatchFound · Countdown { seconds } · GameStart · GameState(GameStateSnapshot) · GameOver { winner } · OpponentDisconnected · Pong { t_ms } · OpponentReconnecting · OpponentReconnected
Entities: Paddle { player_id, y, target_y, velocity_y } · Ball { pos, vel }
Resources: Score · Events · NetQueue · GameRng · RespawnState
Systems: IngestInputs → MoveBall → MovePaddles → CheckCollisions → CheckScoring
- Walls: Reflect Y velocity
- Paddles: Reflect X velocity + spin from both hit position and the paddle's vertical motion
- Speed: +5% per hit, max 24 u/s

