This is the multiplayer stack shared by the Teng Games titles (Arcane Arena and Hammerguy’s Party). Everything described here lives in engine/, which each game repo carries its own copy of. A new game gets all of it by copying engine/ and following Making a new game multiplayer below.
It covers:
tenggames.com.au (GitHub Pages: static files only)
├── /<game>/ the game (HTML + JS)
└── /relay.json {"url":"wss://tenggames-relay.….workers.dev"}
HOST's browser GUEST's browser
┌──────────────────────────────┐ ┌────────────────────────┐
│ page: rendering, input │ │ page: rendering, input │
│ net.js P2PHost │ │ net.js P2PClient │
│ Web Worker: room + game sim │◄── WebRTC ─────►│ SnapDecoder │
│ (hub.js, room.js, game) │ (direct/TURN) │ Predictor │
│ SnapEncoder per guest │ └────────────────────────┘
└──────────────┬───────────────┘ ▲
│ WebSocket (fallback) │ WebSocket
▼ │
Cloudflare Worker "tenggames-relay" ────────────────┘
(one Durable Object per game code; forwards, never reads)
PeerJS cloud server (0.peerjs.com): only introduces peers for WebRTC.
{t:'cmd', …}) and draw what the host tells them.engine/server/room.js and hub.js run unchanged in the host’s worker and in the optional Node server (server.js, for development or self-hosting).| Step | What happens | Where |
|---|---|---|
| 1 | The host’s page starts the worker, and registers with PeerJS as <prefix>-<CODE>, e.g. tenggames-arcane-arena-ABCD. At the same time it opens a WebSocket to the relay at wss://<relay>/<prefix>/<CODE>?role=host. The room opens as soon as either is ready. |
P2PHost.boot() |
| 2 | A guest with the code dials the host over WebRTC through PeerJS. ICE tries a direct path (STUN), then TURN relays. | P2PClient.dialDirect() |
| 3 | If the data channel hasn’t opened within 5 s (DIRECT_MS), or PeerJS says the peer doesn’t exist or can’t be reached, the guest switches to the relay: wss://<relay>/<prefix>/<CODE>?role=guest. |
P2PClient.useRelay() |
| 4 | Both paths end in the same place: the guest sends {t:'join', code, name, token}, and the host’s room replies welcome then lobby. |
hub.js, room.js |
localStorage (tenggames-token), and a refresh rejoins automatically. The same token gets the same seat back, even mid-game.hostLeft, which comes from relay close code 4410 or from the data channel closing and no redial succeeding.Every tick, the host’s room calls game.tick(dt):
TICK_RATE in engine/shared/constants.js).SNAPSHOT_EVERY (2) ticks, the room builds each player’s snapshot with game.snapshot(playerId).tk (the tick number) and ev (the one-shot events for that player).conn.fast).engine/shared/snapcodec.js)Each guest connection in the host worker has a SnapEncoder, and each guest has a SnapDecoder.
{id,…} objects are matched by id;Measured on real bot games:
| Game | Full snapshot | Delta |
|---|---|---|
| Arcane Arena | ~2.4 KB | ~70–220 B |
| Hammerguy’s Party | ~1.4 KB | ~230–400 B |
ackOnSend).a<seq> message. The host builds only on acknowledged snapshots.snap.ev) are never diffed. Each gets a sequence number and is repeated in every snapshot until one carrying it is known to have arrived. The decoder drops repeats, so every event is delivered exactly once, in order, even when snapshots are lost.Wire format: {t:'d', s, b?, d, e?, a?}. The top of snapcodec.js documents it in full.
createDataChannel('snap', { negotiated: true, id: 7, ordered: false, maxRetransmits: 0 }).BACKLOG), the host page tells the worker that the connection is busy.World.pushSnapshot).engine/client/predict.js)The player’s own hero is drawn where it will be once their orders reach the host, instead of a round trip later.
q and remembers when it sent it.me.pr, the hero’s exact movement state, with:
sq, the newest order the host has handled;sk in Hammerguy’s Party, the tick that order first took part in.stepUnits from engine/server/sim.js) from that state. It replays the orders the host hasn’t handled yet at the moments they’ll arrive, up to “now plus one round trip”.Per game:
this.predict = false.Tests (test/predict.test.js) check the forecast against the real host at 1, 50 and 120 ms one-way latency.
| Option | Effect |
|—|—|
| ?room=ABCD | Pre-fills the join code. This is what invite links use. |
| ?net=relay | Skip the direct attempt and always use the relay (host and guests). |
| ?relay=wss://… | Use a different relay. ?relay=off turns the relay off. |
| ?transport=ws | Use the optional Node server at /ws instead of peer-to-peer. |
| ?touch=1 / ?touch=0 | Force phone controls on or off for this browser session. |
| Variable | Default | Effect |
|—|—|—|
| VITE_RELAY_URL | (none) | Relay address when /relay.json isn’t available. |
| VITE_ICE_SERVERS | (none) | JSON array of extra RTCIceServers (your own TURN), tried first. |
| VITE_PEER_HOST, VITE_PEER_PORT, VITE_PEER_PATH, VITE_PEER_SECURE | PeerJS cloud | Use your own PeerJS server, e.g. npx peerjs --port 9000 for local tests. |
| VITE_TRANSPORT | p2p | ws builds a site that uses the Node server. |
/relay.json at the site root holds {"url":"wss://…"}.
/relay.json just uses direct connections.relay/)tenggames-relay, with one Durable Object (Room) per <game>/<CODE>. Its protocol is documented at the top of relay/src/index.js.+id / -id as guests join and leave, and id|message for what they send.[[id, message], …] batches (one frame per tick for all guests), or x|id to disconnect a guest.~ is a heartbeat, answered without waking the Durable Object.ALLOWED_ORIGINS in relay/wrangler.toml lists which sites may use it: tenggames.com.au, jtstacky.github.io and localhost.CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, then the Deploy multiplayer relay workflow. The workflow also runs whenever relay/ changes.| Constant | Value | Where | Meaning |
|—|—|—|—|
| TICK_RATE | 33⅓ / 30 | engine/shared/constants.js | Simulation ticks per second (Arcane / Party). |
| SNAPSHOT_EVERY | 2 | same | Ticks per network snapshot (~16 per second). |
| MAX_PLAYERS | 10 | same | Room size. |
| DIRECT_MS | 5000 | engine/client/net.js | How long a direct connection gets before the relay is tried. |
| DIAL_TIMEOUT_MS, DIAL_ATTEMPTS | 15000, 3 | same | Direct-only retries, used when there’s no relay. |
| RELAY_OPEN_MS | 12000 | same | Relay connection timeout. |
| HEARTBEAT_MS | 25000 | same | Relay keep-alive. |
| BACKLOG | 65536 | same | Send buffer bytes before snapshots are skipped. |
| LOSSY_ID | 7 | same | Data channel id of the snapshot channel. |
| DEFAULT_ICE_SERVERS | Google/Cloudflare STUN, PeerJS + OpenRelay TURN | same | How WebRTC finds a path. |
| MAX_AGE, HISTORY | 100, 128 | engine/shared/snapcodec.js | Oldest usable baseline; decoder history. |
| MAX_STEPS, SNAP_DIST, BLEND | 24, 3 m, 12/s | engine/client/predict.js | Prediction horizon; jump threshold; correction blend speed. |
| INTERP_DELAY / DELAY_MIN…MAX | 110 ms / 40–250 ms | engine/client/render/world.js | Interpolation delay (Arcane fixed / Party adaptive). |
engine/ from either game repo; Hammerguy’s Party’s copy is the more general one. Keep engine/shared/snapcodec.js, engine/client/net.js, host-worker-core.js, server/hub.js and server/room.js as they are.new Game(room, settings) and calls:
tick(dt): advance the simulation.snapshot(playerId): return a plain JSON object. Follow the rules below.command(playerId, m): a player’s order (m.t === 'cmd').events: an array the room empties each snapshot. Push one-shot things here (sounds, explosions, messages), each { k: 'kind', …, to?: playerId }.over: set true when the match ends.mapInfo: { v, … }; bump v to broadcast a new map.mode: a name sent to clients with start.id. It is matched by id, so an entity that moves costs a few bytes rather than being re-sent.round2) so unchanged values stay identical and aren’t re-sent.ev, never in the state. Only events are guaranteed to arrive; a lost snapshot is replaced by the next one.- as an object key: the codec uses it to mark removed keys.game.js): { id, title, options, defaults, Game }.client/host-worker.js): runHostWorker(gameDef).Start the client:
startApp({
…,
p2p: {
prefix: 'tenggames-<game-id>',
createWorker: () => new Worker(new URL('./host-worker.js', import.meta.url), { type: 'module' }),
},
});
The prefix must be unique per game, since it namespaces game codes on PeerJS and the relay.
me: { uid, pr } in each snapshot, where pr is the hero’s movement state plus sq (and sk); see predictState in either game. Use Hammerguy’s Party’s predict.js as the template.scripts/update-games.sh, run the script, and push;/relay.json is shared and the relay accepts any game prefix.npm test # unit + multiplayer tests (incl. snapcodec, predict)
npx peerjs --port 9000 # a local PeerJS server (npm i -g peer)
VITE_PEER_HOST=localhost VITE_PEER_PORT=9000 VITE_PEER_SECURE=false npx vite build
cd ../Chess-tutor/relay && npx wrangler dev --port 8787 --var ALLOWED_ORIGINS:'*'
# serve dist/ plus a relay.json of {"url":"ws://127.0.0.1:8787"}; open two windows
?net=relay to force the relay path.--force-webrtc-ip-handling-policy=disable_non_proxied_udp can’t connect directly, which simulates a strict network and exercises the automatic fallback.| Symptom | Likely cause |
|—|—|
| Stuck on “Connecting to the host…” | Direct connection blocked and no relay. Check /relay.json loads and the relay’s /health answers. |
| “Could not reach the matchmaking server” | Both PeerJS and the relay are unreachable from the host (offline, or a firewall). |
| Console: “The relay does not accept this page’s address” | The page’s origin is missing from ALLOWED_ORIGINS in relay/wrangler.toml. |
| Guests lag only when the host is on a phone | The host’s upload or CPU. Snapshots are now small, but the host still sends one per guest. |
| Own hero jitters against walls | The walls aren’t mirrored in prediction. Use clampToRect/clampToCircle (which record the arena) or add them to predict.js. |