Hammerguy-s-Party

Online multiplayer: how it works and how to reuse it

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:


The big picture

                 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.

Joining: direct first, relay as a fallback

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

Why a relay

Getting the game state to players

Snapshots

Every tick, the host’s room calls game.tick(dt):

Compression: deltas against an acknowledged baseline (engine/shared/snapcodec.js)

Each guest connection in the host worker has a SnapEncoder, and each guest has a SnapDecoder.

Wire format: {t:'d', s, b?, d, e?, a?}. The top of snapcodec.js documents it in full.

The lossy channel (direct connections only)

Congestion

Smooth motion on the guest

Instant response: client-side prediction (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.

  1. Tag orders. The client tags each order with a sequence number q and remembers when it sent it.
  2. Send movement state. Each snapshot includes 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.
  3. Re-run the host’s movement. The client re-runs the host’s own movement code (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”.
  4. Blend out surprises. Anything the client can’t foresee arrives in the next snapshot, such as a knockback, a collision or a teleport. The difference is blended out over a few frames, or snapped if it’s more than 3 m.

Per game:

Tests (test/predict.test.js) check the forecast against the real host at 1, 50 and 120 ms one-way latency.


Settings reference

URL options (for testing; add to the game’s address)

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

Build-time environment variables (Vite)

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

Site files

The relay (site repo, relay/)

Constants

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


Making a new game multiplayer

  1. Copy 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.
  2. Write the game class. The room creates it with 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.
  3. Snapshot rules:
    • Give every entity in an array an id. It is matched by id, so an entity that moves costs a few bytes rather than being re-sent.
    • Round numbers (round2) so unchanged values stay identical and aren’t re-sent.
    • Put one-shot things in ev, never in the state. Only events are guaranteed to arrive; a lost snapshot is replaced by the next one.
    • Don’t use - as an object key: the codec uses it to mark removed keys.
  4. Add a game definition (game.js): { id, title, options, defaults, Game }.
  5. Add a worker entry (client/host-worker.js): runHostWorker(gameDef).
  6. 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.

  7. Optional: prediction. Put 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.
  8. Deploy the game into the site repo like the others:
    • add it to scripts/update-games.sh, run the script, and push;
    • it gets the relay automatically, since /relay.json is shared and the relay accepts any game prefix.

Testing locally

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

Troubleshooting

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