diff --git a/.agent/notes/games/game-examples-issues.md b/.agent/notes/games/game-examples-issues.md new file mode 100644 index 0000000000..a54a6c26f1 --- /dev/null +++ b/.agent/notes/games/game-examples-issues.md @@ -0,0 +1,175 @@ +# Game Examples: Issues Encountered + +Last updated: 2026-02-13 + +This is a running log of issues we hit while iterating on the `examples/game-*` projects, plus any context needed to reproduce and fix them. + +## Runtime Issues + +- `examples/game-fps-arena`: blank/black screen when joining match. + - Symptom: UI renders, tick increments, but viewport stays blank and only shows "click to lock". + - Fix applied: ensure the app root fills the viewport (`#root { height: 100% }`) and fix menu/game layering so the WebGL canvas is visible. + +- `examples/game-fps-arena`: input axes mismatch. + - Symptom: forward/backwards inverted, yaw (left/right look) inverted, pitch OK. + - Fix applied: correct forward/right vectors and add a client/server yaw convention conversion. + +- `examples/game-fps-arena`: jerky camera rotation and movement. + - Symptom: client looks jittery as if server corrections are fighting the client. + - Likely causes to revisit: + - too-aggressive server authority without client prediction/smoothing + - sending inputs at a low rate without interpolation + - applying server snapshots directly to the local player without reconciliation + +- `examples/game-fps-arena`: holding space causes repeated hopping. + - Symptom: player auto-hops repeatedly when space is held. + - Fix applied: edge-trigger jump on keydown while grounded, and send `jump` to the server only once per press. + +- `examples/game-fps-arena`: bullets/tracers initially too small and POV-originated. + - Symptom: tracer is hard to see; visually originates from camera center rather than the gun. + - Fix applied: render tracer with a blocky head + long semi-transparent streak, and spawn the visual tracer from a gun muzzle offset from the camera. + - Note: server hit validation remains eye-based hitscan, while visuals originate at the muzzle for game feel. + +- `examples/game-fps-arena`: other players' jumps not visible. + - Symptom: local jump could be client-only; remote players never appear airborne. + - Fix applied: add server-side jump velocity/gravity state and replicate. + +- `examples/game-fps-arena`: obstacle collisions not working. + - Symptom: player passes through boxes or collision differs between client and server. + - Fix applied: shared obstacle layout and shared circle-vs-AABB resolution logic used by both client and server. + +- `examples/game-fps-arena`: map visuals were box/texture-based and did not match the new grid-based collision world. + - Symptom: world was still rendered as colored cubes with prototype textures, while server/client physics had moved to a tile/grid map. + - Fix applied: render the arena using Kenney `prototype-kit` GLB tiles (floors, walls, thick platform tiles, narrow stairs, doorway indicators) driven by `WORLD_GRID`/`WORLD_RAMPS`. + - Notes: + - Prototype textures are no longer used for this example. + - Keep a small negative-Y base plane under the tiles to avoid gaps and z-fighting. + +- `examples/game-fps-arena`: deterministic tests got flaky when the map introduced a raised mid platform. + - Symptom: vitest hitscan test could fail or time out depending on spawn/obstacles (straight-line move could get blocked by the mid platform). + - Fix applied: prefer a set of known-good lane spawn points in `buildSpawn`, keeping the first two spawns on the same flat lane for deterministic hitscan. + +- `examples/game-fps-arena`: single-shot only firing felt wrong for an arena shooter. + - Symptom: holding mouse button did not keep firing. + - Fix applied: client treats the weapon as automatic and repeatedly calls `fire` while LMB is held. The server stays authoritative and rate limits. + +- `examples/game-fps-arena`: remote players appeared half buried in the floor and blaster models were not visible. + - Symptom: other players looked like they were inside the ground; first-person gun stayed as the fallback box/cylinder or was invisible. + - Fix applied: + - offset the capsule fallback avatar so feet sit on y=0 (CapsuleGeometry is centered by default). + - add the camera to the scene graph so the gun (attached to the camera) renders. + - prefer a larger blaster model from Kenney `blaster-kit` (`blaster-r.glb`) for the first-person gun. + - Follow-up fix applied: + - replicate per-player avatar selection via `PlayerPublicState.avatar` and load the correct Kenney blocky character model per remote player. + - attach a third-person blaster model to remote avatars (best-effort mount) so other players visibly hold a weapon. + - add a lightweight walk cycle (limb swing when moving) and head pitch look for models with separate limb nodes. + +- `examples/game-fps-arena`: prototype-kit tiles overlapped and clipped into each other. + - Symptom: some floor pieces or indicators appeared to clip into the floor, especially around ramps/platforms. + - Fix applied: do not place the thin floor tile under platforms or ramps, and lift doorway indicators slightly above the floor. + +- `examples/game-fps-arena`: remote yaw interpolation flips and player facing mismatch. + - Symptom: remote players flip when crossing a critical angle, and some models appear to face backwards. + - Fix applied: rotate Kenney blocky character instances by `PI` to match the example's `-Z` forward convention and interpolate yaw with wrapped angle math (not linear lerp). + +- `examples/game-fps-arena`: stepping off stairs onto adjacent platforms felt blocked. + - Symptom: players can go partially up stairs but cannot smoothly walk onto the raised platform next to the stairs. + - Fix applied: allow a small step-up onto platform surfaces during `resolveObstaclesXZ` when within `STEP_HEIGHT`. + +- `examples/game-fps-arena`: world sometimes appeared empty due to GLB URLs with spaces. + - Symptom: tile world and/or gun model fails to load, leaving a mostly empty scene. + - Fix applied: + - rename Kenney asset folders at download time to remove spaces (e.g. `Models/GLB format` -> `Models/glb`) to avoid static server path decoding issues. + - update runtime paths to use the normalized folder names. + - keep `encodeURI` in `GLTFLoader` URLs as an extra guard. + - Verified: `/tmp/fps-prefab-world.png` + +- `examples/game-fps-arena`: geometry loaded lazily, causing jarring transitions and silent placeholder fallbacks. + - Symptom: menu could show before assets were ready; if GLBs failed to load, the world/gun could silently fall back or appear empty. + - Fix applied: preload required Kenney geometry (prototype-kit tiles + blaster + blocky character) on initial page load, show a blocking loading screen before the menu, and show a fatal error screen if any geometry fails to load. + - Verified: `/tmp/fps-loading-screen.png`, `/tmp/fps-after-loading-menu.png` + +- `examples/game-idle-base`: crashes at runtime on this machine with Node `v24.13.0`. + - Symptom: actor startup fails with `RuntimeError: memory access out of bounds` originating from `wa-sqlite` (`wa-sqlite-async.mjs`). + - Status: still repros as of 2026-02-13. UI loads briefly, then server-side runtime crashes. + - Suspect: Node 24 + WASM sqlite runtime incompatibility/regression. + +- `examples/game-mmo-chunks`: runtime crash on this machine with Node `v24.13.0`. + - Symptom: `Error: Failed to save actor state: BareError: (byte:0) too large buffer`. + - Status: repro’d during `pnpm dev` session. + - Suspect: actor state serialization exceeding limits, or a bug in the driver/storage layer. + +- `examples/game-fps-arena`: dev server crash due to trace storage state growth. + - Symptom: Vite dev session exits with `Failed to save actor state: BareError: (byte:0) too large buffer` originating from `ActorTracesDriver.set`. + - Fix applied: use `createMemoryDriver()` in `src/actors/index.ts` so long dev sessions do not persist large trace KV blobs to disk. + +- `examples/game-fps-arena`: add combat feedback and scoring semantics. + - Change applied: + - award `+10` points per kill (server authoritative) and broadcast a `kill` event to drive UI feedback. + - slow visual tracers so bullets read at short range even though shots are hitscan. + - add bullet-hole decals on world impacts when a shot does not hit a player. + - add hitmarker (`X`) on hit, hurt vignette when the local player is hit, and killfeed/toast messages. + - add Kenney audio (`sci-fi-sounds`) for shooting and getting hit, and use UI pack click sounds for UI interactions. + - Files: + - `examples/game-fps-arena/src/actors/match.ts` + - `examples/game-fps-arena/src/types.ts` + - `examples/game-fps-arena/frontend/App.tsx` + - `examples/game-fps-arena/frontend/game/ThreeFpsView.tsx` + - `examples/game-fps-arena/scripts/assets/download-assets.mjs` + +- `examples/game-fps-arena`: page scroll + GC hitches during extended play. + - Symptom: the page could scroll (unwanted for a fullscreen game) and the client could hitch after sustained firing. + - Fix applied: + - force `overflow: hidden` on `html, body, #root` to prevent scrollbars from appearing. + - replace per-shot tracer mesh/material allocations with a bounded tracer pool and reuse bullet-hole decals with a ring buffer to avoid constant allocation/disposal churn. + - stop deep-cloning resources for the "prop museum" and lane props by using static clones that share geometry/materials, reducing startup memory and GC pressure. + - Files: + - `examples/game-fps-arena/frontend/App.css` + - `examples/game-fps-arena/frontend/game/ThreeFpsView.tsx` + +- Various examples: RivetKit runtime warnings during dev. + - Vite warning: dynamic imports in rivetkit dist cannot be analyzed (`vite:import-analysis`). + - Log noise: `subscription does not exist in persist` warnings. + - `baseline-browser-mapping` warns the mapping data is out of date. + - These did not block the UI checks but they add noise and may hide real issues. + +- `examples/game-fps-arena`: `unhandled actor start promise rejection` about missing actor name `town`. + - Symptom: log says `no actor in registry for name town`. + - Likely cause: stale persisted actor instance from another example or key collision across examples sharing a driver/data dir. + +## Typecheck Issues + +- `examples/game-idle-base`: TypeScript errors. + - Symptom: `.then(setTop)` where the value was inferred as `Promise`, plus `unknown` DB row typing from `c.db.execute`. + - Fix applied: `await` the call and add safe row decoding/casting. + +- `examples/game-io-arena`: TypeScript errors. + - Symptom: `c.state` inferred as `unknown` inside actor actions; missing/insufficient typings for `d3-quadtree`; `.then(setRoomId)` mismatch. + - Fix applied: add explicit `RoomState` return typing on `createState`, add minimal `d3-quadtree` module declaration, and `await`/narrow connections in frontend code. + +## UI/UX Issues + +- Menu alignment and background misalignment complaints (notably in `game-fps-arena`). + - Fix applied: center menu panels; avoid overlapping layers; improve backdrop/scanline effect; ensure Kenney UI Pack texture is used as a subtle motif instead of a misaligned overlay. + +- UI requirements applied across game examples: + - Use Kenney UI Pack for buttons/controls. + - Avoid monospaced fonts in UI chrome. + - Avoid normal casing: UI chrome moved to `text-transform: uppercase`. + +## Agent-Browser Verification Artifacts + +Screenshots captured while verifying UI layouts: + +- `examples/game-fps-arena`: `/tmp/game-fps-arena-ui.png` +- `examples/game-fps-arena` (post world/model update): `/tmp/fps-menu-updated.png` +- `examples/game-fps-arena` (in match, post world/model update): `/tmp/fps-in-match.png` +- `examples/game-fps-arena` (prototype-kit tiles): `/tmp/fps-tiles-menu.png` +- `examples/game-fps-arena` (in match, prototype-kit tiles): `/tmp/fps-tiles-in-match.png` +- `examples/game-fps-arena` (world fallback + encoded GLB URLs): `/tmp/fps-world-not-empty.png` +- `examples/game-idle-base`: `/tmp/game-idle-base-ui.png` +- `examples/game-io-arena`: `/tmp/game-io-arena-ui.png` +- `examples/game-mmo-chunks`: `/tmp/game-mmo-chunks-ui.png` +- `examples/game-npc-town-ai`: `/tmp/game-npc-town-ai-ui.png` +- `examples/game-party-cah`: `/tmp/game-party-cah-ui.png` +- `examples/game-tic-tac-toe`: `/tmp/game-tic-tac-toe-ui.png` diff --git a/examples/ai-agent-vercel/src/actors.ts b/examples/ai-agent-vercel/src/actors.ts index 81bd367d5b..7f925f7c0e 100644 --- a/examples/ai-agent-vercel/src/actors.ts +++ b/examples/ai-agent-vercel/src/actors.ts @@ -102,7 +102,7 @@ export const agent = actor({ let content = ""; for await (const delta of result.textStream) { - if (c.abortSignal.aborted) { + if (c.aborted) { break; } diff --git a/examples/ai-agent/src/actors.ts b/examples/ai-agent/src/actors.ts index 81bd367d5b..7f925f7c0e 100644 --- a/examples/ai-agent/src/actors.ts +++ b/examples/ai-agent/src/actors.ts @@ -102,7 +102,7 @@ export const agent = actor({ let content = ""; for await (const delta of result.textStream) { - if (c.abortSignal.aborted) { + if (c.aborted) { break; } diff --git a/examples/experimental-durable-streams-ai-agent-vercel/src/actors.ts b/examples/experimental-durable-streams-ai-agent-vercel/src/actors.ts index b81f018fa3..4ccf4acc38 100644 --- a/examples/experimental-durable-streams-ai-agent-vercel/src/actors.ts +++ b/examples/experimental-durable-streams-ai-agent-vercel/src/actors.ts @@ -69,7 +69,7 @@ async function consumeStream(c: ActorContextOf) { offset: chunk.offset, }); - if (c.abortSignal.aborted) break; + if (c.aborted) break; c.state.promptStreamOffset = chunk.offset; @@ -129,9 +129,9 @@ async function consumeStream(c: ActorContextOf) { c.log.error({ msg: "error in consumeStream", error, - aborted: c.abortSignal.aborted, + aborted: c.aborted, }); - if (!c.abortSignal.aborted) { + if (!c.aborted) { c.log.error({ msg: "error consuming prompts", error }); } } diff --git a/examples/experimental-durable-streams-ai-agent/src/actors.ts b/examples/experimental-durable-streams-ai-agent/src/actors.ts index b81f018fa3..4ccf4acc38 100644 --- a/examples/experimental-durable-streams-ai-agent/src/actors.ts +++ b/examples/experimental-durable-streams-ai-agent/src/actors.ts @@ -69,7 +69,7 @@ async function consumeStream(c: ActorContextOf) { offset: chunk.offset, }); - if (c.abortSignal.aborted) break; + if (c.aborted) break; c.state.promptStreamOffset = chunk.offset; @@ -129,9 +129,9 @@ async function consumeStream(c: ActorContextOf) { c.log.error({ msg: "error in consumeStream", error, - aborted: c.abortSignal.aborted, + aborted: c.aborted, }); - if (!c.abortSignal.aborted) { + if (!c.aborted) { c.log.error({ msg: "error consuming prompts", error }); } } diff --git a/examples/multiplayer-game-patterns/README.md b/examples/multiplayer-game-patterns/README.md new file mode 100644 index 0000000000..14de78007d --- /dev/null +++ b/examples/multiplayer-game-patterns/README.md @@ -0,0 +1,33 @@ +# Matchmaking And Session Patterns + +Example project demonstrating seven multiplayer game Rivet Actor scaffolds: + +- io-style (open lobby, 10 tps) +- competitive (filled room, mode + team assignment, 20 tps) +- party (host start + party code, no tick loop) +- async turn-based (invite + open pool, no tick loop) +- open world (chunk index + chunk actors, 10 tps chunk loop) +- ranked (ELO queueing, 20 tps) +- battle royale (queue threshold start, 10 tps) + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/multiplayer-game-patterns +pnpm install +pnpm dev +``` + +## What This Example Covers + +- `src/actors//matchmaker.ts`: SQLite-backed matchmaking coordinator +- `src/actors//match.ts`: Match scaffold with actions/events/lifecycle and optional tick loop +- `src/actors/open-world/world-index.ts`: Coordinator that resolves world positions into chunk actor keys +- `src/actors/open-world/chunk.ts`: Chunk actor keyed by `[worldId, chunkX, chunkY]` for sharded world state +- `frontend/App.tsx`: Simple React UI that runs scripted matchmaking demos +- `tests/matchmaking-and-session-patterns.test.ts`: Golden-path unit tests with simulated multi-player flows + +## License + +MIT diff --git a/examples/multiplayer-game-patterns/frontend/App.tsx b/examples/multiplayer-game-patterns/frontend/App.tsx new file mode 100644 index 0000000000..fbdd68f452 --- /dev/null +++ b/examples/multiplayer-game-patterns/frontend/App.tsx @@ -0,0 +1,346 @@ +import { useEffect, useMemo, useState } from "react"; +import { createClient } from "rivetkit/client"; +import type { registry } from "../src/actors/index.ts"; + +type DemoType = + | "io-style" + | "competitive" + | "party" + | "async-turn-based" + | "ranked" + | "battle-royale"; + +type DemoState = { + running: boolean; + logs: string[]; +}; + +const DEMOS: Array<{ type: DemoType; title: string; description: string }> = [ + { + type: "io-style", + title: "io-style", + description: "Open lobby matchmaking and a 10 tps room scaffold.", + }, + { + type: "competitive", + title: "competitive", + description: "Filled-room queue with mode selection and team assignment at 20 tps.", + }, + { + type: "party", + title: "party", + description: "Host-created party code flow with no tick loop.", + }, + { + type: "async-turn-based", + title: "async turn-based", + description: "Invite and open pool pairing with turn-based state transitions.", + }, + { + type: "ranked", + title: "ranked", + description: "ELO-based matchmaking at 20 tps with post-match rating updates.", + }, + { + type: "battle-royale", + title: "battle royale", + description: "Queue-threshold start with a 10 tps battle royale match scaffold.", + }, +]; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor( + fn: () => Promise, + timeoutMs = 3000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = await fn(); + if (value != null) return value; + await sleep(25); + } + throw new Error("timed out waiting for value"); +} + +async function disposeAll(items: Array<{ dispose: () => Promise } | undefined>) { + await Promise.all( + items + .filter((item): item is { dispose: () => Promise } => Boolean(item)) + .map((item) => item.dispose().catch(() => undefined)), + ); +} + +function nowTime() { + return new Date().toLocaleTimeString(); +} + +export function App() { + const client = useMemo( + () => + createClient({ + endpoint: `${window.location.origin}/api/rivet`, + namespace: "default", + runnerName: "default", + }), + [], + ); + + const [states, setStates] = useState>({ + "io-style": { running: false, logs: [] }, + competitive: { running: false, logs: [] }, + party: { running: false, logs: [] }, + "async-turn-based": { running: false, logs: [] }, + ranked: { running: false, logs: [] }, + "battle-royale": { running: false, logs: [] }, + }); + + useEffect(() => { + return () => { + void client.dispose(); + }; + }, [client]); + + const appendLog = (type: DemoType, message: string) => { + setStates((prev) => { + const existing = prev[type]; + const nextLogs = [`${nowTime()} - ${message}`, ...existing.logs].slice(0, 8); + return { ...prev, [type]: { ...existing, logs: nextLogs } }; + }); + }; + + const setRunning = (type: DemoType, running: boolean) => { + setStates((prev) => ({ ...prev, [type]: { ...prev[type], running } })); + }; + + const runDemo = async (type: DemoType) => { + setRunning(type, true); + setStates((prev) => ({ ...prev, [type]: { ...prev[type], logs: [] } })); + + try { + if (type === "io-style") { + const mm = client.ioStyleMatchmaker.getOrCreate(["main"]); + const firstPlayerId = `io-a-${Date.now()}`; + const secondPlayerId = `io-b-${Date.now()}`; + await mm.queue.findOpenLobby.send({ playerId: firstPlayerId }); + await mm.queue.findOpenLobby.send({ playerId: secondPlayerId }); + const first = await waitFor(() => mm.getLobbyForPlayer({ playerId: firstPlayerId })); + const second = await waitFor(() => + mm.getLobbyForPlayer({ playerId: secondPlayerId }), + ); + appendLog(type, `matchmaker resolved room ${first.matchId}`); + + const a = client.ioStyleMatch + .getOrCreate([first.matchId], { params: { playerToken: first.playerToken } }) + .connect(); + const b = client.ioStyleMatch + .getOrCreate([first.matchId], { params: { playerToken: second.playerToken } }) + .connect(); + await sleep(250); + + const snapshot = await a.getSnapshot(); + appendLog(type, `room is ${snapshot.phase} at tick ${snapshot.tick} with ${snapshot.playerCount} players`); + await disposeAll([a, b]); + } + + if (type === "competitive") { + const mm = client.competitiveMatchmaker.getOrCreate(["main"]); + const players = ["comp-a", "comp-b", "comp-c", "comp-d"]; + for (const playerId of players) { + await mm.queue.queueForMatch.send({ playerId, mode: "duo" }); + appendLog(type, `${playerId} queued`); + } + + const assigned = await waitFor(() => mm.getAssignment({ playerId: players[0] })); + appendLog(type, `filled match ${assigned.matchId} formed for mode ${assigned.mode}`); + const assignments = await Promise.all( + players.map((playerId) => waitFor(() => mm.getAssignment({ playerId }))), + ); + const playerTokenByPlayerId = new Map( + assignments + .filter( + (entry): entry is NonNullable<(typeof assignments)[number]> => + entry != null, + ) + .map((entry) => [entry.playerId, entry.playerToken]), + ); + + const conns = players.map((playerId) => + client.competitiveMatch + .getOrCreate([assigned.matchId], { + params: { playerToken: playerTokenByPlayerId.get(playerId)! }, + }) + .connect(), + ); + + await sleep(120); + const live = await conns[0]!.getSnapshot(); + appendLog(type, `match is ${live.phase} at tick ${live.tick}`); + await conns[0]!.finish({ winnerTeam: 0 }); + appendLog(type, "match finished and assignment cleaned up"); + await disposeAll([...conns]); + } + + if (type === "party") { + const mm = client.partyMatchmaker.getOrCreate(["main"]); + const hostPlayerId = "party-host"; + await mm.queue.createParty.send({ hostPlayerId }); + const created = await waitFor(() => mm.getPartyForHost({ hostPlayerId })); + appendLog(type, `created party code ${created.partyCode}`); + + await mm.queue.joinParty.send({ partyCode: created.partyCode, playerId: "party-guest" }); + const joined = await waitFor(() => + mm.getJoinByPlayer({ partyCode: created.partyCode, playerId: "party-guest" }), + ); + const host = client.partyMatch + .getOrCreate([created.matchId], { + params: { playerToken: created.hostPlayerToken }, + }) + .connect(); + const guest = client.partyMatch + .getOrCreate([created.matchId], { params: { playerToken: joined.playerToken } }) + .connect(); + const match = host; + await match.start(); + const snapshot = await match.getSnapshot(); + appendLog(type, `party phase is ${snapshot.phase}`); + await disposeAll([match, guest]); + } + + if (type === "async-turn-based") { + const mm = client.asyncTurnBasedMatchmaker.getOrCreate(["main"]); + const inviteCode = `invite-${Date.now()}`; + await mm.queue.createInvite.send({ + inviteCode, + fromPlayerId: "turn-a", + toPlayerId: "turn-b", + }); + await mm.queue.acceptInvite.send({ inviteCode, playerId: "turn-b" }); + const accepted = await waitFor(() => mm.getAssignment({ playerId: "turn-b" })); + const turnA = await waitFor(() => mm.getAssignment({ playerId: "turn-a" })); + + appendLog(type, `invite accepted into ${accepted.matchId}`); + const a = client.asyncTurnBasedMatch + .getOrCreate([accepted.matchId], { params: { playerToken: turnA.playerToken } }) + .connect(); + const b = client.asyncTurnBasedMatch + .getOrCreate([accepted.matchId], { params: { playerToken: accepted.playerToken } }) + .connect(); + await a.submitTurn({ move: "open" }); + await b.submitTurn({ move: "reply" }); + await a.finish({ winnerPlayerId: "turn-a" }); + appendLog(type, "turn sequence completed and match closed"); + await disposeAll([a, b]); + } + + if (type === "ranked") { + const mm = client.rankedMatchmaker.getOrCreate(["main"]); + await mm.queue.debugSetRating.send({ playerId: "rank-a", elo: 1200 }); + await mm.queue.debugSetRating.send({ playerId: "rank-b", elo: 1210 }); + await mm.queue.queueForMatch.send({ playerId: "rank-a" }); + await mm.queue.queueForMatch.send({ playerId: "rank-b" }); + const assigned = await waitFor(() => mm.getAssignment({ playerId: "rank-a" })); + const assignedB = await waitFor(() => mm.getAssignment({ playerId: "rank-b" })); + + const a = client.rankedMatch + .getOrCreate([assigned.matchId], { params: { playerToken: assigned.playerToken } }) + .connect(); + const b = client.rankedMatch + .getOrCreate([assigned.matchId], { params: { playerToken: assignedB.playerToken } }) + .connect(); + await sleep(120); + await a.finish({ winnerPlayerId: "rank-a" }); + const aRating = await mm.getRating({ playerId: "rank-a" }); + const bRating = await mm.getRating({ playerId: "rank-b" }); + appendLog(type, `updated ratings -> rank-a: ${aRating.elo}, rank-b: ${bRating.elo}`); + await disposeAll([a, b]); + } + + if (type === "battle-royale") { + const mm = client.battleRoyaleMatchmaker.getOrCreate(["main"]); + await mm.queue.joinQueue.send({ playerId: "br-a" }); + await mm.queue.joinQueue.send({ playerId: "br-b" }); + await mm.queue.joinQueue.send({ playerId: "br-c" }); + const assigned = await waitFor(() => mm.getAssignment({ playerId: "br-a" })); + const assignedB = await waitFor(() => mm.getAssignment({ playerId: "br-b" })); + const assignedC = await waitFor(() => mm.getAssignment({ playerId: "br-c" })); + + appendLog(type, `battle royale match ${assigned.matchId} created`); + const a = client.battleRoyaleMatch + .getOrCreate([assigned.matchId], { params: { playerToken: assigned.playerToken } }) + .connect(); + const b = client.battleRoyaleMatch + .getOrCreate([assigned.matchId], { params: { playerToken: assignedB.playerToken } }) + .connect(); + const cConn = client.battleRoyaleMatch + .getOrCreate([assigned.matchId], { params: { playerToken: assignedC.playerToken } }) + .connect(); + await a.startNow(); + await sleep(220); + const live = await a.getSnapshot(); + appendLog(type, `active tick ${live.tick}, zone radius ${live.zoneRadius.toFixed(2)}`); + await a.eliminate({ victimPlayerId: "br-b" }); + await a.eliminate({ victimPlayerId: "br-c" }); + const final = await a.getSnapshot(); + appendLog(type, `winner: ${final.winnerPlayerId}`); + await disposeAll([a, b, cConn]); + } + } catch (err) { + appendLog(type, `error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setRunning(type, false); + } + }; + + return ( +
+

Matchmaking and Session Patterns

+

+ This UI runs scripted golden-path demos for each matchmaking pattern. It does not render gameplay. +

