How Multiplayer Works
A deep dive into FangDash multiplayer - PartyKit race rooms, seeded deterministic obstacles, host migration, and HMAC connection tokens
TL;DR — Real-time racing runs on a PartyKit WebSocket server. A single shared seed makes every player generate the identical obstacle course locally, so no server-side game simulation is needed. Ghost wolves update live over the socket, and a short-lived HMAC token authorizes the connection.
FangDash multiplayer connects up to 4 players per race over WebSockets, powered by PartyKit. The server manages the room and relays events — it never simulates the game world. This page walks through the architecture end to end.
Architecture
| Component | Role |
|---|---|
PartyKit RaceServer | Manages one race room: connections, lobby state, countdown, and results |
| WebSocket connection | Each player holds a persistent connection to their race room |
| Connection token | A short-lived HMAC token proves the player is signed in |
| Seeded PRNG | A shared seed makes all clients generate the same obstacle course |
Each race room lives in its own RaceServer instance. The web client opens a socket to that room, the server tracks who is connected and their live progress, and it broadcasts events (countdown ticks, position updates, deaths, results) to everyone in the room.
Joining a race: connection tokens
The web client can't read the Better Auth session cookie (it's httpOnly), so it has no credential to hand PartyKit directly. Instead the API mints a short-lived token bound to your user.
Mint — The client calls race.getConnectionToken on the API, which returns an HMAC-SHA256
token with a 120-second lifetime, signed with RACE_TOKEN_SECRET.
Connect — The client opens the race socket and forwards the token as the ?token= query
parameter.
Verify — The PartyKit server verifies the signature with the same shared secret. No round-trip back to the API is needed, and a leaked token expires within minutes.
The token is a compact, JWT-like string: base64url(payload).base64url(hmac), where the payload carries the user id and an expiry. Signature checks run in constant time to avoid timing leaks, and any malformed, tampered, or expired token is rejected.
Room lifecycle
A room moves through four states. Its status drives what the server accepts at each moment.
| Status | What's happening |
|---|---|
waiting | The lobby. Players join (up to 4) and mark themselves ready |
countdown | The host started a 3-second countdown; late joins are blocked |
racing | The shared seed is broadcast and everyone runs the same course |
finished | All players have died; results are sorted and sent |
Lobby — The first player to join becomes the host. New players join while the room is
waiting, up to the 4-player cap.
Countdown — Once at least two players are in the room, the host readies up to trigger a 3-second countdown. If players leave and drop the room below two, the countdown aborts and everyone returns to the lobby.
Racing — When the countdown finishes, the server generates the seed and broadcasts a
race_start. Every client generates the same obstacles and runs. Position updates are
throttled, then relayed to the room.
Results — When the last surviving player dies, the race ends. The server ranks players and broadcasts the final placements.
Host migration
The host has a few privileges: starting the countdown, kicking a lobby player, and calling a rematch. If the host disconnects, the server promotes the next remaining player to host and broadcasts a host_changed event. If the host was the only player left, the room resets to waiting.
Results and placement
When the race ends, players are sorted by score, descending. Ties break on death time — the player who survived longer ranks higher. Each player is assigned a placement (1st, 2nd, 3rd, …), which the API turns into bonus XP for signed-in players. See Levels & XP for the exact bonuses.
Ghost players
Other racers appear as semi-transparent ghost wolves on your screen. Their positions come from player_update messages broadcast over the socket, so you see rivals running, jumping, and wiping out in real time. Ghosts are purely visual — they never affect your own physics or obstacles.
Deterministic obstacles (seeded play)
FangDash uses a seeded PRNG so every player in a race faces the exact same obstacle course — without the server ever simulating the game world.
How it works
In a typical multiplayer game, a central server runs the simulation and streams world state to every client. FangDash takes a different approach: deterministic generation from a shared seed. Instead of syncing obstacle positions over the network, each client generates them independently — and because they all start from the same seed, they all produce the identical sequence.
Step by step
- Seed creation — When the countdown completes, the
RaceServergenerates a random seed withcrypto.randomUUID()(e.g."a3f1b2c4-...") - Broadcast — The server sends a
race_startmessage carrying the seed to every connected player - Local generation — Each client creates a
SeededRandominstance from that seed. The seed string is hashed with djb2 into a 32-bit integer, which initializes the PRNG state - Obstacle spawning — As the run progresses, the spawner calls
rng.next(),rng.between(), andrng.pick()to decide obstacle type, position, and spacing. Because every client calls these methods in the same order from the same initial state, every client sees the same obstacles in the same places - Rematch — On a rematch the host resets the room and a fresh
crypto.randomUUID()seed is generated, so every race has a unique layout
The algorithm: mulberry32
The PRNG is a mulberry32 generator — a fast, 32-bit algorithm that produces well-distributed numbers. It lives in packages/shared/src/seeded-random.ts and exposes:
| Method | Returns | Used for |
|---|---|---|
next() | Float in [0, 1) | Raw random value |
between(min, max) | Float in [min, max) | Obstacle spacing, positions |
intBetween(min, max) | Integer in [min, max] | Selecting obstacle variants |
pick(array) | Random element | Choosing obstacle types |
The seed is turned into the initial 32-bit state by the djb2 string hash (starting from 5381), so any UUID string maps deterministically to one starting point.
Solo mode
Solo play uses the same seeded system — only the seed source changes:
| Mode | Seed source | When generated |
|---|---|---|
| Multiplayer | PartyKit server via crypto.randomUUID() | Once per race, broadcast to all players |
| Solo | Client-side via crypto.randomUUID() | Once per run (a new seed on each restart) |
Both modes run through the same SeededRandom code path — multiplayer seeds are shared, solo seeds are local.
Why this matters
- Fair races — Everyone faces identical obstacles, so skill is the only differentiator
- Consistent code path — Solo and multiplayer share
SeededRandom, reducing mode-specific bugs - No server simulation — The server only manages connections and relays events, which keeps latency low and hosting costs minimal
- Reproducible — A race can be reconstructed from its seed alone, useful for replays and score checks
- Bandwidth-efficient — A single UUID synchronizes the entire obstacle course across every player
Multiplayer Overview
Race up to 4 wolves in real-time FangDash multiplayer on a shared seeded course, with live rival ghosts, a 3-second countdown, and bonus placement XP
Twitch Integration
Sign in with Twitch to save your scores and race your chat in FangDash, and see exactly what Twitch data is stored and what stays private