+ +
+ {DEMOS.map((demo) => { + const state = states[demo.type]; + return ( +
+
+
+

{demo.title}

+

{demo.description}

+
+ +
+ +
+ {state.logs.length === 0 ? ( +

No run yet.

+ ) : ( +
    + {state.logs.map((line) => ( +
  • + {line} +
  • + ))} +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/examples/multiplayer-game-patterns/frontend/main.tsx b/examples/multiplayer-game-patterns/frontend/main.tsx new file mode 100644 index 0000000000..79b2bcc927 --- /dev/null +++ b/examples/multiplayer-game-patterns/frontend/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/multiplayer-game-patterns/index.html b/examples/multiplayer-game-patterns/index.html new file mode 100644 index 0000000000..bd7144b060 --- /dev/null +++ b/examples/multiplayer-game-patterns/index.html @@ -0,0 +1,12 @@ + + + + + + Matchmaking Scaffolds + + +
+ + + diff --git a/examples/multiplayer-game-patterns/package.json b/examples/multiplayer-game-patterns/package.json new file mode 100644 index 0000000000..2b2306177b --- /dev/null +++ b/examples/multiplayer-game-patterns/package.json @@ -0,0 +1,46 @@ +{ + "name": "example-multiplayer-game-patterns", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "vite build && vite build --mode server", + "start": "srvx --static=public/ dist/server.js" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vite-plugin-srvx": "^1.0.2", + "vitest": "^3.1.1" + }, + "dependencies": { + "@hono/node-server": "^1.19.7", + "@hono/node-ws": "^1.3.0", + "hono": "^4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "rivetkit": "*", + "srvx": "^0.10.0" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": [ + "react", + "typescript" + ], + "tags": [ + "real-time", + "game" + ], + "frontendPort": 5173, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/multiplayer-game-patterns/public/assets/index-9bY3DXxE.js b/examples/multiplayer-game-patterns/public/assets/index-9bY3DXxE.js new file mode 100644 index 0000000000..d705c3d926 --- /dev/null +++ b/examples/multiplayer-game-patterns/public/assets/index-9bY3DXxE.js @@ -0,0 +1,90 @@ +var EO=Object.defineProperty;var kh=e=>{throw TypeError(e)};var kO=(e,t,n)=>t in e?EO(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var vn=(e,t,n)=>kO(e,typeof t!="symbol"?t+"":t,n),os=(e,t,n)=>t.has(e)||kh("Cannot "+n);var p=(e,t,n)=>(os(e,t,"read from private field"),n?n.call(e):t.get(e)),ie=(e,t,n)=>t.has(e)?kh("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),re=(e,t,n,i)=>(os(e,t,"write to private field"),i?i.call(e,n):t.set(e,n),n),de=(e,t,n)=>(os(e,t,"access private method"),n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const a of r)if(a.type==="childList")for(const o of a.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const a={};return r.integrity&&(a.integrity=r.integrity),r.referrerPolicy&&(a.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?a.credentials="include":r.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function i(r){if(r.ep)return;r.ep=!0;const a=n(r);fetch(r.href,a)}})();function yc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Qp={exports:{}},bc={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var TO=Symbol.for("react.transitional.element"),UO=Symbol.for("react.fragment");function Jp(e,t,n){var i=null;if(n!==void 0&&(i=""+n),t.key!==void 0&&(i=""+t.key),"key"in t){n={};for(var r in t)r!=="key"&&(n[r]=t[r])}else n=t;return t=n.ref,{$$typeof:TO,type:e,key:i,ref:t!==void 0?t:null,props:n}}bc.Fragment=UO;bc.jsx=Jp;bc.jsxs=Jp;Qp.exports=bc;var ht=Qp.exports,Fp={exports:{}},W={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var kd=Symbol.for("react.transitional.element"),xO=Symbol.for("react.portal"),AO=Symbol.for("react.fragment"),NO=Symbol.for("react.strict_mode"),jO=Symbol.for("react.profiler"),IO=Symbol.for("react.consumer"),DO=Symbol.for("react.context"),RO=Symbol.for("react.forward_ref"),MO=Symbol.for("react.suspense"),CO=Symbol.for("react.memo"),Wp=Symbol.for("react.lazy"),Th=Symbol.iterator;function ZO(e){return e===null||typeof e!="object"?null:(e=Th&&e[Th]||e["@@iterator"],typeof e=="function"?e:null)}var ey={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ty=Object.assign,ny={};function Za(e,t,n){this.props=e,this.context=t,this.refs=ny,this.updater=n||ey}Za.prototype.isReactComponent={};Za.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};Za.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ry(){}ry.prototype=Za.prototype;function Td(e,t,n){this.props=e,this.context=t,this.refs=ny,this.updater=n||ey}var Ud=Td.prototype=new ry;Ud.constructor=Td;ty(Ud,Za.prototype);Ud.isPureReactComponent=!0;var Uh=Array.isArray,Ie={H:null,A:null,T:null,S:null,V:null},iy=Object.prototype.hasOwnProperty;function xd(e,t,n,i,r,a){return n=a.ref,{$$typeof:kd,type:e,key:t,ref:n!==void 0?n:null,props:a}}function LO(e,t){return xd(e.type,t,void 0,void 0,void 0,e.props)}function Ad(e){return typeof e=="object"&&e!==null&&e.$$typeof===kd}function BO(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var xh=/\/+/g;function us(e,t){return typeof e=="object"&&e!==null&&e.key!=null?BO(""+e.key):t.toString(36)}function Ah(){}function HO(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch(typeof e.status=="string"?e.then(Ah,Ah):(e.status="pending",e.then(function(t){e.status==="pending"&&(e.status="fulfilled",e.value=t)},function(t){e.status==="pending"&&(e.status="rejected",e.reason=t)})),e.status){case"fulfilled":return e.value;case"rejected":throw e.reason}}throw e}function Zi(e,t,n,i,r){var a=typeof e;(a==="undefined"||a==="boolean")&&(e=null);var o=!1;if(e===null)o=!0;else switch(a){case"bigint":case"string":case"number":o=!0;break;case"object":switch(e.$$typeof){case kd:case xO:o=!0;break;case Wp:return o=e._init,Zi(o(e._payload),t,n,i,r)}}if(o)return r=r(e),o=i===""?"."+us(e,0):i,Uh(r)?(n="",o!=null&&(n=o.replace(xh,"$&/")+"/"),Zi(r,t,n,"",function(c){return c})):r!=null&&(Ad(r)&&(r=LO(r,n+(r.key==null||e&&e.key===r.key?"":(""+r.key).replace(xh,"$&/")+"/")+o)),t.push(r)),1;o=0;var u=i===""?".":i+":";if(Uh(e))for(var l=0;l>>1,q=O[L];if(0>>1;Lr(Ni,M))Jrr(Zu,Ni)?(O[L]=Zu,O[Jr]=M,L=Jr):(O[L]=Ni,O[te]=M,L=te);else if(Jrr(Zu,M))O[L]=Zu,O[Jr]=M,L=Jr;else break e}}return z}function r(O,z){var M=O.sortIndex-z.sortIndex;return M!==0?M:O.id-z.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,u=o.now();e.unstable_now=function(){return o.now()-u}}var l=[],c=[],s=1,f=null,m=3,v=!1,S=!1,T=!1,k=!1,d=typeof setTimeout=="function"?setTimeout:null,h=typeof clearTimeout=="function"?clearTimeout:null,g=typeof setImmediate<"u"?setImmediate:null;function b(O){for(var z=n(c);z!==null;){if(z.callback===null)i(c);else if(z.startTime<=O)i(c),z.sortIndex=z.expirationTime,t(l,z);else break;z=n(c)}}function E(O){if(T=!1,b(O),!S)if(n(l)!==null)S=!0,D||(D=!0,hn());else{var z=n(c);z!==null&&N(E,z.startTime-O)}}var D=!1,I=-1,C=5,be=-1;function J(){return k?!0:!(e.unstable_now()-beO&&J());){var L=f.callback;if(typeof L=="function"){f.callback=null,m=f.priorityLevel;var q=L(f.expirationTime<=O);if(O=e.unstable_now(),typeof q=="function"){f.callback=q,b(O),z=!0;break t}f===n(l)&&i(l),b(O)}else i(l);f=n(l)}if(f!==null)z=!0;else{var Q=n(c);Q!==null&&N(E,Q.startTime-O),z=!1}}break e}finally{f=null,m=M,v=!1}z=void 0}}finally{z?hn():D=!1}}}var hn;if(typeof g=="function")hn=function(){g(yt)};else if(typeof MessageChannel<"u"){var w=new MessageChannel,B=w.port2;w.port1.onmessage=yt,hn=function(){B.postMessage(null)}}else hn=function(){d(yt,0)};function N(O,z){I=d(function(){O(e.unstable_now())},z)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(O){O.callback=null},e.unstable_forceFrameRate=function(O){0>O||125L?(O.sortIndex=M,t(c,O),n(l)===null&&O===n(c)&&(T?(h(I),I=-1):T=!0,N(E,M-L))):(O.sortIndex=q,t(l,O),S||v||(S=!0,D||(D=!0,hn()))),O},e.unstable_shouldYield=J,e.unstable_wrapCallback=function(O){var z=m;return function(){var M=m;m=z;try{return O.apply(this,arguments)}finally{m=M}}}})(uy);oy.exports=uy;var KO=oy.exports,ly={exports:{}},Tt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var YO=na;function cy(e){var t="https://react.dev/errors/"+e;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(sy)}catch(e){console.error(e)}}sy(),ly.exports=Tt;var QO=ly.exports;/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var tt=KO,fy=na,JO=QO;function x(e){var t="https://react.dev/errors/"+e;if(1Vi||(e.current=af[Vi],af[Vi]=null,Vi--)}function De(e,t){Vi++,af[Vi]=e.current,e.current=t}var Rn=Zn(null),No=Zn(null),Ir=Zn(null),Al=Zn(null);function Nl(e,t){switch(De(Ir,t),De(No,e),De(Rn,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Zv(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Zv(t),e=j0(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}ct(Rn),De(Rn,e)}function Sa(){ct(Rn),ct(No),ct(Ir)}function of(e){e.memoizedState!==null&&De(Al,e);var t=Rn.current,n=j0(t,e.type);t!==n&&(De(No,e),De(Rn,n))}function jl(e){No.current===e&&(ct(Rn),ct(No)),Al.current===e&&(ct(Al),Ho._currentValue=li)}var uf=Object.prototype.hasOwnProperty,Id=tt.unstable_scheduleCallback,ls=tt.unstable_cancelCallback,rz=tt.unstable_shouldYield,iz=tt.unstable_requestPaint,Mn=tt.unstable_now,az=tt.unstable_getCurrentPriorityLevel,py=tt.unstable_ImmediatePriority,yy=tt.unstable_UserBlockingPriority,Il=tt.unstable_NormalPriority,oz=tt.unstable_LowPriority,by=tt.unstable_IdlePriority,uz=tt.log,lz=tt.unstable_setDisableYieldValue,cu=null,qt=null;function Ur(e){if(typeof uz=="function"&&lz(e),qt&&typeof qt.setStrictMode=="function")try{qt.setStrictMode(cu,e)}catch{}}var Vt=Math.clz32?Math.clz32:fz,cz=Math.log,sz=Math.LN2;function fz(e){return e>>>=0,e===0?32:31-(cz(e)/sz|0)|0}var Hu=256,qu=4194304;function ei(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function wc(e,t,n){var i=e.pendingLanes;if(i===0)return 0;var r=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var u=i&134217727;return u!==0?(i=u&~a,i!==0?r=ei(i):(o&=u,o!==0?r=ei(o):n||(n=u&~e,n!==0&&(r=ei(n))))):(u=i&~a,u!==0?r=ei(u):o!==0?r=ei(o):n||(n=i&~e,n!==0&&(r=ei(n)))),r===0?0:t!==0&&t!==r&&!(t&a)&&(a=r&-r,n=t&-t,a>=n||a===32&&(n&4194048)!==0)?t:r}function su(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function dz(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function _y(){var e=Hu;return Hu<<=1,!(Hu&4194048)&&(Hu=256),e}function Sy(){var e=qu;return qu<<=1,!(qu&62914560)&&(qu=4194304),e}function cs(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function fu(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function mz(e,t,n,i,r,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var u=e.entanglements,l=e.expirationTimes,c=e.hiddenUpdates;for(n=o&~n;0)":-1r||l[i]!==c[r]){var s=` +`+l[i].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=r);break}}}finally{fs=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?Li(n):""}function bz(e){switch(e.tag){case 26:case 27:case 5:return Li(e.type);case 16:return Li("Lazy");case 13:return Li("Suspense");case 19:return Li("SuspenseList");case 0:case 15:return ds(e.type,!1);case 11:return ds(e.type.render,!1);case 1:return ds(e.type,!0);case 31:return Li("Activity");default:return""}}function Zh(e){try{var t="";do t+=bz(e),e=e.return;while(e);return t}catch(n){return` +Error generating stack: `+n.message+` +`+n.stack}}function tn(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ky(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function _z(e){var t=ky(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),i=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var r=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return r.call(this)},set:function(o){i=""+o,a.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return i},setValue:function(o){i=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Dl(e){e._valueTracker||(e._valueTracker=_z(e))}function Ty(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),i="";return e&&(i=ky(e)?e.checked?"true":"false":e.value),e=i,e!==n?(t.setValue(e),!0):!1}function Rl(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Sz=/[\n"\\]/g;function un(e){return e.replace(Sz,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function cf(e,t,n,i,r,a,o,u){e.name="",o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?e.type=o:e.removeAttribute("type"),t!=null?o==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+tn(t)):e.value!==""+tn(t)&&(e.value=""+tn(t)):o!=="submit"&&o!=="reset"||e.removeAttribute("value"),t!=null?sf(e,o,tn(t)):n!=null?sf(e,o,tn(n)):i!=null&&e.removeAttribute("value"),r==null&&a!=null&&(e.defaultChecked=!!a),r!=null&&(e.checked=r&&typeof r!="function"&&typeof r!="symbol"),u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"?e.name=""+tn(u):e.removeAttribute("name")}function Uy(e,t,n,i,r,a,o,u){if(a!=null&&typeof a!="function"&&typeof a!="symbol"&&typeof a!="boolean"&&(e.type=a),t!=null||n!=null){if(!(a!=="submit"&&a!=="reset"||t!=null))return;n=n!=null?""+tn(n):"",t=t!=null?""+tn(t):n,u||t===e.value||(e.value=t),e.defaultValue=t}i=i??r,i=typeof i!="function"&&typeof i!="symbol"&&!!i,e.checked=u?e.checked:!!i,e.defaultChecked=!!i,o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"&&(e.name=o)}function sf(e,t,n){t==="number"&&Rl(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function ia(e,t,n,i){if(e=e.options,t){t={};for(var r=0;r"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),df=!1;if(ir)try{var Pa={};Object.defineProperty(Pa,"passive",{get:function(){df=!0}}),window.addEventListener("test",Pa,Pa),window.removeEventListener("test",Pa,Pa)}catch{df=!1}var xr=null,Ld=null,gl=null;function Iy(){if(gl)return gl;var e,t=Ld,n=t.length,i,r="value"in xr?xr.value:xr.textContent,a=r.length;for(e=0;e=yo),Kh=" ",Yh=!1;function Ry(e,t){switch(e){case"keyup":return Pz.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function My(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Yi=!1;function Jz(e,t){switch(e){case"compositionend":return My(t);case"keypress":return t.which!==32?null:(Yh=!0,Kh);case"textInput":return e=t.data,e===Kh&&Yh?null:e;default:return null}}function Fz(e,t){if(Yi)return e==="compositionend"||!Hd&&Ry(e,t)?(e=Iy(),gl=Ld=xr=null,Yi=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=i}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Fh(n)}}function By(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?By(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Hy(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Rl(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Rl(e.document)}return t}function qd(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var o4=ir&&"documentMode"in document&&11>=document.documentMode,Xi=null,mf=null,_o=null,gf=!1;function ev(e,t,n){var i=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;gf||Xi==null||Xi!==Rl(i)||(i=Xi,"selectionStart"in i&&qd(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),_o&&Do(_o,i)||(_o=i,i=ec(mf,"onSelect"),0>=o,r-=o,Jn=1<<32-Vt(t)+r|n<a?a:8;var o=K.T,u={};K.T=u,sm(e,!1,t,n);try{var l=r(),c=K.S;if(c!==null&&c(u,l),l!==null&&typeof l=="object"&&typeof l.then=="function"){var s=h4(l,i);Eo(e,t,s,Gt(e))}else Eo(e,t,i,Gt(e))}catch(f){Eo(e,t,{then:function(){},status:"rejected",reason:f},Gt())}finally{ye.p=a,K.T=o}}function _4(){}function kf(e,t,n,i){if(e.tag!==5)throw Error(x(476));var r=kb(e).queue;Eb(e,r,t,li,n===null?_4:function(){return Tb(e),n(i)})}function kb(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:li,baseState:li,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ar,lastRenderedState:li},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ar,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Tb(e){var t=kb(e).next.queue;Eo(e,t,{},Gt())}function cm(){return pt(Ho)}function Ub(){return Xe().memoizedState}function xb(){return Xe().memoizedState}function S4(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Gt();e=Dr(n);var i=Rr(t,e,n);i!==null&&(Kt(i,t,n),$o(i,t,n)),t={cache:Pd()},e.payload=t;return}t=t.return}}function w4(e,t,n){var i=Gt();n={lane:i,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null},Ac(e)?Nb(t,n):(n=Gd(e,t,n,i),n!==null&&(Kt(n,e,i),jb(n,t,i)))}function Ab(e,t,n){var i=Gt();Eo(e,t,n,i)}function Eo(e,t,n,i){var r={lane:i,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ac(e))Nb(t,r);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,u=a(o,n);if(r.hasEagerState=!0,r.eagerState=u,Yt(u,o))return kc(e,t,r,0),Te===null&&Ec(),!1}catch{}finally{}if(n=Gd(e,t,r,i),n!==null)return Kt(n,e,i),jb(n,t,i),!0}return!1}function sm(e,t,n,i){if(i={lane:2,revertLane:ym(),action:i,hasEagerState:!1,eagerState:null,next:null},Ac(e)){if(t)throw Error(x(479))}else t=Gd(e,n,i,2),t!==null&&Kt(t,e,2)}function Ac(e){var t=e.alternate;return e===ne||t!==null&&t===ne}function Nb(e,t){ua=Hl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function jb(e,t,n){if(n&4194048){var i=t.lanes;i&=e.pendingLanes,n|=i,t.lanes=n,$y(e,n)}}var Vl={readContext:pt,use:Uc,useCallback:qe,useContext:qe,useEffect:qe,useImperativeHandle:qe,useLayoutEffect:qe,useInsertionEffect:qe,useMemo:qe,useReducer:qe,useRef:qe,useState:qe,useDebugValue:qe,useDeferredValue:qe,useTransition:qe,useSyncExternalStore:qe,useId:qe,useHostTransitionStatus:qe,useFormState:qe,useActionState:qe,useOptimistic:qe,useMemoCache:qe,useCacheRefresh:qe},Ib={readContext:pt,use:Uc,useCallback:function(e,t){return At().memoizedState=[e,t===void 0?null:t],e},useContext:pt,useEffect:vv,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,bl(4194308,4,Sb.bind(null,t,e),n)},useLayoutEffect:function(e,t){return bl(4194308,4,e,t)},useInsertionEffect:function(e,t){bl(4,2,e,t)},useMemo:function(e,t){var n=At();t=t===void 0?null:t;var i=e();if(_i){Ur(!0);try{e()}finally{Ur(!1)}}return n.memoizedState=[i,t],i},useReducer:function(e,t,n){var i=At();if(n!==void 0){var r=n(t);if(_i){Ur(!0);try{n(t)}finally{Ur(!1)}}}else r=t;return i.memoizedState=i.baseState=r,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:r},i.queue=e,e=e.dispatch=w4.bind(null,ne,e),[i.memoizedState,e]},useRef:function(e){var t=At();return e={current:e},t.memoizedState=e},useState:function(e){e=zf(e);var t=e.queue,n=Ab.bind(null,ne,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:um,useDeferredValue:function(e,t){var n=At();return lm(n,e,t)},useTransition:function(){var e=zf(!1);return e=Eb.bind(null,ne,e.queue,!0,!1),At().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var i=ne,r=At();if(pe){if(n===void 0)throw Error(x(407));n=n()}else{if(n=t(),Te===null)throw Error(x(349));me&124||ub(i,t,n)}r.memoizedState=n;var a={value:n,getSnapshot:t};return r.queue=a,vv(cb.bind(null,i,a,e),[e]),i.flags|=2048,Ea(9,xc(),lb.bind(null,i,a,n,t),null),n},useId:function(){var e=At(),t=Te.identifierPrefix;if(pe){var n=Fn,i=Jn;n=(i&~(1<<32-Vt(i)-1)).toString(32)+n,t="«"+t+"R"+n,n=ql++,0C?(be=I,I=null):be=I.sibling;var J=m(d,I,g[C],b);if(J===null){I===null&&(I=be);break}e&&I&&J.alternate===null&&t(d,I),h=a(J,h,C),D===null?E=J:D.sibling=J,D=J,I=be}if(C===g.length)return n(d,I),pe&&ti(d,C),E;if(I===null){for(;CC?(be=I,I=null):be=I.sibling;var yt=m(d,I,J.value,b);if(yt===null){I===null&&(I=be);break}e&&I&&yt.alternate===null&&t(d,I),h=a(yt,h,C),D===null?E=yt:D.sibling=yt,D=yt,I=be}if(J.done)return n(d,I),pe&&ti(d,C),E;if(I===null){for(;!J.done;C++,J=g.next())J=f(d,J.value,b),J!==null&&(h=a(J,h,C),D===null?E=J:D.sibling=J,D=J);return pe&&ti(d,C),E}for(I=i(I);!J.done;C++,J=g.next())J=v(I,d,C,J.value,b),J!==null&&(e&&J.alternate!==null&&I.delete(J.key===null?C:J.key),h=a(J,h,C),D===null?E=J:D.sibling=J,D=J);return e&&I.forEach(function(hn){return t(d,hn)}),pe&&ti(d,C),E}function k(d,h,g,b){if(typeof g=="object"&&g!==null&&g.type===qi&&g.key===null&&(g=g.props.children),typeof g=="object"&&g!==null){switch(g.$$typeof){case Bu:e:{for(var E=g.key;h!==null;){if(h.key===E){if(E=g.type,E===qi){if(h.tag===7){n(d,h.sibling),b=r(h,g.props.children),b.return=d,d=b;break e}}else if(h.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===_r&&pv(E)===h.type){n(d,h.sibling),b=r(h,g.props),Fa(b,g),b.return=d,d=b;break e}n(d,h);break}else t(d,h);h=h.sibling}g.type===qi?(b=ci(g.props.children,d.mode,b,g.key),b.return=d,d=b):(b=vl(g.type,g.key,g.props,null,d.mode,b),Fa(b,g),b.return=d,d=b)}return o(d);case oo:e:{for(E=g.key;h!==null;){if(h.key===E)if(h.tag===4&&h.stateNode.containerInfo===g.containerInfo&&h.stateNode.implementation===g.implementation){n(d,h.sibling),b=r(h,g.children||[]),b.return=d,d=b;break e}else{n(d,h);break}else t(d,h);h=h.sibling}b=Ss(g,d.mode,b),b.return=d,d=b}return o(d);case _r:return E=g._init,g=E(g._payload),k(d,h,g,b)}if(uo(g))return S(d,h,g,b);if(Xa(g)){if(E=Xa(g),typeof E!="function")throw Error(x(150));return g=E.call(g),T(d,h,g,b)}if(typeof g.then=="function")return k(d,h,Xu(g),b);if(g.$$typeof===Qn)return k(d,h,Ku(d,g),b);Pu(d,g)}return typeof g=="string"&&g!==""||typeof g=="number"||typeof g=="bigint"?(g=""+g,h!==null&&h.tag===6?(n(d,h.sibling),b=r(h,g),b.return=d,d=b):(n(d,h),b=_s(g,d.mode,b),b.return=d,d=b),o(d)):n(d,h)}return function(d,h,g,b){try{Co=0;var E=k(d,h,g,b);return ca=null,E}catch(I){if(I===pu||I===Tc)throw I;var D=Bt(29,I,null,d.mode);return D.lanes=b,D.return=d,D}finally{}}}var ka=Rb(!0),Mb=Rb(!1),fn=Zn(null),Cn=null;function Or(e){var t=e.alternate;De(We,We.current&1),De(fn,e),Cn===null&&(t===null||za.current!==null||t.memoizedState!==null)&&(Cn=e)}function Cb(e){if(e.tag===22){if(De(We,We.current),De(fn,e),Cn===null){var t=e.alternate;t!==null&&t.memoizedState!==null&&(Cn=e)}}else zr()}function zr(){De(We,We.current),De(fn,fn.current)}function er(e){ct(fn),Cn===e&&(Cn=null),ct(We)}var We=Zn(0);function Gl(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||Vf(n)))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if(t.flags&128)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function Os(e,t,n,i){t=e.memoizedState,n=n(i,t),n=n==null?t:Ae({},t,n),e.memoizedState=n,e.lanes===0&&(e.updateQueue.baseState=n)}var Tf={enqueueSetState:function(e,t,n){e=e._reactInternals;var i=Gt(),r=Dr(i);r.payload=t,n!=null&&(r.callback=n),t=Rr(e,r,i),t!==null&&(Kt(t,e,i),$o(t,e,i))},enqueueReplaceState:function(e,t,n){e=e._reactInternals;var i=Gt(),r=Dr(i);r.tag=1,r.payload=t,n!=null&&(r.callback=n),t=Rr(e,r,i),t!==null&&(Kt(t,e,i),$o(t,e,i))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var n=Gt(),i=Dr(n);i.tag=2,t!=null&&(i.callback=t),t=Rr(e,i,n),t!==null&&(Kt(t,e,n),$o(t,e,n))}};function yv(e,t,n,i,r,a,o){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(i,a,o):t.prototype&&t.prototype.isPureReactComponent?!Do(n,i)||!Do(r,a):!0}function bv(e,t,n,i){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(n,i),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(n,i),t.state!==e&&Tf.enqueueReplaceState(t,t.state,null)}function Si(e,t){var n=t;if("ref"in t){n={};for(var i in t)i!=="ref"&&(n[i]=t[i])}if(e=e.defaultProps){n===t&&(n=Ae({},n));for(var r in e)n[r]===void 0&&(n[r]=e[r])}return n}var Kl=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)};function Zb(e){Kl(e)}function Lb(e){console.error(e)}function Bb(e){Kl(e)}function Yl(e,t){try{var n=e.onUncaughtError;n(t.value,{componentStack:t.stack})}catch(i){setTimeout(function(){throw i})}}function _v(e,t,n){try{var i=e.onCaughtError;i(n.value,{componentStack:n.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(r){setTimeout(function(){throw r})}}function Uf(e,t,n){return n=Dr(n),n.tag=3,n.payload={element:null},n.callback=function(){Yl(e,t)},n}function Hb(e){return e=Dr(e),e.tag=3,e}function qb(e,t,n,i){var r=n.type.getDerivedStateFromError;if(typeof r=="function"){var a=i.value;e.payload=function(){return r(a)},e.callback=function(){_v(t,n,i)}}var o=n.stateNode;o!==null&&typeof o.componentDidCatch=="function"&&(e.callback=function(){_v(t,n,i),typeof r!="function"&&(Mr===null?Mr=new Set([this]):Mr.add(this));var u=i.stack;this.componentDidCatch(i.value,{componentStack:u!==null?u:""})})}function O4(e,t,n,i,r){if(n.flags|=32768,i!==null&&typeof i=="object"&&typeof i.then=="function"){if(t=n.alternate,t!==null&&hu(t,n,r,!0),n=fn.current,n!==null){switch(n.tag){case 13:return Cn===null?Mf():n.alternate===null&&Be===0&&(Be=3),n.flags&=-257,n.flags|=65536,n.lanes=r,i===Sf?n.flags|=16384:(t=n.updateQueue,t===null?n.updateQueue=new Set([i]):t.add(i),Ds(e,i,r)),!1;case 22:return n.flags|=65536,i===Sf?n.flags|=16384:(t=n.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([i])},n.updateQueue=t):(n=t.retryQueue,n===null?t.retryQueue=new Set([i]):n.add(i)),Ds(e,i,r)),!1}throw Error(x(435,n.tag))}return Ds(e,i,r),Mf(),!1}if(pe)return t=fn.current,t!==null?(!(t.flags&65536)&&(t.flags|=256),t.flags|=65536,t.lanes=r,i!==vf&&(e=Error(x(422),{cause:i}),Ro(ln(e,n)))):(i!==vf&&(t=Error(x(423),{cause:i}),Ro(ln(t,n))),e=e.current.alternate,e.flags|=65536,r&=-r,e.lanes|=r,i=ln(i,n),r=Uf(e.stateNode,i,r),ws(e,r),Be!==4&&(Be=2)),!1;var a=Error(x(520),{cause:i});if(a=ln(a,n),Uo===null?Uo=[a]:Uo.push(a),Be!==4&&(Be=2),t===null)return!0;i=ln(i,n),n=t;do{switch(n.tag){case 3:return n.flags|=65536,e=r&-r,n.lanes|=e,e=Uf(n.stateNode,i,e),ws(n,e),!1;case 1:if(t=n.type,a=n.stateNode,(n.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||a!==null&&typeof a.componentDidCatch=="function"&&(Mr===null||!Mr.has(a))))return n.flags|=65536,r&=-r,n.lanes|=r,r=Hb(r),qb(r,e,n,i),ws(n,r),!1}n=n.return}while(n!==null);return!1}var Vb=Error(x(461)),lt=!1;function dt(e,t,n,i){t.child=e===null?Mb(t,null,n,i):ka(t,e.child,n,i)}function Sv(e,t,n,i,r){n=n.render;var a=t.ref;if("ref"in i){var o={};for(var u in i)u!=="ref"&&(o[u]=i[u])}else o=i;return bi(t),i=em(e,t,n,o,a,r),u=tm(),e!==null&&!lt?(nm(e,t,r),or(e,t,r)):(pe&&u&&Yd(t),t.flags|=1,dt(e,t,i,r),t.child)}function wv(e,t,n,i,r){if(e===null){var a=n.type;return typeof a=="function"&&!Kd(a)&&a.defaultProps===void 0&&n.compare===null?(t.tag=15,t.type=a,Gb(e,t,a,i,r)):(e=vl(n.type,null,i,t,t.mode,r),e.ref=t.ref,e.return=t,t.child=e)}if(a=e.child,!fm(e,r)){var o=a.memoizedProps;if(n=n.compare,n=n!==null?n:Do,n(o,i)&&e.ref===t.ref)return or(e,t,r)}return t.flags|=1,e=nr(a,i),e.ref=t.ref,e.return=t,t.child=e}function Gb(e,t,n,i,r){if(e!==null){var a=e.memoizedProps;if(Do(a,i)&&e.ref===t.ref)if(lt=!1,t.pendingProps=i=a,fm(e,r))e.flags&131072&&(lt=!0);else return t.lanes=e.lanes,or(e,t,r)}return xf(e,t,n,i,r)}function Kb(e,t,n){var i=t.pendingProps,r=i.children,a=e!==null?e.memoizedState:null;if(i.mode==="hidden"){if(t.flags&128){if(i=a!==null?a.baseLanes|n:n,e!==null){for(r=t.child=e.child,a=0;r!==null;)a=a|r.lanes|r.childLanes,r=r.sibling;t.childLanes=a&~i}else t.childLanes=0,t.child=null;return $v(e,t,i,n)}if(n&536870912)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&pl(t,a!==null?a.cachePool:null),a!==null?sv(t,a):Of(),Cb(t);else return t.lanes=t.childLanes=536870912,$v(e,t,a!==null?a.baseLanes|n:n,n)}else a!==null?(pl(t,a.cachePool),sv(t,a),zr(),t.memoizedState=null):(e!==null&&pl(t,null),Of(),zr());return dt(e,t,r,n),t.child}function $v(e,t,n,i){var r=Qd();return r=r===null?null:{parent:Fe._currentValue,pool:r},t.memoizedState={baseLanes:n,cachePool:r},e!==null&&pl(t,null),Of(),Cb(t),e!==null&&hu(e,t,i,!0),null}function _l(e,t){var n=t.ref;if(n===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof n!="function"&&typeof n!="object")throw Error(x(284));(e===null||e.ref!==n)&&(t.flags|=4194816)}}function xf(e,t,n,i,r){return bi(t),n=em(e,t,n,i,void 0,r),i=tm(),e!==null&&!lt?(nm(e,t,r),or(e,t,r)):(pe&&i&&Yd(t),t.flags|=1,dt(e,t,n,r),t.child)}function Ov(e,t,n,i,r,a){return bi(t),t.updateQueue=null,n=ab(t,i,n,r),ib(e),i=tm(),e!==null&&!lt?(nm(e,t,a),or(e,t,a)):(pe&&i&&Yd(t),t.flags|=1,dt(e,t,n,a),t.child)}function zv(e,t,n,i,r){if(bi(t),t.stateNode===null){var a=Ji,o=n.contextType;typeof o=="object"&&o!==null&&(a=pt(o)),a=new n(i,a),t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,a.updater=Tf,t.stateNode=a,a._reactInternals=t,a=t.stateNode,a.props=i,a.state=t.memoizedState,a.refs={},Jd(t),o=n.contextType,a.context=typeof o=="object"&&o!==null?pt(o):Ji,a.state=t.memoizedState,o=n.getDerivedStateFromProps,typeof o=="function"&&(Os(t,n,o,i),a.state=t.memoizedState),typeof n.getDerivedStateFromProps=="function"||typeof a.getSnapshotBeforeUpdate=="function"||typeof a.UNSAFE_componentWillMount!="function"&&typeof a.componentWillMount!="function"||(o=a.state,typeof a.componentWillMount=="function"&&a.componentWillMount(),typeof a.UNSAFE_componentWillMount=="function"&&a.UNSAFE_componentWillMount(),o!==a.state&&Tf.enqueueReplaceState(a,a.state,null),zo(t,i,a,r),Oo(),a.state=t.memoizedState),typeof a.componentDidMount=="function"&&(t.flags|=4194308),i=!0}else if(e===null){a=t.stateNode;var u=t.memoizedProps,l=Si(n,u);a.props=l;var c=a.context,s=n.contextType;o=Ji,typeof s=="object"&&s!==null&&(o=pt(s));var f=n.getDerivedStateFromProps;s=typeof f=="function"||typeof a.getSnapshotBeforeUpdate=="function",u=t.pendingProps!==u,s||typeof a.UNSAFE_componentWillReceiveProps!="function"&&typeof a.componentWillReceiveProps!="function"||(u||c!==o)&&bv(t,a,i,o),Sr=!1;var m=t.memoizedState;a.state=m,zo(t,i,a,r),Oo(),c=t.memoizedState,u||m!==c||Sr?(typeof f=="function"&&(Os(t,n,f,i),c=t.memoizedState),(l=Sr||yv(t,n,l,i,m,c,o))?(s||typeof a.UNSAFE_componentWillMount!="function"&&typeof a.componentWillMount!="function"||(typeof a.componentWillMount=="function"&&a.componentWillMount(),typeof a.UNSAFE_componentWillMount=="function"&&a.UNSAFE_componentWillMount()),typeof a.componentDidMount=="function"&&(t.flags|=4194308)):(typeof a.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=i,t.memoizedState=c),a.props=i,a.state=c,a.context=o,i=l):(typeof a.componentDidMount=="function"&&(t.flags|=4194308),i=!1)}else{a=t.stateNode,wf(e,t),o=t.memoizedProps,s=Si(n,o),a.props=s,f=t.pendingProps,m=a.context,c=n.contextType,l=Ji,typeof c=="object"&&c!==null&&(l=pt(c)),u=n.getDerivedStateFromProps,(c=typeof u=="function"||typeof a.getSnapshotBeforeUpdate=="function")||typeof a.UNSAFE_componentWillReceiveProps!="function"&&typeof a.componentWillReceiveProps!="function"||(o!==f||m!==l)&&bv(t,a,i,l),Sr=!1,m=t.memoizedState,a.state=m,zo(t,i,a,r),Oo();var v=t.memoizedState;o!==f||m!==v||Sr||e!==null&&e.dependencies!==null&&Ll(e.dependencies)?(typeof u=="function"&&(Os(t,n,u,i),v=t.memoizedState),(s=Sr||yv(t,n,s,i,m,v,l)||e!==null&&e.dependencies!==null&&Ll(e.dependencies))?(c||typeof a.UNSAFE_componentWillUpdate!="function"&&typeof a.componentWillUpdate!="function"||(typeof a.componentWillUpdate=="function"&&a.componentWillUpdate(i,v,l),typeof a.UNSAFE_componentWillUpdate=="function"&&a.UNSAFE_componentWillUpdate(i,v,l)),typeof a.componentDidUpdate=="function"&&(t.flags|=4),typeof a.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof a.componentDidUpdate!="function"||o===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof a.getSnapshotBeforeUpdate!="function"||o===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),t.memoizedProps=i,t.memoizedState=v),a.props=i,a.state=v,a.context=l,i=s):(typeof a.componentDidUpdate!="function"||o===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof a.getSnapshotBeforeUpdate!="function"||o===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),i=!1)}return a=i,_l(e,t),i=(t.flags&128)!==0,a||i?(a=t.stateNode,n=i&&typeof n.getDerivedStateFromError!="function"?null:a.render(),t.flags|=1,e!==null&&i?(t.child=ka(t,e.child,null,r),t.child=ka(t,null,n,r)):dt(e,t,n,r),t.memoizedState=a.state,e=t.child):e=or(e,t,r),e}function Ev(e,t,n,i){return gu(),t.flags|=256,dt(e,t,n,i),t.child}var zs={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Es(e){return{baseLanes:e,cachePool:Wy()}}function ks(e,t,n){return e=e!==null?e.childLanes&~n:0,t&&(e|=cn),e}function Yb(e,t,n){var i=t.pendingProps,r=!1,a=(t.flags&128)!==0,o;if((o=a)||(o=e!==null&&e.memoizedState===null?!1:(We.current&2)!==0),o&&(r=!0,t.flags&=-129),o=(t.flags&32)!==0,t.flags&=-33,e===null){if(pe){if(r?Or(t):zr(),pe){var u=Ze,l;if(l=u){e:{for(l=u,u=Nn;l.nodeType!==8;){if(!u){u=null;break e}if(l=Sn(l.nextSibling),l===null){u=null;break e}}u=l}u!==null?(t.memoizedState={dehydrated:u,treeContext:si!==null?{id:Jn,overflow:Fn}:null,retryLane:536870912,hydrationErrors:null},l=Bt(18,null,null,0),l.stateNode=u,l.return=t,t.child=l,wt=t,Ze=null,l=!0):l=!1}l||yi(t)}if(u=t.memoizedState,u!==null&&(u=u.dehydrated,u!==null))return Vf(u)?t.lanes=32:t.lanes=536870912,null;er(t)}return u=i.children,i=i.fallback,r?(zr(),r=t.mode,u=Xl({mode:"hidden",children:u},r),i=ci(i,r,n,null),u.return=t,i.return=t,u.sibling=i,t.child=u,r=t.child,r.memoizedState=Es(n),r.childLanes=ks(e,o,n),t.memoizedState=zs,i):(Or(t),Af(t,u))}if(l=e.memoizedState,l!==null&&(u=l.dehydrated,u!==null)){if(a)t.flags&256?(Or(t),t.flags&=-257,t=Ts(e,t,n)):t.memoizedState!==null?(zr(),t.child=e.child,t.flags|=128,t=null):(zr(),r=i.fallback,u=t.mode,i=Xl({mode:"visible",children:i.children},u),r=ci(r,u,n,null),r.flags|=2,i.return=t,r.return=t,i.sibling=r,t.child=i,ka(t,e.child,null,n),i=t.child,i.memoizedState=Es(n),i.childLanes=ks(e,o,n),t.memoizedState=zs,t=r);else if(Or(t),Vf(u)){if(o=u.nextSibling&&u.nextSibling.dataset,o)var c=o.dgst;o=c,i=Error(x(419)),i.stack="",i.digest=o,Ro({value:i,source:null,stack:null}),t=Ts(e,t,n)}else if(lt||hu(e,t,n,!1),o=(n&e.childLanes)!==0,lt||o){if(o=Te,o!==null&&(i=n&-n,i=i&42?1:Dd(i),i=i&(o.suspendedLanes|n)?0:i,i!==0&&i!==l.retryLane))throw l.retryLane=i,Ha(e,i),Kt(o,e,i),Vb;u.data==="$?"||Mf(),t=Ts(e,t,n)}else u.data==="$?"?(t.flags|=192,t.child=e.child,t=null):(e=l.treeContext,Ze=Sn(u.nextSibling),wt=t,pe=!0,fi=null,Nn=!1,e!==null&&(nn[rn++]=Jn,nn[rn++]=Fn,nn[rn++]=si,Jn=e.id,Fn=e.overflow,si=t),t=Af(t,i.children),t.flags|=4096);return t}return r?(zr(),r=i.fallback,u=t.mode,l=e.child,c=l.sibling,i=nr(l,{mode:"hidden",children:i.children}),i.subtreeFlags=l.subtreeFlags&65011712,c!==null?r=nr(c,r):(r=ci(r,u,n,null),r.flags|=2),r.return=t,i.return=t,i.sibling=r,t.child=i,i=r,r=t.child,u=e.child.memoizedState,u===null?u=Es(n):(l=u.cachePool,l!==null?(c=Fe._currentValue,l=l.parent!==c?{parent:c,pool:c}:l):l=Wy(),u={baseLanes:u.baseLanes|n,cachePool:l}),r.memoizedState=u,r.childLanes=ks(e,o,n),t.memoizedState=zs,i):(Or(t),n=e.child,e=n.sibling,n=nr(n,{mode:"visible",children:i.children}),n.return=t,n.sibling=null,e!==null&&(o=t.deletions,o===null?(t.deletions=[e],t.flags|=16):o.push(e)),t.child=n,t.memoizedState=null,n)}function Af(e,t){return t=Xl({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function Xl(e,t){return e=Bt(22,e,null,t),e.lanes=0,e.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},e}function Ts(e,t,n){return ka(t,e.child,null,n),e=Af(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function kv(e,t,n){e.lanes|=t;var i=e.alternate;i!==null&&(i.lanes|=t),yf(e.return,t,n)}function Us(e,t,n,i,r){var a=e.memoizedState;a===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:i,tail:n,tailMode:r}:(a.isBackwards=t,a.rendering=null,a.renderingStartTime=0,a.last=i,a.tail=n,a.tailMode=r)}function Xb(e,t,n){var i=t.pendingProps,r=i.revealOrder,a=i.tail;if(dt(e,t,i.children,n),i=We.current,i&2)i=i&1|2,t.flags|=128;else{if(e!==null&&e.flags&128)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&kv(e,n,t);else if(e.tag===19)kv(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}i&=1}switch(De(We,i),r){case"forwards":for(n=t.child,r=null;n!==null;)e=n.alternate,e!==null&&Gl(e)===null&&(r=n),n=n.sibling;n=r,n===null?(r=t.child,t.child=null):(r=n.sibling,n.sibling=null),Us(t,!1,r,n,a);break;case"backwards":for(n=null,r=t.child,t.child=null;r!==null;){if(e=r.alternate,e!==null&&Gl(e)===null){t.child=r;break}e=r.sibling,r.sibling=n,n=r,r=e}Us(t,!0,n,null,a);break;case"together":Us(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function or(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Vr|=t.lanes,!(n&t.childLanes))if(e!==null){if(hu(e,t,n,!1),(n&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(x(153));if(t.child!==null){for(e=t.child,n=nr(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=nr(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function fm(e,t){return e.lanes&t?!0:(e=e.dependencies,!!(e!==null&&Ll(e)))}function z4(e,t,n){switch(t.tag){case 3:Nl(t,t.stateNode.containerInfo),$r(t,Fe,e.memoizedState.cache),gu();break;case 27:case 5:of(t);break;case 4:Nl(t,t.stateNode.containerInfo);break;case 10:$r(t,t.type,t.memoizedProps.value);break;case 13:var i=t.memoizedState;if(i!==null)return i.dehydrated!==null?(Or(t),t.flags|=128,null):n&t.child.childLanes?Yb(e,t,n):(Or(t),e=or(e,t,n),e!==null?e.sibling:null);Or(t);break;case 19:var r=(e.flags&128)!==0;if(i=(n&t.childLanes)!==0,i||(hu(e,t,n,!1),i=(n&t.childLanes)!==0),r){if(i)return Xb(e,t,n);t.flags|=128}if(r=t.memoizedState,r!==null&&(r.rendering=null,r.tail=null,r.lastEffect=null),De(We,We.current),i)break;return null;case 22:case 23:return t.lanes=0,Kb(e,t,n);case 24:$r(t,Fe,e.memoizedState.cache)}return or(e,t,n)}function Pb(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps)lt=!0;else{if(!fm(e,n)&&!(t.flags&128))return lt=!1,z4(e,t,n);lt=!!(e.flags&131072)}else lt=!1,pe&&t.flags&1048576&&Jy(t,Zl,t.index);switch(t.lanes=0,t.tag){case 16:e:{e=t.pendingProps;var i=t.elementType,r=i._init;if(i=r(i._payload),t.type=i,typeof i=="function")Kd(i)?(e=Si(i,e),t.tag=1,t=zv(null,t,i,e,n)):(t.tag=0,t=xf(null,t,i,e,n));else{if(i!=null){if(r=i.$$typeof,r===Nd){t.tag=11,t=Sv(null,t,i,e,n);break e}else if(r===jd){t.tag=14,t=wv(null,t,i,e,n);break e}}throw t=rf(i)||i,Error(x(306,t,""))}}return t;case 0:return xf(e,t,t.type,t.pendingProps,n);case 1:return i=t.type,r=Si(i,t.pendingProps),zv(e,t,i,r,n);case 3:e:{if(Nl(t,t.stateNode.containerInfo),e===null)throw Error(x(387));i=t.pendingProps;var a=t.memoizedState;r=a.element,wf(e,t),zo(t,i,null,n);var o=t.memoizedState;if(i=o.cache,$r(t,Fe,i),i!==a.cache&&bf(t,[Fe],n,!0),Oo(),i=o.element,a.isDehydrated)if(a={element:i,isDehydrated:!1,cache:o.cache},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){t=Ev(e,t,i,n);break e}else if(i!==r){r=ln(Error(x(424)),t),Ro(r),t=Ev(e,t,i,n);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(Ze=Sn(e.firstChild),wt=t,pe=!0,fi=null,Nn=!0,n=Mb(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(gu(),i===r){t=or(e,t,n);break e}dt(e,t,i,n)}t=t.child}return t;case 26:return _l(e,t),e===null?(n=Vv(t.type,null,t.pendingProps,null))?t.memoizedState=n:pe||(n=t.type,e=t.pendingProps,i=tc(Ir.current).createElement(n),i[vt]=t,i[Dt]=e,gt(i,n,e),ut(i),t.stateNode=i):t.memoizedState=Vv(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return of(t),e===null&&pe&&(i=t.stateNode=D0(t.type,t.pendingProps,Ir.current),wt=t,Nn=!0,r=Ze,Yr(t.type)?(Gf=r,Ze=Sn(i.firstChild)):Ze=r),dt(e,t,t.pendingProps.children,n),_l(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&pe&&((r=i=Ze)&&(i=W4(i,t.type,t.pendingProps,Nn),i!==null?(t.stateNode=i,wt=t,Ze=Sn(i.firstChild),Nn=!1,r=!0):r=!1),r||yi(t)),of(t),r=t.type,a=t.pendingProps,o=e!==null?e.memoizedProps:null,i=a.children,Hf(r,a)?i=null:o!==null&&Hf(r,o)&&(t.flags|=32),t.memoizedState!==null&&(r=em(e,t,p4,null,null,n),Ho._currentValue=r),_l(e,t),dt(e,t,i,n),t.child;case 6:return e===null&&pe&&((e=n=Ze)&&(n=eE(n,t.pendingProps,Nn),n!==null?(t.stateNode=n,wt=t,Ze=null,e=!0):e=!1),e||yi(t)),null;case 13:return Yb(e,t,n);case 4:return Nl(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=ka(t,null,i,n):dt(e,t,i,n),t.child;case 11:return Sv(e,t,t.type,t.pendingProps,n);case 7:return dt(e,t,t.pendingProps,n),t.child;case 8:return dt(e,t,t.pendingProps.children,n),t.child;case 12:return dt(e,t,t.pendingProps.children,n),t.child;case 10:return i=t.pendingProps,$r(t,t.type,i.value),dt(e,t,i.children,n),t.child;case 9:return r=t.type._context,i=t.pendingProps.children,bi(t),r=pt(r),i=i(r),t.flags|=1,dt(e,t,i,n),t.child;case 14:return wv(e,t,t.type,t.pendingProps,n);case 15:return Gb(e,t,t.type,t.pendingProps,n);case 19:return Xb(e,t,n);case 31:return i=t.pendingProps,n=t.mode,i={mode:i.mode,children:i.children},e===null?(n=Xl(i,n),n.ref=t.ref,t.child=n,n.return=t,t=n):(n=nr(e.child,i),n.ref=t.ref,t.child=n,n.return=t,t=n),t;case 22:return Kb(e,t,n);case 24:return bi(t),i=pt(Fe),e===null?(r=Qd(),r===null&&(r=Te,a=Pd(),r.pooledCache=a,a.refCount++,a!==null&&(r.pooledCacheLanes|=n),r=a),t.memoizedState={parent:i,cache:r},Jd(t),$r(t,Fe,r)):(e.lanes&n&&(wf(e,t),zo(t,null,null,n),Oo()),r=e.memoizedState,a=t.memoizedState,r.parent!==i?(r={parent:i,cache:i},t.memoizedState=r,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=r),$r(t,Fe,i)):(i=a.cache,$r(t,Fe,i),i!==r.cache&&bf(t,[Fe],n,!0))),dt(e,t,t.pendingProps.children,n),t.child;case 29:throw t.pendingProps}throw Error(x(156,t.tag))}function Hn(e){e.flags|=4}function Tv(e,t){if(t.type!=="stylesheet"||t.state.loading&4)e.flags&=-16777217;else if(e.flags|=16777216,!C0(t)){if(t=fn.current,t!==null&&((me&4194048)===me?Cn!==null:(me&62914560)!==me&&!(me&536870912)||t!==Cn))throw wo=Sf,eb;e.flags|=8192}}function Qu(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?Sy():536870912,e.lanes|=t,Ta|=t)}function Wa(e,t){if(!pe)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var i=null;n!==null;)n.alternate!==null&&(i=n),n=n.sibling;i===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:i.sibling=null}}function Me(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,i=0;if(t)for(var r=e.child;r!==null;)n|=r.lanes|r.childLanes,i|=r.subtreeFlags&65011712,i|=r.flags&65011712,r.return=e,r=r.sibling;else for(r=e.child;r!==null;)n|=r.lanes|r.childLanes,i|=r.subtreeFlags,i|=r.flags,r.return=e,r=r.sibling;return e.subtreeFlags|=i,e.childLanes=n,t}function E4(e,t,n){var i=t.pendingProps;switch(Xd(t),t.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Me(t),null;case 1:return Me(t),null;case 3:return n=t.stateNode,i=null,e!==null&&(i=e.memoizedState.cache),t.memoizedState.cache!==i&&(t.flags|=2048),rr(Fe),Sa(),n.pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),(e===null||e.child===null)&&(Ja(t)?Hn(t):e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,iv())),Me(t),null;case 26:return n=t.memoizedState,e===null?(Hn(t),n!==null?(Me(t),Tv(t,n)):(Me(t),t.flags&=-16777217)):n?n!==e.memoizedState?(Hn(t),Me(t),Tv(t,n)):(Me(t),t.flags&=-16777217):(e.memoizedProps!==i&&Hn(t),Me(t),t.flags&=-16777217),null;case 27:jl(t),n=Ir.current;var r=t.type;if(e!==null&&t.stateNode!=null)e.memoizedProps!==i&&Hn(t);else{if(!i){if(t.stateNode===null)throw Error(x(166));return Me(t),null}e=Rn.current,Ja(t)?nv(t):(e=D0(r,i,n),t.stateNode=e,Hn(t))}return Me(t),null;case 5:if(jl(t),n=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==i&&Hn(t);else{if(!i){if(t.stateNode===null)throw Error(x(166));return Me(t),null}if(e=Rn.current,Ja(t))nv(t);else{switch(r=tc(Ir.current),e){case 1:e=r.createElementNS("http://www.w3.org/2000/svg",n);break;case 2:e=r.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;default:switch(n){case"svg":e=r.createElementNS("http://www.w3.org/2000/svg",n);break;case"math":e=r.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;case"script":e=r.createElement("div"),e.innerHTML=" + + +
+ + diff --git a/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts b/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts new file mode 100644 index 0000000000..2cb3c8e9ca --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts @@ -0,0 +1,234 @@ +import { actor } from "rivetkit"; +import { err, sleep } from "../../utils.ts"; +import { buildSecret } from "../shared/ids.ts"; + +interface MatchSeat { + playerId: string; + name: string; +} + +interface Player { + playerId: string; + name: string; + joinedAt: number; + alive: boolean; +} + +interface State { + matchId: string; + trusted: boolean; + tickMs: number; + seats: MatchSeat[]; + playerIds: string[]; + players: Record; + playerTokens: Record; + phase: "waiting" | "active" | "finished"; + tick: number; + zoneRadius: number; + winnerPlayerId: string | null; +} + +function requireTrusted(state: State) { + if (!state.trusted) { + err("match is not trusted", "untrusted_lobby"); + } +} + +function findSeatByPlayerId(state: State, playerId: string): MatchSeat | null { + for (const seat of state.seats) { + if (seat.playerId === playerId) { + return seat; + } + } + return null; +} + +function buildSnapshot(state: State) { + // Snapshot exposes scaffold battle royale state for UI and tests. + const alivePlayerIds = Object.values(state.players) + .filter((player) => player.alive) + .map((player) => player.playerId); + return { + matchId: state.matchId, + phase: state.phase, + tick: state.tick, + zoneRadius: state.zoneRadius, + winnerPlayerId: state.winnerPlayerId, + players: state.players, + alivePlayerIds, + }; +} + +function maybeStart(state: State) { + // Auto-start once all assigned players have joined. + if (state.phase !== "waiting") return; + const joined = Object.keys(state.players).length; + if (joined >= state.playerIds.length) { + state.phase = "active"; + } +} + +async function notifyMatchClosed(c: { + state: State; + client: () => any; +}) { + const client = c.client(); + try { + await client.battleRoyaleMatchmaker.getOrCreate(["main"]).queue.matchClosed.send({ + matchId: c.state.matchId, + }); + } catch { + // Best effort during shutdown. + } +} + +async function maybeFinish(c: { + state: State; + broadcast: (event: string, payload: unknown) => void; + client: () => any; +}) { + if (c.state.phase !== "active") return; + const alive = Object.values(c.state.players).filter((player) => player.alive); + if (alive.length > 1) return; + c.state.phase = "finished"; + c.state.winnerPlayerId = alive[0]?.playerId ?? null; + c.broadcast("snapshot", buildSnapshot(c.state)); + await notifyMatchClosed(c); +} + +export const battleRoyaleMatch = actor({ + createState: ( + _c, + input: { matchId: string; tickMs: number; players: MatchSeat[] }, + ): State => ({ + matchId: input.matchId, + trusted: true, + tickMs: input.tickMs, + seats: input.players, + playerIds: input.players.map((player) => player.playerId), + players: {}, + playerTokens: {}, + phase: "waiting", + tick: 0, + zoneRadius: 120, + winnerPlayerId: null, + }), + createConnState: (_c, _params: { playerToken?: string }) => ({ + playerId: null as string | null, + }), + onBeforeConnect: (c, params: { playerToken?: string }) => { + const playerToken = params?.playerToken?.trim(); + if (!playerToken) { + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + err("invalid player token", "invalid_player_token"); + } + }, + onConnect: (c, conn) => { + const playerToken = conn.params?.playerToken?.trim(); + if (!playerToken) { + conn.disconnect("invalid_player_token"); + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + conn.disconnect("invalid_player_token"); + return; + } + const seat = findSeatByPlayerId(c.state, playerId); + if (!seat) { + conn.disconnect("invalid_player_assignment"); + return; + } + if (!c.state.players[seat.playerId]) { + c.state.players[seat.playerId] = { + playerId: seat.playerId, + name: seat.name, + joinedAt: Date.now(), + alive: true, + }; + } + conn.state.playerId = seat.playerId; + maybeStart(c.state); + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDisconnect: async (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + if (!c.state.players[playerId]) return; + + delete c.state.players[playerId]; + conn.state.playerId = null; + + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + return; + } + + await maybeFinish(c); + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDestroy: async (c) => { + await notifyMatchClosed(c); + }, + run: async (c) => { + // This run loop is the 10 tps scaffold tick for battle royale sessions. + while (!c.aborted) { + await sleep(c.state.tickMs); + if (c.aborted) break; + if (c.state.phase !== "active") continue; + c.state.tick += 1; + // Simulate shrinking safe zone to demonstrate phase progression. + c.state.zoneRadius = Math.max(5, c.state.zoneRadius - 0.25); + await maybeFinish(c); + c.broadcast("snapshot", buildSnapshot(c.state)); + } + }, + actions: { + issuePlayerToken: ( + c, + input: { playerId: string }, + ) => { + const seat = findSeatByPlayerId(c.state, input.playerId); + if (!seat) { + err("player is not assigned", "invalid_player"); + } + const playerToken = buildSecret(); + c.state.playerTokens[playerToken] = seat.playerId; + return { playerId: seat.playerId, playerToken }; + }, + startNow: (c) => { + requireTrusted(c.state); + if (!c.conn.state.playerId) { + err("caller is not joined", "not_joined"); + } + // Manual start is useful for testing with partial lobbies. + if (c.state.phase === "waiting") { + c.state.phase = "active"; + c.broadcast("snapshot", buildSnapshot(c.state)); + } + return buildSnapshot(c.state); + }, + eliminate: async (c, input: { victimPlayerId: string }) => { + requireTrusted(c.state); + if (!c.conn.state.playerId) { + err("caller is not joined", "not_joined"); + } + // This action is scaffold logic for elimination and winner resolution. + if (c.state.phase !== "active") { + err("match not active", "match_not_active"); + } + const victim = c.state.players[input.victimPlayerId]; + if (!victim) { + err("victim not found", "victim_not_found"); + } + victim.alive = false; + await maybeFinish(c); + c.broadcast("snapshot", buildSnapshot(c.state)); + return buildSnapshot(c.state); + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts new file mode 100644 index 0000000000..7e2c401878 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts @@ -0,0 +1,329 @@ +import { actor } from "rivetkit"; +import { db, type RawAccess } from "rivetkit/db"; +import { buildId } from "../shared/ids.ts"; +import { sqlInt, sqlString } from "../shared/sql.ts"; + +const MIN_PLAYERS = 3; +const MAX_PLAYERS = 20; +const TICK_MS = 100; + +interface QueueRow { + player_id: string; + queued_at: number; +} + +interface AssignmentRow { + player_id: string; + match_id: string; + assigned_at: number; +} + +interface MatchRow { + match_id: string; +} + +interface MatchSeat { + playerId: string; + name: string; +} + +export const battleRoyaleMatchmaker = actor({ + db: db({ + onMigrate: migrateTables, + }), + run: async (c) => { + while (!c.aborted) { + const [message] = + (await c.queue.next(["joinQueue", "matchClosed"], { + count: 1, + timeout: 100, + })) ?? []; + if (!message) continue; + + if (message.name === "joinQueue") { + const input = message.body as { playerId?: string }; + if (!input?.playerId) { + continue; + } + await processJoinQueue(c, { playerId: input.playerId }); + continue; + } + + if (message.name === "matchClosed") { + const input = message.body as { matchId?: string }; + if (!input?.matchId) { + continue; + } + await processMatchClosed(c, { + matchId: input.matchId, + }); + } + } + }, + actions: { + getAssignment: async (c, input: { playerId: string }) => { + const assignment = await selectAssignment(c.db, input.playerId); + if (!assignment) return null; + const match = await selectMatchById(c.db, assignment.match_id); + if (!match) return null; + const playerToken = await issuePlayerToken(c, { + matchId: assignment.match_id, + playerId: assignment.player_id, + }); + if (!playerToken) return null; + return { + playerId: assignment.player_id, + matchId: assignment.match_id, + playerToken, + assignedAt: Number(assignment.assigned_at), + }; + }, + }, +}); + +type MatchmakerContext = { + db: RawAccess; + client: () => any; +}; + +async function processJoinQueue(c: MatchmakerContext, input: { playerId: string }) { + const existing = await selectAssignment(c.db, input.playerId); + if (existing) { + return; + } + + await enqueuePlayer(c.db, { + playerId: input.playerId, + queuedAt: Date.now(), + }); + + const createInput = await tryCreateMatch(c.db, input.playerId); + if (createInput) { + await createMatchActor(c, createInput); + } +} + +async function processMatchClosed( + c: MatchmakerContext, + input: { matchId: string }, +) { + const match = await selectMatchById(c.db, input.matchId); + if (!match) { + return; + } + await deleteAssignmentsByMatchId(c.db, input.matchId); + await deleteMatchById(c.db, input.matchId); +} + +async function migrateTables(dbHandle: RawAccess) { + // This table stores queued battle royale players by enqueue time. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS queue ( + player_id TEXT PRIMARY KEY, + queued_at INTEGER NOT NULL + ) + `); + // This table maps each queued player to a created battle royale match. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS assignments ( + player_id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + assigned_at INTEGER NOT NULL + ) + `); + // This table stores created battle royale matches and initial player count. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS matches ( + match_id TEXT PRIMARY KEY, + player_count INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + `); + // This index speeds up oldest-first queue reads. + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS queue_order_idx ON queue (queued_at)", + ); +} + +async function selectAssignment( + dbHandle: RawAccess, + playerId: string, +): Promise { + // A player has at most one active battle royale assignment. + const rows = (await dbHandle.execute( + `SELECT player_id, match_id, assigned_at FROM assignments WHERE player_id = ${sqlString(playerId)} LIMIT 1`, + )) as AssignmentRow[]; + return rows[0] ?? null; +} + +async function enqueuePlayer( + dbHandle: RawAccess, + input: { playerId: string; queuedAt: number }, +) { + // Enqueue idempotently so repeated calls do not duplicate rows. + await dbHandle.execute( + `INSERT OR IGNORE INTO queue (player_id, queued_at) + VALUES (${sqlString(input.playerId)}, ${sqlInt(input.queuedAt)})`, + ); +} + +async function tryCreateMatch( + dbHandle: RawAccess, + playerId: string, +): Promise<{ matchId: string; players: MatchSeat[] } | null> { + await beginImmediateTransaction(dbHandle); + try { + // Re-read assignment under lock to avoid creating duplicate matches. + const lockedAssignment = await selectAssignment(dbHandle, playerId); + if (lockedAssignment) { + await commitTransaction(dbHandle); + return null; + } + + // Build one match from the oldest queued players up to cap. + const queueRows = await listQueueByOrder(dbHandle); + if (queueRows.length < MIN_PLAYERS) { + await commitTransaction(dbHandle); + return null; + } + + const selected = queueRows.slice(0, Math.min(queueRows.length, MAX_PLAYERS)); + const matchId = buildId("br"); + const now = Date.now(); + const players = selected.map((row) => ({ + playerId: row.player_id, + name: row.player_id, + })); + await deleteQueuePlayers( + dbHandle, + selected.map((row) => row.player_id), + ); + for (const player of players) { + await insertAssignmentRow(dbHandle, { + playerId: player.playerId, + matchId, + assignedAt: now, + }); + } + await insertMatchRow(dbHandle, { + matchId, + playerCount: selected.length, + createdAt: now, + }); + await commitTransaction(dbHandle); + + return { + matchId, + players, + }; + } catch (err) { + await rollbackTransaction(dbHandle); + throw err; + } +} + +async function listQueueByOrder(dbHandle: RawAccess): Promise { + return (await dbHandle.execute( + "SELECT player_id, queued_at FROM queue ORDER BY queued_at ASC", + )) as QueueRow[]; +} + +async function countQueue(dbHandle: RawAccess): Promise { + const rows = (await dbHandle.execute("SELECT COUNT(*) AS count FROM queue")) as Array<{ + count: number; + }>; + return Number(rows[0]?.count ?? 0); +} + +async function deleteQueuePlayers(dbHandle: RawAccess, playerIds: string[]) { + if (playerIds.length === 0) { + return; + } + const playerSql = playerIds.map((playerId) => sqlString(playerId)).join(", "); + // Remove selected players from queue before writing assignments. + await dbHandle.execute(`DELETE FROM queue WHERE player_id IN (${playerSql})`); +} + +async function insertAssignmentRow( + dbHandle: RawAccess, + input: { playerId: string; matchId: string; assignedAt: number }, +) { + // Persist one assignment row per selected player. + await dbHandle.execute( + `INSERT INTO assignments (player_id, match_id, assigned_at) + VALUES (${sqlString(input.playerId)}, ${sqlString(input.matchId)}, ${sqlInt(input.assignedAt)})`, + ); +} + +async function insertMatchRow( + dbHandle: RawAccess, + input: { matchId: string; playerCount: number; createdAt: number }, +) { + // Persist match metadata for lifecycle and debugging. + await dbHandle.execute( + `INSERT INTO matches (match_id, player_count, created_at) + VALUES (${sqlString(input.matchId)}, ${sqlInt(input.playerCount)}, ${sqlInt(input.createdAt)})`, + ); +} + +async function selectMatchById( + dbHandle: RawAccess, + matchId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT match_id FROM matches WHERE match_id = ${sqlString(matchId)} LIMIT 1`, + )) as MatchRow[]; + return rows[0] ?? null; +} + +async function deleteAssignmentsByMatchId(dbHandle: RawAccess, matchId: string) { + await dbHandle.execute(`DELETE FROM assignments WHERE match_id = ${sqlString(matchId)}`); +} + +async function deleteMatchById(dbHandle: RawAccess, matchId: string) { + await dbHandle.execute(`DELETE FROM matches WHERE match_id = ${sqlString(matchId)}`); +} + +async function beginImmediateTransaction(dbHandle: RawAccess) { + await dbHandle.execute("BEGIN IMMEDIATE"); +} + +async function commitTransaction(dbHandle: RawAccess) { + await dbHandle.execute("COMMIT"); +} + +async function rollbackTransaction(dbHandle: RawAccess) { + await dbHandle.execute("ROLLBACK"); +} + +async function createMatchActor( + c: MatchmakerContext, + input: { matchId: string; players: MatchSeat[] }, +) { + // Create match actor after transaction commit. + const client = c.client(); + await client.battleRoyaleMatch.create([input.matchId], { + input: { + matchId: input.matchId, + tickMs: TICK_MS, + players: input.players, + }, + }); +} + +async function issuePlayerToken( + c: MatchmakerContext, + input: { matchId: string; playerId: string }, +): Promise { + try { + const client = c.client(); + const result = (await client.battleRoyaleMatch + .get([input.matchId]) + .issuePlayerToken({ + playerId: input.playerId, + })) as { playerToken?: string }; + return result.playerToken ?? null; + } catch { + return null; + } +} diff --git a/examples/multiplayer-game-patterns/src/actors/competitive/match.ts b/examples/multiplayer-game-patterns/src/actors/competitive/match.ts new file mode 100644 index 0000000000..4eb8344d18 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/competitive/match.ts @@ -0,0 +1,223 @@ +import { actor } from "rivetkit"; +import { err, sleep } from "../../utils.ts"; +import { buildSecret } from "../shared/ids.ts"; +import type { Mode } from "./matchmaker.ts"; + +interface AssignedPlayer { + playerId: string; + name: string; + teamId: number; +} + +interface Player { + playerId: string; + name: string; + teamId: number; + joinedAt: number; + lastActionAt: number; +} + +interface State { + matchId: string; + trusted: boolean; + mode: Mode; + capacity: number; + tickMs: number; + tick: number; + phase: "waiting" | "live" | "finished"; + assignedPlayers: AssignedPlayer[]; + players: Record; + playerTokens: Record; + winnerTeam: number | null; +} + +function requireTrusted(state: State) { + if (!state.trusted) { + err("match is not trusted", "untrusted_lobby"); + } +} + +function findAssignedByPlayerId(state: State, playerId: string) { + return state.assignedPlayers.find((entry) => entry.playerId === playerId) ?? null; +} + +function maxTeamId(state: State): number { + let max = 0; + for (const assigned of state.assignedPlayers) { + if (assigned.teamId > max) { + max = assigned.teamId; + } + } + return max; +} + +function buildSnapshot(state: State) { + // This snapshot shape is intentionally simple for frontend and test harness use. + return { + matchId: state.matchId, + mode: state.mode, + capacity: state.capacity, + tick: state.tick, + phase: state.phase, + winnerTeam: state.winnerTeam, + assignedPlayers: state.assignedPlayers.map((entry) => ({ + playerId: entry.playerId, + teamId: entry.teamId, + })), + players: state.players, + joinedCount: Object.keys(state.players).length, + }; +} + +export const competitiveMatch = actor({ + createState: ( + _c, + input: { + matchId: string; + mode: Mode; + capacity: number; + tickMs: number; + assignedPlayers: AssignedPlayer[]; + }, + ): State => ({ + matchId: input.matchId, + trusted: true, + mode: input.mode, + capacity: input.capacity, + tickMs: input.tickMs, + tick: 0, + phase: "waiting", + assignedPlayers: input.assignedPlayers, + players: {}, + playerTokens: {}, + winnerTeam: null, + }), + createConnState: (_c, _params: { playerToken?: string }) => ({ + playerId: null as string | null, + }), + onBeforeConnect: (c, params: { playerToken?: string }) => { + const playerToken = params?.playerToken?.trim(); + if (!playerToken) { + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + err("invalid player token", "invalid_player_token"); + } + }, + onConnect: (c, conn) => { + const playerToken = conn.params?.playerToken?.trim(); + if (!playerToken) { + conn.disconnect("invalid_player_token"); + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + conn.disconnect("invalid_player_token"); + return; + } + const assigned = findAssignedByPlayerId(c.state, playerId); + if (!assigned) { + conn.disconnect("invalid_player_assignment"); + return; + } + + const existing = c.state.players[assigned.playerId]; + if (!existing) { + c.state.players[assigned.playerId] = { + playerId: assigned.playerId, + name: assigned.name, + teamId: assigned.teamId, + joinedAt: Date.now(), + lastActionAt: Date.now(), + }; + } + + conn.state.playerId = assigned.playerId; + if (Object.keys(c.state.players).length === c.state.assignedPlayers.length) { + c.state.phase = "live"; + } + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDisconnect: (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + if (!c.state.players[playerId]) return; + + delete c.state.players[playerId]; + conn.state.playerId = null; + + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + return; + } + + if (c.state.phase !== "finished") { + c.state.phase = + Object.keys(c.state.players).length === c.state.assignedPlayers.length + ? "live" + : "waiting"; + } + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDestroy: async (c) => { + const client = c.client(); + try { + // The matchmaker persists competitive assignments in SQLite. + // This callback clears those rows when the match actor goes away. + await client.competitiveMatchmaker.getOrCreate(["main"]).queue.matchCompleted.send({ + matchId: c.state.matchId, + }); + } catch { + // Best effort during shutdown. + } + }, + run: async (c) => { + // This run loop is the 20 tps scaffold tick for competitive sessions. + while (!c.aborted) { + await sleep(c.state.tickMs); + if (c.aborted) break; + if (c.state.phase !== "live") continue; + c.state.tick += 1; + // Broadcast one canonical state update per tick. + c.broadcast("snapshot", buildSnapshot(c.state)); + } + }, + actions: { + issuePlayerToken: ( + c, + input: { playerId: string }, + ) => { + const assigned = findAssignedByPlayerId(c.state, input.playerId); + if (!assigned) { + err("player is not assigned", "invalid_player"); + } + const playerToken = buildSecret(); + c.state.playerTokens[playerToken] = assigned.playerId; + return { playerId: assigned.playerId, teamId: assigned.teamId, playerToken }; + }, + finish: async (c, input: { winnerTeam: number | null }) => { + requireTrusted(c.state); + if (!c.conn.state.playerId) { + err("caller is not joined", "not_joined"); + } + if (input.winnerTeam != null) { + if (input.winnerTeam < 0 || input.winnerTeam > maxTeamId(c.state)) { + err("winner team is invalid", "invalid_winner_team"); + } + } + c.state.phase = "finished"; + c.state.winnerTeam = input.winnerTeam; + c.broadcast("snapshot", buildSnapshot(c.state)); + + const client = c.client(); + // This keeps SQLite assignment rows in sync with match lifecycle. + await client.competitiveMatchmaker.getOrCreate(["main"]).queue.matchCompleted.send({ + matchId: c.state.matchId, + }); + + return buildSnapshot(c.state); + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/competitive/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/competitive/matchmaker.ts new file mode 100644 index 0000000000..4e10db2f77 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/competitive/matchmaker.ts @@ -0,0 +1,402 @@ +import { actor } from "rivetkit"; +import { db, type RawAccess } from "rivetkit/db"; +import { buildId } from "../shared/ids.ts"; +import { sqlInt, sqlString } from "../shared/sql.ts"; + +export type Mode = "duo" | "squad"; + +const TICK_MS = 50; + +const MODE_CONFIG: Record = { + duo: { capacity: 4, teams: 2 }, + squad: { capacity: 8, teams: 2 }, +}; + +interface AssignmentRow { + player_id: string; + match_id: string; + mode: Mode; + team_id: number; + assigned_at: number; +} + +interface MatchRow { + match_id: string; +} + +interface QueueRow { + player_id: string; + queued_at: number; +} + +interface AssignedPlayer { + playerId: string; + name: string; + teamId: number; +} + +interface CreateMatchInput { + matchId: string; + mode: Mode; + tickMs: number; + capacity: number; + assignedPlayers: AssignedPlayer[]; +} + +export const competitiveMatchmaker = actor({ + db: db({ + onMigrate: migrateTables, + }), + run: async (c) => { + while (!c.aborted) { + const [message] = + (await c.queue.next(["queueForMatch", "matchCompleted"], { + count: 1, + timeout: 100, + })) ?? []; + if (!message) continue; + + if (message.name === "queueForMatch") { + const input = message.body as { playerId?: string; mode?: Mode }; + if (!input?.playerId || !input?.mode) { + continue; + } + await processQueueForMatch(c, { + playerId: input.playerId, + mode: input.mode, + }); + continue; + } + + if (message.name === "matchCompleted") { + const input = message.body as { matchId?: string }; + if (!input?.matchId) { + continue; + } + await processMatchCompleted(c, { + matchId: input.matchId, + }); + } + } + }, + actions: { + getAssignment: async (c, input: { playerId: string }) => { + const assignment = await selectAssignment(c.db, input.playerId); + if (!assignment) return null; + const match = await selectMatchById(c.db, assignment.match_id); + if (!match) return null; + const playerToken = await issuePlayerToken(c, { + matchId: assignment.match_id, + playerId: assignment.player_id, + }); + if (!playerToken) return null; + return { + playerId: assignment.player_id, + matchId: assignment.match_id, + mode: assignment.mode, + teamId: Number(assignment.team_id), + playerToken, + assignedAt: Number(assignment.assigned_at), + }; + }, + }, +}); + +type MatchmakerContext = { + db: RawAccess; + client: () => any; +}; + +async function processQueueForMatch( + c: MatchmakerContext, + input: { playerId: string; mode: Mode }, +) { + const modeConfig = MODE_CONFIG[input.mode]; + if (!modeConfig) { + return; + } + + const existing = await selectAssignment(c.db, input.playerId); + if (existing) { + return; + } + + const now = Date.now(); + await upsertQueueEntry(c.db, { + playerId: input.playerId, + mode: input.mode, + queuedAt: now, + }); + + const createInput = await tryCreateMatch(c.db, { + playerId: input.playerId, + mode: input.mode, + capacity: modeConfig.capacity, + teams: modeConfig.teams, + now, + }); + + if (createInput) { + await createMatchActor(c, createInput); + } +} + +async function processMatchCompleted( + c: MatchmakerContext, + input: { matchId: string }, +) { + const match = await selectMatchById(c.db, input.matchId); + if (!match) { + return; + } + await deleteAssignmentsByMatchId(c.db, input.matchId); + await deleteMatchById(c.db, input.matchId); +} + +async function migrateTables(dbHandle: RawAccess) { + // This table stores waiting players grouped by mode and queue time. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS queue ( + player_id TEXT PRIMARY KEY, + mode TEXT NOT NULL, + queued_at INTEGER NOT NULL + ) + `); + // This table maps each queued player to the match and team they were assigned to. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS assignments ( + player_id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + mode TEXT NOT NULL, + team_id INTEGER NOT NULL, + assigned_at INTEGER NOT NULL + ) + `); + // This table records created matches for lifecycle tracking and cleanup. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS matches ( + match_id TEXT PRIMARY KEY, + mode TEXT NOT NULL, + capacity INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + `); + // This index speeds up queue reads by mode and arrival order. + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS queue_mode_idx ON queue (mode, queued_at)", + ); +} + +async function selectAssignment( + dbHandle: RawAccess, + playerId: string, +): Promise { + // A player has at most one active competitive assignment. + const rows = (await dbHandle.execute( + `SELECT player_id, match_id, mode, team_id, assigned_at FROM assignments WHERE player_id = ${sqlString(playerId)} LIMIT 1`, + )) as AssignmentRow[]; + return rows[0] ?? null; +} + +async function upsertQueueEntry( + dbHandle: RawAccess, + input: { playerId: string; mode: Mode; queuedAt: number }, +) { + // Upsert keeps one queue row per player while allowing mode changes. + await dbHandle.execute( + `INSERT INTO queue (player_id, mode, queued_at) + VALUES (${sqlString(input.playerId)}, ${sqlString(input.mode)}, ${sqlInt(input.queuedAt)}) + ON CONFLICT(player_id) DO UPDATE SET + mode = excluded.mode, + queued_at = excluded.queued_at`, + ); +} + +async function tryCreateMatch( + dbHandle: RawAccess, + input: { + playerId: string; + mode: Mode; + capacity: number; + teams: number; + now: number; + }, +): Promise { + await beginImmediateTransaction(dbHandle); + try { + // Re-read assignment after locking to avoid double-match races. + const lockedAssignment = await selectAssignment(dbHandle, input.playerId); + if (lockedAssignment) { + await commitTransaction(dbHandle); + return null; + } + + // Read the full queue for the requested mode in FIFO order. + const queuedPlayers = await listQueueByMode(dbHandle, input.mode); + if (queuedPlayers.length < input.capacity) { + await commitTransaction(dbHandle); + return null; + } + + // Build one full match from the oldest players in this mode queue. + const selected = queuedPlayers.slice(0, input.capacity); + const matchId = buildId(`competitive-${input.mode}`); + const assignedPlayers = selected.map((row, idx) => ({ + playerId: row.player_id, + name: row.player_id, + teamId: idx % input.teams, + })); + + await deleteQueuePlayers( + dbHandle, + selected.map((row) => row.player_id), + ); + await insertMatchRow(dbHandle, { + matchId, + mode: input.mode, + capacity: input.capacity, + createdAt: input.now, + }); + for (const assigned of assignedPlayers) { + await insertAssignmentRow(dbHandle, { + playerId: assigned.playerId, + matchId, + mode: input.mode, + teamId: assigned.teamId, + assignedAt: input.now, + }); + } + + await commitTransaction(dbHandle); + return { + matchId, + mode: input.mode, + tickMs: TICK_MS, + capacity: input.capacity, + assignedPlayers, + }; + } catch (err) { + await rollbackTransaction(dbHandle); + throw err; + } +} + +async function listQueueByMode( + dbHandle: RawAccess, + mode: Mode, +): Promise { + return (await dbHandle.execute( + `SELECT player_id, queued_at FROM queue WHERE mode = ${sqlString(mode)} ORDER BY queued_at ASC`, + )) as QueueRow[]; +} + +async function countQueueByMode( + dbHandle: RawAccess, + mode: Mode, +): Promise { + const rows = (await dbHandle.execute( + `SELECT COUNT(*) AS count FROM queue WHERE mode = ${sqlString(mode)}`, + )) as Array<{ count: number }>; + return Number(rows[0]?.count ?? 0); +} + +async function deleteQueuePlayers(dbHandle: RawAccess, playerIds: string[]) { + if (playerIds.length === 0) { + return; + } + const selectedPlayerSql = playerIds.map((playerId) => sqlString(playerId)).join(", "); + // Remove selected players from queue before persisting assignments. + await dbHandle.execute( + `DELETE FROM queue WHERE player_id IN (${selectedPlayerSql})`, + ); +} + +async function insertMatchRow( + dbHandle: RawAccess, + input: { + matchId: string; + mode: Mode; + capacity: number; + createdAt: number; + }, +) { + await dbHandle.execute( + `INSERT INTO matches (match_id, mode, capacity, created_at) VALUES (${sqlString(input.matchId)}, ${sqlString(input.mode)}, ${sqlInt(input.capacity)}, ${sqlInt(input.createdAt)})`, + ); +} + +async function insertAssignmentRow( + dbHandle: RawAccess, + input: { + playerId: string; + matchId: string; + mode: Mode; + teamId: number; + assignedAt: number; + }, +) { + // Persist a per-player assignment row so clients can poll their outcome. + await dbHandle.execute( + `INSERT INTO assignments (player_id, match_id, mode, team_id, assigned_at) + VALUES (${sqlString(input.playerId)}, ${sqlString(input.matchId)}, ${sqlString(input.mode)}, ${sqlInt(input.teamId)}, ${sqlInt(input.assignedAt)})`, + ); +} + +async function issuePlayerToken( + c: MatchmakerContext, + input: { matchId: string; playerId: string }, +): Promise { + try { + const client = c.client(); + const result = (await client.competitiveMatch + .get([input.matchId]) + .issuePlayerToken({ + playerId: input.playerId, + })) as { playerToken?: string }; + return result.playerToken ?? null; + } catch { + return null; + } +} + +async function selectMatchById( + dbHandle: RawAccess, + matchId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT match_id FROM matches WHERE match_id = ${sqlString(matchId)} LIMIT 1`, + )) as MatchRow[]; + return rows[0] ?? null; +} + +async function deleteAssignmentsByMatchId(dbHandle: RawAccess, matchId: string) { + // Remove assignments when the match actor reports completion. + await dbHandle.execute( + `DELETE FROM assignments WHERE match_id = ${sqlString(matchId)}`, + ); +} + +async function deleteMatchById(dbHandle: RawAccess, matchId: string) { + // Remove persisted match metadata after completion. + await dbHandle.execute(`DELETE FROM matches WHERE match_id = ${sqlString(matchId)}`); +} + +async function beginImmediateTransaction(dbHandle: RawAccess) { + await dbHandle.execute("BEGIN IMMEDIATE"); +} + +async function commitTransaction(dbHandle: RawAccess) { + await dbHandle.execute("COMMIT"); +} + +async function rollbackTransaction(dbHandle: RawAccess) { + await dbHandle.execute("ROLLBACK"); +} + +async function createMatchActor(c: MatchmakerContext, input: CreateMatchInput) { + // Create the match actor after SQL commit so the lock stays short. + const client = c.client(); + await client.competitiveMatch.create([input.matchId], { + input, + }); +} diff --git a/examples/multiplayer-game-patterns/src/actors/index.ts b/examples/multiplayer-game-patterns/src/actors/index.ts new file mode 100644 index 0000000000..6f43a68a1c --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/index.ts @@ -0,0 +1,51 @@ +import { setup } from "rivetkit"; +import { asyncTurnBasedMatch } from "./turn-based/match.ts"; +import { asyncTurnBasedMatchmaker } from "./turn-based/matchmaker.ts"; +import { battleRoyaleMatch } from "./battle-royale/match.ts"; +import { battleRoyaleMatchmaker } from "./battle-royale/matchmaker.ts"; +import { competitiveMatch } from "./competitive/match.ts"; +import { competitiveMatchmaker } from "./competitive/matchmaker.ts"; +import { ioStyleMatch } from "./io-style/match.ts"; +import { ioStyleMatchmaker } from "./io-style/matchmaker.ts"; +import { openWorldChunk } from "./open-world/chunk.ts"; +import { openWorldIndex } from "./open-world/world-index.ts"; +import { partyMatch } from "./party/match.ts"; +import { partyMatchmaker } from "./party/matchmaker.ts"; +import { rankedMatch } from "./ranked/match.ts"; +import { rankedMatchmaker } from "./ranked/matchmaker.ts"; + +export { + asyncTurnBasedMatch, + asyncTurnBasedMatchmaker, + battleRoyaleMatch, + battleRoyaleMatchmaker, + competitiveMatch, + competitiveMatchmaker, + ioStyleMatch, + ioStyleMatchmaker, + openWorldChunk, + openWorldIndex, + partyMatch, + partyMatchmaker, + rankedMatch, + rankedMatchmaker, +}; + +export const registry = setup({ + use: { + ioStyleMatchmaker, + ioStyleMatch, + competitiveMatchmaker, + competitiveMatch, + partyMatchmaker, + partyMatch, + openWorldIndex, + openWorldChunk, + asyncTurnBasedMatchmaker, + asyncTurnBasedMatch, + rankedMatchmaker, + rankedMatch, + battleRoyaleMatchmaker, + battleRoyaleMatch, + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/io-style/match.ts b/examples/multiplayer-game-patterns/src/actors/io-style/match.ts new file mode 100644 index 0000000000..ef4fd51c35 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/io-style/match.ts @@ -0,0 +1,169 @@ +import { actor } from "rivetkit"; +import { err, sleep } from "../../utils.ts"; +import { buildSecret } from "../shared/ids.ts"; + +interface Player { + playerId: string; + name: string; + joinedAt: number; +} + +interface State { + matchId: string; + trusted: boolean; + capacity: number; + tickMs: number; + tick: number; + phase: "lobby" | "live"; + players: Record; + playerTokens: Record; +} + +function requireTrusted(state: State) { + if (!state.trusted) { + err("match is not trusted", "untrusted_lobby"); + } +} + +function buildSnapshot(state: State) { + // Snapshots expose scaffold state to clients without gameplay internals. + return { + matchId: state.matchId, + capacity: state.capacity, + tick: state.tick, + phase: state.phase, + players: state.players, + playerCount: Object.keys(state.players).length, + }; +} + +async function syncRoomIndex(c: { + state: State; + client: () => any; +}) { + // The matchmaker stores room index rows in SQLite. + // This heartbeat call keeps that index in sync with in-memory player counts. + const client = c.client(); + await client.ioStyleMatchmaker.getOrCreate(["main"]).queue.roomHeartbeat.send({ + matchId: c.state.matchId, + playerCount: Object.keys(c.state.players).length, + capacity: c.state.capacity, + }); +} + +export const ioStyleMatch = actor({ + createState: ( + _c, + input: { matchId: string; capacity: number; tickMs: number }, + ): State => ({ + matchId: input.matchId, + trusted: true, + capacity: input.capacity, + tickMs: input.tickMs, + tick: 0, + phase: "lobby", + players: {}, + playerTokens: {}, + }), + createConnState: (_c, _params: { playerToken?: string }) => ({ + playerId: null as string | null, + }), + onBeforeConnect: (c, params: { playerToken?: string }) => { + const playerToken = params?.playerToken?.trim(); + if (!playerToken) { + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + err("invalid player token", "invalid_player_token"); + } + }, + onConnect: async (c, conn) => { + const playerToken = conn.params?.playerToken?.trim(); + if (!playerToken) { + conn.disconnect("invalid_player_token"); + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + conn.disconnect("invalid_player_token"); + return; + } + + if (!c.state.players[playerId]) { + if (Object.keys(c.state.players).length >= c.state.capacity) { + conn.disconnect("room_full"); + return; + } + c.state.players[playerId] = { + playerId, + name: playerId, + joinedAt: Date.now(), + }; + } + + conn.state.playerId = playerId; + c.state.phase = "live"; + await syncRoomIndex(c); + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onWake: async (c) => { + // Publish the initial room row when this actor wakes. + await syncRoomIndex(c); + }, + onDestroy: async (c) => { + const client = c.client(); + try { + await client.ioStyleMatchmaker.getOrCreate(["main"]).queue.roomClosed.send({ + matchId: c.state.matchId, + }); + } catch { + // Best effort during shutdown. + } + }, + onDisconnect: async (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + if (!c.state.players[playerId]) return; + + delete c.state.players[playerId]; + conn.state.playerId = null; + c.state.phase = Object.keys(c.state.players).length > 0 ? "live" : "lobby"; + try { + await syncRoomIndex(c); + } catch { + // Best effort during teardown. + } + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + return; + } + try { + c.broadcast("snapshot", buildSnapshot(c.state)); + } catch { + // Best effort during teardown. + } + }, + run: async (c) => { + // This run loop is the 10 tps scaffold tick for io-style realtime sessions. + while (!c.aborted) { + await sleep(c.state.tickMs); + if (c.aborted) break; + c.state.tick += 1; + c.state.phase = Object.keys(c.state.players).length > 0 ? "live" : "lobby"; + // Broadcast a lightweight tick snapshot to all connected players. + c.broadcast("tick", buildSnapshot(c.state)); + } + }, + actions: { + issuePlayerToken: ( + c, + input: { playerId: string }, + ) => { + const playerToken = buildSecret(); + c.state.playerTokens[playerToken] = input.playerId; + return { playerId: input.playerId, playerToken }; + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts new file mode 100644 index 0000000000..4a013f4091 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts @@ -0,0 +1,327 @@ +import { actor } from "rivetkit"; +import { db, type RawAccess } from "rivetkit/db"; +import { buildId } from "../shared/ids.ts"; +import { sqlInt, sqlString } from "../shared/sql.ts"; + +const DEFAULT_CAPACITY = 32; +const TICK_MS = 100; +const ROOM_STALE_MS = 5 * 60_000; + +interface RoomRow { + room_id: string; + player_count: number; + capacity: number; + updated_at: number; +} + +interface PlayerSessionRow { + player_id: string; + room_id: string; + updated_at: number; +} + +export const ioStyleMatchmaker = actor({ + db: db({ + onMigrate: migrateTables, + }), + run: async (c) => { + while (!c.aborted) { + const [message] = + (await c.queue.next(["findOpenLobby", "roomHeartbeat", "roomClosed"], { + count: 1, + timeout: 100, + })) ?? []; + if (!message) continue; + + if (message.name === "findOpenLobby") { + const input = message.body as { playerId?: string }; + if (!input?.playerId) { + continue; + } + await processFindOpenLobby(c, { playerId: input.playerId }); + continue; + } + + if (message.name === "roomHeartbeat") { + const input = message.body as { + matchId?: string; + playerCount?: number; + capacity?: number; + }; + if ( + !input?.matchId || + typeof input.playerCount !== "number" || + typeof input.capacity !== "number" + ) { + continue; + } + await processRoomHeartbeat(c, { + matchId: input.matchId, + playerCount: input.playerCount, + capacity: input.capacity, + }); + continue; + } + + if (message.name === "roomClosed") { + const input = message.body as { matchId?: string }; + if (!input?.matchId) { + continue; + } + await processRoomClosed(c, { + matchId: input.matchId, + }); + } + } + }, + actions: { + getLobbyForPlayer: async (c, input: { playerId: string }) => { + const session = await selectPlayerSessionByPlayerId(c.db, input.playerId); + if (!session) { + return null; + } + const room = await selectRoomById(c.db, session.room_id); + if (!room) { + return null; + } + const playerToken = await issuePlayerToken(c, { + matchId: room.room_id, + playerId: session.player_id, + }); + if (!playerToken) { + return null; + } + return { + matchId: room.room_id, + playerId: session.player_id, + playerToken, + roomPlayerCount: Number(room.player_count), + roomCapacity: Number(room.capacity), + }; + }, + }, +}); + +type MatchmakerContext = { + db: RawAccess; + client: () => any; +}; + +async function processFindOpenLobby(c: MatchmakerContext, input: { playerId: string }) { + const now = Date.now(); + await pruneStaleRooms(c.db, now); + + const existing = await selectPlayerSessionByPlayerId(c.db, input.playerId); + if (existing) { + const room = await selectRoomById(c.db, existing.room_id); + if (room && Number(room.player_count) < Number(room.capacity)) { + await touchRoom(c.db, room.room_id, now); + return; + } + await deletePlayerSessionByPlayer(c.db, input.playerId); + } + + let room = await selectBestOpenRoom(c.db); + if (!room) { + const matchId = buildId("io"); + await upsertRoom(c.db, { + roomId: matchId, + playerCount: 0, + capacity: DEFAULT_CAPACITY, + updatedAt: now, + }); + await createMatchActor(c, { matchId }); + room = { + room_id: matchId, + player_count: 0, + capacity: DEFAULT_CAPACITY, + updated_at: now, + }; + } + + await upsertPlayerSession(c.db, { + playerId: input.playerId, + roomId: room.room_id, + updatedAt: now, + }); + await touchRoom(c.db, room.room_id, now); +} + +async function processRoomHeartbeat( + c: MatchmakerContext, + input: { matchId: string; playerCount: number; capacity: number }, +) { + const room = await selectRoomById(c.db, input.matchId); + if (!room) { + return; + } + await upsertRoom(c.db, { + roomId: input.matchId, + playerCount: input.playerCount, + capacity: input.capacity, + updatedAt: Date.now(), + }); +} + +async function processRoomClosed( + c: MatchmakerContext, + input: { matchId: string }, +) { + const room = await selectRoomById(c.db, input.matchId); + if (!room) { + return; + } + await deletePlayerSessionsByRoom(c.db, input.matchId); + await deleteRoom(c.db, input.matchId); +} + +async function migrateTables(dbHandle: RawAccess) { + // This table is the matchmaker index for io rooms. + // Each row tracks occupancy and freshness for one room actor. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY, + player_count INTEGER NOT NULL, + capacity INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS player_sessions ( + player_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + // This index makes open-room lookups fast by occupancy and recency. + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS rooms_open_idx ON rooms (player_count, updated_at)", + ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS player_sessions_room_idx ON player_sessions (room_id)", + ); +} + +async function pruneStaleRooms(dbHandle: RawAccess, now: number) { + const cutoff = now - ROOM_STALE_MS; + const staleRooms = (await dbHandle.execute( + `SELECT room_id FROM rooms WHERE player_count = 0 AND updated_at < ${sqlInt(cutoff)}`, + )) as Array<{ room_id: string }>; + if (staleRooms.length === 0) { + return; + } + const roomSql = staleRooms.map((row) => sqlString(row.room_id)).join(", "); + // Remove empty rooms that have gone stale so the index stays clean. + await dbHandle.execute(`DELETE FROM player_sessions WHERE room_id IN (${roomSql})`); + await dbHandle.execute(`DELETE FROM rooms WHERE room_id IN (${roomSql})`); +} + +async function selectRoomById(dbHandle: RawAccess, roomId: string): Promise { + const rows = (await dbHandle.execute( + `SELECT room_id, player_count, capacity, updated_at FROM rooms WHERE room_id = ${sqlString(roomId)} LIMIT 1`, + )) as RoomRow[]; + return rows[0] ?? null; +} + +async function selectBestOpenRoom(dbHandle: RawAccess): Promise { + // Route into the fullest open room so players converge quickly. + const rows = (await dbHandle.execute( + "SELECT room_id, player_count, capacity, updated_at FROM rooms WHERE player_count < capacity ORDER BY player_count DESC, updated_at DESC LIMIT 1", + )) as RoomRow[]; + return rows[0] ?? null; +} + +async function selectPlayerSessionByPlayerId( + dbHandle: RawAccess, + playerId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT player_id, room_id, updated_at FROM player_sessions WHERE player_id = ${sqlString(playerId)} LIMIT 1`, + )) as PlayerSessionRow[]; + return rows[0] ?? null; +} + +async function upsertPlayerSession( + dbHandle: RawAccess, + input: { playerId: string; roomId: string; updatedAt: number }, +) { + await dbHandle.execute( + `INSERT INTO player_sessions (player_id, room_id, updated_at) + VALUES (${sqlString(input.playerId)}, ${sqlString(input.roomId)}, ${sqlInt(input.updatedAt)}) + ON CONFLICT(player_id) DO UPDATE SET + room_id = excluded.room_id, + updated_at = excluded.updated_at`, + ); +} + +async function deletePlayerSessionByPlayer(dbHandle: RawAccess, playerId: string) { + await dbHandle.execute(`DELETE FROM player_sessions WHERE player_id = ${sqlString(playerId)}`); +} + +async function deletePlayerSessionsByRoom(dbHandle: RawAccess, roomId: string) { + await dbHandle.execute(`DELETE FROM player_sessions WHERE room_id = ${sqlString(roomId)}`); +} + +async function touchRoom(dbHandle: RawAccess, roomId: string, now: number) { + // Touch the row so the room is considered active during matchmaking. + await dbHandle.execute( + `UPDATE rooms SET updated_at = ${sqlInt(now)} WHERE room_id = ${sqlString(roomId)}`, + ); +} + +async function upsertRoom( + dbHandle: RawAccess, + input: { + roomId: string; + playerCount: number; + capacity: number; + updatedAt: number; + }, +) { + // Upsert keeps one canonical room row while live occupancy changes. + await dbHandle.execute( + `INSERT INTO rooms (room_id, player_count, capacity, updated_at) + VALUES (${sqlString(input.roomId)}, ${sqlInt(input.playerCount)}, ${sqlInt(input.capacity)}, ${sqlInt(input.updatedAt)}) + ON CONFLICT(room_id) DO UPDATE SET + player_count = excluded.player_count, + capacity = excluded.capacity, + updated_at = excluded.updated_at`, + ); +} + +async function deleteRoom(dbHandle: RawAccess, roomId: string) { + // Remove the room from matchmaking once the actor is closed. + await dbHandle.execute(`DELETE FROM rooms WHERE room_id = ${sqlString(roomId)}`); +} + +async function createMatchActor( + c: MatchmakerContext, + input: { matchId: string }, +) { + // Create a new room actor when no open room is available. + const client = c.client(); + await client.ioStyleMatch.create([input.matchId], { + input: { + matchId: input.matchId, + capacity: DEFAULT_CAPACITY, + tickMs: TICK_MS, + }, + }); +} + +async function issuePlayerToken( + c: MatchmakerContext, + input: { matchId: string; playerId: string }, +): Promise { + try { + const client = c.client(); + const res = (await client.ioStyleMatch + .get([input.matchId]) + .issuePlayerToken({ + playerId: input.playerId, + })) as { playerToken?: string }; + return res.playerToken ?? null; + } catch { + return null; + } +} diff --git a/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts b/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts new file mode 100644 index 0000000000..08f75a137f --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts @@ -0,0 +1,268 @@ +import { actor } from "rivetkit"; +import { err, sleep } from "../../utils.ts"; + +const DEFAULT_TICK_MS = 100; +const MIN_TICK_MS = 33; +const MAX_TICK_MS = 1000; + +interface ChunkPlayer { + playerId: string; + name: string; + worldX: number; + worldY: number; + joinedAt: number; + updatedAt: number; +} + +interface OpenWorldChunkState { + worldId: string; + chunkX: number; + chunkY: number; + chunkSize: number; + tickMs: number; + tick: number; + players: Record; + createdAt: number; + updatedAt: number; +} + +function normalizeTickMs(tickMs: number | undefined): number { + const next = tickMs ?? DEFAULT_TICK_MS; + if (!Number.isFinite(next) || next < MIN_TICK_MS || next > MAX_TICK_MS) { + err( + `tickMs must be between ${MIN_TICK_MS} and ${MAX_TICK_MS}`, + "invalid_tick_ms", + ); + } + return Math.floor(next); +} + +function chunkCoordFor(position: number, chunkSize: number): number { + if (!Number.isFinite(position)) { + err("position must be finite", "invalid_position"); + } + return Math.floor(position / chunkSize); +} + +function belongsToChunk( + state: OpenWorldChunkState, + input: { worldX: number; worldY: number }, +) { + const chunkX = chunkCoordFor(input.worldX, state.chunkSize); + const chunkY = chunkCoordFor(input.worldY, state.chunkSize); + return chunkX === state.chunkX && chunkY === state.chunkY; +} + +function buildSnapshot(state: OpenWorldChunkState) { + return { + worldId: state.worldId, + chunkX: state.chunkX, + chunkY: state.chunkY, + chunkSize: state.chunkSize, + tickMs: state.tickMs, + tick: state.tick, + playerCount: Object.keys(state.players).length, + players: state.players, + updatedAt: state.updatedAt, + }; +} + +function chunkKeyFromPosition( + state: OpenWorldChunkState, + input: { worldX: number; worldY: number }, +): [string, string, string] { + return [ + state.worldId, + String(chunkCoordFor(input.worldX, state.chunkSize)), + String(chunkCoordFor(input.worldY, state.chunkSize)), + ]; +} + +async function ensureDestinationChunk( + c: { client: () => any }, + input: { chunkKey: [string, string, string]; chunkSize: number; tickMs: number }, +) { + const client = c.client(); + const [worldId, chunkX, chunkY] = input.chunkKey; + try { + await client.openWorldChunk.create(input.chunkKey, { + input: { + worldId, + chunkX: Number(chunkX), + chunkY: Number(chunkY), + chunkSize: input.chunkSize, + tickMs: input.tickMs, + }, + }); + } catch { + // The chunk may already exist. + } + await client.openWorldChunk.getOrCreate(input.chunkKey).ensureChunk({ + worldId, + chunkX: Number(chunkX), + chunkY: Number(chunkY), + chunkSize: input.chunkSize, + tickMs: input.tickMs, + }); +} + +export const openWorldChunk = actor({ + createState: ( + c, + input?: { + worldId: string; + chunkX: number; + chunkY: number; + chunkSize: number; + tickMs?: number; + }, + ): OpenWorldChunkState => { + const now = Date.now(); + const rawKey = typeof c.key[0] === "string" ? c.key[0] : "world/0/0"; + const keyParts = rawKey.split("/"); + const keyWorldId = keyParts[0] || "world"; + const keyChunkX = Number(c.key[1] ?? keyParts[1]); + const keyChunkY = Number(c.key[2] ?? keyParts[2]); + return { + worldId: input?.worldId ?? keyWorldId, + chunkX: input?.chunkX ?? (Number.isFinite(keyChunkX) ? keyChunkX : 0), + chunkY: input?.chunkY ?? (Number.isFinite(keyChunkY) ? keyChunkY : 0), + chunkSize: input?.chunkSize ?? 256, + tickMs: normalizeTickMs(input?.tickMs), + tick: 0, + players: {}, + createdAt: now, + updatedAt: now, + }; + }, + connState: { + playerId: null as string | null, + }, + onDisconnect: (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + if (!c.state.players[playerId]) return; + delete c.state.players[playerId]; + conn.state.playerId = null; + c.state.updatedAt = Date.now(); + c.broadcast("snapshot", buildSnapshot(c.state)); + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + } + }, + run: async (c) => { + while (!c.aborted) { + await sleep(c.state.tickMs); + if (c.aborted) break; + c.state.tick += 1; + c.state.updatedAt = Date.now(); + c.broadcast("tick", buildSnapshot(c.state)); + } + }, + actions: { + ensureChunk: ( + c, + input: { + worldId: string; + chunkX: number; + chunkY: number; + chunkSize: number; + tickMs?: number; + }, + ) => { + if (input.worldId !== c.state.worldId) { + err("world does not match actor key", "world_mismatch"); + } + if (input.chunkX !== c.state.chunkX || input.chunkY !== c.state.chunkY) { + err("chunk coordinates do not match actor key", "chunk_mismatch"); + } + if ( + input.chunkSize !== c.state.chunkSize && + Object.keys(c.state.players).length > 0 + ) { + err("chunk size does not match actor", "chunk_size_mismatch"); + } + c.state.chunkSize = input.chunkSize; + if (input.tickMs != null) { + c.state.tickMs = normalizeTickMs(input.tickMs); + } + return buildSnapshot(c.state); + }, + join: (c, input: { playerId: string; name: string; worldX: number; worldY: number }) => { + if (!belongsToChunk(c.state, input)) { + err("player position does not belong to this chunk", "wrong_chunk"); + } + const now = Date.now(); + const existing = c.state.players[input.playerId]; + if (existing) { + existing.worldX = input.worldX; + existing.worldY = input.worldY; + existing.updatedAt = now; + c.conn.state.playerId = input.playerId; + c.state.updatedAt = now; + c.broadcast("snapshot", buildSnapshot(c.state)); + return { joined: true, snapshot: buildSnapshot(c.state) }; + } + c.state.players[input.playerId] = { + playerId: input.playerId, + name: input.name.trim() || input.playerId, + worldX: input.worldX, + worldY: input.worldY, + joinedAt: now, + updatedAt: now, + }; + c.conn.state.playerId = input.playerId; + c.state.updatedAt = now; + c.broadcast("snapshot", buildSnapshot(c.state)); + return { joined: true, snapshot: buildSnapshot(c.state) }; + }, + move: async ( + c, + input: { playerId: string; worldX: number; worldY: number; createNextChunk?: boolean }, + ) => { + if (c.conn.state.playerId !== input.playerId) { + err("caller does not own player", "player_mismatch"); + } + const player = c.state.players[input.playerId]; + if (!player) { + err("player not in this chunk", "not_joined"); + } + + const destinationKey = chunkKeyFromPosition(c.state, input); + const isSameChunk = + destinationKey[1] === String(c.state.chunkX) && + destinationKey[2] === String(c.state.chunkY); + + if (!isSameChunk) { + if (input.createNextChunk !== false) { + await ensureDestinationChunk(c, { + chunkKey: destinationKey, + chunkSize: c.state.chunkSize, + tickMs: c.state.tickMs, + }); + } + delete c.state.players[input.playerId]; + c.conn.state.playerId = null; + c.state.updatedAt = Date.now(); + c.broadcast("snapshot", buildSnapshot(c.state)); + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + } + return { + moved: false as const, + reason: "cross_chunk" as const, + nextChunkKey: destinationKey, + snapshot: buildSnapshot(c.state), + }; + } + + player.worldX = input.worldX; + player.worldY = input.worldY; + player.updatedAt = Date.now(); + c.state.updatedAt = player.updatedAt; + c.broadcast("snapshot", buildSnapshot(c.state)); + return { moved: true as const, snapshot: buildSnapshot(c.state) }; + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/open-world/world-index.ts b/examples/multiplayer-game-patterns/src/actors/open-world/world-index.ts new file mode 100644 index 0000000000..9bfc4199a7 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/open-world/world-index.ts @@ -0,0 +1,232 @@ +import { actor } from "rivetkit"; +import { err } from "../../utils.ts"; + +const DEFAULT_CHUNK_SIZE = 256; +const MIN_CHUNK_SIZE = 32; +const MAX_CHUNK_SIZE = 4096; +const DEFAULT_WINDOW_RADIUS = 1; +const MAX_WINDOW_RADIUS = 4; +const DEFAULT_TICK_MS = 100; + +interface WorldConfig { + worldId: string; + chunkSize: number; + createdAt: number; + updatedAt: number; +} + +interface OpenWorldIndexState { + worlds: Record; +} + +function normalizeChunkSize(chunkSize: number | undefined): number { + if (chunkSize == null) return DEFAULT_CHUNK_SIZE; + if (!Number.isFinite(chunkSize)) { + err("chunk size must be finite", "invalid_chunk_size"); + } + const normalized = Math.floor(chunkSize); + if (normalized < MIN_CHUNK_SIZE || normalized > MAX_CHUNK_SIZE) { + err( + `chunk size must be between ${MIN_CHUNK_SIZE} and ${MAX_CHUNK_SIZE}`, + "chunk_size_out_of_range", + ); + } + return normalized; +} + +function normalizeRadius(radius: number | undefined): number { + const next = radius ?? DEFAULT_WINDOW_RADIUS; + if (!Number.isInteger(next) || next < 0 || next > MAX_WINDOW_RADIUS) { + err( + `radius must be an integer between 0 and ${MAX_WINDOW_RADIUS}`, + "invalid_radius", + ); + } + return next; +} + +function chunkCoordFor(position: number, chunkSize: number): number { + if (!Number.isFinite(position)) { + err("position must be finite", "invalid_position"); + } + return Math.floor(position / chunkSize); +} + +function chunkActorKey( + worldId: string, + chunkX: number, + chunkY: number, +): [string, string, string] { + return [worldId, String(chunkX), String(chunkY)]; +} + +function ensureWorld( + state: OpenWorldIndexState, + input: { worldId: string; chunkSize?: number }, +): WorldConfig { + const worldId = input.worldId.trim(); + if (!worldId) { + err("world id cannot be empty", "invalid_world_id"); + } + + const existing = state.worlds[worldId]; + if (existing) { + if ( + input.chunkSize != null && + normalizeChunkSize(input.chunkSize) !== existing.chunkSize + ) { + err( + "cannot change chunk size for an existing world", + "chunk_size_locked", + ); + } + existing.updatedAt = Date.now(); + return existing; + } + + const now = Date.now(); + const world: WorldConfig = { + worldId, + chunkSize: normalizeChunkSize(input.chunkSize), + createdAt: now, + updatedAt: now, + }; + state.worlds[worldId] = world; + return world; +} + +async function ensureChunkActor( + c: { client: () => any }, + input: { worldId: string; chunkX: number; chunkY: number; chunkSize: number }, +) { + const client = c.client(); + const key = chunkActorKey(input.worldId, input.chunkX, input.chunkY); + try { + await client.openWorldChunk.create(key, { + input: { + worldId: input.worldId, + chunkX: input.chunkX, + chunkY: input.chunkY, + chunkSize: input.chunkSize, + tickMs: DEFAULT_TICK_MS, + }, + }); + } catch { + // The chunk may already exist. + } + await client.openWorldChunk.getOrCreate(key).ensureChunk({ + worldId: input.worldId, + chunkX: input.chunkX, + chunkY: input.chunkY, + chunkSize: input.chunkSize, + tickMs: DEFAULT_TICK_MS, + }); +} + +export const openWorldIndex = actor({ + state: { + worlds: {}, + } as OpenWorldIndexState, + actions: { + registerWorld: (c, input: { worldId: string; chunkSize?: number }) => { + const world = ensureWorld(c.state, input); + return { + worldId: world.worldId, + chunkSize: world.chunkSize, + createdAt: world.createdAt, + updatedAt: world.updatedAt, + }; + }, + resolveChunk: async ( + c, + input: { worldId: string; worldX: number; worldY: number; create?: boolean }, + ) => { + const world = ensureWorld(c.state, { worldId: input.worldId }); + const chunkX = chunkCoordFor(input.worldX, world.chunkSize); + const chunkY = chunkCoordFor(input.worldY, world.chunkSize); + if (input.create !== false) { + await ensureChunkActor(c, { + worldId: world.worldId, + chunkX, + chunkY, + chunkSize: world.chunkSize, + }); + } + return { + worldId: world.worldId, + chunkSize: world.chunkSize, + chunkX, + chunkY, + chunkKey: chunkActorKey(world.worldId, chunkX, chunkY), + }; + }, + ensureChunk: async ( + c, + input: { worldId: string; chunkX: number; chunkY: number; create?: boolean }, + ) => { + const world = ensureWorld(c.state, { worldId: input.worldId }); + if (input.create !== false) { + await ensureChunkActor(c, { + worldId: world.worldId, + chunkX: input.chunkX, + chunkY: input.chunkY, + chunkSize: world.chunkSize, + }); + } + return { + worldId: world.worldId, + chunkSize: world.chunkSize, + chunkX: input.chunkX, + chunkY: input.chunkY, + chunkKey: chunkActorKey(world.worldId, input.chunkX, input.chunkY), + }; + }, + listChunkWindow: async ( + c, + input: { + worldId: string; + centerWorldX: number; + centerWorldY: number; + radius?: number; + create?: boolean; + }, + ) => { + const world = ensureWorld(c.state, { worldId: input.worldId }); + const radius = normalizeRadius(input.radius); + const centerChunkX = chunkCoordFor(input.centerWorldX, world.chunkSize); + const centerChunkY = chunkCoordFor(input.centerWorldY, world.chunkSize); + const chunks: Array<{ + chunkX: number; + chunkY: number; + chunkKey: [string, string, string]; + }> = []; + + for (let y = centerChunkY - radius; y <= centerChunkY + radius; y++) { + for (let x = centerChunkX - radius; x <= centerChunkX + radius; x++) { + if (input.create !== false) { + await ensureChunkActor(c, { + worldId: world.worldId, + chunkX: x, + chunkY: y, + chunkSize: world.chunkSize, + }); + } + chunks.push({ + chunkX: x, + chunkY: y, + chunkKey: chunkActorKey(world.worldId, x, y), + }); + } + } + + return { + worldId: world.worldId, + chunkSize: world.chunkSize, + centerChunkX, + centerChunkY, + radius, + chunks, + }; + }, + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/party/match.ts b/examples/multiplayer-game-patterns/src/actors/party/match.ts new file mode 100644 index 0000000000..40160882da --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/party/match.ts @@ -0,0 +1,178 @@ +import { actor } from "rivetkit"; +import { err } from "../../utils.ts"; +import { buildSecret } from "../shared/ids.ts"; + +interface Player { + playerId: string; + name: string; + joinedAt: number; +} + +interface State { + matchId: string; + partyCode: string; + hostPlayerId: string; + trusted: boolean; + phase: "lobby" | "in_progress" | "finished"; + players: Record; + playerTokens: Record; + startedAt: number | null; +} + +function requireTrusted(state: State) { + if (!state.trusted) { + err("match is not trusted", "untrusted_lobby"); + } +} + +function requireJoinedHost(c: { + state: State; + conn: { state: { playerId: string | null } }; +}) { + const playerId = c.conn.state.playerId; + if (!playerId) { + err("caller is not joined", "not_joined"); + } + if (playerId !== c.state.hostPlayerId) { + err("only host can perform this action", "host_only"); + } +} + +function buildSnapshot(state: State) { + // Party snapshot favors readability over game-specific detail. + return { + matchId: state.matchId, + partyCode: state.partyCode, + hostPlayerId: state.hostPlayerId, + phase: state.phase, + players: state.players, + startedAt: state.startedAt, + }; +} + +async function closeLobby(c: { + state: State; + client: () => any; +}) { + const client = c.client(); + try { + // Party discovery lives in matchmaker SQLite tables. + // This call removes party rows when the match actor shuts down. + await client.partyMatchmaker.getOrCreate(["main"]).queue.closeParty.send({ + partyCode: c.state.partyCode, + }); + } catch { + // Best effort during shutdown. + } +} + +export const partyMatch = actor({ + createState: ( + _c, + input: { matchId: string; partyCode: string; hostPlayerId: string }, + ): State => ({ + matchId: input.matchId, + partyCode: input.partyCode, + hostPlayerId: input.hostPlayerId, + trusted: true, + phase: "lobby", + players: {}, + playerTokens: {}, + startedAt: null, + }), + createConnState: (_c, _params: { playerToken?: string }) => ({ + playerId: null as string | null, + }), + onBeforeConnect: (c, params: { playerToken?: string }) => { + const playerToken = params?.playerToken?.trim(); + if (!playerToken) { + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + err("invalid player token", "invalid_player_token"); + } + if (c.state.phase !== "lobby" && !c.state.players[playerId]) { + err("party is no longer joinable", "party_closed"); + } + }, + onConnect: (c, conn) => { + const playerToken = conn.params?.playerToken?.trim(); + if (!playerToken) { + conn.disconnect("invalid_player_token"); + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + conn.disconnect("invalid_player_token"); + return; + } + if (c.state.phase !== "lobby" && !c.state.players[playerId]) { + conn.disconnect("party_closed"); + return; + } + if (!c.state.players[playerId]) { + c.state.players[playerId] = { + playerId, + name: playerId, + joinedAt: Date.now(), + }; + } + conn.state.playerId = playerId; + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDisconnect: (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + // This scaffold treats disconnect as leaving the party. + delete c.state.players[playerId]; + conn.state.playerId = null; + c.broadcast("snapshot", buildSnapshot(c.state)); + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + } + }, + onDestroy: async (c) => { + await closeLobby(c); + }, + actions: { + issuePlayerToken: ( + c, + input: { playerId: string }, + ) => { + const playerToken = buildSecret(); + c.state.playerTokens[playerToken] = input.playerId; + return { playerId: input.playerId, playerToken }; + }, + start: async (c) => { + requireTrusted(c.state); + requireJoinedHost(c); + if (Object.keys(c.state.players).length < 2) { + err("need at least two players", "not_enough_players"); + } + if (c.state.phase !== "lobby") { + err("party already started", "already_started"); + } + + c.state.phase = "in_progress"; + c.state.startedAt = Date.now(); + c.broadcast("snapshot", buildSnapshot(c.state)); + + const client = c.client(); + // Persist the phase transition in the SQLite party index. + await client.partyMatchmaker.getOrCreate(["main"]).queue.markStarted.send({ + partyCode: c.state.partyCode, + }); + + return buildSnapshot(c.state); + }, + finish: async (c) => { + requireTrusted(c.state); + requireJoinedHost(c); + c.state.phase = "finished"; + c.broadcast("snapshot", buildSnapshot(c.state)); + return buildSnapshot(c.state); + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts new file mode 100644 index 0000000000..8b68522775 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts @@ -0,0 +1,424 @@ +import { actor } from "rivetkit"; +import { db, type RawAccess } from "rivetkit/db"; +import { buildId, buildPartyCode } from "../shared/ids.ts"; +import { sqlInt, sqlString } from "../shared/sql.ts"; + +interface RoomRow { + party_code: string; + match_id: string; + host_player_id: string; + status: string; + created_at: number; + updated_at: number; +} + +interface MemberRow { + party_code: string; + player_id: string; + joined_at: number; +} + +export const partyMatchmaker = actor({ + db: db({ + onMigrate: migrateTables, + }), + run: async (c) => { + while (!c.aborted) { + const [message] = + (await c.queue.next(["createParty", "joinParty", "markStarted", "closeParty"], { + count: 1, + timeout: 100, + })) ?? []; + if (!message) continue; + + if (message.name === "createParty") { + const input = message.body as { hostPlayerId?: string }; + if (!input?.hostPlayerId) { + continue; + } + await processCreateParty(c, { hostPlayerId: input.hostPlayerId }); + continue; + } + + if (message.name === "joinParty") { + const input = message.body as { partyCode?: string; playerId?: string }; + if (!input?.partyCode || !input?.playerId) { + continue; + } + await processJoinParty(c, { + partyCode: input.partyCode, + playerId: input.playerId, + }); + continue; + } + + if (message.name === "markStarted") { + const input = message.body as { partyCode?: string }; + if (!input?.partyCode) { + continue; + } + await processMarkStarted(c, { + partyCode: input.partyCode, + }); + continue; + } + + if (message.name === "closeParty") { + const input = message.body as { partyCode?: string }; + if (!input?.partyCode) { + continue; + } + await processCloseParty(c, { + partyCode: input.partyCode, + }); + } + } + }, + actions: { + getParty: async (c, input: { partyCode: string }) => { + const partyCode = normalizeCode(input.partyCode); + const room = await selectRoomByCode(c.db, partyCode); + if (!room) return null; + + const members = await listMembersByJoinOrder(c.db, partyCode); + return { + partyCode: room.party_code, + matchId: room.match_id, + hostPlayerId: room.host_player_id, + status: room.status, + members: members.map((member) => ({ + playerId: member.player_id, + joinedAt: Number(member.joined_at), + })), + }; + }, + getPartyForHost: async (c, input: { hostPlayerId: string }) => { + const room = await selectLatestRoomByHost(c.db, input.hostPlayerId); + if (!room) { + return null; + } + const hostMember = await selectMemberByPlayer(c.db, room.party_code, room.host_player_id); + if (!hostMember) { + return null; + } + const hostPlayerToken = await issuePlayerToken(c, { + matchId: room.match_id, + playerId: hostMember.player_id, + }); + if (!hostPlayerToken) { + return null; + } + return { + partyCode: room.party_code, + matchId: room.match_id, + hostPlayerId: room.host_player_id, + hostPlayerToken, + status: room.status, + }; + }, + getJoinByPlayer: async (c, input: { partyCode: string; playerId: string }) => { + const partyCode = normalizeCode(input.partyCode); + const room = await selectRoomByCode(c.db, partyCode); + if (!room) { + return null; + } + const member = await selectMemberByPlayer(c.db, partyCode, input.playerId); + if (!member) { + return null; + } + const playerToken = await issuePlayerToken(c, { + matchId: room.match_id, + playerId: member.player_id, + }); + if (!playerToken) { + return null; + } + return { + partyCode, + matchId: room.match_id, + playerId: member.player_id, + playerToken, + }; + }, + }, +}); + +type MatchmakerContext = { + db: RawAccess; + client: () => any; +}; + +async function processCreateParty(c: MatchmakerContext, input: { hostPlayerId: string }) { + const now = Date.now(); + const matchId = buildId("party"); + const partyCode = await allocateCode(c.db); + + await insertRoom(c.db, { + partyCode, + matchId, + hostPlayerId: input.hostPlayerId, + status: "lobby", + createdAt: now, + updatedAt: now, + }); + await upsertMember(c.db, { + partyCode, + playerId: input.hostPlayerId, + joinedAt: now, + }); + + try { + await createMatchActor(c, { + matchId, + partyCode, + hostPlayerId: input.hostPlayerId, + }); + } catch (err) { + await deleteMembersByCode(c.db, partyCode); + await deleteRoomByCode(c.db, partyCode); + throw err; + } +} + +async function processJoinParty( + c: MatchmakerContext, + input: { partyCode: string; playerId: string }, +) { + const partyCode = normalizeCode(input.partyCode); + const room = await selectRoomByCode(c.db, partyCode); + if (!room) { + return; + } + if (room.status !== "lobby") { + return; + } + + const existing = await selectMemberByPlayer(c.db, partyCode, input.playerId); + if (existing) { + await touchRoom(c.db, partyCode, Date.now()); + return; + } + + await upsertMember(c.db, { + partyCode, + playerId: input.playerId, + joinedAt: Date.now(), + }); + await touchRoom(c.db, partyCode, Date.now()); +} + +async function processMarkStarted( + c: MatchmakerContext, + input: { partyCode: string }, +) { + const partyCode = normalizeCode(input.partyCode); + const room = await selectRoomByCode(c.db, partyCode); + if (!room) { + return; + } + await updateRoomStatus(c.db, { + partyCode, + status: "in_progress", + updatedAt: Date.now(), + }); +} + +async function processCloseParty( + c: MatchmakerContext, + input: { partyCode: string }, +) { + const partyCode = normalizeCode(input.partyCode); + const room = await selectRoomByCode(c.db, partyCode); + if (!room) { + return; + } + await deleteMembersByCode(c.db, partyCode); + await deleteRoomByCode(c.db, partyCode); +} + +function normalizeCode(code: string): string { + return code.trim().toUpperCase(); +} + +async function migrateTables(dbHandle: RawAccess) { + // This table stores one row per party room keyed by join code. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS rooms ( + party_code TEXT PRIMARY KEY, + match_id TEXT NOT NULL UNIQUE, + host_player_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + // This table stores party membership in join order. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS members ( + party_code TEXT NOT NULL, + player_id TEXT NOT NULL, + joined_at INTEGER NOT NULL, + PRIMARY KEY (party_code, player_id) + ) + `); + // This index speeds up member list reads for lobby display. + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS members_code_idx ON members (party_code, joined_at)", + ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS members_lookup_idx ON members (party_code, player_id)", + ); +} + +async function allocateCode(dbHandle: RawAccess): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const nextCode = buildPartyCode(); + const existing = await selectRoomByCode(dbHandle, nextCode); + if (!existing) { + return nextCode; + } + } + throw new Error("failed to allocate party code"); +} + +async function selectRoomByCode( + dbHandle: RawAccess, + partyCode: string, +): Promise { + // Party code maps to one room row. + const rows = (await dbHandle.execute( + `SELECT party_code, match_id, host_player_id, status, created_at, updated_at FROM rooms WHERE party_code = ${sqlString(partyCode)} LIMIT 1`, + )) as RoomRow[]; + return rows[0] ?? null; +} + +async function selectRoomByMatchId( + dbHandle: RawAccess, + matchId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT party_code, match_id, host_player_id, status, created_at, updated_at FROM rooms WHERE match_id = ${sqlString(matchId)} LIMIT 1`, + )) as RoomRow[]; + return rows[0] ?? null; +} + +async function selectLatestRoomByHost( + dbHandle: RawAccess, + hostPlayerId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT party_code, match_id, host_player_id, status, created_at, updated_at + FROM rooms + WHERE host_player_id = ${sqlString(hostPlayerId)} + ORDER BY created_at DESC + LIMIT 1`, + )) as RoomRow[]; + return rows[0] ?? null; +} + +async function insertRoom( + dbHandle: RawAccess, + input: { + partyCode: string; + matchId: string; + hostPlayerId: string; + status: "lobby" | "in_progress"; + createdAt: number; + updatedAt: number; + }, +) { + // Insert party metadata row for discovery and lifecycle status. + await dbHandle.execute( + `INSERT INTO rooms (party_code, match_id, host_player_id, status, created_at, updated_at) + VALUES (${sqlString(input.partyCode)}, ${sqlString(input.matchId)}, ${sqlString(input.hostPlayerId)}, ${sqlString(input.status)}, ${sqlInt(input.createdAt)}, ${sqlInt(input.updatedAt)})`, + ); +} + +async function selectMemberByPlayer( + dbHandle: RawAccess, + partyCode: string, + playerId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT party_code, player_id, joined_at FROM members WHERE party_code = ${sqlString(partyCode)} AND player_id = ${sqlString(playerId)} LIMIT 1`, + )) as MemberRow[]; + return rows[0] ?? null; +} + +async function upsertMember( + dbHandle: RawAccess, + input: { partyCode: string; playerId: string; joinedAt: number }, +) { + // Upsert membership so reconnect flows keep the same token. + await dbHandle.execute( + `INSERT INTO members (party_code, player_id, joined_at) + VALUES (${sqlString(input.partyCode)}, ${sqlString(input.playerId)}, ${sqlInt(input.joinedAt)}) + ON CONFLICT(party_code, player_id) DO UPDATE SET + joined_at = excluded.joined_at`, + ); +} + +async function touchRoom(dbHandle: RawAccess, partyCode: string, updatedAt: number) { + // Touch the party row so operators can see recent activity. + await dbHandle.execute( + `UPDATE rooms SET updated_at = ${sqlInt(updatedAt)} WHERE party_code = ${sqlString(partyCode)}`, + ); +} + +async function listMembersByJoinOrder(dbHandle: RawAccess, partyCode: string) { + // Read members in join order for a predictable lobby roster. + return (await dbHandle.execute( + `SELECT player_id, joined_at FROM members WHERE party_code = ${sqlString(partyCode)} ORDER BY joined_at ASC`, + )) as Array<{ player_id: string; joined_at: number }>; +} + +async function updateRoomStatus( + dbHandle: RawAccess, + input: { partyCode: string; status: "in_progress"; updatedAt: number }, +) { + // Mark the party as started so late joins are rejected. + await dbHandle.execute( + `UPDATE rooms SET status = ${sqlString(input.status)}, updated_at = ${sqlInt(input.updatedAt)} WHERE party_code = ${sqlString(input.partyCode)}`, + ); +} + +async function deleteMembersByCode(dbHandle: RawAccess, partyCode: string) { + // Delete member rows before deleting room metadata. + await dbHandle.execute(`DELETE FROM members WHERE party_code = ${sqlString(partyCode)}`); +} + +async function deleteRoomByCode(dbHandle: RawAccess, partyCode: string) { + await dbHandle.execute(`DELETE FROM rooms WHERE party_code = ${sqlString(partyCode)}`); +} + +async function createMatchActor( + c: MatchmakerContext, + input: { + matchId: string; + partyCode: string; + hostPlayerId: string; + }, +) { + // Create the room actor before exposing it through matchmaker state. + const client = c.client(); + await client.partyMatch.create([input.matchId], { + input, + }); +} + +async function issuePlayerToken( + c: MatchmakerContext, + input: { matchId: string; playerId: string }, +): Promise { + try { + const client = c.client(); + const res = (await client.partyMatch + .get([input.matchId]) + .issuePlayerToken({ + playerId: input.playerId, + })) as { playerToken?: string }; + return res.playerToken ?? null; + } catch { + return null; + } +} diff --git a/examples/multiplayer-game-patterns/src/actors/ranked/match.ts b/examples/multiplayer-game-patterns/src/actors/ranked/match.ts new file mode 100644 index 0000000000..e7b4c275a0 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/ranked/match.ts @@ -0,0 +1,198 @@ +import { actor } from "rivetkit"; +import { err, sleep } from "../../utils.ts"; +import { buildSecret } from "../shared/ids.ts"; + +interface MatchSeat { + playerId: string; + name: string; +} + +interface Player { + playerId: string; + name: string; + joinedAt: number; + lastInputAt: number; +} + +interface State { + matchId: string; + tickMs: number; + trusted: boolean; + seats: [MatchSeat, MatchSeat]; + playerIds: [string, string]; + players: Record; + playerTokens: Record; + phase: "waiting" | "live" | "finished"; + tick: number; + winnerPlayerId: string | null; + resultRatings: Record | null; +} + +function requireTrusted(state: State) { + if (!state.trusted) { + err("match is not trusted", "untrusted_lobby"); + } +} + +function findSeatByPlayerId(state: State, playerId: string): MatchSeat | null { + for (const seat of state.seats) { + if (seat.playerId === playerId) { + return seat; + } + } + return null; +} + +function buildSnapshot(state: State) { + // Ranked snapshot includes match phase, tick, and optional rating result. + return { + matchId: state.matchId, + phase: state.phase, + tick: state.tick, + players: state.players, + playerIds: state.playerIds, + winnerPlayerId: state.winnerPlayerId, + resultRatings: state.resultRatings, + }; +} + +export const rankedMatch = actor({ + createState: ( + _c, + input: { + matchId: string; + tickMs: number; + players: [MatchSeat, MatchSeat]; + }, + ): State => ({ + matchId: input.matchId, + tickMs: input.tickMs, + trusted: true, + seats: input.players, + playerIds: [input.players[0].playerId, input.players[1].playerId], + players: {}, + playerTokens: {}, + phase: "waiting", + tick: 0, + winnerPlayerId: null, + resultRatings: null, + }), + createConnState: (_c, _params: { playerToken?: string }) => ({ + playerId: null as string | null, + }), + onBeforeConnect: (c, params: { playerToken?: string }) => { + const playerToken = params?.playerToken?.trim(); + if (!playerToken) { + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + err("invalid player token", "invalid_player_token"); + } + }, + onConnect: (c, conn) => { + const playerToken = conn.params?.playerToken?.trim(); + if (!playerToken) { + conn.disconnect("invalid_player_token"); + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + conn.disconnect("invalid_player_token"); + return; + } + const seat = findSeatByPlayerId(c.state, playerId); + if (!seat) { + conn.disconnect("invalid_player_assignment"); + return; + } + if (!c.state.players[seat.playerId]) { + c.state.players[seat.playerId] = { + playerId: seat.playerId, + name: seat.name, + joinedAt: Date.now(), + lastInputAt: Date.now(), + }; + } + conn.state.playerId = seat.playerId; + if (Object.keys(c.state.players).length === c.state.playerIds.length) { + c.state.phase = "live"; + } + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDisconnect: (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + if (!c.state.players[playerId]) return; + + delete c.state.players[playerId]; + conn.state.playerId = null; + + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + return; + } + + if (c.state.phase !== "finished") { + c.state.phase = + Object.keys(c.state.players).length === c.state.playerIds.length + ? "live" + : "waiting"; + } + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + run: async (c) => { + // This run loop is the 20 tps scaffold tick for ranked matches. + while (!c.aborted) { + await sleep(c.state.tickMs); + if (c.aborted) break; + if (c.state.phase !== "live") continue; + c.state.tick += 1; + // Broadcast a canonical snapshot every tick while live. + c.broadcast("snapshot", buildSnapshot(c.state)); + } + }, + actions: { + issuePlayerToken: ( + c, + input: { playerId: string }, + ) => { + const seat = findSeatByPlayerId(c.state, input.playerId); + if (!seat) { + err("player is not assigned", "invalid_player"); + } + const playerToken = buildSecret(); + c.state.playerTokens[playerToken] = seat.playerId; + return { playerId: seat.playerId, playerToken }; + }, + finish: async (c, input: { winnerPlayerId?: string | null }) => { + requireTrusted(c.state); + if (!c.conn.state.playerId) { + err("caller is not joined", "not_joined"); + } + if (input.winnerPlayerId && !c.state.playerIds.includes(input.winnerPlayerId)) { + err("winner player is not assigned", "invalid_winner_player"); + } + if (c.state.phase === "finished") { + return buildSnapshot(c.state); + } + + c.state.phase = "finished"; + c.state.winnerPlayerId = input.winnerPlayerId ?? null; + const client = c.client(); + try { + // Matchmaker owns SQLite ELO and assignment state. + // Queue the result report so the matchmaker run loop applies updates. + await client.rankedMatchmaker.getOrCreate(["main"]).queue.reportResult.send({ + matchId: c.state.matchId, + winnerPlayerId: c.state.winnerPlayerId, + }); + } catch { + // Best effort during shutdown. + } + c.broadcast("snapshot", buildSnapshot(c.state)); + return buildSnapshot(c.state); + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts new file mode 100644 index 0000000000..3449eb4a4b --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts @@ -0,0 +1,562 @@ +import { actor } from "rivetkit"; +import { db, type RawAccess } from "rivetkit/db"; +import { buildId } from "../shared/ids.ts"; +import { sqlInt, sqlString } from "../shared/sql.ts"; + +const DEFAULT_ELO = 1000; +const K_FACTOR = 24; +const BASE_WINDOW = 100; +const WINDOW_GROWTH_PER_SEC = 25; +const MAX_WINDOW = 400; +const TICK_MS = 50; + +interface QueueRow { + player_id: string; + queued_at: number; + elo: number; +} + +interface AssignmentRow { + player_id: string; + match_id: string; + opponent_player_id: string; + assigned_at: number; +} + +interface MatchRow { + match_id: string; + player_a: string; + player_b: string; + finished_at: number | null; +} + +interface MatchSeat { + playerId: string; + name: string; +} + +interface CreateRankedMatch { + matchId: string; + players: [MatchSeat, MatchSeat]; +} + +export const rankedMatchmaker = actor({ + db: db({ + onMigrate: migrateTables, + }), + run: async (c) => { + while (!c.aborted) { + const [message] = + (await c.queue.next(["debugSetRating", "queueForMatch", "reportResult"], { + count: 1, + timeout: 100, + })) ?? []; + if (!message) continue; + + if (message.name === "debugSetRating") { + const input = message.body as { playerId?: string; elo?: number }; + if (!input?.playerId || typeof input.elo !== "number") { + continue; + } + await processDebugSetRating(c, { + playerId: input.playerId, + elo: input.elo, + }); + continue; + } + + if (message.name === "queueForMatch") { + const input = message.body as { playerId?: string }; + if (!input?.playerId) { + continue; + } + await processQueueForMatch(c, { playerId: input.playerId }); + continue; + } + + if (message.name === "reportResult") { + const input = message.body as { + matchId?: string; + winnerPlayerId?: string | null; + }; + if (!input?.matchId) { + continue; + } + await processReportResult(c, { + matchId: input.matchId, + winnerPlayerId: input.winnerPlayerId, + }); + } + } + }, + actions: { + getRating: async (c, input: { playerId: string }) => { + const row = await selectRating(c.db, input.playerId); + return { + playerId: input.playerId, + elo: Number(row?.elo ?? DEFAULT_ELO), + updatedAt: Number(row?.updated_at ?? Date.now()), + }; + }, + getAssignment: async (c, input: { playerId: string }) => { + const assignment = await selectAssignment(c.db, input.playerId); + if (!assignment) return null; + const match = await selectMatchById(c.db, assignment.match_id); + if (!match) return null; + const playerToken = await issuePlayerToken(c, { + matchId: assignment.match_id, + playerId: assignment.player_id, + }); + if (!playerToken) return null; + return { + playerId: assignment.player_id, + matchId: assignment.match_id, + opponentPlayerId: assignment.opponent_player_id, + playerToken, + assignedAt: Number(assignment.assigned_at), + }; + }, + }, +}); + +type MatchmakerContext = { + db: RawAccess; + client: () => any; +}; + +async function processDebugSetRating( + c: MatchmakerContext, + input: { playerId: string; elo: number }, +) { + await upsertRating(c.db, { + playerId: input.playerId, + elo: input.elo, + updatedAt: Date.now(), + }); +} + +async function processQueueForMatch(c: MatchmakerContext, input: { playerId: string }) { + await ensureRating(c.db, input.playerId); + + const assignment = await selectAssignment(c.db, input.playerId); + if (assignment) { + return; + } + + const now = Date.now(); + await enqueuePlayer(c.db, { + playerId: input.playerId, + queuedAt: now, + }); + + const createInput = await tryCreateMatch(c.db, { + playerId: input.playerId, + now, + }); + if (createInput) { + await createMatchActor(c, createInput); + } +} + +async function processReportResult( + c: MatchmakerContext, + input: { matchId: string; winnerPlayerId?: string | null }, +) { + const match = await selectMatchById(c.db, input.matchId); + if (!match) { + return; + } + + await ensureRating(c.db, match.player_a); + await ensureRating(c.db, match.player_b); + + const ratings = await selectRatings(c.db, [match.player_a, match.player_b]); + const eloA = ratings.get(match.player_a) ?? DEFAULT_ELO; + const eloB = ratings.get(match.player_b) ?? DEFAULT_ELO; + + if (match.finished_at != null) { + return; + } + + if ( + input.winnerPlayerId && + input.winnerPlayerId !== match.player_a && + input.winnerPlayerId !== match.player_b + ) { + return; + } + + let scoreA = 0.5; + let scoreB = 0.5; + if (input.winnerPlayerId === match.player_a) { + scoreA = 1; + scoreB = 0; + } else if (input.winnerPlayerId === match.player_b) { + scoreA = 0; + scoreB = 1; + } + + const expectedA = expectedScore(eloA, eloB); + const expectedB = expectedScore(eloB, eloA); + const nextA = Math.round(eloA + K_FACTOR * (scoreA - expectedA)); + const nextB = Math.round(eloB + K_FACTOR * (scoreB - expectedB)); + + const now = Date.now(); + await updateRating(c.db, { + playerId: match.player_a, + elo: nextA, + updatedAt: now, + }); + await updateRating(c.db, { + playerId: match.player_b, + elo: nextB, + updatedAt: now, + }); + await deleteAssignmentsByMatchId(c.db, input.matchId); + await markMatchFinished(c.db, { + matchId: input.matchId, + finishedAt: now, + }); +} + +function expectedScore(playerA: number, playerB: number) { + return 1 / (1 + 10 ** ((playerB - playerA) / 400)); +} + +function computeSearchWindow(waitMs: number): number { + return Math.min( + MAX_WINDOW, + BASE_WINDOW + Math.floor(waitMs / 1000) * WINDOW_GROWTH_PER_SEC, + ); +} + +async function migrateTables(dbHandle: RawAccess) { + // This table stores current ELO and update time per player. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS ratings ( + player_id TEXT PRIMARY KEY, + elo INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + // This table stores ranked queue entries with enqueue timestamp. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS queue ( + player_id TEXT PRIMARY KEY, + queued_at INTEGER NOT NULL + ) + `); + // This table maps each queued player to a created ranked match. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS assignments ( + player_id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + opponent_player_id TEXT NOT NULL, + assigned_at INTEGER NOT NULL + ) + `); + // This table records match pairings and completion timestamps. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS matches ( + match_id TEXT PRIMARY KEY, + player_a TEXT NOT NULL, + player_b TEXT NOT NULL, + created_at INTEGER NOT NULL, + finished_at INTEGER + ) + `); + // This index speeds up oldest-first queue scans. + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS queue_queued_idx ON queue (queued_at)", + ); +} + +async function ensureRating(dbHandle: RawAccess, playerId: string) { + // Ensure every player has a rating row before matchmaking logic runs. + await dbHandle.execute( + `INSERT OR IGNORE INTO ratings (player_id, elo, updated_at) + VALUES (${sqlString(playerId)}, ${sqlInt(DEFAULT_ELO)}, ${sqlInt(Date.now())})`, + ); +} + +async function selectRating( + dbHandle: RawAccess, + playerId: string, +): Promise<{ elo: number; updated_at: number } | null> { + // Read one rating row for display and debugging clients. + const rows = (await dbHandle.execute( + `SELECT elo, updated_at FROM ratings WHERE player_id = ${sqlString(playerId)} LIMIT 1`, + )) as Array<{ elo: number; updated_at: number }>; + return rows[0] ?? null; +} + +async function upsertRating( + dbHandle: RawAccess, + input: { playerId: string; elo: number; updatedAt: number }, +) { + // Upsert is used so tests can seed deterministic ELO values. + await dbHandle.execute( + `INSERT INTO ratings (player_id, elo, updated_at) + VALUES (${sqlString(input.playerId)}, ${sqlInt(input.elo)}, ${sqlInt(input.updatedAt)}) + ON CONFLICT(player_id) DO UPDATE SET + elo = excluded.elo, + updated_at = excluded.updated_at`, + ); +} + +async function selectAssignment( + dbHandle: RawAccess, + playerId: string, +): Promise { + // A player has at most one active ranked assignment. + const rows = (await dbHandle.execute( + `SELECT player_id, match_id, opponent_player_id, assigned_at FROM assignments WHERE player_id = ${sqlString(playerId)} LIMIT 1`, + )) as AssignmentRow[]; + return rows[0] ?? null; +} + +async function enqueuePlayer( + dbHandle: RawAccess, + input: { playerId: string; queuedAt: number }, +) { + // Enqueue once per player. Repeated calls stay idempotent. + await dbHandle.execute( + `INSERT OR IGNORE INTO queue (player_id, queued_at) VALUES (${sqlString(input.playerId)}, ${sqlInt(input.queuedAt)})`, + ); +} + +async function tryCreateMatch( + dbHandle: RawAccess, + input: { playerId: string; now: number }, +): Promise { + await beginImmediateTransaction(dbHandle); + try { + // Re-read assignment under lock to prevent double pairing races. + const lockedAssignment = await selectAssignment(dbHandle, input.playerId); + if (lockedAssignment) { + await commitTransaction(dbHandle); + return null; + } + + const self = await selectQueuedPlayerWithRating(dbHandle, input.playerId); + if (!self) { + await commitTransaction(dbHandle); + return null; + } + + const waitMs = input.now - Number(self.queued_at); + const window = computeSearchWindow(waitMs); + const opponent = await selectClosestOpponent( + dbHandle, + input.playerId, + Number(self.elo), + window, + ); + if (!opponent) { + await commitTransaction(dbHandle); + return null; + } + + const matchId = buildId("ranked"); + const firstSeat: MatchSeat = { + playerId: input.playerId, + name: input.playerId, + }; + const secondSeat: MatchSeat = { + playerId: opponent.player_id, + name: opponent.player_id, + }; + + await deleteQueuePlayers(dbHandle, [input.playerId, opponent.player_id]); + await insertMatchRow(dbHandle, { + matchId, + playerA: input.playerId, + playerB: opponent.player_id, + createdAt: input.now, + }); + await insertAssignmentRow(dbHandle, { + playerId: firstSeat.playerId, + matchId, + opponentPlayerId: secondSeat.playerId, + assignedAt: input.now, + }); + await insertAssignmentRow(dbHandle, { + playerId: secondSeat.playerId, + matchId, + opponentPlayerId: firstSeat.playerId, + assignedAt: input.now, + }); + await commitTransaction(dbHandle); + + return { + matchId, + players: [firstSeat, secondSeat], + }; + } catch (err) { + await rollbackTransaction(dbHandle); + throw err; + } +} + +async function selectQueuedPlayerWithRating( + dbHandle: RawAccess, + playerId: string, +): Promise { + // Load the queued player and current rating. + const rows = (await dbHandle.execute( + `SELECT q.player_id, q.queued_at, r.elo + FROM queue q + JOIN ratings r ON r.player_id = q.player_id + WHERE q.player_id = ${sqlString(playerId)} + LIMIT 1`, + )) as QueueRow[]; + return rows[0] ?? null; +} + +async function selectClosestOpponent( + dbHandle: RawAccess, + playerId: string, + playerElo: number, + window: number, +): Promise { + // Pick the closest-rated opponent still in queue. + const rows = (await dbHandle.execute( + `SELECT q.player_id, q.queued_at, r.elo + FROM queue q + JOIN ratings r ON r.player_id = q.player_id + WHERE q.player_id != ${sqlString(playerId)} + AND ABS(r.elo - ${sqlInt(playerElo)}) <= ${sqlInt(window)} + ORDER BY ABS(r.elo - ${sqlInt(playerElo)}) ASC, q.queued_at ASC + LIMIT 1`, + )) as QueueRow[]; + return rows[0] ?? null; +} + +async function deleteQueuePlayers(dbHandle: RawAccess, playerIds: string[]) { + if (playerIds.length === 0) { + return; + } + const playerSql = playerIds.map((playerId) => sqlString(playerId)).join(", "); + // Remove paired players from queue before persisting assignments. + await dbHandle.execute(`DELETE FROM queue WHERE player_id IN (${playerSql})`); +} + +async function insertMatchRow( + dbHandle: RawAccess, + input: { + matchId: string; + playerA: string; + playerB: string; + createdAt: number; + }, +) { + await dbHandle.execute( + `INSERT INTO matches (match_id, player_a, player_b, created_at) + VALUES (${sqlString(input.matchId)}, ${sqlString(input.playerA)}, ${sqlString(input.playerB)}, ${sqlInt(input.createdAt)})`, + ); +} + +async function insertAssignmentRow( + dbHandle: RawAccess, + input: { + playerId: string; + matchId: string; + opponentPlayerId: string; + assignedAt: number; + }, +) { + // Store per-player assignment rows for polling and reconnects. + await dbHandle.execute( + `INSERT INTO assignments (player_id, match_id, opponent_player_id, assigned_at) + VALUES (${sqlString(input.playerId)}, ${sqlString(input.matchId)}, ${sqlString(input.opponentPlayerId)}, ${sqlInt(input.assignedAt)})`, + ); +} + +async function createMatchActor(c: MatchmakerContext, input: CreateRankedMatch) { + // Create the match actor after commit so SQL lock time stays short. + const client = c.client(); + await client.rankedMatch.create([input.matchId], { + input: { + matchId: input.matchId, + tickMs: TICK_MS, + players: input.players, + }, + }); +} + +async function issuePlayerToken( + c: MatchmakerContext, + input: { matchId: string; playerId: string }, +): Promise { + try { + const client = c.client(); + const res = (await client.rankedMatch + .get([input.matchId]) + .issuePlayerToken({ + playerId: input.playerId, + })) as { playerToken?: string }; + return res.playerToken ?? null; + } catch { + return null; + } +} + +async function selectMatchById( + dbHandle: RawAccess, + matchId: string, +): Promise { + // Load the match pairing so ratings can be updated deterministically. + const rows = (await dbHandle.execute( + `SELECT match_id, player_a, player_b, finished_at FROM matches WHERE match_id = ${sqlString(matchId)} LIMIT 1`, + )) as MatchRow[]; + return rows[0] ?? null; +} + +async function selectRatings(dbHandle: RawAccess, playerIds: string[]): Promise> { + if (playerIds.length === 0) { + return new Map(); + } + const playerSql = playerIds.map((playerId) => sqlString(playerId)).join(", "); + // Read both ratings in one query to compute expected scores. + const rows = (await dbHandle.execute( + `SELECT player_id, elo FROM ratings WHERE player_id IN (${playerSql})`, + )) as Array<{ player_id: string; elo: number }>; + return new Map(rows.map((row) => [row.player_id, Number(row.elo)])); +} + +async function updateRating( + dbHandle: RawAccess, + input: { playerId: string; elo: number; updatedAt: number }, +) { + await dbHandle.execute( + `UPDATE ratings SET elo = ${sqlInt(input.elo)}, updated_at = ${sqlInt(input.updatedAt)} WHERE player_id = ${sqlString(input.playerId)}`, + ); +} + +async function deleteAssignmentsByMatchId(dbHandle: RawAccess, matchId: string) { + // Clear active assignments after match result has been applied. + await dbHandle.execute( + `DELETE FROM assignments WHERE match_id = ${sqlString(matchId)}`, + ); +} + +async function markMatchFinished( + dbHandle: RawAccess, + input: { matchId: string; finishedAt: number }, +) { + await dbHandle.execute( + `UPDATE matches SET finished_at = ${sqlInt(input.finishedAt)} WHERE match_id = ${sqlString(input.matchId)}`, + ); +} + +async function beginImmediateTransaction(dbHandle: RawAccess) { + await dbHandle.execute("BEGIN IMMEDIATE"); +} + +async function commitTransaction(dbHandle: RawAccess) { + await dbHandle.execute("COMMIT"); +} + +async function rollbackTransaction(dbHandle: RawAccess) { + await dbHandle.execute("ROLLBACK"); +} diff --git a/examples/multiplayer-game-patterns/src/actors/shared/ids.ts b/examples/multiplayer-game-patterns/src/actors/shared/ids.ts new file mode 100644 index 0000000000..d93f922ceb --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/shared/ids.ts @@ -0,0 +1,21 @@ +function randomSuffix() { + return Math.random().toString(36).slice(2, 8); +} + +export function buildId(prefix: string): string { + return `${prefix}-${Date.now()}-${randomSuffix()}`; +} + +export function buildSecret(): string { + return crypto.randomUUID().replaceAll("-", ""); +} + +export function buildPartyCode(length = 6): string { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let code = ""; + for (let i = 0; i < length; i++) { + const idx = Math.floor(Math.random() * alphabet.length); + code += alphabet[idx]; + } + return code; +} diff --git a/examples/multiplayer-game-patterns/src/actors/shared/sql.ts b/examples/multiplayer-game-patterns/src/actors/shared/sql.ts new file mode 100644 index 0000000000..eab0a6a449 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/shared/sql.ts @@ -0,0 +1,10 @@ +export function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function sqlInt(value: number): string { + if (!Number.isFinite(value)) { + throw new Error("invalid sql integer"); + } + return `${Math.trunc(value)}`; +} diff --git a/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts b/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts new file mode 100644 index 0000000000..91c8f02fb9 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts @@ -0,0 +1,230 @@ +import { actor } from "rivetkit"; +import { err } from "../../utils.ts"; +import { buildSecret } from "../shared/ids.ts"; + +interface MatchSeat { + playerId: string; + name: string; +} + +interface TurnPlayer { + playerId: string; + name: string; + joinedAt: number; +} + +interface TurnMove { + turn: number; + playerId: string; + move: string; + at: number; +} + +interface State { + matchId: string; + trusted: boolean; + seats: [MatchSeat, MatchSeat]; + playerIds: [string, string]; + phase: "waiting" | "in_progress" | "finished"; + players: Record; + playerTokens: Record; + turnIndex: number; + moves: TurnMove[]; + winnerPlayerId: string | null; +} + +function requireTrusted(state: State) { + if (!state.trusted) { + err("match is not trusted", "untrusted_lobby"); + } +} + +function findSeatByPlayerId(state: State, playerId: string): MatchSeat | null { + for (const seat of state.seats) { + if (seat.playerId === playerId) { + return seat; + } + } + return null; +} + +function buildSnapshot(state: State) { + // The async scaffold snapshot keeps turn order and move history explicit. + const nextPlayerId = + state.phase === "in_progress" + ? state.playerIds[state.turnIndex % state.playerIds.length] + : null; + + return { + matchId: state.matchId, + playerIds: state.playerIds, + phase: state.phase, + players: state.players, + turnIndex: state.turnIndex, + nextPlayerId, + moves: state.moves, + winnerPlayerId: state.winnerPlayerId, + }; +} + +export const asyncTurnBasedMatch = actor({ + createState: ( + _c, + input: { + matchId: string; + players: [MatchSeat, MatchSeat]; + }, + ): State => ({ + matchId: input.matchId, + trusted: true, + seats: input.players, + playerIds: [input.players[0].playerId, input.players[1].playerId], + phase: "waiting", + players: {}, + playerTokens: {}, + turnIndex: 0, + moves: [], + winnerPlayerId: null, + }), + createConnState: (_c, _params: { playerToken?: string }) => ({ + playerId: null as string | null, + }), + onBeforeConnect: (c, params: { playerToken?: string }) => { + const playerToken = params?.playerToken?.trim(); + if (!playerToken) { + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + err("invalid player token", "invalid_player_token"); + } + }, + onConnect: (c, conn) => { + const playerToken = conn.params?.playerToken?.trim(); + if (!playerToken) { + conn.disconnect("invalid_player_token"); + return; + } + const playerId = c.state.playerTokens[playerToken]; + if (!playerId) { + conn.disconnect("invalid_player_token"); + return; + } + const seat = findSeatByPlayerId(c.state, playerId); + if (!seat) { + conn.disconnect("invalid_player_assignment"); + return; + } + if (!c.state.players[seat.playerId]) { + c.state.players[seat.playerId] = { + playerId: seat.playerId, + name: seat.name, + joinedAt: Date.now(), + }; + } + conn.state.playerId = seat.playerId; + + if (Object.keys(c.state.players).length === c.state.playerIds.length) { + c.state.phase = "in_progress"; + } + + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDisconnect: (c, conn) => { + const playerId = conn.state.playerId; + if (!playerId) return; + if (!c.state.players[playerId]) return; + + delete c.state.players[playerId]; + conn.state.playerId = null; + + if (Object.keys(c.state.players).length === 0) { + c.destroy(); + return; + } + + if (c.state.phase !== "finished") { + c.state.phase = + Object.keys(c.state.players).length === c.state.playerIds.length + ? "in_progress" + : "waiting"; + } + c.broadcast("snapshot", buildSnapshot(c.state)); + }, + onDestroy: async (c) => { + const client = c.client(); + try { + // Matchmaker assignment rows live in SQLite. + // This callback clears those rows when the match actor shuts down. + await client.asyncTurnBasedMatchmaker.getOrCreate(["main"]).queue.matchCompleted.send({ + matchId: c.state.matchId, + }); + } catch { + // Best effort during shutdown. + } + }, + actions: { + issuePlayerToken: ( + c, + input: { playerId: string }, + ) => { + const seat = findSeatByPlayerId(c.state, input.playerId); + if (!seat) { + err("player is not assigned", "invalid_player"); + } + const playerToken = buildSecret(); + c.state.playerTokens[playerToken] = seat.playerId; + return { playerId: seat.playerId, playerToken }; + }, + submitTurn: (c, input: { move: string }) => { + requireTrusted(c.state); + const playerId = c.conn.state.playerId; + if (!playerId) { + err("caller is not joined", "not_joined"); + } + // Turn submission validates phase, ownership, and strict turn order. + if (c.state.phase !== "in_progress") { + err("match is not in progress", "match_not_live"); + } + const expected = c.state.playerIds[c.state.turnIndex % c.state.playerIds.length]; + if (expected !== playerId) { + err("not your turn", "not_your_turn"); + } + + const move: TurnMove = { + turn: c.state.turnIndex, + playerId, + move: input.move, + at: Date.now(), + }; + c.state.moves.push(move); + c.state.turnIndex += 1; + const nextPlayerId = c.state.playerIds[c.state.turnIndex % c.state.playerIds.length]; + // Emit both the committed move and the resulting snapshot. + c.broadcast("turnCommitted", { move, nextPlayerId }); + c.broadcast("snapshot", buildSnapshot(c.state)); + return { ok: true, nextPlayerId }; + }, + finish: async (c, input: { winnerPlayerId?: string | null }) => { + requireTrusted(c.state); + if (!c.conn.state.playerId) { + err("caller is not joined", "not_joined"); + } + if (input.winnerPlayerId && !c.state.playerIds.includes(input.winnerPlayerId)) { + err("winner player is not assigned", "invalid_winner_player"); + } + c.state.phase = "finished"; + c.state.winnerPlayerId = input.winnerPlayerId ?? null; + c.broadcast("snapshot", buildSnapshot(c.state)); + + const client = c.client(); + // Keep matchmaker assignment state in sync with the finished phase. + await client.asyncTurnBasedMatchmaker.getOrCreate(["main"]).queue.matchCompleted.send({ + matchId: c.state.matchId, + }); + + return buildSnapshot(c.state); + }, + getSnapshot: (c) => buildSnapshot(c.state), + }, +}); diff --git a/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts new file mode 100644 index 0000000000..8098c707dc --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts @@ -0,0 +1,488 @@ +import { actor } from "rivetkit"; +import { db, type RawAccess } from "rivetkit/db"; +import { buildId } from "../shared/ids.ts"; +import { sqlInt, sqlString } from "../shared/sql.ts"; + +interface InviteRow { + invite_code: string; + from_player_id: string; + to_player_id: string | null; + status: string; + match_id: string | null; + created_at: number; + updated_at: number; +} + +interface AssignmentRow { + player_id: string; + match_id: string; + assigned_at: number; +} + +interface MatchRow { + match_id: string; +} + +interface QueueRow { + player_id: string; + queued_at: number; +} + +interface MatchSeat { + playerId: string; + name: string; +} + +interface CreateInput { + matchId: string; + players: [MatchSeat, MatchSeat]; +} + +export const asyncTurnBasedMatchmaker = actor({ + db: db({ + onMigrate: migrateTables, + }), + run: async (c) => { + while (!c.aborted) { + const [message] = + (await c.queue.next( + ["createInvite", "acceptInvite", "joinOpenPool", "matchCompleted"], + { + count: 1, + timeout: 100, + }, + )) ?? []; + if (!message) continue; + + if (message.name === "createInvite") { + const input = message.body as { + inviteCode?: string; + fromPlayerId?: string; + toPlayerId?: string | null; + }; + if (!input?.fromPlayerId) { + continue; + } + await processCreateInvite(c, { + inviteCode: input.inviteCode, + fromPlayerId: input.fromPlayerId, + toPlayerId: input.toPlayerId, + }); + continue; + } + + if (message.name === "acceptInvite") { + const input = message.body as { inviteCode?: string; playerId?: string }; + if (!input?.inviteCode || !input?.playerId) { + continue; + } + await processAcceptInvite(c, { + inviteCode: input.inviteCode, + playerId: input.playerId, + }); + continue; + } + + if (message.name === "joinOpenPool") { + const input = message.body as { playerId?: string }; + if (!input?.playerId) { + continue; + } + await processJoinOpenPool(c, { playerId: input.playerId }); + continue; + } + + if (message.name === "matchCompleted") { + const input = message.body as { matchId?: string }; + if (!input?.matchId) { + continue; + } + await processMatchCompleted(c, { + matchId: input.matchId, + }); + } + } + }, + actions: { + getAssignment: async (c, input: { playerId: string }) => { + const assignment = await selectAssignment(c.db, input.playerId); + if (!assignment) return null; + const match = await selectMatchById(c.db, assignment.match_id); + if (!match) return null; + const playerToken = await issuePlayerToken(c, { + matchId: assignment.match_id, + playerId: assignment.player_id, + }); + if (!playerToken) return null; + return { + playerId: assignment.player_id, + matchId: assignment.match_id, + playerToken, + assignedAt: Number(assignment.assigned_at), + }; + }, + }, +}); + +type MatchmakerContext = { + db: RawAccess; + client: () => any; +}; + +async function processCreateInvite( + c: MatchmakerContext, + input: { inviteCode?: string; fromPlayerId: string; toPlayerId?: string | null }, +) { + const inviteCode = input.inviteCode?.trim() || buildId("invite"); + const now = Date.now(); + await insertInvite(c.db, { + inviteCode, + fromPlayerId: input.fromPlayerId, + toPlayerId: input.toPlayerId ?? "", + createdAt: now, + updatedAt: now, + }); +} + +async function processAcceptInvite( + c: MatchmakerContext, + input: { inviteCode: string; playerId: string }, +) { + const invite = await selectInviteByCode(c.db, input.inviteCode); + if (!invite) { + return; + } + + if (invite.status === "accepted" && invite.match_id) { + return; + } + + if (invite.status !== "open") { + return; + } + + if ( + invite.to_player_id && + invite.to_player_id.length > 0 && + invite.to_player_id !== input.playerId + ) { + return; + } + + const createInput = buildCreateInput("async", [invite.from_player_id, input.playerId]); + await createMatch(c, { + ...createInput, + source: "invite", + }); + await markInviteAccepted(c.db, { + inviteCode: input.inviteCode, + matchId: createInput.matchId, + updatedAt: Date.now(), + }); +} + +async function processJoinOpenPool(c: MatchmakerContext, input: { playerId: string }) { + const existing = await selectAssignment(c.db, input.playerId); + if (existing) { + return; + } + + await enqueuePoolPlayer(c.db, { + playerId: input.playerId, + queuedAt: Date.now(), + }); + + const pending = await tryCreatePoolMatch(c.db, input.playerId); + if (pending) { + await createMatch(c, { + ...buildCreateInputFromExisting(pending.matchId, pending.playerIds), + source: "open_pool", + }); + } +} + +async function processMatchCompleted( + c: MatchmakerContext, + input: { matchId: string }, +) { + const match = await selectMatchById(c.db, input.matchId); + if (!match) { + return; + } + await deleteAssignmentsByMatchId(c.db, input.matchId); + await deleteMatchById(c.db, input.matchId); +} + +async function migrateTables(dbHandle: RawAccess) { + // This table stores invite metadata and acceptance status. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS invites ( + invite_code TEXT PRIMARY KEY, + from_player_id TEXT NOT NULL, + to_player_id TEXT, + status TEXT NOT NULL, + match_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + // This table is the open matchmaking pool for async players. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS pool_queue ( + player_id TEXT PRIMARY KEY, + queued_at INTEGER NOT NULL + ) + `); + // This table maps players to the match they were assigned into. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS assignments ( + player_id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + assigned_at INTEGER NOT NULL + ) + `); + // This table records created async matches and their origin. + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS matches ( + match_id TEXT PRIMARY KEY, + source TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + // This index speeds up FIFO opponent lookup in the open pool. + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS pool_queue_idx ON pool_queue (queued_at)", + ); +} + +function buildCreateInput(prefix: string, playerIds: [string, string]): CreateInput { + return buildCreateInputFromExisting(buildId(prefix), playerIds); +} + +function buildCreateInputFromExisting(matchId: string, playerIds: [string, string]): CreateInput { + const players = playerIds.map((playerId) => ({ + playerId, + name: playerId, + })) as [MatchSeat, MatchSeat]; + return { + matchId, + players, + }; +} + +async function insertInvite( + dbHandle: RawAccess, + input: { + inviteCode: string; + fromPlayerId: string; + toPlayerId: string; + createdAt: number; + updatedAt: number; + }, +) { + // Create an invite row with open status until someone accepts it. + await dbHandle.execute( + `INSERT INTO invites (invite_code, from_player_id, to_player_id, status, match_id, created_at, updated_at) + VALUES (${sqlString(input.inviteCode)}, ${sqlString(input.fromPlayerId)}, ${sqlString(input.toPlayerId)}, 'open', NULL, ${sqlInt(input.createdAt)}, ${sqlInt(input.updatedAt)})`, + ); +} + +async function selectInviteByCode( + dbHandle: RawAccess, + inviteCode: string, +): Promise { + // Load one invite row for status and recipient checks. + const rows = (await dbHandle.execute( + `SELECT invite_code, from_player_id, to_player_id, status, match_id, created_at, updated_at FROM invites WHERE invite_code = ${sqlString(inviteCode)} LIMIT 1`, + )) as InviteRow[]; + return rows[0] ?? null; +} + +async function markInviteAccepted( + dbHandle: RawAccess, + input: { inviteCode: string; matchId: string; updatedAt: number }, +) { + // Persist acceptance so repeated calls return the same assignment. + await dbHandle.execute( + `UPDATE invites SET status = 'accepted', match_id = ${sqlString(input.matchId)}, updated_at = ${sqlInt(input.updatedAt)} WHERE invite_code = ${sqlString(input.inviteCode)}`, + ); +} + +async function selectAssignment( + dbHandle: RawAccess, + playerId: string, +): Promise { + // A player has at most one active async assignment at a time. + const rows = (await dbHandle.execute( + `SELECT player_id, match_id, assigned_at FROM assignments WHERE player_id = ${sqlString(playerId)} LIMIT 1`, + )) as AssignmentRow[]; + return rows[0] ?? null; +} + +async function enqueuePoolPlayer( + dbHandle: RawAccess, + input: { playerId: string; queuedAt: number }, +) { + // Enqueue idempotently for open-pool matchmaking. + await dbHandle.execute( + `INSERT OR IGNORE INTO pool_queue (player_id, queued_at) + VALUES (${sqlString(input.playerId)}, ${sqlInt(input.queuedAt)})`, + ); +} + +async function tryCreatePoolMatch( + dbHandle: RawAccess, + playerId: string, +): Promise<{ matchId: string; playerIds: [string, string] } | null> { + return withImmediateTransaction(dbHandle, async () => { + // Lock and recheck assignment to prevent double matching. + const lockedAssignment = await selectAssignment(dbHandle, playerId); + if (lockedAssignment) { + return null; + } + + // Pick the oldest waiting opponent. + const opponent = await selectOldestPoolOpponent(dbHandle, playerId); + if (!opponent) { + return null; + } + + const matchId = buildId("async-pool"); + await deletePoolPlayers(dbHandle, [opponent.player_id, playerId]); + + return { + matchId, + playerIds: [opponent.player_id, playerId], + }; + }); +} + +async function selectOldestPoolOpponent( + dbHandle: RawAccess, + playerId: string, +): Promise { + const rows = (await dbHandle.execute( + `SELECT player_id, queued_at FROM pool_queue WHERE player_id != ${sqlString(playerId)} ORDER BY queued_at ASC LIMIT 1`, + )) as QueueRow[]; + return rows[0] ?? null; +} + +async function deletePoolPlayers(dbHandle: RawAccess, playerIds: string[]) { + if (playerIds.length === 0) { + return; + } + const playerSql = playerIds.map((id) => sqlString(id)).join(", "); + // Remove both players from queue before creating the match. + await dbHandle.execute(`DELETE FROM pool_queue WHERE player_id IN (${playerSql})`); +} + +async function createMatch( + c: MatchmakerContext, + input: CreateInput & { source: "invite" | "open_pool" }, +) { + const now = Date.now(); + await insertMatchRow(c.db, { + matchId: input.matchId, + source: input.source, + createdAt: now, + }); + for (const player of input.players) { + await upsertAssignment(c.db, { + playerId: player.playerId, + matchId: input.matchId, + assignedAt: now, + }); + } + await createMatchActor(c, { + matchId: input.matchId, + players: input.players, + }); +} + +async function insertMatchRow( + dbHandle: RawAccess, + input: { + matchId: string; + source: "invite" | "open_pool"; + createdAt: number; + }, +) { + // Record the match source so operators can see whether it came from invite or pool. + await dbHandle.execute( + `INSERT INTO matches (match_id, source, created_at) VALUES (${sqlString(input.matchId)}, ${sqlString(input.source)}, ${sqlInt(input.createdAt)})`, + ); +} + +async function upsertAssignment( + dbHandle: RawAccess, + input: { playerId: string; matchId: string; assignedAt: number }, +) { + // Upsert keeps one assignment row per player for polling and reconnect support. + await dbHandle.execute( + `INSERT INTO assignments (player_id, match_id, assigned_at) + VALUES (${sqlString(input.playerId)}, ${sqlString(input.matchId)}, ${sqlInt(input.assignedAt)}) + ON CONFLICT(player_id) DO UPDATE SET + match_id = excluded.match_id, + assigned_at = excluded.assigned_at`, + ); +} + +async function selectMatchById(dbHandle: RawAccess, matchId: string): Promise { + const rows = (await dbHandle.execute( + `SELECT match_id FROM matches WHERE match_id = ${sqlString(matchId)} LIMIT 1`, + )) as MatchRow[]; + return rows[0] ?? null; +} + +async function deleteAssignmentsByMatchId(dbHandle: RawAccess, matchId: string) { + // Clear all active assignments for this finished match. + await dbHandle.execute(`DELETE FROM assignments WHERE match_id = ${sqlString(matchId)}`); +} + +async function deleteMatchById(dbHandle: RawAccess, matchId: string) { + await dbHandle.execute(`DELETE FROM matches WHERE match_id = ${sqlString(matchId)}`); +} + +async function withImmediateTransaction( + dbHandle: RawAccess, + run: () => Promise, +): Promise { + // Keep transaction control in one place so query flow reads top-to-bottom. + await dbHandle.execute("BEGIN IMMEDIATE"); + try { + const result = await run(); + await dbHandle.execute("COMMIT"); + return result; + } catch (err) { + await dbHandle.execute("ROLLBACK"); + throw err; + } +} + +async function createMatchActor( + c: MatchmakerContext, + input: { matchId: string; players: [MatchSeat, MatchSeat] }, +) { + // Create the match actor after SQL state is ready. + const client = c.client(); + await client.asyncTurnBasedMatch.create([input.matchId], { + input, + }); +} + +async function issuePlayerToken( + c: MatchmakerContext, + input: { matchId: string; playerId: string }, +): Promise { + try { + const client = c.client(); + const res = (await client.asyncTurnBasedMatch + .get([input.matchId]) + .issuePlayerToken({ + playerId: input.playerId, + })) as { playerToken?: string }; + return res.playerToken ?? null; + } catch { + return null; + } +} diff --git a/examples/multiplayer-game-patterns/src/server.ts b/examples/multiplayer-game-patterns/src/server.ts new file mode 100644 index 0000000000..cb57c85c76 --- /dev/null +++ b/examples/multiplayer-game-patterns/src/server.ts @@ -0,0 +1,7 @@ +import { Hono } from "hono"; +import { registry } from "./actors/index.ts"; + +const app = new Hono(); +app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); + +export default app; diff --git a/examples/multiplayer-game-patterns/src/utils.ts b/examples/multiplayer-game-patterns/src/utils.ts new file mode 100644 index 0000000000..cbd6f23b4c --- /dev/null +++ b/examples/multiplayer-game-patterns/src/utils.ts @@ -0,0 +1,9 @@ +import { UserError } from "rivetkit"; + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function err(message: string, code: string): never { + throw new UserError(message, { code }); +} diff --git a/examples/multiplayer-game-patterns/tests/matchmaking-and-session-patterns.test.ts b/examples/multiplayer-game-patterns/tests/matchmaking-and-session-patterns.test.ts new file mode 100644 index 0000000000..678ddf85ee --- /dev/null +++ b/examples/multiplayer-game-patterns/tests/matchmaking-and-session-patterns.test.ts @@ -0,0 +1,344 @@ +import { setupTest } from "rivetkit/test"; +import { describe, expect, test } from "vitest"; +import { registry } from "../src/actors/index.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(fn: () => Promise, timeoutMs = 3000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = await fn(); + if (value != null) return value; + await sleep(25); + } + throw new Error("timed out waiting for value"); +} + +async function waitUntil(fn: () => Promise, timeoutMs = 3000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await fn()) return; + await sleep(25); + } + throw new Error("timed out waiting for condition"); +} + +describe("matchmaking and session patterns", () => { + test("io-style open lobby + 10 tps match", async (ctx) => { + const { client } = await setupTest(ctx, registry); + + const mm = client.ioStyleMatchmaker.getOrCreate(["main"]).connect(); + const firstPlayerId = "io-a"; + const secondPlayerId = "io-b"; + await mm.queue.findOpenLobby.send({ playerId: firstPlayerId }); + await mm.queue.findOpenLobby.send({ playerId: secondPlayerId }); + + const first = await waitFor(() => mm.getLobbyForPlayer({ playerId: firstPlayerId })); + const second = await waitFor(() => mm.getLobbyForPlayer({ playerId: secondPlayerId })); + expect(second.matchId).toBe(first.matchId); + + const a = client.ioStyleMatch + .getOrCreate([first.matchId], { params: { playerToken: first.playerToken } }) + .connect(); + const b = client.ioStyleMatch + .getOrCreate([first.matchId], { params: { playerToken: second.playerToken } }) + .connect(); + await sleep(260); + + const snapshot = await a.getSnapshot(); + expect(snapshot.playerCount).toBe(2); + expect(snapshot.tick).toBeGreaterThanOrEqual(2); + expect(snapshot.phase).toBe("live"); + + await Promise.all([a.dispose(), b.dispose(), mm.dispose()]); + }, 15_000); + + test("competitive filled-room + team assignment + 20 tps", async (ctx) => { + const { client } = await setupTest(ctx, registry); + + const mm = client.competitiveMatchmaker.getOrCreate(["main"]).connect(); + const players = ["comp-a", "comp-b", "comp-c", "comp-d"]; + + for (const playerId of players) { + await mm.queue.queueForMatch.send({ playerId, mode: "duo" }); + } + + const assignment = await waitFor(() => mm.getAssignment({ playerId: players[0]! })); + if (!assignment) throw new Error("missing competitive assignment"); + const matchId = assignment.matchId; + expect(matchId).toMatch(/^competitive-duo-/); + + const assignments = await Promise.all( + players.map(async (playerId) => { + const assignment = await waitFor(() => mm.getAssignment({ playerId })); + if (!assignment) { + throw new Error(`missing competitive assignment for ${playerId}`); + } + return assignment; + }), + ); + const playerTokenByPlayerId = new Map( + assignments.map((entry) => [entry.playerId, entry.playerToken]), + ); + + const conns = players.map((playerId) => + client.competitiveMatch + .getOrCreate([matchId], { params: { playerToken: playerTokenByPlayerId.get(playerId)! } }) + .connect(), + ); + + const joined = await waitFor(async () => { + const snapshot = await conns[0]!.getSnapshot(); + return snapshot.phase === "live" ? snapshot : null; + }); + expect(joined.phase).toBe("live"); + + const teamCounts: Record = {}; + for (const player of Object.values(joined.players as Record)) { + const key = String(player.teamId); + teamCounts[key] = (teamCounts[key] ?? 0) + 1; + } + expect(teamCounts["0"]).toBe(2); + expect(teamCounts["1"]).toBe(2); + + await sleep(120); + const liveTick = await conns[0]!.getSnapshot(); + expect(liveTick.tick).toBeGreaterThanOrEqual(2); + + await conns[0]!.finish({ winnerTeam: 0 }); + const finished = await conns[1]!.getSnapshot(); + expect(finished.phase).toBe("finished"); + + await waitUntil(async () => (await mm.getAssignment({ playerId: players[0]! })) == null); + + await Promise.all([...conns.map((conn) => conn.dispose()), mm.dispose()]); + }, 15_000); + + test("party host start + party code with no tick loop", async (ctx) => { + const { client } = await setupTest(ctx, registry); + + const mm = client.partyMatchmaker.getOrCreate(["main"]).connect(); + const hostPlayerId = "party-host"; + await mm.queue.createParty.send({ hostPlayerId }); + const created = await waitFor(() => mm.getPartyForHost({ hostPlayerId })); + expect(created.partyCode.length).toBe(6); + + const guestPlayerId = "party-guest"; + await mm.queue.joinParty.send({ partyCode: created.partyCode, playerId: guestPlayerId }); + const joinRes = await waitFor(() => + mm.getJoinByPlayer({ partyCode: created.partyCode, playerId: guestPlayerId }), + ); + + const host = client.partyMatch + .getOrCreate([created.matchId], { params: { playerToken: created.hostPlayerToken } }) + .connect(); + const guest = client.partyMatch + .getOrCreate([created.matchId], { params: { playerToken: joinRes.playerToken } }) + .connect(); + + const started = await host.start(); + expect(started.phase).toBe("in_progress"); + + const room = await waitFor(async () => { + const party = await mm.getParty({ partyCode: created.partyCode }); + return party?.status === "in_progress" ? party : null; + }); + expect(room.status).toBe("in_progress"); + + const finished = await host.finish(); + expect(finished.phase).toBe("finished"); + + await Promise.all([host.dispose(), guest.dispose(), mm.dispose()]); + }, 15_000); + + test("async turn-based invite + open pool without tick loop", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const mm = client.asyncTurnBasedMatchmaker.getOrCreate(["main"]).connect(); + + const inviteCode = `invite-${Date.now()}`; + await mm.queue.createInvite.send({ inviteCode, fromPlayerId: "turn-a", toPlayerId: "turn-b" }); + await mm.queue.acceptInvite.send({ inviteCode, playerId: "turn-b" }); + const accepted = await waitFor(() => mm.getAssignment({ playerId: "turn-b" })); + const turnAAssignment = await waitFor(() => mm.getAssignment({ playerId: "turn-a" })); + + const a = client.asyncTurnBasedMatch + .getOrCreate([accepted.matchId], { params: { playerToken: turnAAssignment.playerToken } }) + .connect(); + const b = client.asyncTurnBasedMatch + .getOrCreate([accepted.matchId], { params: { playerToken: accepted.playerToken } }) + .connect(); + + await expect(b.submitTurn({ move: "bad-first" })).rejects.toMatchObject({ + code: "not_your_turn", + }); + + await a.submitTurn({ move: "open" }); + await b.submitTurn({ move: "reply" }); + const finished = await a.finish({ winnerPlayerId: "turn-a" }); + expect(finished.phase).toBe("finished"); + + await mm.queue.joinOpenPool.send({ playerId: "pool-a" }); + await mm.queue.joinOpenPool.send({ playerId: "pool-b" }); + + const assignedPoolA = await waitFor(() => mm.getAssignment({ playerId: "pool-a" })); + const assignedPoolB = await waitFor(() => mm.getAssignment({ playerId: "pool-b" })); + expect(assignedPoolA.matchId).toBe(assignedPoolB.matchId); + + await Promise.all([a.dispose(), b.dispose(), mm.dispose()]); + }, 15_000); + + test("ranked elo matchmaking + 20 tps", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const mm = client.rankedMatchmaker.getOrCreate(["main"]).connect(); + + await mm.queue.debugSetRating.send({ playerId: "rank-a", elo: 1200 }); + await mm.queue.debugSetRating.send({ playerId: "rank-b", elo: 1210 }); + await waitUntil(async () => (await mm.getRating({ playerId: "rank-a" })).elo === 1200); + await waitUntil(async () => (await mm.getRating({ playerId: "rank-b" })).elo === 1210); + + await mm.queue.queueForMatch.send({ playerId: "rank-a" }); + await mm.queue.queueForMatch.send({ playerId: "rank-b" }); + + const assignment = await waitFor(() => mm.getAssignment({ playerId: "rank-a" })); + if (!assignment) throw new Error("missing ranked assignment"); + const matchId = assignment.matchId; + expect(matchId).toMatch(/^ranked-/); + const rankB = await waitFor(() => mm.getAssignment({ playerId: "rank-b" })); + + const a = client.rankedMatch + .getOrCreate([matchId], { params: { playerToken: assignment.playerToken } }) + .connect(); + const b = client.rankedMatch + .getOrCreate([matchId], { params: { playerToken: rankB.playerToken } }) + .connect(); + + await sleep(120); + const live = await a.getSnapshot(); + expect(live.phase).toBe("live"); + expect(live.tick).toBeGreaterThanOrEqual(2); + + await a.finish({ winnerPlayerId: "rank-a" }); + await waitUntil(async () => (await mm.getRating({ playerId: "rank-a" })).elo > 1200); + await waitUntil(async () => (await mm.getRating({ playerId: "rank-b" })).elo < 1210); + const ra = await mm.getRating({ playerId: "rank-a" }); + const rb = await mm.getRating({ playerId: "rank-b" }); + expect(ra.elo).toBeGreaterThan(1200); + expect(rb.elo).toBeLessThan(1210); + + await Promise.all([a.dispose(), b.dispose(), mm.dispose()]); + }, 15_000); + + test("battle royale queue + 10 tps loop", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const mm = client.battleRoyaleMatchmaker.getOrCreate(["main"]).connect(); + + await mm.queue.joinQueue.send({ playerId: "br-a" }); + await mm.queue.joinQueue.send({ playerId: "br-b" }); + await mm.queue.joinQueue.send({ playerId: "br-c" }); + + const assignment = await waitFor(() => mm.getAssignment({ playerId: "br-a" })); + if (!assignment) throw new Error("missing battle royale assignment"); + const matchId = assignment.matchId; + const assignmentB = await waitFor(() => mm.getAssignment({ playerId: "br-b" })); + const assignmentC = await waitFor(() => mm.getAssignment({ playerId: "br-c" })); + if (!assignmentB || !assignmentC) throw new Error("missing battle royale assignments"); + + const a = client.battleRoyaleMatch + .getOrCreate([matchId], { params: { playerToken: assignment.playerToken } }) + .connect(); + const b = client.battleRoyaleMatch + .getOrCreate([matchId], { params: { playerToken: assignmentB.playerToken } }) + .connect(); + const cConn = client.battleRoyaleMatch + .getOrCreate([matchId], { params: { playerToken: assignmentC.playerToken } }) + .connect(); + await a.startNow(); + + await sleep(220); + const live = await a.getSnapshot(); + expect(live.phase).toBe("active"); + expect(live.tick).toBeGreaterThanOrEqual(2); + expect(live.zoneRadius).toBeLessThan(120); + + await a.eliminate({ victimPlayerId: "br-b" }); + await a.eliminate({ victimPlayerId: "br-c" }); + const finished = await a.getSnapshot(); + expect(finished.phase).toBe("finished"); + expect(finished.winnerPlayerId).toBe("br-a"); + + await waitUntil(async () => (await mm.getAssignment({ playerId: "br-a" })) == null); + + await Promise.all([a.dispose(), b.dispose(), cConn.dispose(), mm.dispose()]); + }, 15_000); + + test("open world chunking with world index + chunk actors", async (ctx) => { + const { client } = await setupTest(ctx, registry); + + const index = client.openWorldIndex.getOrCreate(["main"]).connect(); + const world = await index.registerWorld({ worldId: "world-a", chunkSize: 128 }); + expect(world.chunkSize).toBe(128); + + const windowRes = await index.listChunkWindow({ + worldId: "world-a", + centerWorldX: 32, + centerWorldY: 48, + radius: 1, + }); + expect(windowRes.chunks.length).toBe(9); + expect(windowRes.centerChunkX).toBe(0); + expect(windowRes.centerChunkY).toBe(0); + + const resolved = await index.resolveChunk({ + worldId: "world-a", + worldX: 64, + worldY: 96, + }); + expect(resolved.chunkX).toBe(0); + expect(resolved.chunkY).toBe(0); + + const chunk = client.openWorldChunk.getOrCreate(resolved.chunkKey).connect(); + await chunk.join({ + playerId: "ow-a", + name: "alice", + worldX: 64, + worldY: 96, + }); + const joined = await chunk.getSnapshot(); + expect(joined.playerCount).toBe(1); + expect(joined.chunkX).toBe(0); + + const stay = await chunk.move({ + playerId: "ow-a", + worldX: 110, + worldY: 120, + }); + expect(stay.moved).toBe(true); + + const handoffX = 300; + const expectedNextChunkX = Math.floor(handoffX / joined.chunkSize); + const cross = await chunk.move({ + playerId: "ow-a", + worldX: handoffX, + worldY: 120, + }); + expect(cross.moved).toBe(false); + if (cross.moved) throw new Error("expected cross-chunk handoff"); + expect(cross.reason).toBe("cross_chunk"); + expect(cross.nextChunkKey[1]).toBe(String(expectedNextChunkX)); + expect(cross.nextChunkKey[2]).toBe("0"); + + const nextChunk = client.openWorldChunk.getOrCreate(cross.nextChunkKey).connect(); + await nextChunk.join({ + playerId: "ow-a", + name: "alice", + worldX: handoffX, + worldY: 120, + }); + const nextSnapshot = await nextChunk.getSnapshot(); + expect(nextSnapshot.chunkX).toBe(expectedNextChunkX); + expect(nextSnapshot.chunkY).toBe(0); + expect(nextSnapshot.playerCount).toBe(1); + + await Promise.all([nextChunk.dispose(), chunk.dispose(), index.dispose()]); + }, 15_000); +}); diff --git a/examples/multiplayer-game-patterns/tsconfig.json b/examples/multiplayer-game-patterns/tsconfig.json new file mode 100644 index 0000000000..c3a976819c --- /dev/null +++ b/examples/multiplayer-game-patterns/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node", "vite/client", "vitest"], + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*", "frontend/**/*", "tests/**/*"] +} diff --git a/examples/multiplayer-game-patterns/turbo.json b/examples/multiplayer-game-patterns/turbo.json new file mode 100644 index 0000000000..8d06db6c15 --- /dev/null +++ b/examples/multiplayer-game-patterns/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["rivetkit#build"] + } + } +} diff --git a/examples/multiplayer-game-patterns/vite.config.ts b/examples/multiplayer-game-patterns/vite.config.ts new file mode 100644 index 0000000000..06dae893f5 --- /dev/null +++ b/examples/multiplayer-game-patterns/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import srvx from "vite-plugin-srvx"; + +export default defineConfig({ + plugins: [react(), ...srvx({ entry: "src/server.ts" })], +}); diff --git a/examples/multiplayer-game-patterns/vitest.config.ts b/examples/multiplayer-game-patterns/vitest.config.ts new file mode 100644 index 0000000000..f913a97abd --- /dev/null +++ b/examples/multiplayer-game-patterns/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + server: { + port: 5173, + }, + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/examples/sandbox-coding-agent-vercel/src/actors.ts b/examples/sandbox-coding-agent-vercel/src/actors.ts index 208d90e1cf..d757771a72 100644 --- a/examples/sandbox-coding-agent-vercel/src/actors.ts +++ b/examples/sandbox-coding-agent-vercel/src/actors.ts @@ -188,7 +188,7 @@ export const agent = actor({ for await (const rawEvent of eventStream) { const event = rawEvent as StreamEvent; - if (c.abortSignal.aborted) { + if (c.aborted) { break; } diff --git a/examples/sandbox-coding-agent/src/actors.ts b/examples/sandbox-coding-agent/src/actors.ts index 208d90e1cf..d757771a72 100644 --- a/examples/sandbox-coding-agent/src/actors.ts +++ b/examples/sandbox-coding-agent/src/actors.ts @@ -188,7 +188,7 @@ export const agent = actor({ for await (const rawEvent of eventStream) { const event = rawEvent as StreamEvent; - if (c.abortSignal.aborted) { + if (c.aborted) { break; } diff --git a/examples/sandbox-vercel/frontend/page-data.ts b/examples/sandbox-vercel/frontend/page-data.ts index 0e3c738026..f623d0d24d 100644 --- a/examples/sandbox-vercel/frontend/page-data.ts +++ b/examples/sandbox-vercel/frontend/page-data.ts @@ -120,6 +120,23 @@ export const myActor = actor({ return c.db.select().from(todos); }, }, +});`, + sqliteVanilla: `import { db } from "rivetkit/db"; + +export const notes = actor({ + db: db({ + onMigrate: async (db) => { + await db.execute(\`CREATE TABLE IF NOT EXISTS notes (...)\`); + }, + }), + actions: { + set: async (c, key: string, value: string) => { + await c.db.execute("INSERT OR REPLACE ...", key, value); + }, + get: async (c, key: string) => { + return await c.db.execute("SELECT * FROM notes WHERE key = ?", key); + }, + }, });`, }; @@ -310,6 +327,12 @@ export const ACTION_TEMPLATES: Record = { { label: "Toggle Todo", action: "toggleTodo", args: [1] }, { label: "Delete Todo", action: "deleteTodo", args: [1] }, ], + sqliteVanillaActor: [ + { label: "Set", action: "set", args: ["greeting", "hello world"] }, + { label: "Get", action: "get", args: ["greeting"] }, + { label: "Get All", action: "getAll", args: [] }, + { label: "Remove", action: "remove", args: ["greeting"] }, + ], }; export const PAGE_GROUPS: PageGroup[] = [ @@ -541,6 +564,20 @@ export const PAGE_GROUPS: PageGroup[] = [ actors: ["sqliteDrizzleActor"], snippet: SNIPPETS.sqliteDrizzle, }, + { + id: "sqlite-vanilla", + title: "SQLite Vanilla", + description: + "Use a vanilla SQLite key-value pattern with upserts and queries on a per-actor database.", + docs: [ + { + label: "SQLite", + href: "https://rivet.dev/docs/actors/sqlite", + }, + ], + actors: ["sqliteVanillaActor"], + snippet: SNIPPETS.sqliteVanilla, + }, ], }, { diff --git a/examples/sandbox-vercel/src/actors.ts b/examples/sandbox-vercel/src/actors.ts index 47bcfe5d6c..4bd486270d 100644 --- a/examples/sandbox-vercel/src/actors.ts +++ b/examples/sandbox-vercel/src/actors.ts @@ -38,6 +38,7 @@ import { } from "./actors/state/large-payloads.ts"; import { sqliteRawActor } from "./actors/state/sqlite-raw.ts"; import { sqliteDrizzleActor } from "./actors/state/sqlite-drizzle/mod.ts"; +import { sqliteVanillaActor } from "./actors/state/sqlite-vanilla.ts"; // Connections import { connStateActor } from "./actors/connections/conn-state.ts"; import { rejectConnectionActor } from "./actors/connections/reject-connection.ts"; @@ -149,6 +150,7 @@ export const registry = setup({ largePayloadConnActor, sqliteRawActor, sqliteDrizzleActor, + sqliteVanillaActor, // Realtime and connections connStateActor, rejectConnectionActor, diff --git a/examples/sandbox-vercel/src/actors/lifecycle/run.ts b/examples/sandbox-vercel/src/actors/lifecycle/run.ts index 42dce0de96..868ceb6192 100644 --- a/examples/sandbox-vercel/src/actors/lifecycle/run.ts +++ b/examples/sandbox-vercel/src/actors/lifecycle/run.ts @@ -15,7 +15,7 @@ export const runWithTicks = actor({ c.state.runStarted = true; c.log.info("run handler started"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { c.state.tickCount += 1; c.state.lastTickAt = Date.now(); c.log.info({ msg: "tick", tickCount: c.state.tickCount }); @@ -61,7 +61,7 @@ export const runWithQueueConsumer = actor({ c.state.runStarted = true; c.log.info("run handler started, waiting for messages"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { const message = await c.queue.next("messages", { timeout: 100 }); if (message) { c.log.info({ msg: "received message", body: message.body }); diff --git a/examples/sandbox-vercel/src/actors/queue/keep-awake.ts b/examples/sandbox-vercel/src/actors/queue/keep-awake.ts index dcf9a0a28e..48a79a65f1 100644 --- a/examples/sandbox-vercel/src/actors/queue/keep-awake.ts +++ b/examples/sandbox-vercel/src/actors/queue/keep-awake.ts @@ -22,7 +22,7 @@ export const keepAwake = actor({ completedTasks: [] as CompletedTask[], }, async run(c) { - while (!c.abortSignal.aborted) { + while (!c.aborted) { const job = await c.queue.next("tasks", { timeout: 1000 }); if (job) { const taskId = crypto.randomUUID(); diff --git a/examples/sandbox-vercel/src/actors/queue/worker.ts b/examples/sandbox-vercel/src/actors/queue/worker.ts index e7b710f5dc..420a6d3f30 100644 --- a/examples/sandbox-vercel/src/actors/queue/worker.ts +++ b/examples/sandbox-vercel/src/actors/queue/worker.ts @@ -19,7 +19,7 @@ export const worker = actor({ processed: c.state.processed, }); - while (!c.abortSignal.aborted) { + while (!c.aborted) { const job = await c.queue.next("jobs", { timeout: 1000 }); if (job) { c.state.processed += 1; diff --git a/examples/sandbox-vercel/src/actors/state/sqlite-vanilla.ts b/examples/sandbox-vercel/src/actors/state/sqlite-vanilla.ts new file mode 100644 index 0000000000..f2208c36c4 --- /dev/null +++ b/examples/sandbox-vercel/src/actors/state/sqlite-vanilla.ts @@ -0,0 +1,48 @@ +import { actor } from "rivetkit"; +import { db } from "rivetkit/db"; + +export const sqliteVanillaActor = actor({ + db: db({ + onMigrate: async (db) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + }, + }), + actions: { + set: async (c, key: string, value: string) => { + const updatedAt = Date.now(); + await c.db.execute( + `INSERT INTO notes (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, + value, + updatedAt, + ); + return { key, value, updatedAt }; + }, + get: async (c, key: string) => { + const rows = await c.db.execute( + "SELECT * FROM notes WHERE key = ?", + key, + ); + return rows[0] ?? null; + }, + getAll: async (c) => { + return await c.db.execute("SELECT * FROM notes ORDER BY updated_at DESC"); + }, + remove: async (c, key: string) => { + await c.db.execute("DELETE FROM notes WHERE key = ?", key); + return { deleted: key }; + }, + count: async (c) => { + const rows = await c.db.execute("SELECT COUNT(*) as total FROM notes"); + return rows[0]; + }, + }, +}); diff --git a/examples/sandbox/src/actors/lifecycle/run.ts b/examples/sandbox/src/actors/lifecycle/run.ts index 42dce0de96..868ceb6192 100644 --- a/examples/sandbox/src/actors/lifecycle/run.ts +++ b/examples/sandbox/src/actors/lifecycle/run.ts @@ -15,7 +15,7 @@ export const runWithTicks = actor({ c.state.runStarted = true; c.log.info("run handler started"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { c.state.tickCount += 1; c.state.lastTickAt = Date.now(); c.log.info({ msg: "tick", tickCount: c.state.tickCount }); @@ -61,7 +61,7 @@ export const runWithQueueConsumer = actor({ c.state.runStarted = true; c.log.info("run handler started, waiting for messages"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { const message = await c.queue.next("messages", { timeout: 100 }); if (message) { c.log.info({ msg: "received message", body: message.body }); diff --git a/examples/sandbox/src/actors/queue/keep-awake.ts b/examples/sandbox/src/actors/queue/keep-awake.ts index dcf9a0a28e..48a79a65f1 100644 --- a/examples/sandbox/src/actors/queue/keep-awake.ts +++ b/examples/sandbox/src/actors/queue/keep-awake.ts @@ -22,7 +22,7 @@ export const keepAwake = actor({ completedTasks: [] as CompletedTask[], }, async run(c) { - while (!c.abortSignal.aborted) { + while (!c.aborted) { const job = await c.queue.next("tasks", { timeout: 1000 }); if (job) { const taskId = crypto.randomUUID(); diff --git a/examples/sandbox/src/actors/queue/worker.ts b/examples/sandbox/src/actors/queue/worker.ts index e7b710f5dc..420a6d3f30 100644 --- a/examples/sandbox/src/actors/queue/worker.ts +++ b/examples/sandbox/src/actors/queue/worker.ts @@ -19,7 +19,7 @@ export const worker = actor({ processed: c.state.processed, }); - while (!c.abortSignal.aborted) { + while (!c.aborted) { const job = await c.queue.next("jobs", { timeout: 1000 }); if (job) { c.state.processed += 1; diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts index 7d4b87b454..7c89c540dc 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts @@ -15,7 +15,7 @@ export const runWithTicks = actor({ c.state.runStarted = true; c.log.info("run handler started"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { c.state.tickCount += 1; c.state.lastTickAt = Date.now(); c.log.info({ msg: "tick", tickCount: c.state.tickCount }); @@ -61,7 +61,7 @@ export const runWithQueueConsumer = actor({ c.state.runStarted = true; c.log.info("run handler started, waiting for messages"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { const message = await c.queue.next("messages", { timeout: 100 }); if (message) { c.log.info({ msg: "received message", body: message.body }); diff --git a/rivetkit-typescript/packages/rivetkit/runtime/index.ts b/rivetkit-typescript/packages/rivetkit/runtime/index.ts index c0912abfe7..6bf3e7ab55 100644 --- a/rivetkit-typescript/packages/rivetkit/runtime/index.ts +++ b/rivetkit-typescript/packages/rivetkit/runtime/index.ts @@ -122,6 +122,7 @@ export class Runtime { version: config.serverless.engineVersion, }); } else if (config.serveManager) { + const configuredManagerPort = config.managerPort; let upgradeWebSocket: any; const getUpgradeWebSocket: GetUpgradeWebSocket = () => upgradeWebSocket; @@ -140,6 +141,21 @@ export class Runtime { port: managerPort, }); + // `publicEndpoint` is derived from `config.managerPort` during config parsing, + // but we may have chosen a different free port at runtime. Keep them in sync + // so browser clients that rely on `/metadata` connect to the correct manager. + // + // Only rewrite when `publicEndpoint` is still on the default localhost pattern, + // to avoid clobbering explicitly-configured public endpoints. + if ( + config.publicEndpoint === + `http://127.0.0.1:${configuredManagerPort}` + ) { + config.publicEndpoint = `http://127.0.0.1:${managerPort}`; + config.serverless.publicEndpoint = config.publicEndpoint; + } + config.managerPort = managerPort; + const out = await crossPlatformServe( config, managerPort, diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts index 91a2c230ad..5dbbc253b9 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts @@ -439,8 +439,8 @@ interface BaseActorConfig< * - Tick loops for periodic work * - Custom workflow logic * - * The handler receives an abort signal via `c.abortSignal` that fires - * when the actor is stopping. You should use this to gracefully exit. + * The handler receives an abort signal via `c.abortSignal` and a + * `c.aborted` alias for loop checks. Use these to gracefully exit. * * If this handler exits or throws, the actor will crash and reschedule. * On shutdown, the actor waits for this handler to complete with a diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/contexts/base/actor.ts b/rivetkit-typescript/packages/rivetkit/src/actor/contexts/base/actor.ts index ddb97a7b0b..73c830a1a9 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/contexts/base/actor.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/contexts/base/actor.ts @@ -283,6 +283,13 @@ export class ActorContext< return this.#actor.abortSignal; } + /** + * True when the actor is stopping. + */ + get aborted(): boolean { + return this.#actor.abortSignal.aborted; + } + /** * Forces the actor to sleep. * diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/contexts/run.ts b/rivetkit-typescript/packages/rivetkit/src/actor/contexts/run.ts index 3af4dc07eb..4cf1e162b0 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/contexts/run.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/contexts/run.ts @@ -9,7 +9,7 @@ import { ActorContext } from "./base/actor"; * This context is passed to the `run` handler which executes after the actor * starts. It does not block actor startup and is intended for background tasks. * - * Use `c.abortSignal` to detect when the actor is stopping and gracefully exit. + * Use `c.aborted` (or `c.abortSignal`) to detect when the actor is stopping and gracefully exit. */ export class RunContext< TState, diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts index 2d8043076c..9c417888fa 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts @@ -1262,7 +1262,7 @@ export class ActorInstance< if (timedOut) { this.#rLog.warn({ - msg: "run handler did not complete in time, it may have leaked - ensure you use the abort signal (c.abortSignal) to exit gracefully", + msg: "run handler did not complete in time, it may have leaked - ensure you use c.aborted (or the abort signal c.abortSignal) to exit gracefully", timeoutMs, }); } else { diff --git a/rivetkit-typescript/packages/rivetkit/src/db/mod.ts b/rivetkit-typescript/packages/rivetkit/src/db/mod.ts index 2f586b5b0c..cd66a088b7 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/mod.ts @@ -1,6 +1,8 @@ import type { KvVfsOptions } from "./sqlite-vfs"; import type { DatabaseProvider, RawAccess } from "./config"; +export type { RawAccess } from "./config"; + interface DatabaseFactoryConfig { onMigrate?: (db: RawAccess) => Promise | void; } @@ -62,38 +64,69 @@ export function db({ const kvStore = createActorKvStore(ctx.kv); const db = await ctx.sqliteVfs.open(ctx.actorId, kvStore); + let op = Promise.resolve(); + + const serialize = async (fn: () => Promise): Promise => { + // Ensure wa-sqlite calls are not concurrent. Actors can process multiple + // actions concurrently, and wa-sqlite is not re-entrant. + const next = op.then(fn, fn); + op = next.then( + () => undefined, + () => undefined, + ); + return next; + }; return { execute: async (query, ...args) => { - if (args.length > 0) { - // Use parameterized query when args are provided - const { rows, columns } = await db.query(query, args); - return rows.map((row: unknown[]) => { + return await serialize(async () => { + // `db.exec` does not support binding `?` placeholders. + // + // When parameters are provided: + // - Use `db.query` for statements that return rows (SELECT/PRAGMA/WITH). + // - Use `db.run` for DML statements (INSERT/UPDATE/DELETE, etc). + // + // When no parameters are provided, keep using `db.exec` because it supports + // multiple statements (useful for migrations). + if (args.length > 0) { + const token = query.trimStart().slice(0, 16).toUpperCase(); + const returnsRows = + token.startsWith("SELECT") || + token.startsWith("PRAGMA") || + token.startsWith("WITH"); + + if (returnsRows) { + const { rows, columns } = await db.query(query, args); + return rows.map((row) => { + const rowObj: Record = {}; + for (let i = 0; i < columns.length; i++) { + rowObj[columns[i]] = row[i]; + } + return rowObj; + }); + } + + await db.run(query, args); + return []; + } + + const results: Record[] = []; + let columnNames: string[] | null = null; + await db.exec(query, (row: unknown[], columns: string[]) => { + if (!columnNames) columnNames = columns; const rowObj: Record = {}; for (let i = 0; i < row.length; i++) { - rowObj[columns[i]] = row[i]; + rowObj[columnNames[i]] = row[i]; } - return rowObj; + results.push(rowObj); }); - } - - // Use exec for non-parameterized queries - const results: Record[] = []; - let columnNames: string[] | null = null; - await db.exec(query, (row: unknown[], columns: string[]) => { - if (!columnNames) { - columnNames = columns; - } - const rowObj: Record = {}; - for (let i = 0; i < row.length; i++) { - rowObj[columnNames[i]] = row[i]; - } - results.push(rowObj); + return results; }); - return results; }, close: async () => { - await db.close(); + await serialize(async () => { + await db.close(); + }); }, } satisfies RawAccess; }, diff --git a/rivetkit-typescript/packages/rivetkit/src/test/mod.ts b/rivetkit-typescript/packages/rivetkit/src/test/mod.ts index 5e74fbe482..0686c2bd56 100644 --- a/rivetkit-typescript/packages/rivetkit/src/test/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/test/mod.ts @@ -41,6 +41,8 @@ export async function setupTest>( const parsedConfig = registry.parseConfig(); const managerDriver = driver.manager?.(parsedConfig); invariant(managerDriver, "missing manager driver"); + const getUpgradeWebSocket = () => upgradeWebSocket; + managerDriver.setGetUpgradeWebSocket(getUpgradeWebSocket); // const internalClient = createClientWithDriver( // managerDriver, // ClientConfigSchema.parse({}), @@ -48,7 +50,7 @@ export async function setupTest>( const { router } = buildManagerRouter( parsedConfig, managerDriver, - () => upgradeWebSocket!, + getUpgradeWebSocket, ); // Inject WebSocket diff --git a/website/astro.config.mjs b/website/astro.config.mjs index d8560c73d2..fc2942a8fe 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -55,7 +55,7 @@ export default defineConfig({ }, integrations: [ skillVersion(), - // typecheckCodeBlocks(), // Temporarily disabled due to rivetkit build issues + typecheckCodeBlocks(), generateRoutes(), mdx({ syntaxHighlight: false, diff --git a/website/src/content/cookbook/multiplayer-game.mdx b/website/src/content/cookbook/multiplayer-game.mdx new file mode 100644 index 0000000000..06b67f87d0 --- /dev/null +++ b/website/src/content/cookbook/multiplayer-game.mdx @@ -0,0 +1,212 @@ +--- +title: "Multiplayer Game" +description: "Pragmatic patterns for building multiplayer games with RivetKit: matchmaking, tick loops, realtime state, interest management, and validation." +templates: [] +--- + +Game-focused patterns for building multiplayer games with RivetKit, intended as a practical checklist you can adapt per genre. + +This document uses the base reference game types from `examples/multiplayer-game-patterns/src/actors/*` in alphabetical order. + +| Game Classification | Common Examples | +| --- | --- | +| `battle-royale` | Fortnite, Apex Legends, PUBG, Warzone | +| `competitive` | Counter-Strike competitive, VALORANT ranked, Rocket League playlists | +| `io-style` | Agar.io, Slither.io, surviv.io | +| `open-world` | Minecraft survival servers, Rust-like worlds, MMO zone/chunk worlds | +| `party` | Fall Guys private lobbies, custom game rooms, social party sessions | +| `ranked` | Chess ladders, competitive card games, duel arena ranked queues | +| `turn-based` | Chess correspondence, Words With Friends, async board games | + +## Architecture Patterns + +- Rivet Actor per gameplay boundary (room, chunk, match), not per physics entity. + - In realtime simulations, keep players, projectiles, and objects in actor state. Do not create one actor per player/object/physics body. + +- Coordinator and data actors: use a coordinator (matchmaker/world index) to create/find data actors (rooms/chunks/matches). See [Coordinator & Data Actors](/docs/actors/design-patterns#coordinator--data-actors). +- Lifecycle: data actors can call `c.destroy()` when empty to keep state clean. +- Communication path: prefer direct actor actions/events for game flows, and avoid adding extra internal HTTP hops unless you need an external integration boundary. + +## Matchmaking Patterns + +| Game Classification | Matchmaking Pattern | Actor Keys Recommendation | Behavior In Example | +| --- | --- | --- | --- | +| `battle-royale` | Single global queue, oldest-first fill into large lobbies. | Matchmaker: `battleRoyaleMatchmaker["main"]`
Match: `battleRoyaleMatch[matchId]` | Creates a match once queue reaches minimum players, then fills up to cap. | +| `competitive` | Mode-based fixed-capacity queues (`duo`, `squad`) with team assignment. | Matchmaker: `competitiveMatchmaker["main"]`
Match: `competitiveMatch[matchId]` | Builds only full matches per mode and pre-assigns team IDs. | +| `io-style` | Open-lobby routing to the fullest room below capacity. | Matchmaker: `ioStyleMatchmaker["main"]`
Match: `ioStyleMatch[matchId]` | Reuses active rooms, heartbeats room counts, and auto-creates when needed. | +| `open-world` | Coordinator-based chunk routing from world coordinates. | Index: `openWorldIndex["main"]`
Chunk: `openWorldChunk[worldId,chunkX,chunkY]` | Resolves world positions into chunk keys and preloads nearby chunk windows. | +| `party` | Host-created private party with explicit party-code joins. | Matchmaker: `partyMatchmaker["main"]`
Match: `partyMatch[matchId]` | Uses party code discovery and host-gated phase transitions. | +| `ranked` | ELO-based queue with widening search window over wait time. | Matchmaker: `rankedMatchmaker["main"]`
Match: `rankedMatch[matchId]` | Pairs two players, starts at narrow ELO delta, then expands window by wait time. | +| `turn-based` | Async invite flow plus open-pool pairing. | Matchmaker: `asyncTurnBasedMatchmaker["main"]`
Match: `asyncTurnBasedMatch[matchId]` | Supports direct invites and pooled matching for long-lived asynchronous play. | + +## Actor Ownership Constraints (Current Examples) + +These examples enforce ownership mostly with coordinator-created assignments and per-player join tokens. They are scaffold constraints, not full identity auth. + +| Constraint Area | Current Constraint In Examples | Implication | +| --- | --- | --- | +| Lobby/match creation authority | Coordinator actors create gameplay actors (`*Matchmaker`, `openWorldIndex`) via `create(...)`/`getOrCreate(...)`. Clients are expected to call the coordinator, not create match/chunk actors directly. | Lifecycle and discovery stay centralized in one actor per pattern. | +| Player record creation authority | Gameplay actors create in-memory player rows on `join` after token checks (`joinToken`, party auth, room auth). | Player state inside a lobby/match/chunk is server-created at join time. | +| Match/chunk trust boundary | Coordinator actors own assignment/index rows, and gameplay actors reject unknown players during `onBeforeConnect` via per-player tokens. | Orphaned sessions and unauthorized joins are rejected at connect time. | +| Action ownership after join | Connection state (`c.conn.state.playerId`) is used to gate mutating actions (`not_joined`, `host_only`, `player_mismatch`, turn ownership checks). | A joined connection can only act for its bound player role in that actor. | +| Open-world chunk ownership | Chunk keys are canonical (`openWorldChunk[worldId,chunkX,chunkY]`), `ensureChunk` enforces key/world/chunk consistency, and cross-chunk moves return the next chunk key instead of silently migrating state. | World partition boundaries are explicit and server-checked. | +| Explicit gap: identity source of truth | Many coordinator actions still accept raw `playerId` input from clients (for example queue/join discovery paths) without binding identity to `c.conn.id` or an authenticated principal. | Good for examples, but production code should map connection/auth identity to player ID server-side. | + +## Game Loop And Tick Rates + +### Recommended Tick Rates + +| Game Classification | Tick Rate In Example | Interval | Notes | +| --- | --- | --- | --- | +| `battle-royale` | 10 ticks/sec | `100ms` | Uses a fixed loop for zone progression and match lifecycle checks. | +| `competitive` | 20 ticks/sec | `50ms` | Uses a tighter loop for live team action snapshots. | +| `io-style` | 10 ticks/sec | `100ms` | Uses lightweight periodic room snapshots for drop-in sessions. | +| `open-world` | 10 ticks/sec per chunk actor | `100ms` | Each chunk actor runs independently so load scales by active chunks. | +| `party` | No continuous tick | N/A | Event-driven lobby flow (`join`, `start`, `finish`) without realtime simulation. | +| `ranked` | 20 ticks/sec | `50ms` | Uses fixed ticks while live for deterministic pacing and broadcast cadence. | +| `turn-based` | No continuous tick | N/A | Turn submission drives updates; no realtime loop required. | + +### Implementing Tick Loops + +| Pattern | Use When | Implementation Guidance | +| --- | --- | --- | +| Fixed realtime loop | `battle-royale`, `competitive`, `io-style`, `open-world`, `ranked` | Run in `run` with `sleep(tickMs)` and exit on `c.aborted`. | +| Action-driven updates | `party`, `turn-based` | Mutate and broadcast only on actions/events rather than scheduled ticks. | +| Coarse offline progression | Any mode with idle progression | Use `c.schedule.after(...)` with coarse windows (for example 5 to 15 minutes) and apply catch-up from elapsed wall clock time. | + +## Realtime Data Model + +- Publish authoritative state from the server as events, using snapshots and/or diffs. + - Example pattern: send a full snapshot on join/resync, then send per-tick diffs for regular updates. +- Keep events small and typed. For high-frequency updates, batch per tick. +- Avoid using UI-framework state/hooks for high-frequency game updates. Keep simulation and render loops in engine code, and use framework state for basic UI only (menus, HUD, forms), especially with Canvas or Three.js renderers. +- When all players should receive the same update, use `c.broadcast(...)`. When players should receive different/private data, send per-connection payloads with `conn.send(...)`. +- Frontend bundling: place shared types, constants, and pure helpers in `src/shared/` (for example `src/shared/sim/*`). Avoid importing backend actor modules into browser bundles, since backend modules often include Node-only dependencies (`fs`, SQLite bindings, etc). + +## Interest Management + +### Sharded Worlds + +- Partition very large worlds across data actors (for example chunk actors keyed by `worldId:chunkX:chunkY`). +- Clients subscribe only to nearby partitions (for example a 3x3 chunk window around the player). +- Use this when you are confident the world is large and state-heavy (for example sandbox builders or MMOs), not as a default for small matches. + +### Generic Interest Management + +- Send each client only the state relevant to that player (for example by proximity, line-of-sight, team, or game phase). +- For shooters and action games, limit replication by proximity and optional field-of-view checks. +- Keep filtering server-side so clients never receive data they should not see. + +## Netcode + +| Model | When To Use | How To Implement (Basic) | +| --- | --- | --- | +| Client-authoritative movement | Low-stakes realtime games where responsiveness is prioritized (for example social spaces and simple `.io` loops). | Client sends movement updates at a capped rate. Server validates max speed/delta/bounds, rejects invalid moves, rate limits spam, and periodically resyncs canonical state. | +| Server-authoritative from inputs | Competitive or high-integrity games where fairness and cheat resistance are critical. | Client sends input commands with sequence/timestamp. Server simulates on fixed ticks, resolves rules/collision, publishes authoritative snapshots, and client reconciles local prediction. | +| Client interpolation and smoothing | Any realtime game streaming remote entities under jittery networks. | Buffer snapshots per entity and render slightly in the past. Interpolate most frames, extrapolate briefly when missing updates, and snap when error exceeds threshold. | + +| Game Classification | Recommended Model | Client Has Authority Over | Server Has Authority Over | +| --- | --- | --- | --- | +| `battle-royale` | Server-authoritative from inputs | Local prediction, camera, interpolation of remote players | Match phase, eliminations, zone state, hit/collision resolution, final winner | +| `competitive` | Server-authoritative from inputs | Local prediction and presentation smoothing | Team assignment, action validity, phase transitions, scoring/win conditions | +| `io-style` | Hybrid (client movement + server validation) | Frequent movement intents and cosmetic responsiveness | Bounds/speed checks, room membership, canonical shared snapshot | +| `open-world` | Hybrid by subsystem | Client-side camera and immediate local feel; optional local prediction | Chunk ownership/routing, persistence, cross-player visibility, anti-cheat validation, canonical world state | +| `party` | Server-authoritative lobby control | UI state and local readiness signals | Membership list, host permissions, start/finish transitions | +| `ranked` | Server-authoritative from inputs | Local responsiveness and interpolation only | Input validation, authoritative tick state, match result, rating updates | +| `turn-based` | Server-authoritative turns | Drafting a move before submit | Turn ownership, committed move log, turn order, completion state | + +## Shared Simulation Logic + +Use this for logic shared between client and server while keeping the server authoritative. + +- Keep shared logic basic and pure (movement integration, input transforms, constants). +- Put shared code in `src/shared/`, and keep deterministic simulation helpers in `src/shared/sim/*` with no side effects. +- Do not share actor runtime code (DB access, network calls, `c.*` context, timers). +- The server remains authoritative. Client-side shared logic is for prediction/smoothing and should reconcile to server state. + +## Physics And Spatial Indexing + +| Game Classification | Physics Intensity | Recommended Physics Strategy | Spatial Indexing Guidance | +| --- | --- | --- | --- | +| `battle-royale` | High | Server-authoritative simulation. Use `@dimforge/rapier3d` for shooter-style 3D worlds, or `@dimforge/rapier2d` for top-down 2D variants. | Keep broad queries for loot/nearby entities with `rbush`/`flatbush`; use `d3-quadtree` for proximity lookups. | +| `competitive` | Medium to high | Prefer server-owned collision/rules with deterministic tick updates. Use Rapier when contacts or rigid-body complexity grows. | Use spatial indexing for broadphase and per-team/per-zone visibility filters. | +| `io-style` | Low to medium | Start with kinematic movement and server validation. Add heavy engine simulation only if gameplay demands it. | Add `rbush` or `d3-quadtree` once room entity count is no longer trivially small. | +| `open-world` | Medium to high at scale | Use chunk-local server simulation and escalate to Rapier when per-chunk contacts become complex. | Keep chunk-level broadphase indexes and load only nearby chunk windows per player. | +| `party` | Low | Usually no dedicated physics in lobby phase. If mini-games are added, keep simple kinematic server checks first. | Usually minimal indexing needs unless party mode adds realtime world interactions. | +| `ranked` | Medium to high | Use server-authoritative collision and resolution. Favor deterministic simulation and strict validation before visual polish. | Apply indexing for hit checks and visibility filtering to reduce per-tick costs. | +| `turn-based` | Very low | No continuous realtime physics; treat moves as discrete state transitions with rules validation. | Indexing is optional and usually only needed for board/query convenience at large scale. | + +| Dimension | Primary Engine | Fallback Engines | Notes | +| --- | --- | --- | --- | +| 2D | `@dimforge/rapier2d` | `planck-js`, `matter-js` | Prefer custom kinematic logic first for simple games, then escalate when contacts become complex. | +| 3D | `@dimforge/rapier3d` | `cannon-es`, `ammo.js` | For multiplayer shooters, prefer full dynamic server simulation over collision-only query logic. | + +| Spatial Indexing Default | Recommendation | +| --- | --- | +| Broadphase | Do not use naive `O(n^2)` checks once entity counts can grow. | +| AABB index | Use `rbush` for dynamic sets or `flatbush` for static-ish sets. Insert circles/capsules as AABBs. | +| Point index | Use `d3-quadtree` for nearest-neighbor and within-radius queries. | + +| Escalate To Rapier When | Why | +| --- | --- | +| You need joints, stacked bodies, or stable contact manifolds | These need robust rigid-body/contact solvers. | +| You have many dynamic bodies or high collision density | Purpose-built broadphase and solvers hold up better than custom kinematic logic. | +| You need complex shapes (rotated boxes/polygons in 2D, capsules/convex hulls/triangle meshes in 3D) | Hand-rolled collision code becomes costly and error-prone. | + +| Cross-Cutting Rule | Recommendation | +| --- | --- | +| Engine + indexing | Physics engines and spatial indexing are complementary. Use indexing for interest management and broad queries even with an engine. | +| Engine exclusivity | Physics engines are mutually exclusive in practice for one simulation. Pick one engine per simulation. | +| Backend/runtime boundary | Keep frontend-only libs out of backend simulation paths; treat server state as authoritative. | +| Client mesh raycasts | `three-mesh-bvh` is optional and mostly for client-side fast raycasts on dense static meshes. | + +## Security And Anti-Cheat + +Start with this baseline, then harden further for competitive or high-risk environments. + +### Baseline Checklist + +Apply this checklist to all implementations. + +- Identity: + - Use `c.conn.id` as the authoritative identity of the caller. + - Never accept `playerId` (or similar) from the client as the source of truth. +- Authorization: + - Validate that the caller is allowed to mutate the target entity (room membership, turn ownership, host-only actions). +- Input validation: + - Clamp sizes/lengths and validate enums. + - Validate usernames (length, allowed chars, avoid unbounded Unicode). +- Rate limiting: + - Per-connection rate limits for spammy actions (chat, join/leave, fire, movement updates). +- State integrity: + - Server recomputes derived state (scores, win conditions, placements). + - Avoid client-authoritative changes to inventory/currency/leaderboard totals. + +### Movement Validation + +Use this specifically for client-authoritative movement flows. + +Clients may send position/rotation updates for smoothness, but the server must: + +- Enforce max delta per update (speed cap) based on elapsed time. +- Reject or clamp teleports. +- Enforce world bounds (and basic collision if applicable). +- Rate limit update frequency (for example 20Hz max). + +## Persistence + +Use this section to decide when SQLite is a better fit than in-memory actor state. + +- Rivet Actor state is fine for small ephemeral state (rooms, short-lived matches). +- Prefer SQLite (`rivetkit/db`) when state is: + - Large or table-like (tiles/blocks/buildings/inventory). + - Needs queries/indexes beyond key lookups. + - Expected to persist long-term and grow over time. +- Matchmakers/coordinators may also use SQLite when their indexing state can grow large (room registries, matchmaking pools, large queues). +- When using `rivetkit/db`, assume multiple actions can hit the same actor DB concurrently. Keep DB operations serialized (either by library-provided mutexing or your own). + +## NPCs / AI + +- Assert `OPENAI_API_KEY` is present on backend startup. +- Limit tokens and rate limit per player. +- Keep NPC memory minimal and bounded (short summaries), or skip long-term memory if the game does not need it. diff --git a/website/src/content/docs/actors/appearance.mdx b/website/src/content/docs/actors/appearance.mdx index 1c88856028..98cae507bf 100644 --- a/website/src/content/docs/actors/appearance.mdx +++ b/website/src/content/docs/actors/appearance.mdx @@ -36,6 +36,8 @@ The `icon` property accepts two formats: Use any emoji character directly: ```typescript +import { actor } from "rivetkit"; + const notificationService = actor({ options: { name: "Notifications", @@ -50,6 +52,8 @@ const notificationService = actor({ Use [FontAwesome](https://fontawesome.com/search) icon names without the "fa" prefix: ```typescript +import { actor } from "rivetkit"; + const gameServer = actor({ options: { name: "Game Server", @@ -117,10 +121,14 @@ Instead of returning a function from your run handler factory, return an object ```typescript import type { RunConfig } from "rivetkit"; -function myCustomRunHandler(options: MyOptions): RunConfig { - async function run(c) { +type MyOptions = { + mode?: "safe" | "fast"; +}; + +function myCustomRunHandler(_options: MyOptions): RunConfig { + const run: RunConfig["run"] = async (_c) => { // Your run handler logic... - } + }; return { name: "My Custom Handler", @@ -133,6 +141,14 @@ function myCustomRunHandler(options: MyOptions): RunConfig { Users can then use this directly: ```typescript +import { actor } from "rivetkit"; + +const myCustomRunHandler = (_options: Record) => ({ + name: "My Custom Handler", + icon: "bolt", + run: async () => {}, +}); + const myActor = actor({ run: myCustomRunHandler({ /* options */ }), // Automatically gets "My Custom Handler" name and "bolt" icon @@ -142,6 +158,14 @@ const myActor = actor({ Actor-level `options.name` and `options.icon` always take precedence, allowing users to override library defaults: ```typescript +import { actor } from "rivetkit"; + +const myCustomRunHandler = (_options: Record) => ({ + name: "My Custom Handler", + icon: "bolt", + run: async () => {}, +}); + const myActor = actor({ options: { name: "Custom Name", // Overrides "My Custom Handler" diff --git a/website/src/content/docs/actors/index.mdx b/website/src/content/docs/actors/index.mdx index dc54badde8..70291312c8 100644 --- a/website/src/content/docs/actors/index.mdx +++ b/website/src/content/docs/actors/index.mdx @@ -875,6 +875,8 @@ Find the full client guides here: Customize how actors appear in the UI with display names and icons: ```typescript +import { actor } from "rivetkit"; + const chatRoom = actor({ options: { name: "Chat Room", diff --git a/website/src/content/docs/actors/lifecycle.mdx b/website/src/content/docs/actors/lifecycle.mdx index 6d0fa1bc9e..f47d8963a7 100644 --- a/website/src/content/docs/actors/lifecycle.mdx +++ b/website/src/content/docs/actors/lifecycle.mdx @@ -249,7 +249,7 @@ The `run` hook is called after the actor starts and runs in the background witho - Custom workflow logic - Background processing -The handler receives an abort signal via `c.abortSignal` that fires when the actor is stopping. You should always check or listen to this signal to exit gracefully. +The handler exposes `c.aborted` for loop checks and `c.abortSignal` for canceling operations when the actor is stopping. You should always check or listen for shutdown to exit gracefully. **Important behavior:** - If the `run` handler exits (returns), the actor will crash and reschedule @@ -266,7 +266,7 @@ const tickActor = actor({ run: async (c) => { c.log.info("Background loop started"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { c.state.tickCount++; c.log.info({ msg: "tick", count: c.state.tickCount }); @@ -299,7 +299,7 @@ const queueConsumer = actor({ run: async (c) => { c.log.info("Queue consumer started"); - while (!c.abortSignal.aborted) { + while (!c.aborted) { // Wait for next message with timeout const message = await c.queue.next("tasks", { timeout: 1000 }); @@ -366,17 +366,21 @@ The `onBeforeConnect` hook does NOT return connection state - it's used solely f ```typescript import { actor } from "rivetkit"; +function validateToken(token: string): boolean { + return token.length > 0; +} + +type ConnParams = { + userId?: string; + role?: string; + authToken?: string; +}; + const chatRoom = actor({ state: { messages: [] }, - // Method 1: Use a static default connection state - connState: { - role: "guest", - joinTime: 0, - }, - // Method 2: Dynamically create connection state - createConnState: (c, params: { userId?: string, role?: string }) => { + createConnState: (_c, params: ConnParams) => { return { userId: params.userId || "anonymous", role: params.role || "guest", @@ -385,7 +389,7 @@ const chatRoom = actor({ }, // Validate connections before accepting them - onBeforeConnect: (c, params: { authToken?: string }) => { + onBeforeConnect: (_c, params: ConnParams) => { // Validate authentication const authToken = params.authToken; if (!authToken || !validateToken(authToken)) { @@ -412,7 +416,14 @@ Executed after the client has successfully connected. Can be async. Receives the import { actor } from "rivetkit"; const chatRoom = actor({ - state: { users: {}, messages: [] }, + state: { + users: {} as Record, + messages: [] as string[], + }, + + createConnState: (_c, params: { userId?: string }) => ({ + userId: params.userId ?? "anonymous", + }), onConnect: (c, conn) => { // Add user to the room's user list using connection state @@ -444,7 +455,14 @@ Called when a client disconnects from the actor. Can be async. Receives the conn import { actor } from "rivetkit"; const chatRoom = actor({ - state: { users: {}, messages: [] }, + state: { + users: {} as Record, + messages: [] as string[], + }, + + createConnState: (_c, params: { userId?: string }) => ({ + userId: params.userId ?? "anonymous", + }), onDisconnect: (c, conn) => { // Update user status when they disconnect @@ -468,7 +486,7 @@ const chatRoom = actor({ [API Reference](/typedoc/interfaces/rivetkit.mod.RequestContext.html) -The `onRequest` hook handles HTTP requests sent to your actor at `/actors/{actorName}/http/*` endpoints. Can be async. It receives the request context and a standard `Request` object, and should return a `Response` object or `void` to continue default routing. +The `onRequest` hook handles HTTP requests sent to your actor at `/actors/{actorName}/http/*` endpoints. Can be async. It receives the request context and a standard `Request` object, and should return a `Response` object. See [Request Handler](/docs/actors/request-handler) for more details. @@ -491,8 +509,7 @@ const apiActor = actor({ }); } - // Return void to continue to default routing - return; + return new Response("Not found", { status: 404 }); }, actions: { /* ... */ } @@ -561,23 +578,21 @@ const loggingActor = actor({ console.log(`Action ${actionName} called with args:`, args); console.log(`Action ${actionName} returned:`, output); - // Add metadata to all responses - return { - data: output, - metadata: { - actionName, - timestamp: Date.now(), - requestId: crypto.randomUUID(), - userId: c.conn.state?.userId - } - }; + c.state.requestCount++; + c.broadcast("actionResponseLogged", { + actionName, + timestamp: Date.now(), + requestCount: c.state.requestCount, + }); + + return output; }, actions: { getUserData: (c, userId: string) => { c.state.requestCount++; - // This will be wrapped with metadata by onBeforeActionResponse + // This response is returned after onBeforeActionResponse runs return { userId, profile: { name: "John Doe", email: "john@example.com" }, @@ -586,7 +601,7 @@ const loggingActor = actor({ }, getStats: (c) => { - // This will also be wrapped with metadata + // This also passes through onBeforeActionResponse return { requestCount: c.state.requestCount, uptime: process.uptime() @@ -688,7 +703,10 @@ Common use cases: import { actor } from "rivetkit"; const gameRoom = actor({ - state: { players: {}, scores: {} }, + state: { + players: {} as Record, + scores: {} as Record, + }, actions: { playerJoined: (c, playerId: string) => { @@ -756,7 +774,7 @@ const dataProcessor = actor({ ### Actor Shutdown Abort Signal -The `c.abortSignal` provides an `AbortSignal` that fires when the actor is stopping. Use this to cancel ongoing operations when the actor sleeps or is destroyed. +The `c.abortSignal` provides an `AbortSignal` that fires when the actor is stopping, and `c.aborted` is the shorthand boolean for loop checks. Use these to cancel ongoing operations when the actor sleeps or is destroyed. ```typescript import { actor } from "rivetkit"; @@ -787,9 +805,7 @@ import { actor, ActorContextOf } from "rivetkit"; const myActor = actor({ state: { count: 0 }, - - // Use external function in lifecycle hook - onWake: (c) => logActorStarted(c) + actions: {}, }); // Simple external function with typed context @@ -803,7 +819,7 @@ See [Types](/docs/actors/types) for more details on using `ActorContextOf`. ## Full Example ```typescript -import { actor, CreateContext } from "rivetkit"; +import { actor } from "rivetkit"; interface CounterInput { initialCount?: number; @@ -831,7 +847,7 @@ interface ConnState { const counter = actor({ // Initialize state with input - createState: (c: CreateContext, input: CounterInput): CounterState => ({ + createState: (_c, input: CounterInput): CounterState => ({ count: input.initialCount ?? 0, stepSize: input.stepSize ?? 1, name: input.name ?? "Unnamed Counter", @@ -860,7 +876,7 @@ const counter = actor({ // Background task (does not block startup) run: async (c) => { - while (!c.abortSignal.aborted) { + while (!c.aborted) { // Example: periodic logging console.log(`Counter "${c.state.name}" is at ${c.state.count}`); await new Promise((resolve) => { @@ -896,18 +912,11 @@ const counter = actor({ console.log(`User ${conn.state.userId} disconnected from "${c.state.name}"`); }, - // Transform all action responses + // Observe action responses before they are sent onBeforeActionResponse: (c, actionName, args, output) => { c.state.requestCount++; - - return { - data: output, - metadata: { - action: actionName, - timestamp: Date.now(), - requestNumber: c.state.requestCount - } - }; + console.log(`Action ${actionName} called`, args); + return output; }, // Define actions diff --git a/website/src/content/docs/actors/queue.mdx b/website/src/content/docs/actors/queue.mdx index 949a6beeb8..fea048b918 100644 --- a/website/src/content/docs/actors/queue.mdx +++ b/website/src/content/docs/actors/queue.mdx @@ -44,6 +44,20 @@ if (!msg) return; // Process the message... ``` +`c.queue.next()` throws `ActorAborted` when the actor is shutting down. Do not wrap `c.queue.next()` in `try/catch` inside `run` loops, or you may swallow shutdown and keep looping. + +```typescript @nocheck +run: async (c) => { + while (!c.aborted) { + const msg = await c.queue.next("tasks", { timeout: 1000 }); + if (!msg) continue; + + // Catch processing errors separately if needed. + await processMessage(msg); + } +} +``` + Each message includes a stable `id` string you can log or correlate across systems. Use `wait: true` to hold the message until you explicitly complete it: @@ -88,6 +102,7 @@ Messages are retried indefinitely. There is no forced timeout on the actor side. - `QueueCompleteNotAllowed`: `msg.complete()` called when `wait: false`. - `QueueMessagePending`: `c.queue.next()` called while a previous `wait: true` message is still pending. - `QueueAlreadyCompleted`: `msg.complete()` called more than once. +- `ActorAborted`: `c.queue.next()` interrupted because the actor is stopping. If a message remains pending for more than 30 seconds, the actor logs a warning but does not time out automatically. diff --git a/website/src/integrations/typecheck-code-blocks.ts b/website/src/integrations/typecheck-code-blocks.ts index 544f037be7..3fd47ecbaf 100644 --- a/website/src/integrations/typecheck-code-blocks.ts +++ b/website/src/integrations/typecheck-code-blocks.ts @@ -232,11 +232,6 @@ function wrapCodeForTypecheck(code: string): string { // For partial snippets without imports, add common imports and wrap in async IIFE const imports: string[] = []; - // Add rivetkit import if code uses rivetkit types and doesn't have imports - if (code.includes("actor(") || code.includes("registry(") || code.includes("createClient(")) { - imports.push('import { actor, registry, createClient, UserError } from "rivetkit";'); - } - // Add hono import if code uses Hono if (code.includes("Hono") && !hasImports) { imports.push('import { Hono } from "hono";'); diff --git a/workflow-friction.md b/workflow-friction.md index 39733733c9..30604b76d8 100644 --- a/workflow-friction.md +++ b/workflow-friction.md @@ -211,12 +211,57 @@ actor.connection.approve(requestId, "Admin"); /> ``` +## Matchmaking Example Type Safety + +### 11. `c.client()` and `client: () => any` in multiplayer examples remove actor-to-actor type safety + +**Problem:** The `examples/multiplayer-game-patterns` actors use `any` for internal actor clients. This bypasses compile-time checks for action names and payload shapes in the most security-sensitive paths (lobby validation, join authorization, and lifecycle updates). + +**Problematic locations:** +- `examples/multiplayer-game-patterns/src/actors/turn-based/match.ts:95` +- `examples/multiplayer-game-patterns/src/actors/turn-based/match.ts:112` +- `examples/multiplayer-game-patterns/src/actors/turn-based/match.ts:192` +- `examples/multiplayer-game-patterns/src/actors/ranked/match.ts:86` +- `examples/multiplayer-game-patterns/src/actors/ranked/match.ts:150` +- `examples/multiplayer-game-patterns/src/actors/competitive/match.ts:101` +- `examples/multiplayer-game-patterns/src/actors/competitive/match.ts:118` +- `examples/multiplayer-game-patterns/src/actors/competitive/match.ts:196` +- `examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts:73` +- `examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts:75` +- `examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts:89` +- `examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts:122` +- `examples/multiplayer-game-patterns/src/actors/io-style/match.ts:41` +- `examples/multiplayer-game-patterns/src/actors/io-style/match.ts:45` +- `examples/multiplayer-game-patterns/src/actors/io-style/match.ts:73` +- `examples/multiplayer-game-patterns/src/actors/io-style/match.ts:91` +- `examples/multiplayer-game-patterns/src/actors/io-style/match.ts:140` +- `examples/multiplayer-game-patterns/src/actors/party/match.ts:54` +- `examples/multiplayer-game-patterns/src/actors/party/match.ts:56` +- `examples/multiplayer-game-patterns/src/actors/party/match.ts:88` +- `examples/multiplayer-game-patterns/src/actors/party/match.ts:121` +- `examples/multiplayer-game-patterns/src/actors/party/match.ts:178` +- `examples/multiplayer-game-patterns/src/actors/open-world/world-index.ts:99` +- `examples/multiplayer-game-patterns/src/actors/open-world/world-index.ts:102` +- `examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts:82` +- `examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts:85` + +**Impact:** +- API drift between matchmaker and match actors is not caught by TypeScript. +- Mistyped control/join/auth payload fields can compile and fail only at runtime. +- Security controls become easier to accidentally bypass through incorrect method names or payload shapes. +- Refactors are harder because IDE rename/type tooling cannot validate these call sites. + +**Workaround used:** Keep `any` casts local and manually guard critical responses (`validateLobby` and `authorizeJoin`) before mutating state. + +**Desired fix:** Expose a typed registry client for actor runtime contexts so examples can use `c.client()` without `any`. + ## Summary The biggest pain points are: 1. **Type system gaps** - loop context doesn't have the right type, requiring manual type helpers 2. **Missing methods** - `send()` and `broadcast()` weren't exposed on expected interfaces 3. **Package structure** - internal packages not accessible, need re-exports through public API +4. **Actor client typing gaps** - actor-to-actor calls in multiplayer examples require `any`, removing compile-time safety in auth-critical flows These issues could be addressed by: - Making `ActorWorkflowContext` the declared type for loop callbacks