diff --git a/examples/multiplayer-game-patterns/frontend/App.tsx b/examples/multiplayer-game-patterns/frontend/App.tsx index 7b27b9b8d2..611538b1b3 100644 --- a/examples/multiplayer-game-patterns/frontend/App.tsx +++ b/examples/multiplayer-game-patterns/frontend/App.tsx @@ -52,6 +52,18 @@ type Route = | { page: "game"; pattern: PatternId; matchInfo: unknown }; const PATTERNS: Array<{ id: PatternId; title: string; description: string; icon: LucideIcon }> = [ + { + id: "physics-2d", + title: "Physics 2D", + description: "Shared Rapier 2D physics at 10 TPS with client-side prediction and network smoothing.", + icon: Box, + }, + { + id: "physics-3d", + title: "Physics 3D", + description: "Shared Rapier 3D physics at 10 TPS with Three.js rendering and network smoothing.", + icon: Boxes, + }, { id: "io-style", title: "IO-Style", @@ -65,16 +77,10 @@ const PATTERNS: Array<{ id: PatternId; title: string; description: string; icon: icon: Swords, }, { - id: "party", - title: "Party", - description: "Event-driven party lobby with invite codes and host controls.", - icon: Users, - }, - { - id: "turn-based", - title: "Turn-Based", - description: "Tic-tac-toe with invite codes and open matchmaking pool.", - icon: Grid3X3, + id: "battle-royale", + title: "Battle Royale", + description: "Waiting lobby into shrinking zone gameplay. Last player standing wins.", + icon: Skull, }, { id: "ranked", @@ -82,12 +88,6 @@ const PATTERNS: Array<{ id: PatternId; title: string; description: string; icon: description: "1v1 ELO-based matchmaking with expanding rating windows. First to 5 kills.", icon: Trophy, }, - { - id: "battle-royale", - title: "Battle Royale", - description: "Waiting lobby into shrinking zone gameplay. Last player standing wins.", - icon: Skull, - }, { id: "open-world", title: "Open World", @@ -101,16 +101,16 @@ const PATTERNS: Array<{ id: PatternId; title: string; description: string; icon: icon: Factory, }, { - id: "physics-2d", - title: "Physics 2D", - description: "Shared Rapier 2D physics at 10 TPS with client-side prediction and network smoothing.", - icon: Box, + id: "turn-based", + title: "Turn-Based", + description: "Tic-tac-toe with invite codes and open matchmaking pool.", + icon: Grid3X3, }, { - id: "physics-3d", - title: "Physics 3D", - description: "Shared Rapier 3D physics at 10 TPS with Three.js rendering and network smoothing.", - icon: Boxes, + id: "party", + title: "Party", + description: "Event-driven party lobby with invite codes and host controls.", + icon: Users, }, ]; diff --git a/examples/multiplayer-game-patterns/frontend/games/arena/arena-game.ts b/examples/multiplayer-game-patterns/frontend/games/arena/arena-game.ts index 5cb98d62c0..ef8ac08ee4 100644 --- a/examples/multiplayer-game-patterns/frontend/games/arena/arena-game.ts +++ b/examples/multiplayer-game-patterns/frontend/games/arena/arena-game.ts @@ -9,17 +9,6 @@ const MOVE_SPEED = 200; // pixels per second (matches server MAX_SPEED) const SHOT_LINE_DURATION = 150; // ms to show shot line const RUBBER_BAND_THRESHOLD = 40; // pixels before snapping local player to server -const TEAM_COLORS = ["#ff4f00", "#3b82f6", "#30d158", "#bf5af2"]; - -function colorFromId(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) | 0; - } - const hue = ((hash % 360) + 360) % 360; - return `hsl(${hue}, 70%, 55%)`; -} - interface ShotLine { fromX: number; fromY: number; @@ -37,7 +26,7 @@ export class ArenaGame { private stopped = false; private rafId = 0; private worldSize = 600; - private targets: Record = {}; + private targets: Record = {}; private display: Record = {}; private keys: Record = {}; private phase: "waiting" | "live" | "finished" = "waiting"; @@ -60,7 +49,7 @@ export class ArenaGame { ) { this.conn = client.arenaMatch .get([matchInfo.matchId], { - params: { playerToken: matchInfo.playerToken }, + params: { playerId: matchInfo.playerId }, }) .connect(); @@ -71,7 +60,7 @@ export class ArenaGame { phase: "waiting" | "live" | "finished"; winnerTeam: number | null; winnerPlayerId: string | null; - players: Record; + players: Record; }; this.worldSize = snap.worldSize; this.scoreLimit = snap.scoreLimit; @@ -178,13 +167,6 @@ export class ArenaGame { this.conn.shoot({ dirX: dx / mag, dirY: dy / mag }).catch(() => {}); }; - private getPlayerColor(id: string, teamId: number): string { - if (teamId >= 0) { - return TEAM_COLORS[teamId % TEAM_COLORS.length]!; - } - return colorFromId(id); - } - private draw = () => { if (this.stopped) return; const now = performance.now(); @@ -278,7 +260,7 @@ export class ArenaGame { py = d.y * sy; } - const color = this.getPlayerColor(id, target.teamId); + const color = target.color; ctx.beginPath(); ctx.arc(px, py, PLAYER_RADIUS, 0, Math.PI * 2); diff --git a/examples/multiplayer-game-patterns/frontend/games/arena/bot.ts b/examples/multiplayer-game-patterns/frontend/games/arena/bot.ts index 49c73aa42f..dd5ce4f692 100644 --- a/examples/multiplayer-game-patterns/frontend/games/arena/bot.ts +++ b/examples/multiplayer-game-patterns/frontend/games/arena/bot.ts @@ -1,6 +1,8 @@ import type { GameClient } from "../../client.ts"; import type { Mode } from "../../../src/actors/arena/config.ts"; +import type { ArenaMatchInfo } from "./menu.tsx"; import { ArenaGame } from "./arena-game.ts"; +import { waitForAssignment } from "./wait-for-assignment.ts"; export class ArenaBot { private game: ArenaGame | null = null; @@ -16,30 +18,19 @@ export class ArenaBot { try { const mm = this.client.arenaMatchmaker.getOrCreate(["main"]).connect(); this.mm = mm; - const result = await mm.send("queueForMatch", { mode: this.mode }, { wait: true, timeout: 120_000 }); - const response = (result as { - response?: { playerId: string; registrationToken: string }; - })?.response; - if (!response || this.destroyed) return; - await mm.registerPlayer({ - playerId: response.playerId, - registrationToken: response.registrationToken, - }); + const response = await mm.queueForMatch({ + mode: this.mode, + }) as { playerId?: string }; + if (!response?.playerId || this.destroyed) return; - // Poll for assignment until match is filled. - while (!this.destroyed) { - const assignment = await mm.getAssignment({ - playerId: response.playerId, - registrationToken: response.registrationToken, - }); - if (assignment) { - mm.dispose(); - this.mm = null; - this.game = new ArenaGame(null, this.client, assignment, { bot: true }); - return; - } - await new Promise((r) => setTimeout(r, 200)); - } + const assignment = await waitForAssignment( + mm, + response.playerId, + ); + if (this.destroyed) return; + mm.dispose(); + this.mm = null; + this.game = new ArenaGame(null, this.client, assignment, { bot: true }); } catch { // Bot failed to join. } diff --git a/examples/multiplayer-game-patterns/frontend/games/arena/menu.tsx b/examples/multiplayer-game-patterns/frontend/games/arena/menu.tsx index 0afb0fdef4..ebc62fcda4 100644 --- a/examples/multiplayer-game-patterns/frontend/games/arena/menu.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/arena/menu.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { GameClient } from "../../client.ts"; import { type Mode, MODE_CONFIG } from "../../../src/actors/arena/config.ts"; import { ArenaBot } from "./bot.ts"; +import { waitForAssignment } from "./wait-for-assignment.ts"; export interface ArenaMatchInfo { matchId: string; playerId: string; - playerToken: string; teamId: number; mode: Mode; } @@ -90,42 +90,21 @@ export function ArenaMenu({ setStatus("queued"); - // Queue message completes immediately with playerId. - const result = await mm.send( - "queueForMatch", - { mode }, - { wait: true, timeout: 120_000 }, - ); - const response = ( - result as { - response?: { playerId: string; registrationToken: string }; - } - )?.response; - if (!response || abortRef.current) + const response = await mm.queueForMatch({ + mode, + }) as { playerId?: string }; + if (!response?.playerId || abortRef.current) throw new Error("Failed to queue"); myPlayerId = response.playerId; - await mm.registerPlayer({ - playerId: myPlayerId, - registrationToken: response.registrationToken, - }); - // Fetch current queue sizes since the broadcast during queue - // processing may have fired before the WebSocket was connected. const sizes = await mm.getQueueSizes(); if (!abortRef.current) setQueueCount((sizes as Record)[mode] ?? 0); - // Poll for assignment until this connection is matched. - while (!matched && !abortRef.current) { - const existing = await mm.getAssignment({ - playerId: myPlayerId, - registrationToken: response.registrationToken, - }); - if (existing) { - resolveMatch(existing as ArenaMatchInfo); - break; - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } + const assignment = await waitForAssignment( + mm, + myPlayerId, + ); + resolveMatch(assignment); } catch (err) { if (abortRef.current) return; await cleanup(); diff --git a/examples/multiplayer-game-patterns/frontend/games/arena/wait-for-assignment.ts b/examples/multiplayer-game-patterns/frontend/games/arena/wait-for-assignment.ts new file mode 100644 index 0000000000..f2f025ba3c --- /dev/null +++ b/examples/multiplayer-game-patterns/frontend/games/arena/wait-for-assignment.ts @@ -0,0 +1,70 @@ +interface Assignment { + playerId: string; +} + +type AssignmentConnection = { + getAssignment: ( + input: { playerId: string }, + ) => Promise>; + on: ( + event: "assignmentReady", + handler: (raw: unknown) => void, + ) => (() => void) | undefined; +}; + +function normalizeError(err: unknown): Error { + if (err instanceof Error) return err; + return new Error(String(err)); +} + +export async function waitForAssignment( + mm: AssignmentConnection, + playerId: string, + timeoutMs = 120_000, +): Promise { + const existing = await readAssignment(mm, playerId); + if (existing) return existing as T; + + return await new Promise((resolve, reject) => { + let settled = false; + let timeout: number | null = null; + let off: (() => void) | undefined; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + if (timeout !== null) window.clearTimeout(timeout); + off?.(); + fn(); + }; + + off = mm.on("assignmentReady", (raw: unknown) => { + const next = raw as T; + if (next.playerId !== playerId) return; + settle(() => resolve(next)); + }); + + timeout = window.setTimeout(() => { + settle(() => reject(new Error("Timed out waiting for assignment"))); + }, timeoutMs); + + void readAssignment(mm, playerId) + .then((next) => { + if (!next) return; + settle(() => resolve(next)); + }) + .catch((err) => { + settle(() => reject(normalizeError(err))); + }); + }); +} + +async function readAssignment( + mm: AssignmentConnection, + playerId: string, +): Promise { + const nested = await mm.getAssignment({ playerId }); + const resolved = await nested; + if (!resolved) return null; + return resolved as T; +} diff --git a/examples/multiplayer-game-patterns/frontend/games/battle-royale/battle-royale-game.ts b/examples/multiplayer-game-patterns/frontend/games/battle-royale/battle-royale-game.ts index 9b1608eecc..5d876db2cf 100644 --- a/examples/multiplayer-game-patterns/frontend/games/battle-royale/battle-royale-game.ts +++ b/examples/multiplayer-game-patterns/frontend/games/battle-royale/battle-royale-game.ts @@ -10,15 +10,6 @@ const SHOT_LINE_DURATION = 150; const RUBBER_BAND_THRESHOLD = 40; const VIEWPORT_SIZE = 600; -function colorFromId(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) | 0; - } - const hue = ((hash % 360) + 360) % 360; - return `hsl(${hue}, 70%, 55%)`; -} - interface ShotLine { fromX: number; fromY: number; @@ -36,7 +27,7 @@ export class BattleRoyaleGame { private stopped = false; private rafId = 0; private worldSize = 1200; - private targets: Record = {}; + private targets: Record = {}; private display: Record = {}; private keys: Record = {}; private phase: "lobby" | "live" | "finished" = "lobby"; @@ -62,7 +53,9 @@ export class BattleRoyaleGame { ) { this.conn = client.battleRoyaleMatch .get([matchInfo.matchId], { - params: { playerToken: matchInfo.playerToken }, + params: { + playerId: matchInfo.playerId, + }, }) .connect(); @@ -76,7 +69,7 @@ export class BattleRoyaleGame { capacity: number; lobbyCountdown: number | null; zone: { centerX: number; centerY: number; radius: number }; - players: Record; + players: Record; }; this.worldSize = snap.worldSize; this.phase = snap.phase; @@ -305,7 +298,7 @@ export class BattleRoyaleGame { if (px < -50 || px > VIEWPORT_SIZE + 50 || py < -50 || py > VIEWPORT_SIZE + 50) continue; - const color = isMe ? "#ff4f00" : colorFromId(id); + const color = target.color; // Player circle. ctx.beginPath(); diff --git a/examples/multiplayer-game-patterns/frontend/games/battle-royale/bot.ts b/examples/multiplayer-game-patterns/frontend/games/battle-royale/bot.ts index 1c73d28494..36dcd8f50a 100644 --- a/examples/multiplayer-game-patterns/frontend/games/battle-royale/bot.ts +++ b/examples/multiplayer-game-patterns/frontend/games/battle-royale/bot.ts @@ -14,7 +14,12 @@ export class BattleRoyaleBot { const mm = this.client.battleRoyaleMatchmaker.getOrCreate(["main"]).connect(); const result = await mm.send("findMatch", {}, { wait: true, timeout: 10_000 }); mm.dispose(); - const response = (result as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; + const response = (result as { + response?: { + matchId: string; + playerId: string; + }; + })?.response; if (!response || this.destroyed) return; this.game = new BattleRoyaleGame(null, this.client, response, { bot: true }); diff --git a/examples/multiplayer-game-patterns/frontend/games/battle-royale/menu.tsx b/examples/multiplayer-game-patterns/frontend/games/battle-royale/menu.tsx index 7d5aa2e756..dd5e0b5083 100644 --- a/examples/multiplayer-game-patterns/frontend/games/battle-royale/menu.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/battle-royale/menu.tsx @@ -4,7 +4,6 @@ import type { GameClient } from "../../client.ts"; export interface BattleRoyaleMatchInfo { matchId: string; playerId: string; - playerToken: string; } export function BattleRoyaleMenu({ @@ -26,7 +25,7 @@ export function BattleRoyaleMenu({ const result = await mm.send("findMatch", {}, { wait: true, timeout: 10_000 }); mm.dispose(); const response = (result as { response?: BattleRoyaleMatchInfo })?.response; - if (!response?.matchId || !response?.playerToken || !response?.playerId) { + if (!response?.matchId || !response?.playerId) { throw new Error("Matchmaker did not return a valid match"); } onReady(response); diff --git a/examples/multiplayer-game-patterns/frontend/games/io-style/bot.ts b/examples/multiplayer-game-patterns/frontend/games/io-style/bot.ts index 28a0a75ddf..a06f2b4a9b 100644 --- a/examples/multiplayer-game-patterns/frontend/games/io-style/bot.ts +++ b/examples/multiplayer-game-patterns/frontend/games/io-style/bot.ts @@ -14,7 +14,12 @@ export class IoBot { const mm = this.client.ioStyleMatchmaker.getOrCreate(["main"]).connect(); const result = await mm.send("findLobby", {}, { wait: true, timeout: 10_000 }); mm.dispose(); - const response = (result as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; + const response = (result as { + response?: { + matchId: string; + playerId: string; + }; + })?.response; if (!response || this.destroyed) return; this.game = new IoGame(null, this.client, response, { bot: true }); diff --git a/examples/multiplayer-game-patterns/frontend/games/io-style/io-game.ts b/examples/multiplayer-game-patterns/frontend/games/io-style/io-game.ts index 35103a0258..34d69985ef 100644 --- a/examples/multiplayer-game-patterns/frontend/games/io-style/io-game.ts +++ b/examples/multiplayer-game-patterns/frontend/games/io-style/io-game.ts @@ -5,15 +5,6 @@ const PLAYER_RADIUS = 12; const LERP_FACTOR = 0.2; const GRID_SPACING = 50; -function colorFromId(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) | 0; - } - const hue = ((hash % 360) + 360) % 360; - return `hsl(${hue}, 70%, 55%)`; -} - type IoStyleMatchConn = ReturnType< ReturnType["connect"] >; @@ -22,7 +13,7 @@ export class IoGame { private stopped = false; private rafId = 0; private worldSize = 600; - private targets: Record = {}; + private targets: Record = {}; private display: Record = {}; private keys: Record = {}; private lastIx = 0; @@ -38,14 +29,16 @@ export class IoGame { ) { this.conn = client.ioStyleMatch .get([matchInfo.matchId], { - params: { playerToken: matchInfo.playerToken }, + params: { + playerId: matchInfo.playerId, + }, }) .connect(); this.conn.on("snapshot", (raw: unknown) => { const snap = raw as { worldSize: number; - players: Record; + players: Record; }; this.worldSize = snap.worldSize; for (const [id, pos] of Object.entries(snap.players)) { @@ -145,7 +138,7 @@ export class IoGame { ctx.beginPath(); ctx.arc(px, py, PLAYER_RADIUS, 0, Math.PI * 2); - ctx.fillStyle = isMe ? "#ff4f00" : colorFromId(id); + ctx.fillStyle = target.color; ctx.fill(); if (isMe) { ctx.lineWidth = 2; diff --git a/examples/multiplayer-game-patterns/frontend/games/io-style/menu.tsx b/examples/multiplayer-game-patterns/frontend/games/io-style/menu.tsx index c99b6d08c9..928ebb7587 100644 --- a/examples/multiplayer-game-patterns/frontend/games/io-style/menu.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/io-style/menu.tsx @@ -4,7 +4,6 @@ import type { GameClient } from "../../client.ts"; export interface IoStyleMatchInfo { matchId: string; playerId: string; - playerToken: string; } export function IoStyleMenu({ @@ -26,7 +25,7 @@ export function IoStyleMenu({ const result = await mm.send("findLobby", {}, { wait: true, timeout: 10_000 }); mm.dispose(); const response = (result as { response?: IoStyleMatchInfo })?.response; - if (!response?.matchId || !response?.playerToken || !response?.playerId) { + if (!response?.matchId || !response?.playerId) { throw new Error("Matchmaker did not return a valid lobby"); } onReady(response); diff --git a/examples/multiplayer-game-patterns/frontend/games/open-world/bot.ts b/examples/multiplayer-game-patterns/frontend/games/open-world/bot.ts index 37f820b75d..9c8532bfdc 100644 --- a/examples/multiplayer-game-patterns/frontend/games/open-world/bot.ts +++ b/examples/multiplayer-game-patterns/frontend/games/open-world/bot.ts @@ -1,4 +1,5 @@ import type { GameClient } from "../../client.ts"; +import { CHUNK_SIZE, WORLD_ID } from "../../../src/actors/open-world/config.ts"; import { OpenWorldGame } from "./open-world-game.ts"; export class OpenWorldBot { @@ -11,17 +12,15 @@ export class OpenWorldBot { private async start() { try { - const index = this.client.openWorldIndex.getOrCreate(["main"]).connect(); - const result = await index.send( - "getChunkForPosition", - { x: 300, y: 300, playerName: `Bot-${Math.random().toString(36).slice(2, 6)}` }, - { wait: true, timeout: 10_000 }, - ); - index.dispose(); - const response = (result as { response?: { chunkKey: [string, number, number]; playerId: string; playerToken: string } })?.response; - if (!response || this.destroyed) return; + const response = resolveChunkForPosition(300, 300); + if (this.destroyed) return; - this.game = new OpenWorldGame(null, this.client, { ...response, playerName: "Bot" }, { bot: true }); + this.game = new OpenWorldGame( + null, + this.client, + { ...response, playerName: `Bot-${Math.random().toString(36).slice(2, 6)}` }, + { bot: true }, + ); } catch { // Bot failed to join. } @@ -32,3 +31,16 @@ export class OpenWorldBot { this.game?.destroy(); } } + +function resolveChunkForPosition( + x: number, + y: number, +): { chunkKey: [string, number, number]; spawnX: number; spawnY: number } { + const chunkX = Math.floor(x / CHUNK_SIZE); + const chunkY = Math.floor(y / CHUNK_SIZE); + return { + chunkKey: [WORLD_ID, chunkX, chunkY], + spawnX: ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + spawnY: ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + }; +} diff --git a/examples/multiplayer-game-patterns/frontend/games/open-world/menu.tsx b/examples/multiplayer-game-patterns/frontend/games/open-world/menu.tsx index e5e6fbdeb8..f70823a765 100644 --- a/examples/multiplayer-game-patterns/frontend/games/open-world/menu.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/open-world/menu.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import type { GameClient } from "../../client.ts"; +import { CHUNK_SIZE, WORLD_ID } from "../../../src/actors/open-world/config.ts"; export interface OpenWorldMatchInfo { chunkKey: [string, number, number]; - playerId: string; - playerToken: string; + spawnX: number; + spawnY: number; playerName: string; } @@ -25,23 +26,8 @@ export function OpenWorldMenu({ if (!name.trim()) return; setStatus("loading"); setError(""); - try { - const index = client.openWorldIndex.getOrCreate(["main"]).connect(); - const result = await index.send( - "getChunkForPosition", - { x: 600, y: 600, playerName: name.trim() }, - { wait: true, timeout: 10_000 }, - ); - index.dispose(); - const response = ( - result as { response?: { chunkKey: [string, number, number]; playerId: string; playerToken: string } } - )?.response; - if (!response?.chunkKey) throw new Error("Failed to enter world"); - onReady({ ...response, playerName: name.trim() }); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStatus("error"); - } + const response = resolveChunkForPosition(600, 600); + onReady({ ...response, playerName: name.trim() }); }; return ( @@ -81,3 +67,16 @@ export function OpenWorldMenu({ ); } + +function resolveChunkForPosition( + x: number, + y: number, +): { chunkKey: [string, number, number]; spawnX: number; spawnY: number } { + const chunkX = Math.floor(x / CHUNK_SIZE); + const chunkY = Math.floor(y / CHUNK_SIZE); + return { + chunkKey: [WORLD_ID, chunkX, chunkY], + spawnX: ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + spawnY: ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + }; +} diff --git a/examples/multiplayer-game-patterns/frontend/games/open-world/open-world-game.ts b/examples/multiplayer-game-patterns/frontend/games/open-world/open-world-game.ts index fb1d175301..0dc75314f1 100644 --- a/examples/multiplayer-game-patterns/frontend/games/open-world/open-world-game.ts +++ b/examples/multiplayer-game-patterns/frontend/games/open-world/open-world-game.ts @@ -1,5 +1,7 @@ import type { GameClient } from "../../client.ts"; import type { OpenWorldMatchInfo } from "./menu.tsx"; +import { CHUNK_SIZE, WORLD_ID } from "../../../src/actors/open-world/config.ts"; +import { getPlayerColor } from "../../../src/actors/player-color.ts"; const PLAYER_RADIUS = 12; const LERP_FACTOR = 0.2; @@ -10,15 +12,6 @@ const MIN_ZOOM = 0.25; const MAX_ZOOM = 2; const ZOOM_STEP = 0.15; -function colorFromId(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) | 0; - } - const hue = ((hash % 360) + 360) % 360; - return `hsl(${hue}, 70%, 55%)`; -} - type OpenWorldChunkConn = ReturnType< ReturnType["connect"] >; @@ -27,19 +20,29 @@ interface ChunkConnection { cx: number; cy: number; conn: OpenWorldChunkConn; - players: Record; + players: Record; display: Record; - blocks: Set; + blocks: Map; + selfPresent: boolean; +} + +interface ChunkSnapshot { + chunkSize: number; + chunkX: number; + chunkY: number; + selfPlayerId: string | null; + players: Record; + blocks?: Record | string[]; } export class OpenWorldGame { private stopped = false; private rafId = 0; - private chunkSize = 1200; + private chunkSize = CHUNK_SIZE; private chunkX: number; private chunkY: number; - private playerId: string; - private playerToken: string; + private readonly playerId = createPlayerId(); + private playerName: string; private keys: Record = {}; private lastIx = 0; private lastIy = 0; @@ -60,11 +63,18 @@ export class OpenWorldGame { ) { this.chunkX = matchInfo.chunkKey[1]; this.chunkY = matchInfo.chunkKey[2]; - this.playerId = matchInfo.playerId; - this.playerToken = matchInfo.playerToken; + this.playerName = matchInfo.playerName; - this.connectToChunk(matchInfo.chunkKey[1], matchInfo.chunkKey[2], matchInfo.playerToken, true); - this.updateAdjacentChunks(); + this.connectToChunk( + matchInfo.chunkKey[1], + matchInfo.chunkKey[2], + ); + void this.setPrimaryChunk( + matchInfo.chunkKey[1], + matchInfo.chunkKey[2], + matchInfo.spawnX, + matchInfo.spawnY, + ); if (options.bot) { this.botInterval = window.setInterval(() => { @@ -86,16 +96,15 @@ export class OpenWorldGame { } } - private connectToChunk(cx: number, cy: number, playerToken: string | null, isPrimary: boolean) { + private connectToChunk(cx: number, cy: number): ChunkConnection { const key = chunkKey(cx, cy); - if (this.chunks.has(key)) return; - - const params = playerToken - ? { playerToken } - : { observer: "true" }; + const existing = this.chunks.get(key); + if (existing) return existing; const conn = this.client.openWorldChunk - .getOrCreate(["default", String(cx), String(cy)], { params }) + .getOrCreate([WORLD_ID, String(cx), String(cy)], { + params: { playerId: this.playerId }, + }) .connect(); const chunk: ChunkConnection = { @@ -104,18 +113,15 @@ export class OpenWorldGame { conn, players: {}, display: {}, - blocks: new Set(), + blocks: new Map(), + selfPresent: false, }; conn.on("snapshot", (raw: unknown) => { - const snap = raw as { - chunkSize: number; - chunkX: number; - chunkY: number; - players: Record; - blocks?: string[]; - }; + const snap = raw as ChunkSnapshot; this.chunkSize = snap.chunkSize; + const isPrimaryChunk = chunk.cx === this.chunkX && chunk.cy === this.chunkY; + chunk.selfPresent = snap.selfPlayerId === this.playerId; for (const [id, pos] of Object.entries(snap.players)) { chunk.players[id] = pos; if (!chunk.display[id]) chunk.display[id] = { x: pos.x, y: pos.y }; @@ -127,11 +133,17 @@ export class OpenWorldGame { } } if (snap.blocks) { - chunk.blocks = new Set(snap.blocks); + if (Array.isArray(snap.blocks)) { + chunk.blocks = new Map( + snap.blocks.map((blockKey) => [blockKey, "#ff4f00"]), + ); + } else { + chunk.blocks = new Map(Object.entries(snap.blocks)); + } } // Client-driven chunk transfer detection (only for primary chunk). - if (isPrimary) { + if (isPrimaryChunk) { const me = snap.players[this.playerId]; if (me && !this.transferring) { const atLeft = me.x <= 0 && this.lastIx < 0; @@ -146,6 +158,7 @@ export class OpenWorldGame { }); this.chunks.set(key, chunk); + return chunk; } private disconnectChunk(cx: number, cy: number) { @@ -157,6 +170,61 @@ export class OpenWorldGame { } } + private async setPrimaryChunk( + cx: number, + cy: number, + spawnX: number, + spawnY: number, + ) { + const previousChunkX = this.chunkX; + const previousChunkY = this.chunkY; + const oldPrimaryKey = chunkKey(previousChunkX, previousChunkY); + const newPrimaryKey = chunkKey(cx, cy); + const oldPrimary = this.chunks.get(oldPrimaryKey); + + const newPrimary = this.connectToChunk(cx, cy); + + await newPrimary.conn + .enterChunk({ + name: this.playerName, + spawnX, + spawnY, + }) + .catch(() => {}); + + // Keep the old primary active until the target chunk confirms this connection's presence. + const ready = await this.waitForSelfInChunk(cx, cy); + if (!ready || this.stopped) { + if (oldPrimaryKey !== newPrimaryKey) { + newPrimary.conn.leaveChunk().catch(() => {}); + } + return; + } + + this.chunkX = cx; + this.chunkY = cy; + this.updateAdjacentChunks(); + + if (oldPrimary && oldPrimaryKey !== newPrimaryKey) { + oldPrimary.conn.leaveChunk().catch(() => {}); + } + + newPrimary.conn + .setInput({ inputX: this.lastIx, inputY: this.lastIy, sprint: this.lastSprint }) + .catch(() => {}); + } + + private async waitForSelfInChunk(cx: number, cy: number): Promise { + const key = chunkKey(cx, cy); + const deadline = Date.now() + 1500; + while (!this.stopped && Date.now() < deadline) { + const chunk = this.chunks.get(key); + if (chunk?.selfPresent) return true; + await sleep(16); + } + return false; + } + /** Compute which chunk indices are visible based on player position and zoom. */ private getVisibleChunkRange(): { minCx: number; maxCx: number; minCy: number; maxCy: number } { const primaryChunk = this.chunks.get(chunkKey(this.chunkX, this.chunkY)); @@ -199,7 +267,7 @@ export class OpenWorldGame { for (const key of needed) { if (!this.chunks.has(key)) { const [cx, cy] = parseChunkKey(key); - this.connectToChunk(cx, cy, null, false); + this.connectToChunk(cx, cy); } } } @@ -213,57 +281,21 @@ export class OpenWorldGame { else if (this.lastIx > 0) absX += 1; if (this.lastIy < 0) absY -= 1; else if (this.lastIy > 0) absY += 1; - - const primaryChunk = this.chunks.get(chunkKey(this.chunkX, this.chunkY)); - const myName = primaryChunk?.players[this.playerId]?.name ?? "Player"; - - const index = this.client.openWorldIndex.getOrCreate(["main"]).connect(); - const result = await index.send( - "getChunkForPosition", - { x: absX, y: absY, playerName: myName }, - { wait: true, timeout: 10_000 }, + const response = resolveChunkForPosition(absX, absY); + if (this.stopped) return; + await this.setPrimaryChunk( + response.chunkKey[1], + response.chunkKey[2], + response.spawnX, + response.spawnY, ); - index.dispose(); - const response = ( - result as { response?: { chunkKey: [string, number, number]; playerId: string; playerToken: string } } - )?.response; - if (!response || this.stopped) return; - - // Remove old player from old chunk so it doesn't appear as a ghost. - const oldPrimary = this.chunks.get(chunkKey(this.chunkX, this.chunkY)); - const oldPlayerId = this.playerId; - oldPrimary?.conn.removePlayer({ playerId: oldPlayerId }).catch(() => {}); - - // Update identity. - this.playerId = response.playerId; - this.playerToken = response.playerToken; - this.chunkX = response.chunkKey[1]; - this.chunkY = response.chunkKey[2]; - - // Disconnect any existing observer connection to the target chunk - // so connectToChunk can establish a proper primary connection. - this.disconnectChunk(this.chunkX, this.chunkY); - - // Connect to new primary chunk. - this.connectToChunk(this.chunkX, this.chunkY, response.playerToken, true); - - // Pre-initialize display position so the player doesn't snap to center. - const newPrimary = this.chunks.get(chunkKey(this.chunkX, this.chunkY)); - if (newPrimary) { - const expectedLocalX = absX - this.chunkX * this.chunkSize; - const expectedLocalY = absY - this.chunkY * this.chunkSize; - newPrimary.display[response.playerId] = { x: expectedLocalX, y: expectedLocalY }; - } - - // Update visible chunks. - this.updateAdjacentChunks(); - - // Re-send current input to new primary. - newPrimary?.conn.setInput({ inputX: this.lastIx, inputY: this.lastIy, sprint: this.lastSprint }).catch(() => {}); } catch { // Transfer failed, stay in current chunk. } finally { this.transferring = false; + // Flush the latest key state after transfer so key-up events that happened + // during transfer do not leave stale movement latched on the new chunk. + this.sendInput(); } } @@ -406,6 +438,7 @@ export class OpenWorldGame { const localPlayerY = meDisplay?.y ?? this.chunkSize / 2; const worldPlayerX = this.chunkX * this.chunkSize + localPlayerX; const worldPlayerY = this.chunkY * this.chunkSize + localPlayerY; + const selfColor = me?.color ?? getPlayerColor(this.playerId); // Periodically update which chunks we're connected to as the player moves. const now = Date.now(); @@ -443,16 +476,16 @@ export class OpenWorldGame { // Draw blocks from all connected chunks. for (const chunk of this.chunks.values()) { - for (const blockKey of chunk.blocks) { + for (const [blockKey, blockColor] of chunk.blocks) { const [gx, gy] = blockKey.split(",").map(Number); const wx = chunk.cx * this.chunkSize + gx! * BLOCK_SIZE; const wy = chunk.cy * this.chunkSize + gy! * BLOCK_SIZE; const sx = wx - camX; const sy = wy - camY; if (sx + BLOCK_SIZE < 0 || sx > viewportSize || sy + BLOCK_SIZE < 0 || sy > viewportSize) continue; - ctx.fillStyle = "rgba(255, 79, 0, 0.3)"; + ctx.fillStyle = colorWithAlpha(blockColor, 0.3); ctx.fillRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE); - ctx.strokeStyle = "rgba(255, 79, 0, 0.6)"; + ctx.strokeStyle = colorWithAlpha(blockColor, 0.65); ctx.lineWidth = 1 / scale; ctx.strokeRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE); } @@ -466,7 +499,7 @@ export class OpenWorldGame { const gy = Math.floor(mWorldY / BLOCK_SIZE) * BLOCK_SIZE; const sx = gx - camX; const sy = gy - camY; - ctx.strokeStyle = "rgba(255, 79, 0, 0.3)"; + ctx.strokeStyle = colorWithAlpha(selfColor, 0.4); ctx.lineWidth = 1 / scale; ctx.strokeRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE); } @@ -522,7 +555,7 @@ export class OpenWorldGame { // Draw players from all connected chunks. for (const chunk of this.chunks.values()) { for (const [id, target] of Object.entries(chunk.players)) { - if (id === this.playerId && chunk.cx === this.chunkX && chunk.cy === this.chunkY) continue; + if (id === this.playerId) continue; const d = chunk.display[id]; if (!d) continue; d.x += (target.x - d.x) * LERP_FACTOR; @@ -537,7 +570,7 @@ export class OpenWorldGame { ctx.beginPath(); ctx.arc(px, py, PLAYER_RADIUS / scale, 0, Math.PI * 2); - ctx.fillStyle = colorFromId(id); + ctx.fillStyle = target.color; ctx.fill(); ctx.fillStyle = "#ffffff"; @@ -552,7 +585,7 @@ export class OpenWorldGame { const selfScreenY = viewportSize / 2; ctx.beginPath(); ctx.arc(selfScreenX, selfScreenY, PLAYER_RADIUS / scale, 0, Math.PI * 2); - ctx.fillStyle = "#ff4f00"; + ctx.fillStyle = selfColor; ctx.fill(); ctx.lineWidth = 2 / scale; ctx.strokeStyle = "#ffffff"; @@ -562,7 +595,12 @@ export class OpenWorldGame { ctx.fillStyle = "#ffffff"; ctx.font = `${11 / scale}px sans-serif`; ctx.textAlign = "center"; - ctx.fillText(me.name || this.playerId.slice(0, 6), selfScreenX, selfScreenY - PLAYER_RADIUS / scale - 4 / scale); + const fallbackId = this.playerId || "self"; + ctx.fillText( + me.name || fallbackId.slice(0, 6), + selfScreenX, + selfScreenY - PLAYER_RADIUS / scale - 4 / scale, + ); } ctx.restore(); @@ -614,3 +652,41 @@ function parseChunkKey(key: string): [number, number] { const [cx, cy] = key.split(",").map(Number); return [cx!, cy!]; } + +function resolveChunkForPosition( + x: number, + y: number, +): { chunkKey: [string, number, number]; spawnX: number; spawnY: number } { + const chunkX = Math.floor(x / CHUNK_SIZE); + const chunkY = Math.floor(y / CHUNK_SIZE); + return { + chunkKey: [WORLD_ID, chunkX, chunkY], + spawnX: ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + spawnY: ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + }; +} + +function createPlayerId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + const random = Math.floor(Math.random() * 1_000_000_000).toString(36); + return `player-${Date.now().toString(36)}-${random}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function colorWithAlpha(color: string, alpha: number): string { + const normalized = color.trim(); + if (/^#[0-9a-fA-F]{6}$/.test(normalized)) { + const r = Number.parseInt(normalized.slice(1, 3), 16); + const g = Number.parseInt(normalized.slice(3, 5), 16); + const b = Number.parseInt(normalized.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return color; +} diff --git a/examples/multiplayer-game-patterns/frontend/games/party/bot.ts b/examples/multiplayer-game-patterns/frontend/games/party/bot.ts index 2a95d1db9a..5886308c57 100644 --- a/examples/multiplayer-game-patterns/frontend/games/party/bot.ts +++ b/examples/multiplayer-game-patterns/frontend/games/party/bot.ts @@ -18,11 +18,23 @@ export class PartyBot { { wait: true, timeout: 10_000 }, ); mm.dispose(); - const response = (result as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; + const response = (result as { + response?: { + matchId: string; + playerId: string; + joinToken: string; + playerName: string; + }; + })?.response; if (!response || this.destroyed) return; this.conn = this.client.partyMatch - .get([response.matchId], { params: { playerToken: response.playerToken } }) + .get([response.matchId], { + params: { + playerId: response.playerId, + joinToken: response.joinToken, + }, + }) .connect(); // Auto-ready after a short delay. diff --git a/examples/multiplayer-game-patterns/frontend/games/party/game.tsx b/examples/multiplayer-game-patterns/frontend/games/party/game.tsx index 45cfec435a..6dc2361e6c 100644 --- a/examples/multiplayer-game-patterns/frontend/games/party/game.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/party/game.tsx @@ -7,7 +7,7 @@ interface PartySnapshot { matchId: string; partyCode: string; phase: "waiting" | "playing" | "finished"; - members: Record; + members: Record; } export function PartyGame({ @@ -20,7 +20,7 @@ export function PartyGame({ onLeave: () => void; }) { const [snapshot, setSnapshot] = useState(null); - const [nameInput, setNameInput] = useState("Player"); + const [nameInput, setNameInput] = useState(matchInfo.playerName || "Player"); // biome-ignore lint/suspicious/noExplicitAny: connection handle const connRef = useRef(null); const botsRef = useRef([]); @@ -34,7 +34,12 @@ export function PartyGame({ useEffect(() => { const conn = client.partyMatch - .get([matchInfo.matchId], { params: { playerToken: matchInfo.playerToken } }) + .get([matchInfo.matchId], { + params: { + playerId: matchInfo.playerId, + joinToken: matchInfo.joinToken, + }, + }) .connect(); connRef.current = conn; @@ -128,7 +133,7 @@ export function PartyGame({ {memberList.map(([id, member]) => (
- + {member.name} {id === matchInfo.playerId ? " (You)" : ""} diff --git a/examples/multiplayer-game-patterns/frontend/games/party/menu.tsx b/examples/multiplayer-game-patterns/frontend/games/party/menu.tsx index 8c72accb49..79e429f764 100644 --- a/examples/multiplayer-game-patterns/frontend/games/party/menu.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/party/menu.tsx @@ -4,8 +4,9 @@ import type { GameClient } from "../../client.ts"; export interface PartyMatchInfo { matchId: string; playerId: string; - playerToken: string; partyCode: string; + joinToken: string; + playerName: string; } export function PartyMenu({ @@ -54,7 +55,14 @@ export function PartyMenu({ ); mm.dispose(); const response = ( - result as { response?: { matchId: string; playerId: string; playerToken: string } } + result as { + response?: { + matchId: string; + playerId: string; + joinToken: string; + playerName: string; + }; + } )?.response; if (!response?.matchId) throw new Error("Failed to join party"); onReady({ ...response, partyCode: joinCode.trim().toUpperCase() }); diff --git a/examples/multiplayer-game-patterns/frontend/games/physics-2d/menu.tsx b/examples/multiplayer-game-patterns/frontend/games/physics-2d/menu.tsx index 94e0af0d71..6c4ffe1077 100644 --- a/examples/multiplayer-game-patterns/frontend/games/physics-2d/menu.tsx +++ b/examples/multiplayer-game-patterns/frontend/games/physics-2d/menu.tsx @@ -5,6 +5,10 @@ export interface Physics2dMatchInfo { name: string; } +function generatePlayerName(): string { + return `Player#${Math.floor(Math.random() * 10000).toString().padStart(4, "0")}`; +} + export function Physics2dMenu({ onReady, onBack, @@ -13,7 +17,7 @@ export function Physics2dMenu({ onReady: (info: Physics2dMatchInfo) => void; onBack: () => void; }) { - const [name, setName] = useState("Player"); + const [name, setName] = useState(generatePlayerName); return (
@@ -39,7 +43,7 @@ export function Physics2dMenu({
players in queue
+
+ in queue: {formatQueueDuration(queueDurationMs)} +
+ {ratingWindow !== null && ( +
+ match window: ±{ratingWindow} + {ratingRange + ? ` (${ratingRange.min} - ${ratingRange.max})` + : ""} +
+ )}

Turn-Based

@@ -115,62 +153,78 @@ export function TurnBasedMenu({ />
-
- -
-
-
- Quick Play -
- + {isWaiting ? ( +
+
Quick Play
+
Waiting for match...
+ ) : ( + <> +
+ +
+
+
+ Quick Play +
+ + +
-
+
-
-
- Join by Code +
+
+ Join by Code +
+ setJoinCode(e.target.value.toUpperCase())} + className="text-input" + maxLength={6} + style={{ + width: "100%", + fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace", + fontSize: 16, + letterSpacing: 3, + textAlign: "center", + }} + /> + +
- setJoinCode(e.target.value.toUpperCase())} - className="text-input" - maxLength={6} - style={{ - width: "100%", - fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace", - fontSize: 16, - letterSpacing: 3, - textAlign: "center", - }} - /> - -
-
+ + )} {status === "error" && (
diff --git a/examples/multiplayer-game-patterns/frontend/games/turn-based/wait-for-assignment.ts b/examples/multiplayer-game-patterns/frontend/games/turn-based/wait-for-assignment.ts new file mode 100644 index 0000000000..f2f025ba3c --- /dev/null +++ b/examples/multiplayer-game-patterns/frontend/games/turn-based/wait-for-assignment.ts @@ -0,0 +1,70 @@ +interface Assignment { + playerId: string; +} + +type AssignmentConnection = { + getAssignment: ( + input: { playerId: string }, + ) => Promise>; + on: ( + event: "assignmentReady", + handler: (raw: unknown) => void, + ) => (() => void) | undefined; +}; + +function normalizeError(err: unknown): Error { + if (err instanceof Error) return err; + return new Error(String(err)); +} + +export async function waitForAssignment( + mm: AssignmentConnection, + playerId: string, + timeoutMs = 120_000, +): Promise { + const existing = await readAssignment(mm, playerId); + if (existing) return existing as T; + + return await new Promise((resolve, reject) => { + let settled = false; + let timeout: number | null = null; + let off: (() => void) | undefined; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + if (timeout !== null) window.clearTimeout(timeout); + off?.(); + fn(); + }; + + off = mm.on("assignmentReady", (raw: unknown) => { + const next = raw as T; + if (next.playerId !== playerId) return; + settle(() => resolve(next)); + }); + + timeout = window.setTimeout(() => { + settle(() => reject(new Error("Timed out waiting for assignment"))); + }, timeoutMs); + + void readAssignment(mm, playerId) + .then((next) => { + if (!next) return; + settle(() => resolve(next)); + }) + .catch((err) => { + settle(() => reject(normalizeError(err))); + }); + }); +} + +async function readAssignment( + mm: AssignmentConnection, + playerId: string, +): Promise { + const nested = await mm.getAssignment({ playerId }); + const resolved = await nested; + if (!resolved) return null; + return resolved as T; +} diff --git a/examples/multiplayer-game-patterns/src/actors/arena/match.ts b/examples/multiplayer-game-patterns/src/actors/arena/match.ts index 2a92e3e052..cb79854d8f 100644 --- a/examples/multiplayer-game-patterns/src/actors/arena/match.ts +++ b/examples/multiplayer-game-patterns/src/actors/arena/match.ts @@ -1,10 +1,5 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; import { interval } from "rivetkit/utils"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; import { registry } from "../index.ts"; import { type Mode, @@ -15,11 +10,12 @@ import { SHOOT_ANGLE, SCORE_LIMIT, } from "./config.ts"; +import { getPlayerColor } from "../player-color.ts"; interface PlayerEntry { - token: string; connId: string | null; - teamId: number; // -1 for FFA + teamId: number; + color: string; x: number; y: number; lastPositionAt: number; @@ -40,7 +36,6 @@ interface State { interface AssignedPlayer { playerId: string; - token: string; teamId: number; } @@ -62,9 +57,9 @@ export const arenaMatch = actor({ const players: Record = {}; for (const ap of input.assignedPlayers) { players[ap.playerId] = { - token: ap.token, connId: null, teamId: ap.teamId, + color: getPlayerColor(ap.playerId), x: Math.random() * WORLD_SIZE, y: Math.random() * WORLD_SIZE, lastPositionAt: Date.now(), @@ -83,59 +78,16 @@ export const arenaMatch = actor({ winnerPlayerId: null, }; }, - onBeforeConnect: ( - c, - params: { playerToken?: string; internalToken?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - if (params?.internalToken === INTERNAL_TOKEN) return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { - code: "auth_required", - }); - } - if (!findPlayerByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { - code: "invalid_player_token", - }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - const isAssignedPlayer = findPlayerByConnId(c.state, c.conn.id) !== null; - if ( - invoke.kind === "action" && - (invoke.name === "updatePosition" || - invoke.name === "shoot" || - invoke.name === "getSnapshot") - ) { - return !isInternal && isAssignedPlayer; - } - if ( - invoke.kind === "subscribe" && - (invoke.name === "snapshot" || invoke.name === "shoot") - ) { - return !isInternal && isAssignedPlayer; - } - return false; - }, onConnect: (c, conn) => { - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findPlayerByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); + const playerId = (conn.params as { playerId?: string })?.playerId; + if (!playerId) return; + const player = c.state.players[playerId]; + if (!player) { + conn.disconnect("invalid_player"); return; } - const [, player] = found; player.connId = conn.id; - // Check if all players have connected → transition to live. if (c.state.phase === "waiting") { const allConnected = Object.values(c.state.players).every( (p) => p.connId !== null, @@ -150,7 +102,7 @@ export const arenaMatch = actor({ onDestroy: async (c) => { const client = c.client(); await client.arenaMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("matchCompleted", { matchId: c.state.matchId }); }, onDisconnect: (c, conn) => { @@ -199,7 +151,6 @@ export const arenaMatch = actor({ let newX: number; let newY: number; if (dist > maxDist && dist > 0) { - // Clamp to max allowed distance along the movement vector. newX = player.x + (dx / dist) * maxDist; newY = player.y + (dy / dist) * maxDist; } else { @@ -226,7 +177,6 @@ export const arenaMatch = actor({ throw new UserError("match is not live", { code: "not_live" }); } - // Normalize direction. const mag = Math.sqrt( input.dirX * input.dirX + input.dirY * input.dirY, ); @@ -234,14 +184,12 @@ export const arenaMatch = actor({ const ndx = input.dirX / mag; const ndy = input.dirY / mag; - // Find closest valid hit. let closestId: string | null = null; let closestDist = Infinity; for (const [targetId, target] of Object.entries(c.state.players)) { if (targetId === shooterId) continue; if (!target.alive) continue; - // In team modes, skip teammates. if (shooter.teamId >= 0 && target.teamId === shooter.teamId) continue; const tx = target.x - shooter.x; @@ -249,7 +197,6 @@ export const arenaMatch = actor({ const targetDist = Math.sqrt(tx * tx + ty * ty); if (targetDist > SHOOT_RANGE || targetDist === 0) continue; - // Check angle between shot direction and target direction. const dot = (tx / targetDist) * ndx + (ty / targetDist) * ndy; const angle = Math.acos(Math.max(-1, Math.min(1, dot))); if (angle > SHOOT_ANGLE) continue; @@ -263,7 +210,6 @@ export const arenaMatch = actor({ if (closestId) { const victim = c.state.players[closestId]!; shooter.score += 1; - // Respawn victim at a random position. victim.x = Math.random() * WORLD_SIZE; victim.y = Math.random() * WORLD_SIZE; victim.lastPositionAt = Date.now(); @@ -279,7 +225,6 @@ export const arenaMatch = actor({ }; c.broadcast("shoot", shootEvent); - // Check win condition after scoring. checkWinCondition(c); if ((c.state.phase as string) === "finished") { broadcastSnapshot(c); @@ -301,7 +246,6 @@ function checkWinCondition(c: ActorContextOf) { } } } else { - // Team mode: sum scores per team. const teamScores: Record = {}; for (const player of Object.values(c.state.players)) { teamScores[player.teamId] = (teamScores[player.teamId] ?? 0) + player.score; @@ -330,7 +274,7 @@ interface Snapshot { winnerPlayerId: string | null; worldSize: number; scoreLimit: number; - players: Record; + players: Record; } interface ShootEvent { @@ -343,12 +287,13 @@ interface ShootEvent { } function buildSnapshot(c: ActorContextOf): Snapshot { - const players: Record = {}; + const players: Record = {}; for (const [id, entry] of Object.entries(c.state.players)) { players[id] = { x: entry.x, y: entry.y, teamId: entry.teamId, + color: entry.color, score: entry.score, }; } @@ -366,16 +311,6 @@ function buildSnapshot(c: ActorContextOf): Snapshot { }; } -function findPlayerByToken( - state: State, - token: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.token === token) return [id, entry]; - } - return null; -} - function findPlayerByConnId( state: State, connId: string, diff --git a/examples/multiplayer-game-patterns/src/actors/arena/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/arena/matchmaker.ts index ce70017690..05bb53d5f9 100644 --- a/examples/multiplayer-game-patterns/src/actors/arena/matchmaker.ts +++ b/examples/multiplayer-game-patterns/src/actors/arena/matchmaker.ts @@ -1,14 +1,19 @@ -import { actor, type ActorContextOf, queue, UserError } from "rivetkit"; +/* +This matchmaker uses a queue and fill flow. +1. queueForMatch adds players to player_pool for a specific mode. +2. Once enough players are queued for that mode, fillMatch removes them from the pool and creates a match actor. +3. The matchmaker stores assignments and broadcasts assignmentReady so each client can connect to the new match. +4. onDisconnect unqueues pending players so stale queue entries do not stay in the pool. +*/ +import { actor, type ActorContextOf, queue } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; import { registry } from "../index.ts"; import { type Mode, MODE_CONFIG } from "./config.ts"; export interface ArenaAssignment { matchId: string; playerId: string; - playerToken: string; teamId: number; mode: Mode; connId: string | null; @@ -17,11 +22,6 @@ export interface ArenaAssignment { type QueuePlayerRow = { player_id: string; conn_id: string | null; - registration_token: string | null; -}; - -type StoredArenaAssignment = ArenaAssignment & { - registrationToken: string | null; }; export const arenaMatchmaker = actor({ @@ -29,72 +29,24 @@ export const arenaMatchmaker = actor({ db: db({ onMigrate: migrateTables, }), - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "queue" && invoke.name === "queueForMatch") { - return !isInternal; - } - if (invoke.kind === "queue" && invoke.name === "matchCompleted") { - return isInternal; - } - if ( - invoke.kind === "action" && - (invoke.name === "registerPlayer" || - invoke.name === "getQueueSizes" || - invoke.name === "getAssignment") - ) { - return !isInternal; - } - if (invoke.kind === "subscribe" && invoke.name === "queueUpdate") { - return !isInternal; - } - return false; - }, queues: { - queueForMatch: queue< - { mode: Mode }, - { playerId: string; registrationToken: string } - >(), + queueForMatch: queue<{ + mode: Mode; + playerId: string; + connId: string; + }>(), + unqueueForMatch: queue<{ connId: string }>(), matchCompleted: queue<{ matchId: string }>(), }, actions: { - registerPlayer: async ( - c, - { - playerId, - registrationToken, - }: { playerId: string; registrationToken: string }, - ) => { - await c.db.execute( - `UPDATE player_pool SET conn_id = ? WHERE player_id = ? AND registration_token = ?`, - c.conn.id, - playerId, - registrationToken, - ); - const playerPoolChangeRows = await c.db.execute<{ changed: number }>( - `SELECT changes() AS changed`, - ); - const playerPoolChanges = playerPoolChangeRows[0]?.changed ?? 0; - await c.db.execute( - `UPDATE assignments SET conn_id = ? WHERE player_id = ? AND registration_token = ?`, - c.conn.id, + queueForMatch: async (c, { mode }: { mode: Mode }) => { + const playerId = crypto.randomUUID(); + await c.queue.send("queueForMatch", { + mode, playerId, - registrationToken, - ); - const assignmentChangeRows = await c.db.execute<{ changed: number }>( - `SELECT changes() AS changed`, - ); - const assignmentChanges = assignmentChangeRows[0]?.changed ?? 0; - if (playerPoolChanges === 0 && assignmentChanges === 0) { - throw new UserError("forbidden", { code: "forbidden" }); - } + connId: c.conn.id, + }); + return { playerId }; }, getQueueSizes: async (c) => { const rows = await c.db.execute<{ mode: string; cnt: number }>( @@ -108,30 +60,24 @@ export const arenaMatchmaker = actor({ }, getAssignment: async ( c, - { - playerId, - registrationToken, - }: { playerId: string; registrationToken: string }, + { playerId }: { playerId: string }, ) => { const rows = await c.db.execute<{ match_id: string; player_id: string; - player_token: string; team_id: number; mode: string; conn_id: string | null; }>( - `SELECT * FROM assignments WHERE player_id = ? AND conn_id = ? AND registration_token = ?`, + `SELECT * FROM assignments WHERE player_id = ? AND conn_id = ?`, playerId, c.conn.id, - registrationToken, ); if (rows.length === 0) return null; const row = rows[0]!; return { matchId: row.match_id, playerId: row.player_id, - playerToken: row.player_token, teamId: row.team_id, mode: row.mode as Mode, connId: row.conn_id, @@ -139,20 +85,28 @@ export const arenaMatchmaker = actor({ }, }, onDisconnect: async (c, conn) => { - await c.db.execute(`DELETE FROM player_pool WHERE conn_id = ?`, conn.id); - await broadcastQueueSizes(c); + await c.queue.send("unqueueForMatch", { connId: conn.id }); }, run: async (c) => { - for await (const message of c.queue.iter({ completable: true })) { + for await (const message of c.queue.iter()) { if (message.name === "queueForMatch") { - const queueResult = await processQueueEntry(c, message.body.mode); - await message.complete(queueResult); + await processQueueEntry( + c, + message.body.mode, + message.body.playerId, + message.body.connId, + ); + } else if (message.name === "unqueueForMatch") { + await c.db.execute( + `DELETE FROM player_pool WHERE conn_id = ?`, + message.body.connId, + ); + await broadcastQueueSizes(c); } else if (message.name === "matchCompleted") { await c.db.execute( `DELETE FROM matches WHERE match_id = ?`, message.body.matchId, ); - await message.complete(); } } }, @@ -174,23 +128,21 @@ async function broadcastQueueSizes( async function processQueueEntry( c: ActorContextOf, mode: Mode, -): Promise<{ playerId: string; registrationToken: string }> { - const playerId = crypto.randomUUID(); - const registrationToken = crypto.randomUUID(); + playerId: string, + connId: string, +): Promise { const config = MODE_CONFIG[mode]; - // Insert player into pool. await c.db.execute( - `INSERT OR REPLACE INTO player_pool (player_id, mode, queued_at, registration_token) VALUES (?, ?, ?, ?)`, + `INSERT OR REPLACE INTO player_pool (player_id, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`, playerId, mode, Date.now(), - registrationToken, + connId, ); await broadcastQueueSizes(c); - // Count queued players for this mode. const countRows = await c.db.execute<{ cnt: number }>( `SELECT COUNT(*) as cnt FROM player_pool WHERE mode = ?`, mode, @@ -200,8 +152,6 @@ async function processQueueEntry( if (count >= config.capacity) { await fillMatch(c, mode, config); } - - return { playerId, registrationToken }; } async function fillMatch( @@ -209,9 +159,8 @@ async function fillMatch( mode: Mode, config: { capacity: number; teams: number }, ) { - // Pop oldest N players. const queued = await c.db.execute( - `SELECT player_id, conn_id, registration_token FROM player_pool WHERE mode = ? ORDER BY queued_at ASC LIMIT ?`, + `SELECT player_id, conn_id FROM player_pool WHERE mode = ? ORDER BY queued_at ASC LIMIT ?`, mode, config.capacity, ); @@ -219,26 +168,20 @@ async function fillMatch( const queuedPlayers = queued.map((r) => ({ playerId: r.player_id, connId: r.conn_id, - registrationToken: r.registration_token, })); const playerIds = queuedPlayers.map((r) => r.playerId); - // Remove from queue. for (const pid of playerIds) { await c.db.execute(`DELETE FROM player_pool WHERE player_id = ?`, pid); } - // Assign teams and generate tokens. const matchId = crypto.randomUUID(); const assignedPlayers = queuedPlayers.map((queuedPlayer, idx) => ({ playerId: queuedPlayer.playerId, - token: crypto.randomUUID(), connId: queuedPlayer.connId, - registrationToken: queuedPlayer.registrationToken, teamId: config.teams > 0 ? idx % config.teams : -1, })); - // Create match actor with all players in input. const client = c.client(); await client.arenaMatch.create([matchId], { input: { @@ -247,13 +190,11 @@ async function fillMatch( capacity: config.capacity, assignedPlayers: assignedPlayers.map((ap) => ({ playerId: ap.playerId, - token: ap.token, teamId: ap.teamId, })), }, }); - // Insert match record. await c.db.execute( `INSERT INTO matches (match_id, mode, capacity, created_at) VALUES (?, ?, ?, ?)`, matchId, @@ -264,27 +205,22 @@ async function fillMatch( await broadcastQueueSizes(c); - // Store assignments in DB so bots can poll for them. - const assignments: StoredArenaAssignment[] = assignedPlayers.map((ap) => ({ - matchId, - playerId: ap.playerId, - playerToken: ap.token, - teamId: ap.teamId, - mode, - connId: ap.connId, - registrationToken: ap.registrationToken, - })); - for (const a of assignments) { + for (const ap of assignedPlayers) { await c.db.execute( - `INSERT INTO assignments (player_id, match_id, player_token, team_id, mode, conn_id, registration_token) VALUES (?, ?, ?, ?, ?, ?, ?)`, - a.playerId, - a.matchId, - a.playerToken, - a.teamId, - a.mode, - a.connId, - a.registrationToken, + `INSERT INTO assignments (player_id, match_id, team_id, mode, conn_id) VALUES (?, ?, ?, ?, ?)`, + ap.playerId, + matchId, + ap.teamId, + mode, + ap.connId, ); + c.broadcast("assignmentReady", { + matchId, + playerId: ap.playerId, + teamId: ap.teamId, + mode, + connId: ap.connId, + } satisfies ArenaAssignment); } } @@ -294,8 +230,7 @@ async function migrateTables(dbHandle: RawAccess) { player_id TEXT PRIMARY KEY, mode TEXT NOT NULL, queued_at INTEGER NOT NULL, - conn_id TEXT, - registration_token TEXT + conn_id TEXT ) `); await dbHandle.execute(` @@ -310,29 +245,9 @@ async function migrateTables(dbHandle: RawAccess) { CREATE TABLE IF NOT EXISTS assignments ( player_id TEXT PRIMARY KEY, match_id TEXT NOT NULL, - player_token TEXT NOT NULL, team_id INTEGER NOT NULL, mode TEXT NOT NULL, - conn_id TEXT, - registration_token TEXT + conn_id TEXT ) `); - await ensureColumn(dbHandle, "player_pool", "registration_token", "TEXT"); - await ensureColumn(dbHandle, "assignments", "registration_token", "TEXT"); -} - -async function ensureColumn( - dbHandle: RawAccess, - table: "player_pool" | "assignments", - column: "registration_token", - definition: "TEXT", -) { - const columns = await dbHandle.execute<{ name: string }>( - `PRAGMA table_info(${table})`, - ); - if (!columns.some((col) => col.name === column)) { - await dbHandle.execute( - `ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`, - ); - } } diff --git a/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts b/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts index adbffa58a5..826268caa5 100644 --- a/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts +++ b/examples/multiplayer-game-patterns/src/actors/battle-royale/match.ts @@ -1,10 +1,5 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; import { interval } from "rivetkit/utils"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; import { registry } from "../index.ts"; import { LOBBY_CAPACITY, @@ -21,10 +16,11 @@ import { PLAYER_MAX_HP, LOBBY_COUNTDOWN_TICKS, } from "./config.ts"; +import { getPlayerColor } from "../player-color.ts"; interface PlayerEntry { - token: string; connId: string | null; + color: string; x: number; y: number; lastPositionAt: number; @@ -45,6 +41,10 @@ interface State { lobbyCountdown: number | null; } +interface ConnParams { + playerId?: string; +} + export const battleRoyaleMatch = actor({ options: { name: "Battle Royale - Match", icon: "skull-crossbones" }, events: { @@ -61,57 +61,36 @@ export const battleRoyaleMatch = actor({ winnerId: null, lobbyCountdown: null, }), - onBeforeConnect: ( - c, - params: { playerToken?: string; internalToken?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - if (params?.internalToken === INTERNAL_TOKEN) return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { code: "auth_required" }); - } - if (!findPlayerByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { code: "invalid_player_token" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - const isAssignedPlayer = findPlayerByConnId(c.state, c.conn.id) !== null; - if (invoke.kind === "action" && invoke.name === "createPlayer") { - return isInternal; - } - if ( - invoke.kind === "action" && - (invoke.name === "updatePosition" || - invoke.name === "shoot" || - invoke.name === "getSnapshot") - ) { - return !isInternal && isAssignedPlayer; - } - if ( - invoke.kind === "subscribe" && - (invoke.name === "snapshot" || invoke.name === "shoot") - ) { - return !isInternal && isAssignedPlayer; - } - return false; - }, onConnect: async (c, conn) => { - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findPlayerByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); + const params = (conn.params as ConnParams | null) ?? {}; + const playerId = params.playerId; + if (!playerId) { + conn.disconnect("invalid_player"); return; } - const [, player] = found; - player.connId = conn.id; - player.disconnectedAt = null; + const existingPlayer = c.state.players[playerId]; + if (existingPlayer) { + existingPlayer.connId = conn.id; + existingPlayer.disconnectedAt = null; + } else { + const confirmed = await claimPendingPlayer(c, playerId); + if (!confirmed) { + conn.disconnect("player_not_pending"); + return; + } + const pos = randomPositionInZone(c.state.zone); + c.state.players[playerId] = { + connId: conn.id, + color: getPlayerColor(playerId), + x: pos.x, + y: pos.y, + lastPositionAt: Date.now(), + hp: PLAYER_MAX_HP, + alive: true, + placement: null, + disconnectedAt: null, + }; + } await updateMatchmaker(c); broadcastSnapshot(c); @@ -129,7 +108,7 @@ export const battleRoyaleMatch = actor({ onDestroy: async (c) => { const client = c.client(); await client.battleRoyaleMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("closeMatch", { matchId: c.state.matchId }); }, run: async (c) => { @@ -141,19 +120,22 @@ export const battleRoyaleMatch = actor({ c.state.tick += 1; const now = Date.now(); - // Remove players disconnected > 5s in lobby phase. if (c.state.phase === "lobby") { + let removedPlayer = false; for (const [id, player] of Object.entries(c.state.players)) { if ( player.disconnectedAt && now - player.disconnectedAt > 5000 ) { delete c.state.players[id]; + removedPlayer = true; } } + if (removedPlayer) { + await updateMatchmaker(c); + } } - // Lobby countdown logic. if (c.state.phase === "lobby") { const connectedCount = Object.values(c.state.players).filter( (p) => p.connId !== null, @@ -169,7 +151,6 @@ export const battleRoyaleMatch = actor({ } } - // Live phase: zone shrink + damage. if (c.state.phase === "live") { if (c.state.tick > ZONE_SHRINK_START_TICK) { c.state.zone.radius = Math.max( @@ -178,7 +159,6 @@ export const battleRoyaleMatch = actor({ ); } - // Zone damage. for (const [id, player] of Object.entries(c.state.players)) { if (!player.alive) continue; const dx = player.x - c.state.zone.centerX; @@ -195,7 +175,6 @@ export const battleRoyaleMatch = actor({ } } - // Check for winner. const alivePlayers = Object.entries(c.state.players).filter( ([, p]) => p.alive, ); @@ -213,20 +192,6 @@ export const battleRoyaleMatch = actor({ } }, actions: { - createPlayer: (c, input: { playerId: string; playerToken: string }) => { - const pos = randomPositionInZone(c.state.zone); - c.state.players[input.playerId] = { - token: input.playerToken, - connId: null, - x: pos.x, - y: pos.y, - lastPositionAt: Date.now(), - hp: PLAYER_MAX_HP, - alive: true, - placement: null, - disconnectedAt: Date.now(), - }; - }, updatePosition: (c, input: { x: number; y: number }) => { const found = findPlayerByConnId(c.state, c.conn.id); if (!found) { @@ -316,7 +281,6 @@ export const battleRoyaleMatch = actor({ }; c.broadcast("shoot", shootEvent); - // Check for winner after elimination. const alivePlayers = Object.entries(c.state.players).filter( ([, p]) => p.alive, ); @@ -345,7 +309,6 @@ function startGame(c: ActorContextOf) { radius: ZONE_INITIAL_RADIUS, }; - // Reset positions to random spots within the zone. for (const player of Object.values(c.state.players)) { const pos = randomPositionInZone(c.state.zone); player.x = pos.x; @@ -368,14 +331,36 @@ function randomPositionInZone(zone: { centerX: number; centerY: number; radius: async function updateMatchmaker(c: ActorContextOf) { const client = c.client(); await client.battleRoyaleMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("updateMatch", { matchId: c.state.matchId, - playerCount: Object.values(c.state.players).filter((p) => p.connId !== null).length, + connectedPlayerCount: Object.keys(c.state.players).length, isStarted: c.state.phase !== "lobby", }); } +async function claimPendingPlayer( + c: ActorContextOf, + playerId: string, +): Promise { + const client = c.client(); + const result = await client.battleRoyaleMatchmaker + .getOrCreate(["main"]) + .send( + "pendingPlayerConnected", + { + matchId: c.state.matchId, + playerId, + }, + { wait: true, timeout: 3_000 }, + ); + if (result.status !== "completed") { + return false; + } + const response = (result as { response?: { accepted?: boolean } }).response; + return response?.accepted === true; +} + function countAlivePlayers(state: State): number { return Object.values(state.players).filter((p) => p.alive).length; } @@ -391,7 +376,7 @@ interface Snapshot { lobbyCountdown: number | null; worldSize: number; zone: { centerX: number; centerY: number; radius: number }; - players: Record; + players: Record; } interface ShootEvent { @@ -413,6 +398,7 @@ function buildSnapshot(c: ActorContextOf): Snapshot { players[id] = { x: entry.x, y: entry.y, + color: entry.color, hp: entry.hp, maxHp: PLAYER_MAX_HP, alive: entry.alive, @@ -434,16 +420,6 @@ function buildSnapshot(c: ActorContextOf): Snapshot { }; } -function findPlayerByToken( - state: State, - token: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.token === token) return [id, entry]; - } - return null; -} - function findPlayerByConnId( state: State, connId: string, diff --git a/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts index d3f6dde3a1..8d4b911938 100644 --- a/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts +++ b/examples/multiplayer-game-patterns/src/actors/battle-royale/matchmaker.ts @@ -1,90 +1,66 @@ -import { actor, queue, UserError } from "rivetkit"; +/* +This matchmaker uses a two phase join flow. +1. findMatch claims a slot immediately and returns matchId and playerId. +2. The client connects to the match actor with playerId. +3. The match actor claims that pending player through pendingPlayerConnected before first join. +4. Pending players expire after JOIN_RESERVATION_TTL_MS, which removes never connected players. +5. updateMatch reports occupied player count and started state, while player_count stays pending + occupied. +*/ +import { actor, type ActorContextOf, queue } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; import { registry } from "../index.ts"; import { LOBBY_CAPACITY } from "./config.ts"; +const JOIN_RESERVATION_TTL_MS = 15_000; + export const battleRoyaleMatchmaker = actor({ options: { name: "Battle Royale - Matchmaker", icon: "skull-crossbones" }, db: db({ onMigrate: migrateTables, }), - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "queue" && invoke.name === "findMatch") { - return !isInternal; - } - if ( - invoke.kind === "queue" && - (invoke.name === "updateMatch" || invoke.name === "closeMatch") - ) { - return isInternal; - } - return false; - }, queues: { findMatch: queue< Record, - { matchId: string; playerId: string; playerToken: string } + { matchId: string; playerId: string } + >(), + pendingPlayerConnected: queue< + { matchId: string; playerId: string }, + { accepted: boolean } >(), - updateMatch: queue<{ matchId: string; playerCount: number; isStarted: boolean }>(), + updateMatch: queue<{ + matchId: string; + connectedPlayerCount: number; + isStarted: boolean; + }>(), closeMatch: queue<{ matchId: string }>(), }, run: async (c) => { for await (const message of c.queue.iter({ completable: true })) { - if (message.name === "findMatch") { - // Find a non-started match with room. - const rows = await c.db.execute<{ match_id: string; player_count: number }>( - `SELECT match_id, player_count FROM matches WHERE is_started = 0 AND player_count < ? ORDER BY player_count DESC, created_at ASC LIMIT 1`, - LOBBY_CAPACITY, - ); - let matchId = rows[0]?.match_id ?? null; - - if (!matchId) { - matchId = crypto.randomUUID(); - await c.db.execute( - `INSERT INTO matches (match_id, player_count, is_started, created_at) VALUES (?, ?, ?, ?)`, - matchId, - 0, - 0, - Date.now(), - ); - const client = c.client(); - await client.battleRoyaleMatch.create([matchId], { - input: { matchId }, - }); - } - - const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); - const client = c.client(); - await client.battleRoyaleMatch - .get([matchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ playerId, playerToken }); + const now = Date.now(); + await expirePendingPlayers(c, now); - await c.db.execute( - `UPDATE matches SET player_count = player_count + 1 WHERE match_id = ?`, - matchId, - ); - - await message.complete({ matchId, playerId, playerToken }); + if (message.name === "findMatch") { + const result = await processFindMatch(c, now); + await message.complete(result); + } else if (message.name === "pendingPlayerConnected") { + const result = await processPendingPlayerConnected(c, message.body, now); + await message.complete(result); } else if (message.name === "updateMatch") { await c.db.execute( - `UPDATE matches SET player_count = ?, is_started = ? WHERE match_id = ?`, - message.body.playerCount, + `UPDATE matches SET connected_player_count = ?, is_started = ?, updated_at = ? WHERE match_id = ?`, + message.body.connectedPlayerCount, message.body.isStarted ? 1 : 0, + now, message.body.matchId, ); + await syncClaimedPlayerCount(c, message.body.matchId, now); await message.complete(); } else if (message.name === "closeMatch") { + await c.db.execute( + `DELETE FROM pending_players WHERE match_id = ?`, + message.body.matchId, + ); await c.db.execute( `DELETE FROM matches WHERE match_id = ?`, message.body.matchId, @@ -95,13 +71,178 @@ export const battleRoyaleMatchmaker = actor({ }, }); +async function processFindMatch( + c: ActorContextOf, + now: number, +): Promise<{ matchId: string; playerId: string }> { + const rows = await c.db.execute<{ match_id: string }>( + `SELECT match_id FROM matches WHERE is_started = 0 AND player_count < ? ORDER BY player_count DESC, created_at ASC LIMIT 1`, + LOBBY_CAPACITY, + ); + let matchId = rows[0]?.match_id ?? null; + + if (!matchId) { + matchId = crypto.randomUUID(); + await c.db.execute( + `INSERT INTO matches (match_id, player_count, connected_player_count, is_started, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + matchId, + 0, + 0, + 0, + now, + now, + ); + const client = c.client(); + await client.battleRoyaleMatch.create([matchId], { + input: { matchId }, + }); + } + + const playerId = crypto.randomUUID(); + const expiresAt = now + JOIN_RESERVATION_TTL_MS; + + await c.db.execute( + `INSERT INTO pending_players (match_id, player_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, + matchId, + playerId, + expiresAt, + now, + ); + await syncClaimedPlayerCount(c, matchId, now); + + return { matchId, playerId }; +} + +async function processPendingPlayerConnected( + c: ActorContextOf, + input: { matchId: string; playerId: string }, + now: number, +): Promise<{ accepted: boolean }> { + const rows = await c.db.execute<{ expires_at: number }>( + `SELECT expires_at FROM pending_players WHERE match_id = ? AND player_id = ? LIMIT 1`, + input.matchId, + input.playerId, + ); + const row = rows[0]; + if (!row) { + return { accepted: false }; + } + if (row.expires_at <= now) { + await c.db.execute( + `DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`, + input.matchId, + input.playerId, + ); + await syncClaimedPlayerCount(c, input.matchId, now); + return { accepted: false }; + } + + await c.db.execute( + `DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`, + input.matchId, + input.playerId, + ); + await syncClaimedPlayerCount(c, input.matchId, now); + + return { accepted: true }; +} + +async function expirePendingPlayers( + c: ActorContextOf, + now: number, +): Promise { + const rows = await c.db.execute<{ match_id: string }>( + `SELECT DISTINCT match_id FROM pending_players WHERE expires_at <= ?`, + now, + ); + if (rows.length === 0) { + return; + } + + await c.db.execute( + `DELETE FROM pending_players WHERE expires_at <= ?`, + now, + ); + + for (const row of rows) { + await syncClaimedPlayerCount(c, row.match_id, now); + } +} + +async function syncClaimedPlayerCount( + c: ActorContextOf, + matchId: string, + now: number, +): Promise { + await c.db.execute( + `UPDATE matches + SET player_count = connected_player_count + COALESCE( + (SELECT COUNT(*) FROM pending_players WHERE match_id = ?), + 0 + ), + updated_at = ? + WHERE match_id = ?`, + matchId, + now, + matchId, + ); +} + async function migrateTables(dbHandle: RawAccess) { await dbHandle.execute(` CREATE TABLE IF NOT EXISTS matches ( match_id TEXT PRIMARY KEY, player_count INTEGER NOT NULL, + connected_player_count INTEGER NOT NULL DEFAULT 0, is_started INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + await ensureColumn( + dbHandle, + "matches", + "connected_player_count", + "INTEGER NOT NULL DEFAULT 0", + ); + await ensureColumn( + dbHandle, + "matches", + "updated_at", + "INTEGER NOT NULL DEFAULT 0", + ); + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS pending_players ( + match_id TEXT NOT NULL, + player_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (match_id, player_id) ) `); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS matches_open_idx ON matches (is_started, player_count, created_at)", + ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS pending_players_match_idx ON pending_players (match_id)", + ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS pending_players_expire_idx ON pending_players (expires_at)", + ); +} + +async function ensureColumn( + dbHandle: RawAccess, + table: "matches", + column: "connected_player_count" | "updated_at", + definition: "INTEGER NOT NULL DEFAULT 0", +) { + const columns = await dbHandle.execute<{ name: string }>( + `PRAGMA table_info(${table})`, + ); + if (!columns.some((col) => col.name === column)) { + await dbHandle.execute( + `ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`, + ); + } } diff --git a/examples/multiplayer-game-patterns/src/actors/idle/leaderboard.ts b/examples/multiplayer-game-patterns/src/actors/idle/leaderboard.ts index 447531ff21..9bcf2a79ce 100644 --- a/examples/multiplayer-game-patterns/src/actors/idle/leaderboard.ts +++ b/examples/multiplayer-game-patterns/src/actors/idle/leaderboard.ts @@ -1,6 +1,5 @@ -import { actor, type ActorContextOf, event, UserError } from "rivetkit"; +import { actor, type ActorContextOf, event, queue } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, isInternalToken } from "../../auth.ts"; export interface LeaderboardEntry { playerId: string; @@ -13,52 +12,40 @@ export const idleLeaderboard = actor({ db: db({ onMigrate: migrateTables, }), + queues: { + updateScore: queue<{ + playerId: string; + playerName: string; + totalProduced: number; + }>(), + }, events: { leaderboardUpdate: event(), }, - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "action" && invoke.name === "updateScore") { - return isInternal; - } - if (invoke.kind === "action" && invoke.name === "getTopScores") { - return true; - } - if (invoke.kind === "subscribe" && invoke.name === "leaderboardUpdate") { - return !isInternal; - } - return false; - }, actions: { - updateScore: async ( - c, - input: { playerId: string; playerName: string; totalProduced: number }, - ) => { + getTopScores: async (c, input: { limit?: number }) => { + return await getTop(c, input.limit ?? 10); + }, + }, + run: async (c) => { + for await (const message of c.queue.iter()) { + if (message.name !== "updateScore") continue; + await c.db.execute( `INSERT INTO scores (player_id, player_name, total_produced, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(player_id) DO UPDATE SET player_name = ?, total_produced = ?, updated_at = ?`, - input.playerId, - input.playerName, - input.totalProduced, + ON CONFLICT(player_id) DO UPDATE SET player_name = ?, total_produced = ?, updated_at = ?`, + message.body.playerId, + message.body.playerName, + message.body.totalProduced, Date.now(), - input.playerName, - input.totalProduced, + message.body.playerName, + message.body.totalProduced, Date.now(), ); const top = await getTop(c, 10); c.broadcast("leaderboardUpdate", top); - }, - getTopScores: async (c, input: { limit?: number }) => { - return await getTop(c, input.limit ?? 10); - }, + } }, }); diff --git a/examples/multiplayer-game-patterns/src/actors/idle/world.ts b/examples/multiplayer-game-patterns/src/actors/idle/world.ts index 905825b547..2d57a808d7 100644 --- a/examples/multiplayer-game-patterns/src/actors/idle/world.ts +++ b/examples/multiplayer-game-patterns/src/actors/idle/world.ts @@ -1,9 +1,4 @@ -import { actor, type ActorContextOf, event, UserError } from "rivetkit"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; +import { actor, type ActorContextOf, event } from "rivetkit"; import { registry } from "../index.ts"; import { BUILDINGS, STARTING_RESOURCES, type BuildingType } from "./config.ts"; @@ -44,32 +39,6 @@ export const idleWorld = actor({ events: { stateUpdate: event(), }, - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if ( - invoke.kind === "action" && - (invoke.name === "initialize" || - invoke.name === "build" || - invoke.name === "getState" || - invoke.name === "getLeaderboard") - ) { - return !isInternal; - } - if (invoke.kind === "action" && invoke.name === "collectProduction") { - return isInternal; - } - if (invoke.kind === "subscribe" && invoke.name === "stateUpdate") { - return !isInternal; - } - return false; - }, state: { playerId: "", playerName: "", @@ -85,10 +54,14 @@ export const idleWorld = actor({ return; } c.state.playerName = input.playerName; - if (input.playerId) c.state.playerId = input.playerId; + const keyPlayerId = Array.isArray(c.key) ? c.key[0] : c.key; + if (input.playerId) { + c.state.playerId = input.playerId; + } else if (typeof keyPlayerId === "string" && keyPlayerId) { + c.state.playerId = keyPlayerId; + } c.state.initialized = true; - // Build initial farm. const farmType = BUILDINGS.find((b) => b.id === "farm")!; const building: BuildingEntry = { id: crypto.randomUUID(), @@ -98,6 +71,7 @@ export const idleWorld = actor({ }; c.state.buildings.push(building); scheduleCollection(c, building.id, farmType.productionIntervalMs); + updateLeaderboard(c); broadcastState(c); }, build: (c, input: { buildingTypeId: string }) => { @@ -127,12 +101,10 @@ export const idleWorld = actor({ const buildingType = BUILDINGS.find((b: BuildingType) => b.id === building.typeId); if (!buildingType) return; - // Calculate production with offline catch-up. const now = Date.now(); const elapsed = now - building.lastCollectedAt; const intervals = Math.floor(elapsed / buildingType.productionIntervalMs); if (intervals <= 0) { - // Schedule for next interval. const remaining = buildingType.productionIntervalMs - elapsed; scheduleCollection(c, building.id, remaining); return; @@ -143,10 +115,8 @@ export const idleWorld = actor({ c.state.totalProduced += produced; building.lastCollectedAt = now; - // Schedule next collection. scheduleCollection(c, building.id, buildingType.productionIntervalMs); - // Update leaderboard. updateLeaderboard(c); broadcastState(c); }, @@ -171,8 +141,8 @@ function scheduleCollection( function updateLeaderboard(c: ActorContextOf) { const client = c.client(); client.idleLeaderboard - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) - .updateScore({ + .getOrCreate(["main"]) + .send("updateScore", { playerId: c.state.playerId, playerName: c.state.playerName, totalProduced: c.state.totalProduced, diff --git a/examples/multiplayer-game-patterns/src/actors/index.ts b/examples/multiplayer-game-patterns/src/actors/index.ts index 8a062712e5..38b1732f5b 100644 --- a/examples/multiplayer-game-patterns/src/actors/index.ts +++ b/examples/multiplayer-game-patterns/src/actors/index.ts @@ -8,7 +8,6 @@ import { idleWorld } from "./idle/world.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 { physics2dWorld } from "./physics-2d/world.ts"; @@ -30,7 +29,6 @@ export { ioStyleMatch, ioStyleMatchmaker, openWorldChunk, - openWorldIndex, partyMatch, partyMatchmaker, physics2dWorld, @@ -54,7 +52,6 @@ export const registry = setup({ ioStyleMatchmaker, ioStyleMatch, openWorldChunk, - openWorldIndex, partyMatch, partyMatchmaker, physics2dWorld, diff --git a/examples/multiplayer-game-patterns/src/actors/io-style/match.ts b/examples/multiplayer-game-patterns/src/actors/io-style/match.ts index a72328f605..c2c3367a18 100644 --- a/examples/multiplayer-game-patterns/src/actors/io-style/match.ts +++ b/examples/multiplayer-game-patterns/src/actors/io-style/match.ts @@ -1,19 +1,15 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; import { interval } from "rivetkit/utils"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; import { registry } from "../index.ts"; +import { getPlayerColor } from "../player-color.ts"; import { CAPACITY, SPEED, WORLD_SIZE } from "./config.ts"; const TICK_MS = 100; const DISCONNECT_GRACE_MS = 5000; interface PlayerEntry { - token: string; connId: string | null; + color: string; x: number; y: number; inputX: number; @@ -27,6 +23,10 @@ interface State { players: Record; } +interface ConnParams { + playerId?: string; +} + export const ioStyleMatch = actor({ options: { name: "IO - Match", icon: "earth-americas" }, events: { @@ -37,56 +37,33 @@ export const ioStyleMatch = actor({ tick: 0, players: {}, }), - onBeforeConnect: ( - c, - params: { playerToken?: string; internalToken?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - if (params?.internalToken === INTERNAL_TOKEN) return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { - code: "auth_required", - }); - } - if (!findPlayerByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { - code: "invalid_player_token", - }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - const isAssignedPlayer = findPlayerByConnId(c.state, c.conn.id) !== null; - if (invoke.kind === "action" && invoke.name === "createPlayer") { - return isInternal; - } - if ( - invoke.kind === "action" && - (invoke.name === "setInput" || invoke.name === "getSnapshot") - ) { - return !isInternal && isAssignedPlayer; - } - if (invoke.kind === "subscribe" && invoke.name === "snapshot") { - return !isInternal && isAssignedPlayer; - } - return false; - }, onConnect: async (c, conn) => { - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findPlayerByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); + const params = (conn.params as ConnParams | null) ?? {}; + const playerId = params.playerId; + if (!playerId) { + conn.disconnect("invalid_player"); return; } - const [, player] = found; - player.connId = conn.id; - player.disconnectedAt = null; + const existingPlayer = c.state.players[playerId]; + if (existingPlayer) { + existingPlayer.connId = conn.id; + existingPlayer.disconnectedAt = null; + } else { + const confirmed = await claimPendingPlayer(c, playerId); + if (!confirmed) { + conn.disconnect("player_not_pending"); + return; + } + c.state.players[playerId] = { + connId: conn.id, + color: getPlayerColor(playerId), + x: Math.random() * WORLD_SIZE, + y: Math.random() * WORLD_SIZE, + inputX: 0, + inputY: 0, + disconnectedAt: null, + }; + } await updateMatchmaker(c); broadcastSnapshot(c); @@ -94,7 +71,7 @@ export const ioStyleMatch = actor({ onDestroy: async (c) => { const client = c.client(); await client.ioStyleMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("closeMatch", { matchId: c.state.matchId, }); @@ -117,13 +94,14 @@ export const ioStyleMatch = actor({ c.state.tick += 1; const now = Date.now(); + let removedPlayer = false; for (const [id, player] of Object.entries(c.state.players)) { - // Remove players that disconnected beyond the grace period. if ( player.disconnectedAt && now - player.disconnectedAt > DISCONNECT_GRACE_MS ) { delete c.state.players[id]; + removedPlayer = true; continue; } @@ -136,24 +114,14 @@ export const ioStyleMatch = actor({ Math.min(WORLD_SIZE, player.y + SPEED * player.inputY), ); } + if (removedPlayer) { + await updateMatchmaker(c); + } broadcastSnapshot(c); } }, actions: { - // Called by matchmaker to add a player - createPlayer: (c, input: { playerId: string; playerToken: string }) => { - c.state.players[input.playerId] = { - token: input.playerToken, - connId: null, - x: Math.random() * WORLD_SIZE, - y: Math.random() * WORLD_SIZE, - inputX: 0, - inputY: 0, - disconnectedAt: Date.now(), - }; - }, - // Called by player to update state setInput: (c, input: { inputX: number; inputY: number }) => { const found = findPlayerByConnId(c.state, c.conn.id); if (!found) { @@ -176,27 +144,49 @@ function broadcastSnapshot(c: ActorContextOf) { async function updateMatchmaker(c: ActorContextOf) { const client = c.client(); await client.ioStyleMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("updateMatch", { matchId: c.state.matchId, - playerCount: activePlayerCount(c.state), + connectedPlayerCount: occupiedPlayerCount(c.state), }); } +async function claimPendingPlayer( + c: ActorContextOf, + playerId: string, +): Promise { + const client = c.client(); + const result = await client.ioStyleMatchmaker + .getOrCreate(["main"]) + .send( + "pendingPlayerConnected", + { + matchId: c.state.matchId, + playerId, + }, + { wait: true, timeout: 3_000 }, + ); + if (result.status !== "completed") { + return false; + } + const response = (result as { response?: { accepted?: boolean } }).response; + return response?.accepted === true; +} + interface Snapshot { matchId: string; capacity: number; tick: number; playerCount: number; worldSize: number; - players: Record; + players: Record; } function buildSnapshot(c: ActorContextOf): Snapshot { - const players: Record = {}; + const players: Record = {}; for (const [id, entry] of Object.entries(c.state.players)) { if (entry.disconnectedAt) continue; - players[id] = { x: entry.x, y: entry.y }; + players[id] = { x: entry.x, y: entry.y, color: entry.color }; } return { matchId: c.state.matchId, @@ -208,16 +198,6 @@ function buildSnapshot(c: ActorContextOf): Snapshot { }; } -function findPlayerByToken( - state: State, - token: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.token === token) return [id, entry]; - } - return null; -} - function findPlayerByConnId( state: State, connId: string, @@ -235,3 +215,7 @@ function activePlayerCount(state: State): number { } return count; } + +function occupiedPlayerCount(state: State): number { + return Object.keys(state.players).length; +} diff --git a/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts index eee4b219ae..68820d96f6 100644 --- a/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts +++ b/examples/multiplayer-game-patterns/src/actors/io-style/matchmaker.ts @@ -1,57 +1,61 @@ -import { actor, type ActorContextOf, queue, UserError } from "rivetkit"; +/* +This matchmaker uses a two phase open lobby flow. +1. findLobby claims a slot immediately and returns matchId and playerId. +2. The client connects to the match actor with playerId. +3. The match actor claims that pending player through pendingPlayerConnected before first join. +4. Pending players expire after JOIN_RESERVATION_TTL_MS, which removes never connected players. +5. updateMatch reports occupied player count while player_count stays pending + occupied. +*/ +import { actor, type ActorContextOf, queue } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; import { registry } from "../index.ts"; import { CAPACITY } from "./config.ts"; +const JOIN_RESERVATION_TTL_MS = 15_000; + export const ioStyleMatchmaker = actor({ options: { name: "IO - Matchmaker", icon: "earth-americas" }, db: db({ onMigrate: migrateTables, }), - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "queue" && invoke.name === "findLobby") { - return !isInternal; - } - if ( - invoke.kind === "queue" && - (invoke.name === "updateMatch" || invoke.name === "closeMatch") - ) { - return isInternal; - } - return false; - }, queues: { - // Sent by player - findLobby: queue, { matchId: string; playerId: string; playerToken: string }>(), - // Sent from match actor - updateMatch: queue<{ matchId: string; playerCount: number }>(), - // Sent from match actor + findLobby: queue< + Record, + { matchId: string; playerId: string } + >(), + pendingPlayerConnected: queue< + { matchId: string; playerId: string }, + { accepted: boolean } + >(), + updateMatch: queue<{ matchId: string; connectedPlayerCount: number }>(), closeMatch: queue<{ matchId: string }>(), }, run: async (c) => { for await (const message of c.queue.iter({ completable: true })) { + const now = Date.now(); + await expirePendingPlayers(c, now); + if (message.name === "findLobby") { - const result = await processFindLobby(c); + const result = await processFindLobby(c, now); + await message.complete(result); + } else if (message.name === "pendingPlayerConnected") { + const result = await processPendingPlayerConnected(c, message.body, now); await message.complete(result); } else if (message.name === "updateMatch") { await c.db.execute( - `UPDATE matches SET player_count = ?, updated_at = ? WHERE match_id = ?`, - message.body.playerCount, - Date.now(), + `UPDATE matches SET connected_player_count = ?, updated_at = ? WHERE match_id = ?`, + message.body.connectedPlayerCount, + now, message.body.matchId, ); + await syncClaimedPlayerCount(c, message.body.matchId, now); await message.complete(); } else if (message.name === "closeMatch") { + await c.db.execute( + `DELETE FROM pending_players WHERE match_id = ?`, + message.body.matchId, + ); await c.db.execute( `DELETE FROM matches WHERE match_id = ?`, message.body.matchId, @@ -64,7 +68,8 @@ export const ioStyleMatchmaker = actor({ async function processFindLobby( c: ActorContextOf, -): Promise<{ matchId: string; playerId: string; playerToken: string }> { + now: number, +): Promise<{ matchId: string; playerId: string }> { const rows = await c.db.execute<{ match_id: string; player_count: number }>( `SELECT match_id, player_count FROM matches WHERE player_count < ? ORDER BY player_count DESC, updated_at DESC LIMIT 1`, CAPACITY, @@ -74,10 +79,11 @@ async function processFindLobby( if (!matchId) { matchId = crypto.randomUUID(); await c.db.execute( - `INSERT INTO matches (match_id, player_count, updated_at) VALUES (?, ?, ?)`, + `INSERT INTO matches (match_id, player_count, connected_player_count, updated_at) VALUES (?, ?, ?, ?)`, matchId, 0, - Date.now(), + 0, + now, ); const client = c.client(); await client.ioStyleMatch.create([matchId], { @@ -86,18 +92,90 @@ async function processFindLobby( } const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); - const client = c.client(); - await client.ioStyleMatch.get([matchId], { params: { internalToken: INTERNAL_TOKEN } }).createPlayer({ + const expiresAt = now + JOIN_RESERVATION_TTL_MS; + + await c.db.execute( + `INSERT INTO pending_players (match_id, player_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, + matchId, playerId, - playerToken, - }); + expiresAt, + now, + ); + await syncClaimedPlayerCount(c, matchId, now); + + return { matchId, playerId }; +} + +async function processPendingPlayerConnected( + c: ActorContextOf, + input: { matchId: string; playerId: string }, + now: number, +): Promise<{ accepted: boolean }> { + const rows = await c.db.execute<{ expires_at: number }>( + `SELECT expires_at FROM pending_players WHERE match_id = ? AND player_id = ? LIMIT 1`, + input.matchId, + input.playerId, + ); + const row = rows[0]; + if (!row) { + return { accepted: false }; + } + if (row.expires_at <= now) { + await c.db.execute( + `DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`, + input.matchId, + input.playerId, + ); + await syncClaimedPlayerCount(c, input.matchId, now); + return { accepted: false }; + } + + await c.db.execute( + `DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`, + input.matchId, + input.playerId, + ); + await syncClaimedPlayerCount(c, input.matchId, now); + return { accepted: true }; +} + +async function expirePendingPlayers( + c: ActorContextOf, + now: number, +): Promise { + const rows = await c.db.execute<{ match_id: string }>( + `SELECT DISTINCT match_id FROM pending_players WHERE expires_at <= ?`, + now, + ); + if (rows.length === 0) return; + + await c.db.execute( + `DELETE FROM pending_players WHERE expires_at <= ?`, + now, + ); + + for (const row of rows) { + await syncClaimedPlayerCount(c, row.match_id, now); + } +} + +async function syncClaimedPlayerCount( + c: ActorContextOf, + matchId: string, + now: number, +): Promise { await c.db.execute( - `UPDATE matches SET player_count = player_count + 1, updated_at = ? WHERE match_id = ?`, - Date.now(), + `UPDATE matches + SET player_count = connected_player_count + COALESCE( + (SELECT COUNT(*) FROM pending_players WHERE match_id = ?), + 0 + ), + updated_at = ? + WHERE match_id = ?`, + matchId, + now, matchId, ); - return { matchId, playerId, playerToken }; } async function migrateTables(dbHandle: RawAccess) { @@ -105,10 +183,54 @@ async function migrateTables(dbHandle: RawAccess) { CREATE TABLE IF NOT EXISTS matches ( match_id TEXT PRIMARY KEY, player_count INTEGER NOT NULL, + connected_player_count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ) `); + await ensureColumn( + dbHandle, + "matches", + "connected_player_count", + "INTEGER NOT NULL DEFAULT 0", + ); + await ensureColumn( + dbHandle, + "matches", + "updated_at", + "INTEGER NOT NULL DEFAULT 0", + ); + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS pending_players ( + match_id TEXT NOT NULL, + player_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (match_id, player_id) + ) + `); await dbHandle.execute( "CREATE INDEX IF NOT EXISTS matches_open_idx ON matches (player_count, updated_at)", ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS pending_players_match_idx ON pending_players (match_id)", + ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS pending_players_expire_idx ON pending_players (expires_at)", + ); +} + +async function ensureColumn( + dbHandle: RawAccess, + table: "matches", + column: "connected_player_count" | "updated_at", + definition: "INTEGER NOT NULL DEFAULT 0", +) { + const columns = await dbHandle.execute<{ name: string }>( + `PRAGMA table_info(${table})`, + ); + if (!columns.some((col) => col.name === column)) { + await dbHandle.execute( + `ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`, + ); + } } diff --git a/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts b/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts index bdb06b4de0..e3cdbdd219 100644 --- a/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts +++ b/examples/multiplayer-game-patterns/src/actors/open-world/chunk.ts @@ -1,25 +1,24 @@ -import { actor, type ActorContextOf, event, UserError } from "rivetkit"; +import { actor, event } from "rivetkit"; import { interval } from "rivetkit/utils"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; import { CHUNK_SIZE, TICK_MS, SPEED, SPRINT_MULTIPLIER } from "./config.ts"; +import { getPlayerColor } from "../player-color.ts"; -const DISCONNECT_GRACE_MS = 5000; const GRID_COLS = Math.floor(CHUNK_SIZE / 50); interface PlayerEntry { - token: string; - connId: string | null; + connId: string; name: string; + color: string; x: number; y: number; inputX: number; inputY: number; sprint: boolean; - disconnectedAt: number | null; +} + +interface ConnectionMeta { + playerId: string; + inChunk: boolean; } interface State { @@ -27,123 +26,74 @@ interface State { chunkX: number; chunkY: number; tick: number; + connections: Record; players: Record; - blocks: string[]; - initialized: boolean; + blocks: Record; } +// The open world is partitioned into fixed size chunks keyed by world ID and chunk coordinates. +// Clients can keep multiple chunk connections open to observe nearby state. +// Each connection carries a stable player ID while explicit enter/leave actions control chunk membership. export const openWorldChunk = actor({ options: { name: "Open World - Chunk", icon: "map" }, events: { snapshot: event(), }, - state: { - worldId: "", - chunkX: 0, - chunkY: 0, - tick: 0, - players: {} as Record, - blocks: [] as string[], - initialized: false as boolean, - } satisfies State, - onBeforeConnect: ( - c, - params: { playerToken?: string; internalToken?: string; observer?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - if (params?.internalToken === INTERNAL_TOKEN) return; - // Allow observer connections (for viewing adjacent chunks). - if (params?.observer === "true") return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { code: "auth_required" }); - } - if (!findPlayerByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { code: "invalid_player_token" }); - } - }, - canInvoke: (c, invoke) => { - const params = c.conn.params as - | { internalToken?: string; observer?: string } - | undefined; - const isInternal = isInternalToken(params); - const isObserver = params?.observer === "true"; - const isAssignedPlayer = findPlayerByConnId(c.state, c.conn.id) !== null; - if ( - invoke.kind === "action" && - (invoke.name === "initialize" || invoke.name === "createPlayer") - ) { - return isInternal; - } - if (invoke.kind === "action" && invoke.name === "getSnapshot") { - return isObserver || isAssignedPlayer; - } - if ( - invoke.kind === "action" && - (invoke.name === "setInput" || - invoke.name === "placeBlock" || - invoke.name === "removeBlock") - ) { - return isAssignedPlayer; - } - if (invoke.kind === "action" && invoke.name === "removePlayer") { - return isAssignedPlayer || isInternal; - } - if (invoke.kind === "subscribe" && invoke.name === "snapshot") { - return isObserver || isAssignedPlayer; - } - return false; + createState: (c): State => { + const key = Array.isArray(c.key) ? c.key : [c.key]; + const chunkX = Number(key[1] ?? "0"); + const chunkY = Number(key[2] ?? "0"); + return { + worldId: key[0] ?? "", + chunkX: Number.isFinite(chunkX) ? chunkX : 0, + chunkY: Number.isFinite(chunkY) ? chunkY : 0, + tick: 0, + connections: {}, + players: {}, + blocks: {}, + }; }, onConnect: (c, conn) => { - // Observer connections just receive broadcasts. - if (conn.params?.observer === "true") return; - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findPlayerByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); - return; - } - const [, player] = found; - player.connId = conn.id; - player.disconnectedAt = null; + if (!c.state.connections) c.state.connections = {}; + const playerId = parsePlayerId(conn.params) ?? conn.id; + c.state.connections[conn.id] = { + playerId, + inChunk: false, + }; broadcastSnapshot(c); }, onDisconnect: (c, conn) => { - const found = findPlayerByConnId(c.state, conn.id); - if (!found) return; - const [, player] = found; - player.connId = null; - player.disconnectedAt = Date.now(); + const meta = c.state.connections?.[conn.id]; + if (!meta) return; + delete c.state.connections[conn.id]; + + const player = c.state.players[meta.playerId]; + if (player?.connId === conn.id) { + delete c.state.players[meta.playerId]; + } broadcastSnapshot(c); }, run: async (c) => { - if (!c.state.blocks) c.state.blocks = []; + if (Array.isArray(c.state.blocks)) { + const migratedBlocks: Record = {}; + for (const blockKey of c.state.blocks) { + migratedBlocks[blockKey] = "#ff4f00"; + } + c.state.blocks = migratedBlocks; + } + if (!c.state.blocks) c.state.blocks = {}; + if (!c.state.connections) c.state.connections = {}; const tick = interval(TICK_MS); while (!c.aborted) { await tick(); if (c.aborted) break; c.state.tick += 1; - const now = Date.now(); - - for (const [id, player] of Object.entries(c.state.players)) { - // Remove disconnected beyond grace. - if ( - player.disconnectedAt && - now - player.disconnectedAt > DISCONNECT_GRACE_MS - ) { - delete c.state.players[id]; - continue; - } - + for (const player of Object.values(c.state.players)) { const speed = player.sprint ? SPEED * SPRINT_MULTIPLIER : SPEED; player.x += speed * player.inputX; player.y += speed * player.inputY; - // Clamp to chunk bounds. player.x = Math.max(0, Math.min(CHUNK_SIZE - 1, player.x)); player.y = Math.max(0, Math.min(CHUNK_SIZE - 1, player.y)); } @@ -152,71 +102,105 @@ export const openWorldChunk = actor({ } }, actions: { - initialize: (c, input: { worldId: string; chunkX: number; chunkY: number }) => { - if (c.state.initialized) return; - c.state.worldId = input.worldId; - c.state.chunkX = input.chunkX; - c.state.chunkY = input.chunkY; - c.state.initialized = true; + enterChunk: ( + c, + input: { name: string; spawnX?: number; spawnY?: number }, + ) => { + const meta = getConnectionMeta(c, c.conn.id); + if (!meta) return; + + const nameRaw = input.name.trim(); + const name = nameRaw ? nameRaw.slice(0, 24) : "Player"; + const existing = c.state.players[meta.playerId]; + c.state.players[meta.playerId] = { + connId: c.conn.id, + name, + color: existing?.color ?? getPlayerColor(meta.playerId), + x: clampToChunk(input.spawnX) ?? CHUNK_SIZE / 2, + y: clampToChunk(input.spawnY) ?? CHUNK_SIZE / 2, + inputX: 0, + inputY: 0, + sprint: false, + }; + meta.inChunk = true; + broadcastSnapshot(c); }, - createPlayer: ( + addPlayer: ( c, - input: { playerId: string; playerToken: string; playerName: string; x: number; y: number }, + input: { name: string; spawnX?: number; spawnY?: number }, ) => { - c.state.players[input.playerId] = { - token: input.playerToken, - connId: null, - name: input.playerName, - x: input.x, - y: input.y, + const meta = getConnectionMeta(c, c.conn.id); + if (!meta) return; + + const nameRaw = input.name.trim(); + const name = nameRaw ? nameRaw.slice(0, 24) : "Player"; + const existing = c.state.players[meta.playerId]; + c.state.players[meta.playerId] = { + connId: c.conn.id, + name, + color: existing?.color ?? getPlayerColor(meta.playerId), + x: clampToChunk(input.spawnX) ?? CHUNK_SIZE / 2, + y: clampToChunk(input.spawnY) ?? CHUNK_SIZE / 2, inputX: 0, inputY: 0, sprint: false, - disconnectedAt: Date.now(), }; + meta.inChunk = true; + broadcastSnapshot(c); + }, + leaveChunk: (c) => { + const meta = getConnectionMeta(c, c.conn.id); + if (!meta) return; + + meta.inChunk = false; + const player = c.state.players[meta.playerId]; + if (player?.connId === c.conn.id) { + delete c.state.players[meta.playerId]; + } + broadcastSnapshot(c); + }, + removePlayer: (c) => { + const meta = getConnectionMeta(c, c.conn.id); + if (!meta) return; + + meta.inChunk = false; + const player = c.state.players[meta.playerId]; + if (player?.connId === c.conn.id) { + delete c.state.players[meta.playerId]; + } + broadcastSnapshot(c); }, setInput: (c, input: { inputX: number; inputY: number; sprint?: boolean }) => { - const found = findPlayerByConnId(c.state, c.conn.id); - if (!found) return; - const [, player] = found; + const player = getControlledPlayer(c, c.conn.id); + if (!player) return; player.inputX = Math.max(-1, Math.min(1, input.inputX)); player.inputY = Math.max(-1, Math.min(1, input.inputY)); player.sprint = !!input.sprint; }, - removePlayer: (c, input: { playerId: string }) => { - if (isInternalToken(c.conn.params as { internalToken?: string } | undefined)) { - delete c.state.players[input.playerId]; - broadcastSnapshot(c); - return; - } - const found = findPlayerByConnId(c.state, c.conn.id); - if (!found) return; - const [playerId] = found; - delete c.state.players[playerId]; - broadcastSnapshot(c); - }, placeBlock: (c, input: { gridX: number; gridY: number }) => { - if (!findPlayerByConnId(c.state, c.conn.id)) return; - if (!c.state.blocks) c.state.blocks = []; + const player = getControlledPlayer(c, c.conn.id); + if (!player) return; + if (Array.isArray(c.state.blocks)) c.state.blocks = {}; + if (!c.state.blocks) c.state.blocks = {}; const { gridX, gridY } = input; if (gridX < 0 || gridX >= GRID_COLS || gridY < 0 || gridY >= GRID_COLS) return; const key = `${gridX},${gridY}`; - if (!c.state.blocks.includes(key)) { - c.state.blocks.push(key); + if (c.state.blocks[key] !== player.color) { + c.state.blocks[key] = player.color; broadcastSnapshot(c); } }, removeBlock: (c, input: { gridX: number; gridY: number }) => { - if (!findPlayerByConnId(c.state, c.conn.id)) return; - if (!c.state.blocks) c.state.blocks = []; + if (!getConnectionMeta(c, c.conn.id)) return; + if (Array.isArray(c.state.blocks)) c.state.blocks = {}; + if (!c.state.blocks) c.state.blocks = {}; const key = `${input.gridX},${input.gridY}`; - const idx = c.state.blocks.indexOf(key); - if (idx !== -1) { - c.state.blocks.splice(idx, 1); + if (key in c.state.blocks) { + delete c.state.blocks[key]; broadcastSnapshot(c); } }, - getSnapshot: (c) => buildSnapshot(c), + getSnapshot: (c) => buildSnapshot(c, null), }, }); @@ -226,15 +210,18 @@ interface Snapshot { chunkY: number; chunkSize: number; tick: number; - players: Record; - blocks: string[]; + selfPlayerId: string | null; + players: Record; + blocks: Record; } -function buildSnapshot(c: ActorContextOf): Snapshot { +function buildSnapshot( + c: { state: State }, + playerId: string | null, +): Snapshot { const players: Snapshot["players"] = {}; - for (const [id, entry] of Object.entries(c.state.players)) { - if (entry.disconnectedAt) continue; - players[id] = { x: entry.x, y: entry.y, name: entry.name }; + for (const [id, player] of Object.entries(c.state.players)) { + players[id] = { x: player.x, y: player.y, name: player.name, color: player.color }; } return { worldId: c.state.worldId, @@ -242,31 +229,65 @@ function buildSnapshot(c: ActorContextOf): Snapshot { chunkY: c.state.chunkY, chunkSize: CHUNK_SIZE, tick: c.state.tick, + selfPlayerId: playerId, players, - blocks: c.state.blocks, + blocks: Array.isArray(c.state.blocks) + ? Object.fromEntries(c.state.blocks.map((blockKey) => [blockKey, "#ff4f00"])) + : { ...c.state.blocks }, }; } -function broadcastSnapshot(c: ActorContextOf) { - c.broadcast("snapshot", buildSnapshot(c)); +function broadcastSnapshot(c: { + state: State; + conns: Map< + string, + { + id: string; + send: (eventName: "snapshot", data: Snapshot) => void; + } + >; +}) { + for (const conn of c.conns.values()) { + const meta = c.state.connections?.[conn.id]; + const player = meta ? c.state.players[meta.playerId] : undefined; + const selfPlayerId = + meta?.inChunk && player?.connId === conn.id ? meta.playerId : null; + try { + conn.send("snapshot", buildSnapshot(c, selfPlayerId)); + } catch { + // Skip connections that are not fully established yet. + } + } } -function findPlayerByToken( - state: State, - token: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.token === token) return [id, entry]; - } - return null; +function clampToChunk(raw: number | undefined): number | null { + if (raw === undefined) return null; + const value = Number(raw); + if (!Number.isFinite(value)) return null; + return Math.max(0, Math.min(CHUNK_SIZE - 1, value)); } -function findPlayerByConnId( - state: State, +function parsePlayerId(params: unknown): string | null { + const playerId = (params as { playerId?: string } | null)?.playerId; + if (typeof playerId !== "string") return null; + const trimmed = playerId.trim(); + return trimmed ? trimmed.slice(0, 64) : null; +} + +function getConnectionMeta( + c: { state: State }, connId: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.connId === connId) return [id, entry]; - } - return null; +): ConnectionMeta | null { + return c.state.connections?.[connId] ?? null; +} + +function getControlledPlayer( + c: { state: State }, + connId: string, +): PlayerEntry | null { + const meta = getConnectionMeta(c, connId); + if (!meta?.inChunk) return null; + const player = c.state.players[meta.playerId]; + if (!player || player.connId !== connId) return null; + return player; } 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 deleted file mode 100644 index 7e621fc14a..0000000000 --- a/examples/multiplayer-game-patterns/src/actors/open-world/world-index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { actor, queue, UserError } from "rivetkit"; - -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; -import { registry } from "../index.ts"; -import { CHUNK_SIZE, WORLD_ID } from "./config.ts"; - -export const openWorldIndex = actor({ - options: { name: "Open World - Index", icon: "map" }, - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "queue" && invoke.name === "getChunkForPosition") { - return !isInternal; - } - return false; - }, - queues: { - getChunkForPosition: queue< - { x: number; y: number; playerName: string }, - { chunkKey: [string, number, number]; playerId: string; playerToken: string } - >(), - }, - run: async (c) => { - for await (const message of c.queue.iter({ completable: true })) { - try { - if (message.name === "getChunkForPosition") { - const { x, y, playerName } = message.body; - const chunkX = Math.floor(x / CHUNK_SIZE); - const chunkY = Math.floor(y / CHUNK_SIZE); - - const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); - - // Create player in the target chunk. - const client = c.client(); - const localX = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; - const localY = ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; - const chunk = client.openWorldChunk - .getOrCreate([WORLD_ID, String(chunkX), String(chunkY)], { - params: { internalToken: INTERNAL_TOKEN }, - }); - await chunk.initialize({ worldId: WORLD_ID, chunkX, chunkY }); - await chunk.createPlayer({ - playerId, - playerToken, - playerName, - x: localX, - y: localY, - }); - - await message.complete({ - chunkKey: [WORLD_ID, chunkX, chunkY], - playerId, - playerToken, - }); - } - } catch (err) { - console.error("Error processing queue message:", err); - } - } - }, -}); diff --git a/examples/multiplayer-game-patterns/src/actors/party/match.ts b/examples/multiplayer-game-patterns/src/actors/party/match.ts index 20906dcaab..d09a85bb44 100644 --- a/examples/multiplayer-game-patterns/src/actors/party/match.ts +++ b/examples/multiplayer-game-patterns/src/actors/party/match.ts @@ -1,16 +1,18 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; import { registry } from "../index.ts"; -import type { PartyPhase } from "./config.ts"; +import { getPlayerColor } from "../player-color.ts"; +import { MAX_PARTY_SIZE, type PartyPhase } from "./config.ts"; + +interface PartyConnState { + playerId: string; + playerName: string; + isHost: boolean; +} interface MemberEntry { - token: string; - connId: string | null; + connId: string; name: string; + color: string; isHost: boolean; isReady: boolean; } @@ -27,113 +29,131 @@ export const partyMatch = actor({ events: { partyUpdate: event(), }, - createState: (_c, input: { matchId: string; partyCode: string }): State => ({ + createState: ( + _c, + input: { matchId: string; partyCode: string; hostPlayerId: string }, + ): State => ({ matchId: input.matchId, partyCode: input.partyCode, phase: "waiting", members: {}, }), - onBeforeConnect: ( + createConnState: async ( c, - params: { playerToken?: string; internalToken?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); + params: { playerId?: string; joinToken?: string }, + ): Promise => { + const playerId = params?.playerId; + const joinToken = params?.joinToken; + const matchId = c.key[0]; + + if (!matchId || !playerId || !joinToken) { + throw new UserError("invalid join params", { code: "invalid_join_params" }); } - if (params?.internalToken === INTERNAL_TOKEN) return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { code: "auth_required" }); + + const client = c.client(); + const result = await client.partyMatchmaker + .getOrCreate(["main"]) + .send( + "verifyJoin", + { + matchId, + playerId, + joinToken, + }, + { wait: true, timeout: 3_000 }, + ); + if (result.status !== "completed") { + throw new UserError("join verification timed out", { + code: "join_verification_timed_out", + }); } - if (!findMemberByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { code: "invalid_player_token" }); + const response = (result as { + response?: { allowed?: boolean; playerName?: string; isHost?: boolean }; + }).response; + if (!response?.allowed || !response.playerName) { + throw new UserError("invalid join ticket", { code: "invalid_join_ticket" }); } + + return { + playerId, + playerName: response.playerName, + isHost: response.isHost === true, + }; }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - const isAssignedMember = findMemberByConnId(c.state, c.conn.id) !== null; - if (invoke.kind === "action" && invoke.name === "createPlayer") { - return isInternal; - } - if ( - invoke.kind === "action" && - (invoke.name === "setName" || - invoke.name === "toggleReady" || - invoke.name === "startGame" || - invoke.name === "finishGame" || - invoke.name === "getSnapshot") - ) { - return !isInternal && isAssignedMember; - } - if (invoke.kind === "subscribe" && invoke.name === "partyUpdate") { - return !isInternal && isAssignedMember; + onConnect: async (c, conn) => { + const playerId = conn.state.playerId; + const existing = c.state.members[playerId]; + if (!existing && Object.keys(c.state.members).length >= MAX_PARTY_SIZE) { + conn.disconnect("party_full"); + return; } - return false; - }, - onConnect: (c, conn) => { - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findMemberByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); + if (existing && existing.connId !== conn.id) { + conn.disconnect("duplicate_player"); return; } - const [, member] = found; - member.connId = conn.id; + + const hasHost = Object.values(c.state.members).some((member) => member.isHost); + const isHost = conn.state.isHost || !hasHost; + if (isHost) { + for (const member of Object.values(c.state.members)) { + member.isHost = false; + } + } + + c.state.members[playerId] = { + connId: conn.id, + name: conn.state.playerName, + color: existing?.color ?? getPlayerColor(playerId), + isHost, + isReady: existing?.isReady ?? false, + }; + broadcastSnapshot(c); + await updatePartySize(c); }, - onDisconnect: (c, conn) => { - const found = findMemberByConnId(c.state, conn.id); - if (!found) return; - const [, member] = found; - member.connId = null; + onDisconnect: async (c, conn) => { + const playerId = conn.state.playerId; + const member = c.state.members[playerId]; + if (!member || member.connId !== conn.id) return; + + const removedHost = member.isHost; + delete c.state.members[playerId]; + + if (removedHost) { + promoteNextHost(c.state); + } + broadcastSnapshot(c); + await updatePartySize(c); }, onDestroy: async (c) => { const client = c.client(); await client.partyMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("closeParty", { matchId: c.state.matchId }); }, actions: { - createPlayer: ( - c, - input: { playerId: string; playerToken: string; playerName: string; isHost: boolean }, - ) => { - c.state.members[input.playerId] = { - token: input.playerToken, - connId: null, - name: input.playerName, - isHost: input.isHost, - isReady: false, - }; - }, setName: (c, input: { name: string }) => { - const found = findMemberByConnId(c.state, c.conn.id); - if (!found) { + const member = c.state.members[c.conn.state.playerId]; + if (!member || member.connId !== c.conn.id) { throw new UserError("member not found", { code: "member_not_found" }); } - const [, member] = found; member.name = input.name.trim() || "Player"; broadcastSnapshot(c); }, toggleReady: (c) => { - const found = findMemberByConnId(c.state, c.conn.id); - if (!found) { + const member = c.state.members[c.conn.state.playerId]; + if (!member || member.connId !== c.conn.id) { throw new UserError("member not found", { code: "member_not_found" }); } - const [, member] = found; member.isReady = !member.isReady; broadcastSnapshot(c); }, startGame: (c) => { - const found = findMemberByConnId(c.state, c.conn.id); - if (!found) { + const member = c.state.members[c.conn.state.playerId]; + if (!member || member.connId !== c.conn.id) { throw new UserError("member not found", { code: "member_not_found" }); } - const [, member] = found; if (!member.isHost) { throw new UserError("only host can start", { code: "not_host" }); } @@ -144,11 +164,10 @@ export const partyMatch = actor({ broadcastSnapshot(c); }, finishGame: (c) => { - const found = findMemberByConnId(c.state, c.conn.id); - if (!found) { + const member = c.state.members[c.conn.state.playerId]; + if (!member || member.connId !== c.conn.id) { throw new UserError("member not found", { code: "member_not_found" }); } - const [, member] = found; if (!member.isHost) { throw new UserError("only host can finish", { code: "not_host" }); } @@ -166,7 +185,7 @@ interface PartySnapshot { matchId: string; partyCode: string; phase: PartyPhase; - members: Record; + members: Record; } function buildSnapshot(c: ActorContextOf): PartySnapshot { @@ -174,9 +193,10 @@ function buildSnapshot(c: ActorContextOf): PartySnapshot { for (const [id, entry] of Object.entries(c.state.members)) { members[id] = { name: entry.name, + color: entry.color, isHost: entry.isHost, isReady: entry.isReady, - connected: entry.connId !== null, + connected: true, }; } return { @@ -191,22 +211,23 @@ function broadcastSnapshot(c: ActorContextOf) { c.broadcast("partyUpdate", buildSnapshot(c)); } -function findMemberByToken( - state: State, - token: string, -): [string, MemberEntry] | null { - for (const [id, entry] of Object.entries(state.members)) { - if (entry.token === token) return [id, entry]; - } - return null; +async function updatePartySize(c: ActorContextOf) { + const client = c.client(); + await client.partyMatchmaker + .getOrCreate(["main"]) + .send("updatePartySize", { + matchId: c.state.matchId, + playerCount: Object.keys(c.state.members).length, + }); } -function findMemberByConnId( - state: State, - connId: string, -): [string, MemberEntry] | null { - for (const [id, entry] of Object.entries(state.members)) { - if (entry.connId === connId) return [id, entry]; +function promoteNextHost(state: State) { + for (const member of Object.values(state.members)) { + member.isHost = false; + } + const next = Object.values(state.members)[0]; + if (next) { + next.isHost = true; + next.isReady = false; } - return null; } diff --git a/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts index 0768f78931..2d2a6dfdb9 100644 --- a/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts +++ b/examples/multiplayer-game-patterns/src/actors/party/matchmaker.ts @@ -1,7 +1,14 @@ +/* +This matchmaker uses a private party flow with join tickets. +1. createParty creates a party actor and issues a host join ticket. +2. joinParty validates capacity and issues a join ticket for the party. +3. partyMatch verifies join tickets during createConnState before adding members. +4. partyMatch pushes connected member count updates through updatePartySize. +5. closeParty removes the party row and all join tickets. +*/ import { actor, queue, UserError } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; import { registry } from "../index.ts"; import { generatePartyCode, generatePlayerName, MAX_PARTY_SIZE } from "./config.ts"; @@ -10,69 +17,72 @@ export const partyMatchmaker = actor({ db: db({ onMigrate: migrateTables, }), - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if ( - invoke.kind === "queue" && - (invoke.name === "createParty" || invoke.name === "joinParty") - ) { - return !isInternal; - } - if (invoke.kind === "queue" && invoke.name === "closeParty") { - return isInternal; - } - return false; - }, queues: { createParty: queue< { hostName?: string }, - { matchId: string; playerId: string; playerToken: string; partyCode: string } + { + matchId: string; + playerId: string; + partyCode: string; + joinToken: string; + playerName: string; + } >(), joinParty: queue< { partyCode: string; playerName?: string }, - { matchId: string; playerId: string; playerToken: string } + { + matchId: string; + playerId: string; + joinToken: string; + playerName: string; + } + >(), + verifyJoin: queue< + { matchId: string; playerId: string; joinToken: string }, + { allowed: boolean; playerName?: string; isHost: boolean } >(), + updatePartySize: queue<{ matchId: string; playerCount: number }>(), closeParty: queue<{ matchId: string }>(), }, run: async (c) => { for await (const message of c.queue.iter({ completable: true })) { if (message.name === "createParty") { + const now = Date.now(); const matchId = crypto.randomUUID(); const partyCode = generatePartyCode(); const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); + const joinToken = crypto.randomUUID(); + const playerName = message.body.hostName || generatePlayerName(); const client = c.client(); await client.partyMatch.create([matchId], { - input: { matchId, partyCode }, + input: { matchId, partyCode, hostPlayerId: playerId }, }); - await client.partyMatch - .get([matchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ - playerId, - playerToken, - playerName: message.body.hostName || generatePlayerName(), - isHost: true, - }); - await c.db.execute( `INSERT INTO parties (match_id, party_code, host_player_id, player_count, created_at) VALUES (?, ?, ?, ?, ?)`, matchId, partyCode, playerId, 1, - Date.now(), + now, + ); + await c.db.execute( + `INSERT INTO join_tickets (join_token, match_id, player_id, player_name, created_at) VALUES (?, ?, ?, ?, ?)`, + joinToken, + matchId, + playerId, + playerName, + now, ); - await message.complete({ matchId, playerId, playerToken, partyCode }); + await message.complete({ + matchId, + playerId, + partyCode, + joinToken, + playerName, + }); } else if (message.name === "joinParty") { const code = message.body.partyCode.toUpperCase().trim(); const rows = await c.db.execute<{ match_id: string; player_count: number }>( @@ -87,25 +97,66 @@ export const partyMatchmaker = actor({ throw new UserError("Party is full", { code: "party_full" }); } + const now = Date.now(); const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); - const client = c.client(); - await client.partyMatch - .get([row.match_id], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ - playerId, - playerToken, - playerName: message.body.playerName || generatePlayerName(), - isHost: false, - }); + const joinToken = crypto.randomUUID(); + const playerName = message.body.playerName || generatePlayerName(); await c.db.execute( `UPDATE parties SET player_count = player_count + 1 WHERE match_id = ?`, row.match_id, ); + await c.db.execute( + `INSERT INTO join_tickets (join_token, match_id, player_id, player_name, created_at) VALUES (?, ?, ?, ?, ?)`, + joinToken, + row.match_id, + playerId, + playerName, + now, + ); - await message.complete({ matchId: row.match_id, playerId, playerToken }); + await message.complete({ + matchId: row.match_id, + playerId, + joinToken, + playerName, + }); + } else if (message.name === "verifyJoin") { + const rows = await c.db.execute<{ + player_name: string; + host_player_id: string; + }>( + `SELECT jt.player_name, p.host_player_id + FROM join_tickets jt + INNER JOIN parties p ON p.match_id = jt.match_id + WHERE jt.join_token = ? AND jt.match_id = ? AND jt.player_id = ? + LIMIT 1`, + message.body.joinToken, + message.body.matchId, + message.body.playerId, + ); + const row = rows[0]; + if (!row) { + await message.complete({ allowed: false, isHost: false }); + continue; + } + await message.complete({ + allowed: true, + playerName: row.player_name, + isHost: row.host_player_id === message.body.playerId, + }); + } else if (message.name === "updatePartySize") { + await c.db.execute( + `UPDATE parties SET player_count = ? WHERE match_id = ?`, + message.body.playerCount, + message.body.matchId, + ); + await message.complete(); } else if (message.name === "closeParty") { + await c.db.execute( + `DELETE FROM join_tickets WHERE match_id = ?`, + message.body.matchId, + ); await c.db.execute( `DELETE FROM parties WHERE match_id = ?`, message.body.matchId, @@ -126,4 +177,19 @@ async function migrateTables(dbHandle: RawAccess) { created_at INTEGER NOT NULL ) `); + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS join_tickets ( + join_token TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS join_tickets_match_idx ON join_tickets (match_id)", + ); + await dbHandle.execute( + "CREATE INDEX IF NOT EXISTS join_tickets_lookup_idx ON join_tickets (join_token, match_id, player_id)", + ); } diff --git a/examples/multiplayer-game-patterns/src/actors/physics-2d/world.ts b/examples/multiplayer-game-patterns/src/actors/physics-2d/world.ts index f5be7114e9..8b4de5b42f 100644 --- a/examples/multiplayer-game-patterns/src/actors/physics-2d/world.ts +++ b/examples/multiplayer-game-patterns/src/actors/physics-2d/world.ts @@ -1,6 +1,7 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; import { interval } from "rivetkit/utils"; import RAPIER from "@dimforge/rapier2d-compat"; +import { getPlayerColor } from "../player-color.ts"; import { TICK_MS, SUB_STEPS, @@ -16,6 +17,7 @@ const DISCONNECT_GRACE_MS = 5000; interface PlayerEntry { connId: string; name: string; + color: string; bodyHandle: number; inputX: number; jump: boolean; @@ -37,7 +39,7 @@ interface Snapshot { tick: number; serverTime: number; bodies: BodySnapshot[]; - players: Record; + players: Record; } export const physics2dWorld = actor({ @@ -54,21 +56,6 @@ export const physics2dWorld = actor({ throw new UserError("name too long", { code: "invalid_name" }); } }, - canInvoke: (c, invoke) => { - const isConnectedPlayer = c.vars.players[c.conn.id] !== undefined; - if ( - invoke.kind === "action" && - (invoke.name === "setInput" || - invoke.name === "spawnBox" || - invoke.name === "getSnapshot") - ) { - return isConnectedPlayer; - } - if (invoke.kind === "subscribe" && invoke.name === "snapshot") { - return isConnectedPlayer; - } - return false; - }, state: { tick: 0, // [id, x, y, hw, hh] — persisted every tick so positions survive restarts. @@ -98,6 +85,7 @@ export const physics2dWorld = actor({ c.vars.players[conn.id] = { connId: conn.id, name, + color: getPlayerColor(conn.id), bodyHandle: body.handle, inputX: 0, jump: false, @@ -224,9 +212,11 @@ export const physics2dWorld = actor({ const id = `spawned-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const hw = 0.2 + Math.random() * 0.2; const hh = 0.2 + Math.random() * 0.2; + const angle = Math.random() * Math.PI * 2; const bodyDesc = RAPIER.RigidBodyDesc.dynamic() .setTranslation(input.x, input.y) + .setRotation(angle) .setLinearDamping(0.5) .setCcdEnabled(true); const body = world.createRigidBody(bodyDesc); @@ -285,7 +275,7 @@ function buildSnapshot(c: ActorContextOf): Snapshot { hw: PLAYER_RADIUS, hh: PLAYER_RADIUS, }); - players[id] = { x: pos.x, y: pos.y, name: entry.name }; + players[id] = { x: pos.x, y: pos.y, name: entry.name, color: entry.color }; } return { tick: c.state.tick, serverTime: Date.now(), bodies, players }; diff --git a/examples/multiplayer-game-patterns/src/actors/physics-3d/world.ts b/examples/multiplayer-game-patterns/src/actors/physics-3d/world.ts index fa82830f2a..4e85fc07d1 100644 --- a/examples/multiplayer-game-patterns/src/actors/physics-3d/world.ts +++ b/examples/multiplayer-game-patterns/src/actors/physics-3d/world.ts @@ -1,6 +1,7 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; import { interval } from "rivetkit/utils"; import RAPIER from "@dimforge/rapier3d-compat"; +import { getPlayerColor } from "../player-color.ts"; import { TICK_MS, SUB_STEPS, @@ -16,6 +17,7 @@ const DISCONNECT_GRACE_MS = 5000; interface PlayerEntry { connId: string; name: string; + color: string; bodyHandle: number; inputX: number; inputZ: number; @@ -28,6 +30,9 @@ interface BodySnapshot { x: number; y: number; z: number; + hx: number; + hy: number; + hz: number; qx: number; qy: number; qz: number; @@ -41,7 +46,7 @@ interface Snapshot { tick: number; serverTime: number; bodies: BodySnapshot[]; - players: Record; + players: Record; } export const physics3dWorld = actor({ @@ -58,21 +63,6 @@ export const physics3dWorld = actor({ throw new UserError("name too long", { code: "invalid_name" }); } }, - canInvoke: (c, invoke) => { - const isConnectedPlayer = c.vars.players[c.conn.id] !== undefined; - if ( - invoke.kind === "action" && - (invoke.name === "setInput" || - invoke.name === "spawnBox" || - invoke.name === "getSnapshot") - ) { - return isConnectedPlayer; - } - if (invoke.kind === "subscribe" && invoke.name === "snapshot") { - return isConnectedPlayer; - } - return false; - }, state: { tick: 0, // [id, x, y, z, hx, hy, hz] — persisted every tick so positions survive restarts. @@ -105,6 +95,7 @@ export const physics3dWorld = actor({ c.vars.players[conn.id] = { connId: conn.id, name, + color: getPlayerColor(conn.id), bodyHandle: body.handle, inputX: 0, inputZ: 0, @@ -242,9 +233,11 @@ export const physics3dWorld = actor({ if (!world) return; const id = `spawned-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const h = 0.2 + Math.random() * 0.3; + const rotation = randomQuaternion(); const bodyDesc = RAPIER.RigidBodyDesc.dynamic() .setTranslation(input.x, 5, input.z) + .setRotation(rotation) .setLinearDamping(0.5) .setCcdEnabled(true); const body = world.createRigidBody(bodyDesc); @@ -272,11 +265,15 @@ function buildSnapshot(c: ActorContextOf): Snapshot { const pos = body.translation(); const rot = body.rotation(); const vel = body.linvel(); + const size = c.vars.dynamicSizes[id] ?? { hx: 0.25, hy: 0.25, hz: 0.25 }; bodies.push({ id, x: pos.x, y: pos.y, z: pos.z, + hx: size.hx, + hy: size.hy, + hz: size.hz, qx: rot.x, qy: rot.y, qz: rot.z, @@ -300,6 +297,9 @@ function buildSnapshot(c: ActorContextOf): Snapshot { x: pos.x, y: pos.y, z: pos.z, + hx: PLAYER_RADIUS, + hy: PLAYER_RADIUS, + hz: PLAYER_RADIUS, qx: rot.x, qy: rot.y, qz: rot.z, @@ -308,7 +308,13 @@ function buildSnapshot(c: ActorContextOf): Snapshot { vy: vel.y, vz: vel.z, }); - players[id] = { x: pos.x, y: pos.y, z: pos.z, name: entry.name }; + players[id] = { + x: pos.x, + y: pos.y, + z: pos.z, + name: entry.name, + color: entry.color, + }; } return { tick: c.state.tick, serverTime: Date.now(), bodies, players }; @@ -317,3 +323,20 @@ function buildSnapshot(c: ActorContextOf): Snapshot { function broadcastSnapshot(c: ActorContextOf) { c.broadcast("snapshot", buildSnapshot(c)); } + +function randomQuaternion(): { x: number; y: number; z: number; w: number } { + // Shoemake method for uniform random 3D rotation. + const u1 = Math.random(); + const u2 = Math.random(); + const u3 = Math.random(); + const sqrt1MinusU1 = Math.sqrt(1 - u1); + const sqrtU1 = Math.sqrt(u1); + const theta1 = 2 * Math.PI * u2; + const theta2 = 2 * Math.PI * u3; + return { + x: sqrt1MinusU1 * Math.sin(theta1), + y: sqrt1MinusU1 * Math.cos(theta1), + z: sqrtU1 * Math.sin(theta2), + w: sqrtU1 * Math.cos(theta2), + }; +} diff --git a/examples/multiplayer-game-patterns/src/actors/player-color.ts b/examples/multiplayer-game-patterns/src/actors/player-color.ts new file mode 100644 index 0000000000..db80b66f9a --- /dev/null +++ b/examples/multiplayer-game-patterns/src/actors/player-color.ts @@ -0,0 +1,23 @@ +const PLAYER_COLOR_PALETTE = [ + "#ff6b6b", + "#4ecdc4", + "#45b7d1", + "#f7b801", + "#5c7cfa", + "#20c997", + "#f06595", + "#ffa94d", + "#74c0fc", + "#94d82d", + "#e599f7", + "#ffd43b", +]; + +export function getPlayerColor(playerId: string): string { + let hash = 0; + for (let i = 0; i < playerId.length; i++) { + hash = (hash * 31 + playerId.charCodeAt(i)) | 0; + } + const index = ((hash % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length; + return PLAYER_COLOR_PALETTE[index]!; +} diff --git a/examples/multiplayer-game-patterns/src/actors/ranked/leaderboard.ts b/examples/multiplayer-game-patterns/src/actors/ranked/leaderboard.ts index a28e6ade0d..e19efb0e9d 100644 --- a/examples/multiplayer-game-patterns/src/actors/ranked/leaderboard.ts +++ b/examples/multiplayer-game-patterns/src/actors/ranked/leaderboard.ts @@ -1,6 +1,5 @@ -import { actor, type ActorContextOf, event, UserError } from "rivetkit"; +import { actor, type ActorContextOf, event, queue } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, isInternalToken } from "../../auth.ts"; export interface LeaderboardEntry { username: string; @@ -14,50 +13,45 @@ export const rankedLeaderboard = actor({ db: db({ onMigrate: migrateTables, }), + queues: { + updatePlayer: queue<{ + username: string; + rating: number; + wins: number; + losses: number; + }>(), + }, events: { leaderboardUpdate: event(), }, - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "action" && invoke.name === "updatePlayer") { - return isInternal; - } - if (invoke.kind === "action" && invoke.name === "getTopScores") { - return true; - } - if (invoke.kind === "subscribe" && invoke.name === "leaderboardUpdate") { - return !isInternal; - } - return false; - }, actions: { updatePlayer: async (c, input: { username: string; rating: number; wins: number; losses: number }) => { + await c.queue.send("updatePlayer", input); + }, + getTopScores: async (c) => { + return await getTop(c); + }, + }, + run: async (c) => { + for await (const message of c.queue.iter()) { + if (message.name !== "updatePlayer") continue; + await c.db.execute( `INSERT INTO leaderboard (username, rating, wins, losses, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(username) DO UPDATE SET rating = ?, wins = ?, losses = ?, updated_at = ?`, - input.username, - input.rating, - input.wins, - input.losses, + ON CONFLICT(username) DO UPDATE SET rating = ?, wins = ?, losses = ?, updated_at = ?`, + message.body.username, + message.body.rating, + message.body.wins, + message.body.losses, Date.now(), - input.rating, - input.wins, - input.losses, + message.body.rating, + message.body.wins, + message.body.losses, Date.now(), ); const top = await getTop(c); c.broadcast("leaderboardUpdate", top); - }, - getTopScores: async (c) => { - return await getTop(c); - }, + } }, }); diff --git a/examples/multiplayer-game-patterns/src/actors/ranked/match.ts b/examples/multiplayer-game-patterns/src/actors/ranked/match.ts index 0e40f3be19..3f4a58d757 100644 --- a/examples/multiplayer-game-patterns/src/actors/ranked/match.ts +++ b/examples/multiplayer-game-patterns/src/actors/ranked/match.ts @@ -1,10 +1,5 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; import { interval } from "rivetkit/utils"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; import { registry } from "../index.ts"; import { TICK_MS, @@ -15,10 +10,11 @@ import { SCORE_LIMIT, K_FACTOR, } from "./config.ts"; +import { getPlayerColor } from "../player-color.ts"; interface PlayerEntry { - token: string; connId: string | null; + color: string; x: number; y: number; lastPositionAt: number; @@ -33,12 +29,12 @@ interface State { phase: "waiting" | "live" | "finished"; players: Record; winnerId: string | null; + resultReported: boolean; } interface AssignedPlayer { username: string; rating: number; - token: string; } export const rankedMatch = actor({ @@ -47,77 +43,44 @@ export const rankedMatch = actor({ snapshot: event(), shoot: event(), }, - createState: ( - _c, - input: { matchId: string; assignedPlayers: AssignedPlayer[] }, - ): State => { - const players: Record = {}; - for (const ap of input.assignedPlayers) { - players[ap.username] = { - token: ap.token, - connId: null, - x: Math.random() * WORLD_SIZE, - y: Math.random() * WORLD_SIZE, - lastPositionAt: Date.now(), - alive: true, - score: 0, - rating: ap.rating, + createState: ( + _c, + input: { matchId: string; assignedPlayers: AssignedPlayer[] }, + ): State => { + const players: Record = {}; + for (const ap of input.assignedPlayers) { + players[ap.username] = { + connId: null, + color: getPlayerColor(ap.username), + x: Math.random() * WORLD_SIZE, + y: Math.random() * WORLD_SIZE, + lastPositionAt: Date.now(), + alive: true, + score: 0, + rating: ap.rating, + }; + } + return { + matchId: input.matchId, + tick: 0, + phase: "waiting", + players, + winnerId: null, + resultReported: false, }; - } - return { - matchId: input.matchId, - tick: 0, - phase: "waiting", - players, - winnerId: null, - }; - }, - onBeforeConnect: ( - c, - params: { playerToken?: string; internalToken?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - if (params?.internalToken === INTERNAL_TOKEN) return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { code: "auth_required" }); - } - if (!findPlayerByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { code: "invalid_player_token" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - const isAssignedPlayer = findPlayerByConnId(c.state, c.conn.id) !== null; - if ( - invoke.kind === "action" && - (invoke.name === "updatePosition" || - invoke.name === "shoot" || - invoke.name === "getSnapshot") - ) { - return !isInternal && isAssignedPlayer; - } - if ( - invoke.kind === "subscribe" && - (invoke.name === "snapshot" || invoke.name === "shoot") - ) { - return !isInternal && isAssignedPlayer; - } - return false; - }, + }, onConnect: (c, conn) => { - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findPlayerByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); + const params = conn.params as { username?: string; playerId?: string }; + const playerId = params.username ?? params.playerId; + if (!playerId) { + conn.disconnect("invalid_player"); + return; + } + const player = c.state.players[playerId]; + if (!player) { + conn.disconnect("invalid_player"); return; } - const [, player] = found; player.connId = conn.id; if (c.state.phase === "waiting") { @@ -131,35 +94,7 @@ export const rankedMatch = actor({ broadcastSnapshot(c); }, onDestroy: async (c) => { - const client = c.client(); - const entries = Object.entries(c.state.players); - if (c.state.winnerId && entries.length === 2) { - const loserUsername = entries.find(([id]) => id !== c.state.winnerId)?.[0]; - const winner = c.state.players[c.state.winnerId]; - const loser = loserUsername ? c.state.players[loserUsername] : undefined; - if (winner && loser && loserUsername) { - const [newWR, newLR] = calculateElo(winner.rating, loser.rating); - await client.rankedMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) - .send("matchCompleted", { - matchId: c.state.matchId, - winnerUsername: c.state.winnerId, - loserUsername, - winnerNewRating: newWR, - loserNewRating: newLR, - }); - return; - } - } - await client.rankedMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) - .send("matchCompleted", { - matchId: c.state.matchId, - winnerUsername: "", - loserUsername: "", - winnerNewRating: 0, - loserNewRating: 0, - }); + await reportMatchCompletedIfNeeded(c, { allowNoResult: true }); }, onDisconnect: (c, conn) => { const found = findPlayerByConnId(c.state, conn.id); @@ -173,9 +108,13 @@ export const rankedMatch = actor({ while (!c.aborted) { await tick(); if (c.aborted) break; - if (c.state.phase !== "live") continue; + if (c.state.phase !== "live") { + await reportMatchCompletedIfNeeded(c); + continue; + } c.state.tick += 1; checkWinCondition(c); + await reportMatchCompletedIfNeeded(c); broadcastSnapshot(c); } }, @@ -183,7 +122,7 @@ export const rankedMatch = actor({ updatePosition: (c, input: { x: number; y: number }) => { const found = findPlayerByConnId(c.state, c.conn.id); if (!found) { - throw new UserError("player not found", { code: "player_not_found" }); + return; } const [, player] = found; if (!player.alive) { @@ -215,7 +154,7 @@ export const rankedMatch = actor({ shoot: (c, input: { dirX: number; dirY: number }) => { const found = findPlayerByConnId(c.state, c.conn.id); if (!found) { - throw new UserError("player not found", { code: "player_not_found" }); + return; } const [shooterId, shooter] = found; if (!shooter.alive) { @@ -290,6 +229,50 @@ function checkWinCondition(c: ActorContextOf) { } } +async function reportMatchCompletedIfNeeded( + c: ActorContextOf, + options?: { allowNoResult?: boolean }, +) { + const allowNoResult = options?.allowNoResult ?? false; + + if (c.state.resultReported) return; + if (c.state.phase !== "finished" && !allowNoResult) return; + + const client = c.client(); + const entries = Object.entries(c.state.players); + if (c.state.winnerId && entries.length === 2) { + const loserUsername = entries.find(([id]) => id !== c.state.winnerId)?.[0]; + const winner = c.state.players[c.state.winnerId]; + const loser = loserUsername ? c.state.players[loserUsername] : undefined; + if (winner && loser && loserUsername) { + const [newWR, newLR] = calculateElo(winner.rating, loser.rating); + await client.rankedMatchmaker + .getOrCreate(["main"]) + .send("matchCompleted", { + matchId: c.state.matchId, + winnerUsername: c.state.winnerId, + loserUsername, + winnerNewRating: newWR, + loserNewRating: newLR, + }); + c.state.resultReported = true; + return; + } + } + if (!allowNoResult) return; + + await client.rankedMatchmaker + .getOrCreate(["main"]) + .send("matchCompleted", { + matchId: c.state.matchId, + winnerUsername: "", + loserUsername: "", + winnerNewRating: 0, + loserNewRating: 0, + }); + c.state.resultReported = true; +} + function calculateElo(winnerRating: number, loserRating: number): [number, number] { const expectedW = 1 / (1 + 10 ** ((loserRating - winnerRating) / 400)); const expectedL = 1 - expectedW; @@ -310,7 +293,7 @@ interface Snapshot { winnerId: string | null; worldSize: number; scoreLimit: number; - players: Record; + players: Record; } interface ShootEvent { @@ -328,6 +311,7 @@ function buildSnapshot(c: ActorContextOf): Snapshot { players[id] = { x: entry.x, y: entry.y, + color: entry.color, score: entry.score, rating: entry.rating, }; @@ -352,13 +336,3 @@ function findPlayerByConnId( } return null; } - -function findPlayerByToken( - state: State, - token: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.token === token) return [id, entry]; - } - return null; -} diff --git a/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts index 9531854cf2..fc4e052dd1 100644 --- a/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts +++ b/examples/multiplayer-game-patterns/src/actors/ranked/matchmaker.ts @@ -1,7 +1,14 @@ -import { actor, type ActorContextOf, queue, UserError } from "rivetkit"; +/* +This matchmaker uses a rating window flow. +1. queueForMatch adds players to player_pool with current rating and queue time. +2. attemptPairing scans the pool and looks for two players whose rating windows overlap. +3. createRankedMatch removes paired players, creates a match actor, stores assignments, and broadcasts assignmentReady. +4. matchCompleted updates player rating actors and leaderboard entries, then removes the match row. +*/ +import { actor, type ActorContextOf, queue } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; +import { interval } from "rivetkit/utils"; -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; import { registry } from "../index.ts"; import { INITIAL_RATING_WINDOW, @@ -13,7 +20,6 @@ export interface RankedAssignment { matchId: string; username: string; rating: number; - playerToken: string; connId: string | null; } @@ -22,48 +28,22 @@ type QueuePlayerRow = { rating: number; queued_at: number; conn_id: string | null; - registration_token: string | null; }; -type StoredRankedAssignment = RankedAssignment & { - registrationToken: string | null; -}; +const QUEUE_UPDATE_TICK_MS = 1000; +const PAIRING_RETRY_TICK_MS = 2000; export const rankedMatchmaker = actor({ options: { name: "Ranked - Matchmaker", icon: "ranking-star" }, db: db({ onMigrate: migrateTables, }), - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if (invoke.kind === "queue" && invoke.name === "queueForMatch") { - return !isInternal; - } - if (invoke.kind === "queue" && invoke.name === "matchCompleted") { - return isInternal; - } - if ( - invoke.kind === "action" && - (invoke.name === "registerPlayer" || - invoke.name === "getQueueSize" || - invoke.name === "getAssignment") - ) { - return !isInternal; - } - if (invoke.kind === "subscribe" && invoke.name === "queueUpdate") { - return !isInternal; - } - return false; - }, queues: { - queueForMatch: queue<{ username: string }, { registrationToken: string }>(), + queueForMatch: queue<{ + username: string; + connId: string; + }>(), + unqueueForMatch: queue<{ connId: string }>(), matchCompleted: queue<{ matchId: string; winnerUsername: string; @@ -73,35 +53,13 @@ export const rankedMatchmaker = actor({ }>(), }, actions: { - registerPlayer: async ( - c, - { username, registrationToken }: { username: string; registrationToken: string }, - ) => { - await c.db.execute( - `UPDATE player_pool SET conn_id = ? WHERE username = ? AND registration_token = ?`, - c.conn.id, + queueForMatch: async (c, { username }: { username: string }) => { + const connId = c.conn.id; + await c.queue.send("queueForMatch", { username, - registrationToken, - ); - const playerPoolChangeRows = await c.db.execute<{ changed: number }>( - `SELECT changes() AS changed`, - ); - const playerPoolChanges = playerPoolChangeRows[0]?.changed ?? 0; - - await c.db.execute( - `UPDATE assignments SET conn_id = ? WHERE username = ? AND registration_token = ?`, - c.conn.id, - username, - registrationToken, - ); - const assignmentChangeRows = await c.db.execute<{ changed: number }>( - `SELECT changes() AS changed`, - ); - const assignmentChanges = assignmentChangeRows[0]?.changed ?? 0; - - if (playerPoolChanges === 0 && assignmentChanges === 0) { - throw new UserError("forbidden", { code: "forbidden" }); - } + connId, + }); + return { queued: true, connId }; }, getQueueSize: async (c) => { const rows = await c.db.execute<{ cnt: number }>( @@ -111,22 +69,17 @@ export const rankedMatchmaker = actor({ }, getAssignment: async ( c, - { - username, - registrationToken, - }: { username: string; registrationToken: string }, + { username }: { username: string }, ) => { const rows = await c.db.execute<{ match_id: string; username: string; rating: number; - player_token: string; conn_id: string | null; }>( - `SELECT * FROM assignments WHERE username = ? AND conn_id = ? AND registration_token = ?`, + `SELECT * FROM assignments WHERE username = ? AND conn_id = ?`, username, c.conn.id, - registrationToken, ); if (rows.length === 0) return null; const row = rows[0]!; @@ -134,63 +87,71 @@ export const rankedMatchmaker = actor({ matchId: row.match_id, username: row.username, rating: row.rating, - playerToken: row.player_token, connId: row.conn_id, }; }, }, onDisconnect: async (c, conn) => { - await c.db.execute(`DELETE FROM player_pool WHERE conn_id = ?`, conn.id); - await broadcastQueueSize(c); + await c.queue.send("unqueueForMatch", { connId: conn.id }); }, run: async (c) => { - for await (const message of c.queue.iter({ completable: true })) { + c.waitUntil(runQueueUpdateTicker(c)); + + while (!c.aborted) { + const [message] = await c.queue.next({ + timeout: PAIRING_RETRY_TICK_MS, + }); + if (!message) { + // Retry pairing periodically so widening rating windows can form matches + // even when no new queue messages arrive. + await attemptPairing(c); + continue; + } + if (message.name === "queueForMatch") { - const { username } = message.body; - const registrationToken = crypto.randomUUID(); + const { username, connId } = message.body; - // Ensure player actor exists and look up ELO. const client = c.client(); - const playerHandle = client.rankedPlayer.getOrCreate([username], { - params: { internalToken: INTERNAL_TOKEN }, - }); + const playerHandle = client.rankedPlayer.getOrCreate([username]); await playerHandle.initialize({ username }); const rating = await playerHandle.getRating() as number; + // Clear any stale assignment for this username before re-queueing. + await c.db.execute( + `DELETE FROM assignments WHERE username = ?`, + username, + ); + await c.db.execute( - `INSERT OR REPLACE INTO player_pool (username, rating, queued_at, registration_token) VALUES (?, ?, ?, ?)`, + `INSERT OR REPLACE INTO player_pool (username, rating, queued_at, conn_id) VALUES (?, ?, ?, ?)`, username, rating, Date.now(), - registrationToken, + connId, ); - await broadcastQueueSize(c); + await sendQueueUpdates(c); await attemptPairing(c); - await message.complete({ registrationToken }); + } else if (message.name === "unqueueForMatch") { + await c.db.execute( + `DELETE FROM player_pool WHERE conn_id = ?`, + message.body.connId, + ); + await sendQueueUpdates(c); } else if (message.name === "matchCompleted") { const body = message.body; const client = c.client(); if (body.winnerUsername && body.loserUsername) { - // Update player actors with new ratings. - const winnerHandle = client.rankedPlayer.getOrCreate([body.winnerUsername], { - params: { internalToken: INTERNAL_TOKEN }, - }); + const winnerHandle = client.rankedPlayer.getOrCreate([body.winnerUsername]); await winnerHandle.applyMatchResult({ won: true, newRating: body.winnerNewRating }); - const loserHandle = client.rankedPlayer.getOrCreate([body.loserUsername], { - params: { internalToken: INTERNAL_TOKEN }, - }); + const loserHandle = client.rankedPlayer.getOrCreate([body.loserUsername]); await loserHandle.applyMatchResult({ won: false, newRating: body.loserNewRating }); - // Fetch updated profiles for leaderboard. const winnerProfile = await winnerHandle.getProfile() as { username: string; rating: number; wins: number; losses: number }; const loserProfile = await loserHandle.getProfile() as { username: string; rating: number; wins: number; losses: number }; - // Update leaderboard. - const lb = client.rankedLeaderboard.getOrCreate(["main"], { - params: { internalToken: INTERNAL_TOKEN }, - }); + const lb = client.rankedLeaderboard.getOrCreate(["main"]); await lb.updatePlayer(winnerProfile); await lb.updatePlayer(loserProfile); } @@ -199,7 +160,10 @@ export const rankedMatchmaker = actor({ `DELETE FROM matches WHERE match_id = ?`, body.matchId, ); - await message.complete(); + await c.db.execute( + `DELETE FROM assignments WHERE match_id = ?`, + body.matchId, + ); } } }, @@ -262,16 +226,12 @@ async function createRankedMatch( { username: a.username, rating: a.rating, - token: crypto.randomUUID(), connId: a.conn_id, - registrationToken: a.registration_token, }, { username: b.username, rating: b.rating, - token: crypto.randomUUID(), connId: b.conn_id, - registrationToken: b.registration_token, }, ] as const; @@ -282,7 +242,6 @@ async function createRankedMatch( assignedPlayers: assignedPlayers.map((p) => ({ username: p.username, rating: p.rating, - token: p.token, })), }, }); @@ -293,35 +252,82 @@ async function createRankedMatch( Date.now(), ); - await broadcastQueueSize(c); + await sendQueueUpdates(c); - // Store assignments so clients can poll for them. - const assignments: StoredRankedAssignment[] = assignedPlayers.map((player) => ({ - matchId, - username: player.username, - rating: player.rating, - playerToken: player.token, - connId: player.connId, - registrationToken: player.registrationToken, - })); - for (const assignment of assignments) { + for (const player of assignedPlayers) { await c.db.execute( - `INSERT INTO assignments (username, match_id, rating, player_token, conn_id, registration_token) VALUES (?, ?, ?, ?, ?, ?)`, - assignment.username, - assignment.matchId, - assignment.rating, - assignment.playerToken, - assignment.connId, - assignment.registrationToken, + `INSERT OR REPLACE INTO assignments (username, match_id, rating, conn_id) VALUES (?, ?, ?, ?)`, + player.username, + matchId, + player.rating, + player.connId, ); + c.broadcast("assignmentReady", { + matchId, + username: player.username, + rating: player.rating, + connId: player.connId, + } satisfies RankedAssignment); } } -async function broadcastQueueSize(c: ActorContextOf) { - const rows = await c.db.execute<{ cnt: number }>( - `SELECT COUNT(*) as cnt FROM player_pool`, +function calculateRatingWindow(now: number, queuedAt: number): number { + const waitSec = Math.max(0, (now - queuedAt) / 1000); + return Math.min( + INITIAL_RATING_WINDOW + WINDOW_EXPAND_PER_SEC * waitSec, + MAX_RATING_WINDOW, ); - c.broadcast("queueUpdate", { count: rows[0]?.cnt ?? 0 }); +} + +async function sendQueueUpdates(c: ActorContextOf) { + if (c.conns.size === 0) return; + + const now = Date.now(); + const pool = await c.db.execute( + `SELECT * FROM player_pool`, + ); + const count = pool.length; + + const playerByConnId = new Map(); + for (const row of pool) { + if (!row.conn_id) continue; + const existing = playerByConnId.get(row.conn_id); + if (!existing || row.queued_at > existing.queued_at) { + playerByConnId.set(row.conn_id, row); + } + } + + for (const [connId, conn] of c.conns.entries()) { + const player = playerByConnId.get(connId); + if (!player) { + conn.send("queueUpdate", { + queued: false, + count, + }); + continue; + } + + const ratingWindow = calculateRatingWindow(now, player.queued_at); + conn.send("queueUpdate", { + queued: true, + count, + username: player.username, + rating: player.rating, + queueDurationMs: Math.max(0, now - player.queued_at), + ratingWindow: Math.round(ratingWindow), + ratingMin: Math.round(player.rating - ratingWindow), + ratingMax: Math.round(player.rating + ratingWindow), + }); + } +} + +async function runQueueUpdateTicker(c: ActorContextOf) { + const tick = interval(QUEUE_UPDATE_TICK_MS); + while (!c.aborted) { + await tick(); + if (c.aborted) break; + await sendQueueUpdates(c); + } } async function migrateTables(dbHandle: RawAccess) { @@ -330,8 +336,7 @@ async function migrateTables(dbHandle: RawAccess) { username TEXT PRIMARY KEY, rating INTEGER NOT NULL, queued_at INTEGER NOT NULL, - conn_id TEXT, - registration_token TEXT + conn_id TEXT ) `); await dbHandle.execute(` @@ -345,28 +350,7 @@ async function migrateTables(dbHandle: RawAccess) { username TEXT PRIMARY KEY, match_id TEXT NOT NULL, rating INTEGER NOT NULL, - player_token TEXT NOT NULL, - conn_id TEXT, - registration_token TEXT + conn_id TEXT ) `); - - await ensureColumn(dbHandle, "player_pool", "registration_token", "TEXT"); - await ensureColumn(dbHandle, "assignments", "registration_token", "TEXT"); -} - -async function ensureColumn( - dbHandle: RawAccess, - table: "player_pool" | "assignments", - column: "registration_token", - definition: "TEXT", -) { - const columns = await dbHandle.execute<{ name: string }>( - `PRAGMA table_info(${table})`, - ); - if (!columns.some((col) => col.name === column)) { - await dbHandle.execute( - `ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`, - ); - } } diff --git a/examples/multiplayer-game-patterns/src/actors/ranked/player.ts b/examples/multiplayer-game-patterns/src/actors/ranked/player.ts index cfded37e0d..b800757ea0 100644 --- a/examples/multiplayer-game-patterns/src/actors/ranked/player.ts +++ b/examples/multiplayer-game-patterns/src/actors/ranked/player.ts @@ -1,5 +1,4 @@ -import { actor, event, UserError } from "rivetkit"; -import { hasInvalidInternalToken, isInternalToken } from "../../auth.ts"; +import { actor, event } from "rivetkit"; import { DEFAULT_RATING } from "./config.ts"; export const rankedPlayer = actor({ @@ -7,31 +6,6 @@ export const rankedPlayer = actor({ events: { stateUpdate: event(), }, - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if ( - invoke.kind === "action" && - (invoke.name === "initialize" || - invoke.name === "getProfile" || - invoke.name === "getRating") - ) { - return true; - } - if (invoke.kind === "action" && invoke.name === "applyMatchResult") { - return isInternal; - } - if (invoke.kind === "subscribe" && invoke.name === "stateUpdate") { - return !isInternal; - } - return false; - }, state: { username: "", rating: DEFAULT_RATING, diff --git a/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts b/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts index 01993e6442..aede92b9f0 100644 --- a/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts +++ b/examples/multiplayer-game-patterns/src/actors/turn-based/match.ts @@ -1,16 +1,15 @@ import { actor, type ActorContextOf, event, UserError } from "rivetkit"; -import { - hasInvalidInternalToken, - INTERNAL_TOKEN, - isInternalToken, -} from "../../auth.ts"; +import { interval } from "rivetkit/utils"; import { registry } from "../index.ts"; +import { getPlayerColor } from "../player-color.ts"; import { BOARD_SIZE, type CellValue, type GameResult } from "./config.ts"; +const EMPTY_MATCH_DESTROY_DELAY_MS = 10_000; + interface PlayerEntry { - token: string; connId: string | null; name: string; + color: string; symbol: "X" | "O"; } @@ -21,6 +20,7 @@ interface State { currentTurn: "X" | "O"; result: GameResult; moveCount: number; + emptySince: number | null; } export const turnBasedMatch = actor({ @@ -40,53 +40,19 @@ export const turnBasedMatch = actor({ currentTurn: "X", result: null, moveCount: 0, + emptySince: null, }; }, - onBeforeConnect: ( - c, - params: { playerToken?: string; internalToken?: string }, - ) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - if (params?.internalToken === INTERNAL_TOKEN) return; - const playerToken = params?.playerToken?.trim(); - if (!playerToken) { - throw new UserError("authentication required", { code: "auth_required" }); - } - if (!findPlayerByToken(c.state, playerToken)) { - throw new UserError("invalid player token", { code: "invalid_player_token" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - const isAssignedPlayer = findPlayerByConnId(c.state, c.conn.id) !== null; - if (invoke.kind === "action" && invoke.name === "createPlayer") { - return isInternal; - } - if ( - invoke.kind === "action" && - (invoke.name === "makeMove" || invoke.name === "getSnapshot") - ) { - return !isInternal && isAssignedPlayer; - } - if (invoke.kind === "subscribe" && invoke.name === "gameUpdate") { - return !isInternal && isAssignedPlayer; - } - return false; - }, onConnect: (c, conn) => { - const playerToken = conn.params?.playerToken?.trim(); - if (!playerToken) return; - const found = findPlayerByToken(c.state, playerToken); - if (!found) { - conn.disconnect("invalid_player_token"); + const playerId = (conn.params as { playerId?: string })?.playerId; + if (!playerId) return; + const player = c.state.players[playerId]; + if (!player) { + conn.disconnect("invalid_player"); return; } - const [, player] = found; player.connId = conn.id; + c.state.emptySince = null; broadcastSnapshot(c); }, onDisconnect: (c, conn) => { @@ -96,27 +62,46 @@ export const turnBasedMatch = actor({ player.connId = null; broadcastSnapshot(c); - // Destroy the match if no one is connected. const anyConnected = Object.values(c.state.players).some((p) => p.connId !== null); if (!anyConnected) { - c.destroy(); + c.state.emptySince = Date.now(); + } + }, + run: async (c) => { + const tick = interval(1_000); + while (!c.aborted) { + await tick(); + if (c.aborted) break; + + const anyConnected = Object.values(c.state.players).some( + (p) => p.connId !== null, + ); + if (anyConnected) { + c.state.emptySince = null; + continue; + } + if (c.state.emptySince === null) continue; + if (Date.now() - c.state.emptySince >= EMPTY_MATCH_DESTROY_DELAY_MS) { + c.destroy(); + break; + } } }, onDestroy: async (c) => { const client = c.client(); await client.turnBasedMatchmaker - .getOrCreate(["main"], { params: { internalToken: INTERNAL_TOKEN } }) + .getOrCreate(["main"]) .send("closeMatch", { matchId: c.state.matchId }); }, actions: { createPlayer: ( c, - input: { playerId: string; playerToken: string; playerName: string; symbol: "X" | "O" }, + input: { playerId: string; playerName: string; symbol: "X" | "O" }, ) => { c.state.players[input.playerId] = { - token: input.playerToken, connId: null, name: input.playerName, + color: getPlayerColor(input.playerId), symbol: input.symbol, }; }, @@ -143,7 +128,6 @@ export const turnBasedMatch = actor({ c.state.board[row]![col] = player.symbol; c.state.moveCount += 1; - // Check win/draw. const winner = checkWinner(c.state.board); if (winner) { c.state.result = winner === "X" ? "x_wins" : "o_wins"; @@ -160,19 +144,16 @@ export const turnBasedMatch = actor({ }); function checkWinner(board: CellValue[][]): "X" | "O" | null { - // Rows. for (let r = 0; r < BOARD_SIZE; r++) { if (board[r]![0] !== "" && board[r]![0] === board[r]![1] && board[r]![1] === board[r]![2]) { return board[r]![0] as "X" | "O"; } } - // Columns. for (let c = 0; c < BOARD_SIZE; c++) { if (board[0]![c] !== "" && board[0]![c] === board[1]![c] && board[1]![c] === board[2]![c]) { return board[0]![c] as "X" | "O"; } } - // Diagonals. if (board[0]![0] !== "" && board[0]![0] === board[1]![1] && board[1]![1] === board[2]![2]) { return board[0]![0] as "X" | "O"; } @@ -188,7 +169,7 @@ interface GameSnapshot { currentTurn: "X" | "O"; result: GameResult; moveCount: number; - players: Record; + players: Record; } function buildSnapshot(c: ActorContextOf): GameSnapshot { @@ -196,6 +177,7 @@ function buildSnapshot(c: ActorContextOf): GameSnapshot { for (const [id, entry] of Object.entries(c.state.players)) { players[id] = { name: entry.name, + color: entry.color, symbol: entry.symbol, connected: entry.connId !== null, }; @@ -214,16 +196,6 @@ function broadcastSnapshot(c: ActorContextOf) { c.broadcast("gameUpdate", buildSnapshot(c)); } -function findPlayerByToken( - state: State, - token: string, -): [string, PlayerEntry] | null { - for (const [id, entry] of Object.entries(state.players)) { - if (entry.token === token) return [id, entry]; - } - return null; -} - function findPlayerByConnId( state: State, connId: string, diff --git a/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts b/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts index 1372fcb541..8cacb3de38 100644 --- a/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts +++ b/examples/multiplayer-game-patterns/src/actors/turn-based/matchmaker.ts @@ -1,59 +1,94 @@ -import { actor, queue, UserError } from "rivetkit"; +/* +This matchmaker supports invite and queued public matchmaking flows. +1. createGame creates a private match with the first player as X. +2. joinByCode joins an existing private match by invite code as O. +3. queueForMatch action enqueues players into the public pool. +4. When two players are queued, the matchmaker creates a match and stores public assignments without invite codes. +5. getAssignment lets clients poll for their match assignment. +6. onDisconnect unqueues waiting players so stale queue entries do not remain. +*/ +import { actor, type ActorContextOf, queue, UserError } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; -import { hasInvalidInternalToken, INTERNAL_TOKEN, isInternalToken } from "../../auth.ts"; import { registry } from "../index.ts"; import { generateInviteCode } from "./config.ts"; +export interface TurnBasedAssignment { + matchId: string; + playerId: string; + inviteCode?: string; + connId: string | null; +} + +type QueuePlayerRow = { + player_id: string; + player_name: string; + queued_at: number; + conn_id: string | null; +}; + export const turnBasedMatchmaker = actor({ options: { name: "Turn-Based - Matchmaker", icon: "chess-board" }, db: db({ onMigrate: migrateTables, }), - onBeforeConnect: (_c, params: { internalToken?: string }) => { - if (hasInvalidInternalToken(params)) { - throw new UserError("forbidden", { code: "forbidden" }); - } - }, - canInvoke: (c, invoke) => { - const isInternal = isInternalToken( - c.conn.params as { internalToken?: string } | undefined, - ); - if ( - invoke.kind === "queue" && - (invoke.name === "createGame" || - invoke.name === "joinByCode" || - invoke.name === "findMatch") - ) { - return !isInternal; - } - if (invoke.kind === "queue" && invoke.name === "closeMatch") { - return isInternal; - } - return false; - }, queues: { createGame: queue< { playerName: string }, - { matchId: string; playerId: string; playerToken: string; inviteCode: string } + { matchId: string; playerId: string; inviteCode: string } >(), joinByCode: queue< { inviteCode: string; playerName: string }, - { matchId: string; playerId: string; playerToken: string } - >(), - findMatch: queue< - { playerName: string }, - { matchId: string; playerId: string; playerToken: string; inviteCode?: string } + { matchId: string; playerId: string } >(), + queueForMatch: queue<{ + playerId: string; + playerName: string; + connId: string; + }>(), + unqueueForMatch: queue<{ connId: string }>(), closeMatch: queue<{ matchId: string }>(), }, + actions: { + queueForMatch: async (c, { playerName }: { playerName: string }) => { + const playerId = crypto.randomUUID(); + await c.queue.send("queueForMatch", { + playerId, + playerName, + connId: c.conn.id, + }); + return { playerId }; + }, + getAssignment: async (c, { playerId }: { playerId: string }) => { + const rows = await c.db.execute<{ + match_id: string; + player_id: string; + invite_code: string | null; + conn_id: string | null; + }>( + `SELECT * FROM assignments WHERE player_id = ? AND conn_id = ?`, + playerId, + c.conn.id, + ); + if (rows.length === 0) return null; + const row = rows[0]!; + return { + matchId: row.match_id, + playerId: row.player_id, + inviteCode: row.invite_code ?? undefined, + connId: row.conn_id, + } satisfies TurnBasedAssignment; + }, + }, + onDisconnect: async (c, conn) => { + await c.queue.send("unqueueForMatch", { connId: conn.id }); + }, run: async (c) => { for await (const message of c.queue.iter({ completable: true })) { if (message.name === "createGame") { const matchId = crypto.randomUUID(); const inviteCode = generateInviteCode(); const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); const client = c.client(); await client.turnBasedMatch.create([matchId], { @@ -61,10 +96,9 @@ export const turnBasedMatchmaker = actor({ }); await client.turnBasedMatch - .get([matchId], { params: { internalToken: INTERNAL_TOKEN } }) + .get([matchId]) .createPlayer({ playerId, - playerToken, playerName: message.body.playerName, symbol: "X" as const, }); @@ -78,7 +112,7 @@ export const turnBasedMatchmaker = actor({ Date.now(), ); - await message.complete({ matchId, playerId, playerToken, inviteCode }); + await message.complete({ matchId, playerId, inviteCode }); } else if (message.name === "joinByCode") { const code = message.body.inviteCode.toUpperCase().trim(); const rows = await c.db.execute<{ match_id: string; player_count: number }>( @@ -90,13 +124,11 @@ export const turnBasedMatchmaker = actor({ if (row.player_count >= 2) throw new UserError("Game is full", { code: "game_full" }); const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); const client = c.client(); await client.turnBasedMatch - .get([row.match_id], { params: { internalToken: INTERNAL_TOKEN } }) + .get([row.match_id]) .createPlayer({ playerId, - playerToken, playerName: message.body.playerName, symbol: "O" as const, }); @@ -106,58 +138,21 @@ export const turnBasedMatchmaker = actor({ row.match_id, ); - await message.complete({ matchId: row.match_id, playerId, playerToken }); - } else if (message.name === "findMatch") { - // Look for open pool game with 1 player. - const rows = await c.db.execute<{ match_id: string }>( - `SELECT match_id FROM matches WHERE is_open_pool = 1 AND player_count = 1 ORDER BY created_at ASC LIMIT 1`, + await message.complete({ matchId: row.match_id, playerId }); + } else if (message.name === "queueForMatch") { + await processQueueEntry(c, message.body); + await message.complete(); + } else if (message.name === "unqueueForMatch") { + await c.db.execute( + `DELETE FROM player_pool WHERE conn_id = ?`, + message.body.connId, ); - let matchId = rows[0]?.match_id ?? null; - const playerId = crypto.randomUUID(); - const playerToken = crypto.randomUUID(); - const client = c.client(); - - if (matchId) { - // Join existing game as O. - await client.turnBasedMatch - .get([matchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ - playerId, - playerToken, - playerName: message.body.playerName, - symbol: "O" as const, - }); - await c.db.execute( - `UPDATE matches SET player_count = 2 WHERE match_id = ?`, - matchId, - ); - } else { - // Create new open pool game. - matchId = crypto.randomUUID(); - const inviteCode = generateInviteCode(); - await client.turnBasedMatch.create([matchId], { - input: { matchId }, - }); - await client.turnBasedMatch - .get([matchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ - playerId, - playerToken, - playerName: message.body.playerName, - symbol: "X" as const, - }); - await c.db.execute( - `INSERT INTO matches (match_id, invite_code, player_count, is_open_pool, created_at) VALUES (?, ?, ?, ?, ?)`, - matchId, - inviteCode, - 1, - 1, - Date.now(), - ); - } - - await message.complete({ matchId, playerId, playerToken }); + await message.complete(); } else if (message.name === "closeMatch") { + await c.db.execute( + `DELETE FROM assignments WHERE match_id = ?`, + message.body.matchId, + ); await c.db.execute( `DELETE FROM matches WHERE match_id = ?`, message.body.matchId, @@ -168,6 +163,88 @@ export const turnBasedMatchmaker = actor({ }, }); +async function processQueueEntry( + c: ActorContextOf, + entry: { + playerId: string; + playerName: string; + connId: string; + }, +): Promise { + await c.db.execute( + `INSERT OR REPLACE INTO player_pool (player_id, player_name, queued_at, conn_id) VALUES (?, ?, ?, ?)`, + entry.playerId, + entry.playerName, + Date.now(), + entry.connId, + ); + + await attemptPairing(c); +} + +async function attemptPairing( + c: ActorContextOf, +): Promise { + const queued = await c.db.execute( + `SELECT player_id, player_name, queued_at, conn_id FROM player_pool ORDER BY queued_at ASC LIMIT 2`, + ); + if (queued.length < 2) return; + + const a = queued[0]!; + const b = queued[1]!; + + await c.db.execute(`DELETE FROM player_pool WHERE player_id = ?`, a.player_id); + await c.db.execute(`DELETE FROM player_pool WHERE player_id = ?`, b.player_id); + + const matchId = crypto.randomUUID(); + const inviteCode = generateInviteCode(); + + const client = c.client(); + await client.turnBasedMatch.create([matchId], { + input: { matchId }, + }); + + await client.turnBasedMatch + .get([matchId]) + .createPlayer({ + playerId: a.player_id, + playerName: a.player_name, + symbol: "X" as const, + }); + await client.turnBasedMatch + .get([matchId]) + .createPlayer({ + playerId: b.player_id, + playerName: b.player_name, + symbol: "O" as const, + }); + + await c.db.execute( + `INSERT INTO matches (match_id, invite_code, player_count, is_open_pool, created_at) VALUES (?, ?, ?, ?, ?)`, + matchId, + inviteCode, + 2, + 1, + Date.now(), + ); + + const assignedPlayers = [a, b] as const; + for (const player of assignedPlayers) { + await c.db.execute( + `INSERT INTO assignments (player_id, match_id, invite_code, conn_id) VALUES (?, ?, ?, ?)`, + player.player_id, + matchId, + null, + player.conn_id, + ); + c.broadcast("assignmentReady", { + matchId, + playerId: player.player_id, + connId: player.conn_id, + } satisfies TurnBasedAssignment); + } +} + async function migrateTables(dbHandle: RawAccess) { await dbHandle.execute(` CREATE TABLE IF NOT EXISTS matches ( @@ -178,4 +255,20 @@ async function migrateTables(dbHandle: RawAccess) { created_at INTEGER NOT NULL ) `); + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS player_pool ( + player_id TEXT PRIMARY KEY, + player_name TEXT NOT NULL, + queued_at INTEGER NOT NULL, + conn_id TEXT + ) + `); + await dbHandle.execute(` + CREATE TABLE IF NOT EXISTS assignments ( + player_id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + invite_code TEXT, + conn_id TEXT + ) + `); } diff --git a/examples/multiplayer-game-patterns/src/auth.ts b/examples/multiplayer-game-patterns/src/auth.ts deleted file mode 100644 index 3a6168eba2..0000000000 --- a/examples/multiplayer-game-patterns/src/auth.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Token for actor-to-actor communication. -export const INTERNAL_TOKEN = "internal"; - -export function isInternalToken(params: { internalToken?: string } | null | undefined): boolean { - return params?.internalToken === INTERNAL_TOKEN; -} - -export function hasInvalidInternalToken( - params: { internalToken?: string } | null | undefined, -): boolean { - return params?.internalToken !== undefined && params.internalToken !== INTERNAL_TOKEN; -} 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 index 70c40807a5..66dfda0d46 100644 --- a/examples/multiplayer-game-patterns/tests/matchmaking-and-session-patterns.test.ts +++ b/examples/multiplayer-game-patterns/tests/matchmaking-and-session-patterns.test.ts @@ -1,12 +1,9 @@ import { setupTest } from "rivetkit/test"; import { describe, expect, test } from "vitest"; import { registry } from "../src/actors/index.ts"; -import { INTERNAL_TOKEN } from "../src/auth.ts"; +import { CHUNK_SIZE, WORLD_ID } from "../src/actors/open-world/config.ts"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const expectForbidden = async (promise: Promise) => { - await expect(promise).rejects.toMatchObject({ code: "forbidden" }); -}; describe("matchmaking and session patterns", () => { test("io-style open lobby + 10 tps match with movement", async (ctx) => { @@ -25,19 +22,29 @@ describe("matchmaking and session patterns", () => { ); expect(firstQueueResult?.status).toBe("completed"); expect(secondQueueResult?.status).toBe("completed"); - const firstResponse = (firstQueueResult as { response?: { matchId?: string; playerId?: string; playerToken?: string } })?.response; - const secondResponse = (secondQueueResult as { response?: { matchId?: string; playerId?: string; playerToken?: string } })?.response; + const firstResponse = (firstQueueResult as { + response?: { matchId?: string; playerId?: string }; + })?.response; + const secondResponse = (secondQueueResult as { + response?: { matchId?: string; playerId?: string }; + })?.response; expect(firstResponse?.playerId).toBeTypeOf("string"); - expect(firstResponse?.playerToken).toBeTypeOf("string"); expect(secondResponse?.playerId).toBeTypeOf("string"); - expect(secondResponse?.playerToken).toBeTypeOf("string"); expect(secondResponse?.matchId).toBe(firstResponse?.matchId); const a = client.ioStyleMatch - .getOrCreate([firstResponse!.matchId!], { params: { playerToken: firstResponse!.playerToken! } }) + .getOrCreate([firstResponse!.matchId!], { + params: { + playerId: firstResponse!.playerId!, + }, + }) .connect(); const b = client.ioStyleMatch - .getOrCreate([firstResponse!.matchId!], { params: { playerToken: secondResponse!.playerToken! } }) + .getOrCreate([firstResponse!.matchId!], { + params: { + playerId: secondResponse!.playerId!, + }, + }) .connect(); await sleep(260); @@ -47,7 +54,6 @@ describe("matchmaking and session patterns", () => { expect(Object.keys(snapshot.players)).toHaveLength(2); expect(snapshot.worldSize).toBe(600); - // Record initial position and send input to move right. const initialX = snapshot.players[firstResponse!.playerId!]!.x; await a.setInput({ inputX: 1, inputY: 0 }); await sleep(350); @@ -64,33 +70,25 @@ describe("matchmaking and session patterns", () => { interface Assignment { matchId: string; playerId: string; - playerToken: string; teamId: number; mode: string; } - // Queue all 4 players concurrently. Each send returns immediately with a playerId. const mm = client.arenaMatchmaker.getOrCreate(["main"]).connect(); const results = await Promise.all( Array.from({ length: 4 }, () => - mm.send("queueForMatch", { mode: "duo" }, { wait: true, timeout: 10_000 }), + mm.queueForMatch({ mode: "duo" }), ), ); const queueEntries = results.map((r) => { - const response = (r as { - response?: { playerId: string; registrationToken: string }; - })?.response; - expect(response?.playerId).toBeTypeOf("string"); - expect(response?.registrationToken).toBeTypeOf("string"); + const response = r as { playerId?: string }; + expect(response.playerId).toBeTypeOf("string"); return { - playerId: response!.playerId, - registrationToken: response!.registrationToken, + playerId: response.playerId!, }; }); - await Promise.all(queueEntries.map((entry) => mm.registerPlayer(entry))); - // Poll for assignments. The match should already be filled since we queued 4 players. const assignments: Assignment[] = []; for (const entry of queueEntries) { let assignment: Assignment | null = null; @@ -100,29 +98,25 @@ describe("matchmaking and session patterns", () => { } expect(assignment).not.toBeNull(); expect(assignment!.matchId).toBeTypeOf("string"); - expect(assignment!.playerToken).toBeTypeOf("string"); assignments.push(assignment!); } const matchId = assignments[0]!.matchId; expect(assignments.every((a) => a.matchId === matchId)).toBe(true); - // Connect all players to the match. const conns = assignments.map((a) => client.arenaMatch - .get([a.matchId], { params: { playerToken: a.playerToken } }) + .get([a.matchId], { params: { playerId: a.playerId } }) .connect(), ); await sleep(200); - // All connected → phase should be live. const player0Id = assignments[0]!.playerId; const snap1 = await conns[0]!.getSnapshot(); expect(snap1.phase).toBe("live"); expect(Object.keys(snap1.players)).toHaveLength(4); expect(snap1.worldSize).toBe(600); - // Test updatePosition on the first player. const initialX = snap1.players[player0Id]!.x; const initialY = snap1.players[player0Id]!.y; await conns[0]!.updatePosition({ x: initialX + 10, y: initialY }); @@ -130,8 +124,6 @@ describe("matchmaking and session patterns", () => { const snap2 = await conns[0]!.getSnapshot(); expect(snap2.players[player0Id]!.x).toBeCloseTo(initialX + 10, 0); - // Test shoot: player 0 (team 0) shoots toward player 1 (team 1, different team). - // Move them close together with multiple small steps. const targetPlayerId = assignments[1]!.playerId; for (let step = 0; step < 30; step++) { const snapStep = await conns[0]!.getSnapshot(); @@ -141,7 +133,6 @@ describe("matchmaking and session patterns", () => { const dy = target.y - me.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 50) break; - // Move toward target (speed-limited by server). const moveBy = Math.min(dist - 30, 40); await conns[0]!.updatePosition({ x: me.x + (dx / dist) * moveBy, @@ -150,7 +141,6 @@ describe("matchmaking and session patterns", () => { await sleep(60); } - // Get positions, then shoot toward the target. let scored = false; for (let attempt = 0; attempt < 6 && !scored; attempt++) { const snapBeforeShoot = await conns[0]!.getSnapshot(); @@ -175,20 +165,31 @@ describe("matchmaking and session patterns", () => { test("party lobby with host controls and member management", async (ctx) => { const { client } = await setupTest(ctx, registry); - // Create a party. const mm = client.partyMatchmaker.getOrCreate(["main"]).connect(); const createResult = await mm.send( "createParty", { hostName: "Host" }, { wait: true, timeout: 5_000 }, ); - const createResponse = (createResult as { response?: { matchId: string; playerId: string; playerToken: string; partyCode: string } })?.response; + const createResponse = (createResult as { + response?: { + matchId: string; + playerId: string; + partyCode: string; + joinToken: string; + playerName: string; + }; + })?.response; expect(createResponse?.matchId).toBeTypeOf("string"); expect(createResponse?.partyCode).toHaveLength(6); - // Host connects. const hostConn = client.partyMatch - .get([createResponse!.matchId], { params: { playerToken: createResponse!.playerToken } }) + .get([createResponse!.matchId], { + params: { + playerId: createResponse!.playerId, + joinToken: createResponse!.joinToken, + }, + }) .connect(); await sleep(200); @@ -199,24 +200,34 @@ describe("matchmaking and session patterns", () => { const hostMember = snap1.members[createResponse!.playerId]; expect(hostMember.isHost).toBe(true); - // Second player joins by code. const joinResult = await mm.send( "joinParty", { partyCode: createResponse!.partyCode, playerName: "Player2" }, { wait: true, timeout: 5_000 }, ); - const joinResponse = (joinResult as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; + const joinResponse = (joinResult as { + response?: { + matchId: string; + playerId: string; + joinToken: string; + playerName: string; + }; + })?.response; expect(joinResponse?.matchId).toBe(createResponse!.matchId); const p2Conn = client.partyMatch - .get([joinResponse!.matchId], { params: { playerToken: joinResponse!.playerToken } }) + .get([joinResponse!.matchId], { + params: { + playerId: joinResponse!.playerId, + joinToken: joinResponse!.joinToken, + }, + }) .connect(); await sleep(200); const snap2 = await hostConn.getSnapshot(); expect(Object.keys(snap2.members)).toHaveLength(2); - // Toggle ready and start game. await p2Conn.toggleReady(); await sleep(100); await hostConn.startGame(); @@ -225,14 +236,19 @@ describe("matchmaking and session patterns", () => { const snap3 = await hostConn.getSnapshot(); expect(snap3.phase).toBe("playing"); - // Finish game. await hostConn.finishGame(); await sleep(100); const snap4 = await hostConn.getSnapshot(); expect(snap4.phase).toBe("finished"); - await Promise.all([hostConn.dispose(), p2Conn.dispose(), mm.dispose()]); + await p2Conn.dispose(); + await sleep(100); + + const snap5 = await hostConn.getSnapshot(); + expect(Object.keys(snap5.members)).toHaveLength(1); + + await Promise.all([hostConn.dispose(), mm.dispose()]); }, 15_000); test("turn-based tic-tac-toe with moves and win detection", async (ctx) => { @@ -240,31 +256,28 @@ describe("matchmaking and session patterns", () => { const mm = client.turnBasedMatchmaker.getOrCreate(["main"]).connect(); - // Player X creates a game. const createResult = await mm.send( "createGame", { playerName: "PlayerX" }, { wait: true, timeout: 5_000 }, ); - const createResponse = (createResult as { response?: { matchId: string; playerId: string; playerToken: string; inviteCode: string } })?.response; + const createResponse = (createResult as { response?: { matchId: string; playerId: string; inviteCode: string } })?.response; expect(createResponse?.matchId).toBeTypeOf("string"); expect(createResponse?.inviteCode).toHaveLength(6); - // Player O joins by code. const joinResult = await mm.send( "joinByCode", { inviteCode: createResponse!.inviteCode, playerName: "PlayerO" }, { wait: true, timeout: 5_000 }, ); - const joinResponse = (joinResult as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; + const joinResponse = (joinResult as { response?: { matchId: string; playerId: string } })?.response; expect(joinResponse?.matchId).toBe(createResponse!.matchId); - // Both connect. const xConn = client.turnBasedMatch - .get([createResponse!.matchId], { params: { playerToken: createResponse!.playerToken } }) + .get([createResponse!.matchId], { params: { playerId: createResponse!.playerId } }) .connect(); const oConn = client.turnBasedMatch - .get([joinResponse!.matchId], { params: { playerToken: joinResponse!.playerToken } }) + .get([joinResponse!.matchId], { params: { playerId: joinResponse!.playerId } }) .connect(); await sleep(200); @@ -272,16 +285,15 @@ describe("matchmaking and session patterns", () => { expect(snap1.currentTurn).toBe("X"); expect(Object.keys(snap1.players)).toHaveLength(2); - // Play a quick game: X wins with top row. - await xConn.makeMove({ row: 0, col: 0 }); // X + await xConn.makeMove({ row: 0, col: 0 }); await sleep(50); - await oConn.makeMove({ row: 1, col: 0 }); // O + await oConn.makeMove({ row: 1, col: 0 }); await sleep(50); - await xConn.makeMove({ row: 0, col: 1 }); // X + await xConn.makeMove({ row: 0, col: 1 }); await sleep(50); - await oConn.makeMove({ row: 1, col: 1 }); // O + await oConn.makeMove({ row: 1, col: 1 }); await sleep(50); - await xConn.makeMove({ row: 0, col: 2 }); // X wins + await xConn.makeMove({ row: 0, col: 2 }); await sleep(100); const snap2 = await xConn.getSnapshot(); @@ -291,6 +303,51 @@ describe("matchmaking and session patterns", () => { await Promise.all([xConn.dispose(), oConn.dispose(), mm.dispose()]); }, 15_000); + test("turn-based public queue omits invite code and starts match", async (ctx) => { + const { client } = await setupTest(ctx, registry); + + const mm = client.turnBasedMatchmaker.getOrCreate(["main"]).connect(); + const q1 = await mm.queueForMatch({ playerName: "PublicA" }) as { playerId?: string }; + const q2 = await mm.queueForMatch({ playerName: "PublicB" }) as { playerId?: string }; + expect(q1.playerId).toBeTypeOf("string"); + expect(q2.playerId).toBeTypeOf("string"); + + type PublicAssignment = { + matchId: string; + playerId: string; + inviteCode?: string; + }; + + const waitAssignment = async (playerId: string): Promise => { + for (let i = 0; i < 50; i++) { + const assignment = await mm.getAssignment({ playerId }) as PublicAssignment | null; + if (assignment) return assignment; + await sleep(100); + } + throw new Error("timed out waiting for public assignment"); + }; + + const a1 = await waitAssignment(q1.playerId!); + const a2 = await waitAssignment(q2.playerId!); + expect(a1.matchId).toBe(a2.matchId); + expect(a1.inviteCode).toBeUndefined(); + expect(a2.inviteCode).toBeUndefined(); + + const c1 = client.turnBasedMatch + .get([a1.matchId], { params: { playerId: a1.playerId } }) + .connect(); + const c2 = client.turnBasedMatch + .get([a2.matchId], { params: { playerId: a2.playerId } }) + .connect(); + await sleep(250); + + const snap = await c1.getSnapshot(); + expect(Object.keys(snap.players)).toHaveLength(2); + expect(snap.result).toBeNull(); + + await Promise.all([c1.dispose(), c2.dispose(), mm.dispose()]); + }, 15_000); + test("ranked 1v1 matchmaking with ELO pairing", async (ctx) => { const { client } = await setupTest(ctx, registry); @@ -298,44 +355,22 @@ describe("matchmaking and session patterns", () => { matchId: string; username: string; rating: number; - playerToken: string; } const usernames = ["TestPlayer1", "TestPlayer2"]; - // Queue both players concurrently. const mm = client.rankedMatchmaker.getOrCreate(["main"]).connect(); - const queueResults = await Promise.all( - usernames.map((username) => - mm.send("queueForMatch", { username }, { wait: true, timeout: 10_000 }), - ), - ); - const registrationTokenByUsername = new Map(); - for (const [idx, username] of usernames.entries()) { - const response = (queueResults[idx] as { - response?: { registrationToken: string }; - })?.response; - expect(response?.registrationToken).toBeTypeOf("string"); - registrationTokenByUsername.set(username, response!.registrationToken); - } await Promise.all( usernames.map((username) => - mm.registerPlayer({ - username, - registrationToken: registrationTokenByUsername.get(username)!, - }), + mm.queueForMatch({ username }), ), ); - // Poll for assignments. const assignments: RankedAssignment[] = []; for (const username of usernames) { let assignment: RankedAssignment | null = null; for (let i = 0; i < 50 && !assignment; i++) { - assignment = await mm.getAssignment({ - username, - registrationToken: registrationTokenByUsername.get(username)!, - }) as RankedAssignment | null; + assignment = await mm.getAssignment({ username }) as RankedAssignment | null; if (!assignment) await sleep(100); } expect(assignment).not.toBeNull(); @@ -344,10 +379,9 @@ describe("matchmaking and session patterns", () => { assignments.push(assignment!); } - // Connect both players to the match. const conns = assignments.map((a) => client.rankedMatch - .get([a.matchId], { params: { playerToken: a.playerToken } }) + .get([a.matchId], { params: { username: a.username } }) .connect(), ); await sleep(200); @@ -360,22 +394,96 @@ describe("matchmaking and session patterns", () => { await Promise.all([...conns.map((c) => c.dispose()), mm.dispose()]); }, 15_000); + test("ranked allows re-queueing the same usernames", async (ctx) => { + const { client } = await setupTest(ctx, registry); + + interface RankedAssignment { + matchId: string; + username: string; + rating: number; + } + + const usernames = ["RequeueA", "RequeueB"]; + const mm = client.rankedMatchmaker.getOrCreate(["main"]).connect(); + let previousMatchId: string | null = null; + + for (let round = 0; round < 2; round++) { + await Promise.all( + usernames.map((username) => + mm.queueForMatch({ username }), + ), + ); + + const assignments: RankedAssignment[] = []; + for (const username of usernames) { + let assignment: RankedAssignment | null = null; + for (let i = 0; i < 80 && !assignment; i++) { + const next = await mm.getAssignment({ username }) as RankedAssignment | null; + if (next && (!previousMatchId || next.matchId !== previousMatchId)) { + assignment = next; + break; + } + await sleep(100); + } + expect(assignment).not.toBeNull(); + assignments.push(assignment!); + } + + const matchId = assignments[0]!.matchId; + expect(assignments.every((a) => a.matchId === matchId)).toBe(true); + previousMatchId = matchId; + + const conns = assignments.map((a) => + client.rankedMatch + .get([a.matchId], { params: { username: a.username } }) + .connect(), + ); + await sleep(200); + + const snap = await conns[0]!.getSnapshot(); + expect(Object.keys(snap.players)).toHaveLength(2); + + await Promise.all(conns.map((c) => c.dispose())); + await sleep(100); + } + + await mm.dispose(); + }, 20_000); + test("battle-royale lobby matchmaking and snapshot", async (ctx) => { const { client } = await setupTest(ctx, registry); const mm = client.battleRoyaleMatchmaker.getOrCreate(["main"]).connect(); const result1 = await mm.send("findMatch", {}, { wait: true, timeout: 5_000 }); const result2 = await mm.send("findMatch", {}, { wait: true, timeout: 5_000 }); - const r1 = (result1 as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; - const r2 = (result2 as { response?: { matchId: string; playerId: string; playerToken: string } })?.response; + const r1 = (result1 as { + response?: { + matchId: string; + playerId: string; + }; + })?.response; + const r2 = (result2 as { + response?: { + matchId: string; + playerId: string; + }; + })?.response; expect(r1?.matchId).toBeTypeOf("string"); expect(r2?.matchId).toBe(r1?.matchId); const a = client.battleRoyaleMatch - .get([r1!.matchId], { params: { playerToken: r1!.playerToken } }) + .get([r1!.matchId], { + params: { + playerId: r1!.playerId, + }, + }) .connect(); const b = client.battleRoyaleMatch - .get([r2!.matchId], { params: { playerToken: r2!.playerToken } }) + .get([r2!.matchId], { + params: { + playerId: r2!.playerId, + }, + }) .connect(); await sleep(300); @@ -392,20 +500,19 @@ describe("matchmaking and session patterns", () => { test("open-world chunk routing and movement", async (ctx) => { const { client } = await setupTest(ctx, registry); - const index = client.openWorldIndex.getOrCreate(["main"]).connect(); - const result = await index.send( - "getChunkForPosition", - { x: 600, y: 600, playerName: "Explorer" }, - { wait: true, timeout: 5_000 }, - ); - const response = (result as { response?: { chunkKey: [string, number, number]; playerId: string; playerToken: string } })?.response; - expect(response?.chunkKey).toEqual(["default", 0, 0]); - expect(response?.playerId).toBeTypeOf("string"); - expect(response?.playerToken).toBeTypeOf("string"); + const response = resolveChunkForPosition(600, 600); + expect(response.chunkKey).toEqual([WORLD_ID, 0, 0]); + expect(response?.spawnX).toBeTypeOf("number"); + expect(response?.spawnY).toBeTypeOf("number"); const chunk = client.openWorldChunk - .getOrCreate(["default", "0", "0"], { params: { playerToken: response!.playerToken } }) + .getOrCreate([WORLD_ID, "0", "0"]) .connect(); + await chunk.addPlayer({ + name: "Explorer", + spawnX: response.spawnX, + spawnY: response.spawnY, + }); await sleep(300); const snap = await chunk.getSnapshot(); @@ -413,80 +520,86 @@ describe("matchmaking and session patterns", () => { expect(snap.chunkY).toBe(0); expect(snap.chunkSize).toBe(1200); expect(Object.keys(snap.players)).toHaveLength(1); + const myConnId = Object.entries(snap.players).find(([, p]) => p.name === "Explorer")?.[0]; + expect(myConnId).toBeTypeOf("string"); - // Send movement input. await chunk.setInput({ inputX: 1, inputY: 0 }); await sleep(350); const snap2 = await chunk.getSnapshot(); - const myPos = snap2.players[response!.playerId]; + const myPos = myConnId ? snap2.players[myConnId] : undefined; expect(myPos).toBeDefined(); expect(myPos!.x).toBeGreaterThan(600); - await Promise.all([chunk.dispose(), index.dispose()]); + await chunk.removePlayer(); + await chunk.dispose(); }, 15_000); test("open-world chunk transfer moves player to new chunk", async (ctx) => { const { client } = await setupTest(ctx, registry); - // Spawn player near the right edge of chunk 0,0. - const index = client.openWorldIndex.getOrCreate(["main"]).connect(); - const result = await index.send( - "getChunkForPosition", - { x: 1190, y: 600, playerName: "Traveler" }, - { wait: true, timeout: 5_000 }, - ); - const r1 = (result as { response?: { chunkKey: [string, number, number]; playerId: string; playerToken: string } })?.response; - expect(r1?.chunkKey).toEqual(["default", 0, 0]); + const r1 = resolveChunkForPosition(1190, 600); + expect(r1.chunkKey).toEqual([WORLD_ID, 0, 0]); - // Connect and move right until clamped at boundary. const chunk0 = client.openWorldChunk - .getOrCreate(["default", "0", "0"], { params: { playerToken: r1!.playerToken } }) + .getOrCreate([WORLD_ID, "0", "0"]) .connect(); + const chunk1 = client.openWorldChunk + .getOrCreate([WORLD_ID, "1", "0"]) + .connect(); + await chunk0.addPlayer({ + name: "Traveler", + spawnX: r1.spawnX, + spawnY: r1.spawnY, + }); await sleep(200); await chunk0.setInput({ inputX: 1, inputY: 0 }); await sleep(500); const snapAtEdge = await chunk0.getSnapshot(); - const posAtEdge = snapAtEdge.players[r1!.playerId]; + const travelerConnId = Object.entries(snapAtEdge.players).find( + ([, p]) => p.name === "Traveler", + )?.[0]; + const posAtEdge = travelerConnId ? snapAtEdge.players[travelerConnId] : undefined; expect(posAtEdge).toBeDefined(); - expect(posAtEdge!.x).toBe(1199); // Clamped to CHUNK_SIZE - 1. + expect(posAtEdge!.x).toBe(1199); - // Now simulate what the client does: request transfer to the next chunk. - const absX = 0 * 1200 + posAtEdge!.x + 1; // One pixel into next chunk. + const absX = 0 * 1200 + posAtEdge!.x + 1; const absY = 0 * 1200 + posAtEdge!.y; - const transferResult = await index.send( - "getChunkForPosition", - { x: absX, y: absY, playerName: "Traveler" }, - { wait: true, timeout: 5_000 }, - ); - const r2 = (transferResult as { response?: { chunkKey: [string, number, number]; playerId: string; playerToken: string } })?.response; - expect(r2?.chunkKey).toEqual(["default", 1, 0]); - expect(r2?.playerId).toBeTypeOf("string"); - expect(r2?.playerToken).toBeTypeOf("string"); - - // Connect to new chunk and verify player exists there. - const chunk1 = client.openWorldChunk - .getOrCreate(["default", "1", "0"], { params: { playerToken: r2!.playerToken } }) - .connect(); + const r2 = resolveChunkForPosition(absX, absY); + expect(r2.chunkKey).toEqual([WORLD_ID, 1, 0]); + expect(r2.spawnX).toBeTypeOf("number"); + expect(r2.spawnY).toBeTypeOf("number"); + + await chunk0.removePlayer(); + await chunk1.addPlayer({ + name: "Traveler", + spawnX: r2.spawnX, + spawnY: r2.spawnY, + }); await sleep(300); const snapNewChunk = await chunk1.getSnapshot(); expect(snapNewChunk.chunkX).toBe(1); expect(snapNewChunk.chunkY).toBe(0); - const newPos = snapNewChunk.players[r2!.playerId]; + const newTravelerConnId = Object.entries(snapNewChunk.players).find( + ([, p]) => p.name === "Traveler", + )?.[0]; + const newPos = newTravelerConnId + ? snapNewChunk.players[newTravelerConnId] + : undefined; expect(newPos).toBeDefined(); - // Player should be at x=0 (just crossed boundary), not at center. expect(newPos!.x).toBeLessThan(100); - // Player should be able to move in the new chunk. await chunk1.setInput({ inputX: 1, inputY: 0 }); await sleep(350); const snapMoved = await chunk1.getSnapshot(); - expect(snapMoved.players[r2!.playerId]!.x).toBeGreaterThan(newPos!.x); + const movedPos = newTravelerConnId ? snapMoved.players[newTravelerConnId] : undefined; + expect(movedPos).toBeDefined(); + expect(movedPos!.x).toBeGreaterThan(newPos!.x); - await Promise.all([chunk0.dispose(), chunk1.dispose(), index.dispose()]); + await Promise.all([chunk0.dispose(), chunk1.dispose()]); }, 15_000); test("idle building and production with leaderboard", async (ctx) => { @@ -495,7 +608,6 @@ describe("matchmaking and session patterns", () => { const playerId = crypto.randomUUID(); const world = client.idleWorld.getOrCreate([playerId]).connect(); - // Initialize. await world.initialize({ playerName: "Builder" }); const state1 = await world.getState(); expect(state1.playerName).toBe("Builder"); @@ -503,7 +615,6 @@ describe("matchmaking and session patterns", () => { expect(state1.buildings).toHaveLength(1); expect(state1.buildings[0]!.typeId).toBe("farm"); - // Check leaderboard actor. const lb = client.idleLeaderboard.getOrCreate(["main"]).connect(); await sleep(100); const scores = await lb.getTopScores({ limit: 10 }); @@ -511,179 +622,17 @@ describe("matchmaking and session patterns", () => { await Promise.all([world.dispose(), lb.dispose()]); }, 15_000); - - test("forbidden access control paths are blocked across matchmaking patterns", async (ctx) => { - const { client } = await setupTest(ctx, registry); - - await expectForbidden( - client.ioStyleMatchmaker - .getOrCreate(["main"]) - .send("updateMatch", { matchId: "nope", playerCount: 1 }), - ); - await expectForbidden( - client.arenaMatchmaker - .getOrCreate(["main"]) - .send("matchCompleted", { matchId: "nope" }), - ); - await expectForbidden( - client.partyMatchmaker - .getOrCreate(["main"]) - .send("closeParty", { matchId: "nope" }), - ); - await expectForbidden( - client.turnBasedMatchmaker - .getOrCreate(["main"]) - .send("closeMatch", { matchId: "nope" }), - ); - await expectForbidden( - client.rankedMatchmaker - .getOrCreate(["main"]) - .send("matchCompleted", { - matchId: "nope", - winnerUsername: "a", - loserUsername: "b", - winnerNewRating: 1200, - loserNewRating: 1200, - }), - ); - await expectForbidden( - client.battleRoyaleMatchmaker - .getOrCreate(["main"]) - .send("updateMatch", { - matchId: "nope", - playerCount: 2, - isStarted: false, - }), - ); - - const arenaMm = client.arenaMatchmaker.getOrCreate(["main"]).connect(); - const arenaQueueResult = await arenaMm.send( - "queueForMatch", - { mode: "1v1" }, - { wait: true, timeout: 5_000 }, - ); - const arenaQueueResponse = (arenaQueueResult as { - response?: { playerId: string; registrationToken: string }; - })?.response; - expect(arenaQueueResponse?.playerId).toBeTypeOf("string"); - expect(arenaQueueResponse?.registrationToken).toBeTypeOf("string"); - await expectForbidden( - arenaMm.registerPlayer({ - playerId: arenaQueueResponse!.playerId, - registrationToken: "wrong-registration-token", - }), - ); - await arenaMm.dispose(); - - const rankedMm = client.rankedMatchmaker.getOrCreate(["main"]).connect(); - const rankedQueueResult = await rankedMm.send( - "queueForMatch", - { username: `Forbidden#${crypto.randomUUID()}` }, - { wait: true, timeout: 5_000 }, - ); - const rankedQueueResponse = (rankedQueueResult as { - response?: { registrationToken: string }; - })?.response; - expect(rankedQueueResponse?.registrationToken).toBeTypeOf("string"); - await expectForbidden( - rankedMm.registerPlayer({ - username: `Forbidden#${crypto.randomUUID()}`, - registrationToken: rankedQueueResponse!.registrationToken, - }), - ); - await rankedMm.dispose(); - - const ioMatchId = crypto.randomUUID(); - await client.ioStyleMatch.create([ioMatchId], { input: { matchId: ioMatchId } }); - const ioPlayerToken = crypto.randomUUID(); - await client.ioStyleMatch - .get([ioMatchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ playerId: "io-player", playerToken: ioPlayerToken }); - await expectForbidden( - client.ioStyleMatch - .get([ioMatchId], { params: { playerToken: ioPlayerToken } }) - .createPlayer({ playerId: "other", playerToken: crypto.randomUUID() }), - ); - - const partyMatchId = crypto.randomUUID(); - await client.partyMatch.create([partyMatchId], { - input: { matchId: partyMatchId, partyCode: "ABC123" }, - }); - const partyPlayerToken = crypto.randomUUID(); - await client.partyMatch - .get([partyMatchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ - playerId: "party-player", - playerToken: partyPlayerToken, - playerName: "Party Player", - isHost: true, - }); - await expectForbidden( - client.partyMatch - .get([partyMatchId], { params: { playerToken: partyPlayerToken } }) - .createPlayer({ - playerId: "intruder", - playerToken: crypto.randomUUID(), - playerName: "Intruder", - isHost: false, - }), - ); - - const turnMatchId = crypto.randomUUID(); - await client.turnBasedMatch.create([turnMatchId], { - input: { matchId: turnMatchId }, - }); - const turnPlayerToken = crypto.randomUUID(); - await client.turnBasedMatch - .get([turnMatchId], { params: { internalToken: INTERNAL_TOKEN } }) - .createPlayer({ - playerId: "turn-player", - playerToken: turnPlayerToken, - playerName: "Turn Player", - symbol: "X", - }); - await expectForbidden( - client.turnBasedMatch - .get([turnMatchId], { params: { playerToken: turnPlayerToken } }) - .createPlayer({ - playerId: "intruder", - playerToken: crypto.randomUUID(), - playerName: "Intruder", - symbol: "O", - }), - ); - - const observerChunk = client.openWorldChunk - .getOrCreate(["default", "0", "0"], { params: { observer: "true" } }) - .connect(); - await expectForbidden( - observerChunk.initialize({ worldId: "default", chunkX: 0, chunkY: 0 }), - ); - await expectForbidden(observerChunk.placeBlock({ gridX: 0, gridY: 0 })); - await observerChunk.dispose(); - - await expectForbidden( - client.rankedPlayer - .getOrCreate(["forbidden-player"]) - .applyMatchResult({ won: true, newRating: 1300 }), - ); - await expectForbidden( - client.rankedLeaderboard - .getOrCreate(["main"]) - .updatePlayer({ username: "forbidden", rating: 1200, wins: 1, losses: 0 }), - ); - await expectForbidden( - client.idleLeaderboard - .getOrCreate(["main"]) - .updateScore({ playerId: "x", playerName: "x", totalProduced: 1 }), - ); - - const idleWorld = client.idleWorld.getOrCreate([crypto.randomUUID()]).connect(); - await idleWorld.initialize({ playerName: "Builder" }); - const idleState = await idleWorld.getState(); - await expectForbidden( - idleWorld.collectProduction({ buildingId: idleState.buildings[0]!.id }), - ); - await idleWorld.dispose(); - }, 15_000); }); + +function resolveChunkForPosition( + x: number, + y: number, +): { chunkKey: [string, number, number]; spawnX: number; spawnY: number } { + const chunkX = Math.floor(x / CHUNK_SIZE); + const chunkY = Math.floor(y / CHUNK_SIZE); + return { + chunkKey: [WORLD_ID, chunkX, chunkY], + spawnX: ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + spawnY: ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + }; +} diff --git a/examples/next-js/next.config.ts b/examples/next-js/next.config.ts index 7921f35d74..64626507f1 100644 --- a/examples/next-js/next.config.ts +++ b/examples/next-js/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + serverExternalPackages: ["@rivetkit/sqlite", "@rivetkit/sqlite-vfs"], }; export default nextConfig; diff --git a/rivetkit-typescript/CLAUDE.md b/rivetkit-typescript/CLAUDE.md index ae4c13f358..c87d5c92a9 100644 --- a/rivetkit-typescript/CLAUDE.md +++ b/rivetkit-typescript/CLAUDE.md @@ -3,7 +3,7 @@ ## Tree-Shaking Boundaries - Do not import `@rivetkit/workflow-engine` outside the `rivetkit/workflow` entrypoint so it remains tree-shakeable. -- Do not import SQLite VFS or `wa-sqlite` outside the `rivetkit/db` (or `@rivetkit/sqlite-vfs`) entrypoint so SQLite support remains tree-shakeable. +- Do not import SQLite VFS or `@rivetkit/sqlite` outside the `rivetkit/db` (or `@rivetkit/sqlite-vfs`) entrypoint so SQLite support remains tree-shakeable. - Importing `rivetkit/db` (or `@rivetkit/sqlite-vfs`) is the explicit opt-in for SQLite. Do not lazily load SQLite from `rivetkit/db`; it may be imported eagerly inside that entrypoint. - Core drivers must remain SQLite-agnostic. Any SQLite-specific wiring belongs behind the `rivetkit/db` or `@rivetkit/sqlite-vfs` boundary. diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts index e1dbf4a024..d85c7e881e 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts @@ -166,16 +166,16 @@ export const registry = setup({ workflowStopTeardownActor, // From actor-db-raw.ts dbActorRaw, - // From actor-db-drizzle.ts - dbActorDrizzle, - // From db-lifecycle.ts - dbLifecycle, - dbLifecycleFailing, - dbLifecycleObserver, - // From stateless.ts - statelessActor, - // From access-control.ts - accessControlActor, - accessControlNoQueuesActor, - }, - }); + // From actor-db-drizzle.ts + dbActorDrizzle, + // From db-lifecycle.ts + dbLifecycle, + dbLifecycleFailing, + dbLifecycleObserver, + // From stateless.ts + statelessActor, + // From access-control.ts + accessControlActor, + accessControlNoQueuesActor, + }, +}); diff --git a/rivetkit-typescript/packages/rivetkit/package.json b/rivetkit-typescript/packages/rivetkit/package.json index fe0c527440..556d7c15f0 100644 --- a/rivetkit-typescript/packages/rivetkit/package.json +++ b/rivetkit-typescript/packages/rivetkit/package.json @@ -239,7 +239,7 @@ "tar": "^7.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", - "wa-sqlite": "^1.0.0", + "@rivetkit/sqlite": "^0.1.0", "zod": "^4.1.0" }, "devDependencies": { diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts b/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts index d3492b628f..76fa35d656 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/driver.ts @@ -69,13 +69,16 @@ export interface ActorDriver { ): Promise | undefined>; /** - * SQLite VFS instance for creating KV-backed databases. + * Returns a SQLite VFS instance for creating KV-backed databases. * If not provided, the database provider will need an override. * - * wa-sqlite's async build is not re-entrant per module instance. Drivers - * should scope this instance to a single actor when using KV-backed SQLite. + * @rivetkit/sqlite's async build is not re-entrant per module instance. Drivers + * should return a new instance per call for actor-level isolation. + * + * This is a method (not a property) so drivers can use dynamic imports, + * keeping the core driver tree-shakeable from @rivetkit/sqlite. */ - sqliteVfs?: SqliteVfs; + getSqliteVfs?(): SqliteVfs | Promise; /** * Requests the actor to go to sleep. diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts index b528bde144..beaf013455 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts @@ -1409,10 +1409,12 @@ export class ActorInstance< let client: InferDatabaseClient | undefined; try { - // Every actor gets its own SqliteVfs/wa-sqlite instance. The async - // wa-sqlite build is not re-entrant, and sharing one instance across + // Every actor gets its own SqliteVfs/@rivetkit/sqlite instance. The async + // @rivetkit/sqlite build is not re-entrant, and sharing one instance across // actors can cause cross-actor contention and runtime corruption. - this.#sqliteVfs ??= this.driver.sqliteVfs; + if (!this.#sqliteVfs && this.driver.getSqliteVfs) { + this.#sqliteVfs = await this.driver.getSqliteVfs(); + } client = await this.#config.db.createClient({ actorId: this.#actorId, diff --git a/rivetkit-typescript/packages/rivetkit/src/db/config.ts b/rivetkit-typescript/packages/rivetkit/src/db/config.ts index 0287c4e1a9..2101154bfe 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/config.ts @@ -36,7 +36,7 @@ export interface DatabaseProviderContext { /** * SQLite VFS instance for creating KV-backed databases. - * This should be actor-scoped because wa-sqlite is not re-entrant per + * This should be actor-scoped because @rivetkit/sqlite is not re-entrant per * module instance. */ sqliteVfs?: SqliteVfs; diff --git a/rivetkit-typescript/packages/rivetkit/src/db/drizzle/mod.ts b/rivetkit-typescript/packages/rivetkit/src/db/drizzle/mod.ts index 3582d51103..255af1ac9c 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/drizzle/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/drizzle/mod.ts @@ -56,8 +56,8 @@ function createActorKvStore(kv: { } /** - * Mutex to serialize async operations on a wa-sqlite database handle. - * wa-sqlite is not safe for concurrent operations on the same handle. + * Mutex to serialize async operations on a @rivetkit/sqlite database handle. + * @rivetkit/sqlite is not safe for concurrent operations on the same handle. */ class DbMutex { #locked = false; @@ -89,7 +89,7 @@ class DbMutex { } /** - * Create a sqlite-proxy async callback from a wa-sqlite Database + * Create a sqlite-proxy async callback from a @rivetkit/sqlite Database */ function createProxyCallback( waDb: Database, @@ -126,7 +126,7 @@ function createProxyCallback( } /** - * Run inline migrations via the wa-sqlite Database. + * Run inline migrations via the @rivetkit/sqlite Database. * Migrations use the same embedded format as drizzle-orm's durable-sqlite. */ async function runInlineMigrations( @@ -186,7 +186,7 @@ export function db< >( config?: DatabaseFactoryConfig, ): DatabaseProvider & RawAccess> { - // Store the wa-sqlite Database instance alongside the drizzle client + // Store the @rivetkit/sqlite Database instance alongside the drizzle client let waDbInstance: Database | null = null; const mutex = new DbMutex(); @@ -240,7 +240,7 @@ export function db< }) as TRow[]; } // Use exec for non-parameterized queries since - // wa-sqlite's query() can crash on some statements. + // @rivetkit/sqlite's query() can crash on some statements. const results: Record[] = []; let columnNames: string[] | null = null; await waDb.exec( diff --git a/rivetkit-typescript/packages/rivetkit/src/db/mod.ts b/rivetkit-typescript/packages/rivetkit/src/db/mod.ts index b0bba20c27..7521752f68 100644 --- a/rivetkit-typescript/packages/rivetkit/src/db/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/db/mod.ts @@ -78,8 +78,8 @@ export function db({ let op: Promise = 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. + // Ensure @rivetkit/sqlite calls are not concurrent. Actors can process multiple + // actions concurrently, and @rivetkit/sqlite is not re-entrant. const next = op.then(fn, fn); op = next.then( () => undefined, diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts index 4f44f4cd4e..ee2fdd020e 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/file-system/actor.ts @@ -1,6 +1,6 @@ import type { AnyClient } from "@/client/client"; import type { RawDatabaseClient } from "@/db/config"; -import { SqliteVfs } from "@rivetkit/sqlite-vfs"; +import type { SqliteVfs } from "@rivetkit/sqlite-vfs"; import type { ActorDriver, AnyActorInstance, @@ -82,10 +82,18 @@ export class FileSystemActorDriver implements ActorDriver { } /** SQLite VFS instance for creating KV-backed databases */ - get sqliteVfs(): SqliteVfs { - // The async wa-sqlite build is not re-entrant per module instance. + async getSqliteVfs(): Promise { + // Dynamic import keeps @rivetkit/sqlite out of the main entrypoint bundle, + // preserving tree-shakeability for environments that don't use SQLite. + // The async @rivetkit/sqlite build is not re-entrant per module instance. // Returning a fresh SqliteVfs here gives each actor its own module, // allowing actor-level parallelism without cross-actor re-entry. + // + // The specifier is built via concatenation so that bundlers like + // wrangler's esbuild cannot statically analyze and attempt to + // bundle the module (it is never used on Cloudflare Workers). + const specifier = "@rivetkit/" + "sqlite-vfs"; + const { SqliteVfs } = await import(specifier); return new SqliteVfs(); } diff --git a/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts b/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts index 701229c093..5806aba71f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts +++ b/rivetkit-typescript/packages/rivetkit/src/inspector/actor-inspector.ts @@ -115,7 +115,7 @@ export class ActorInspector { "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle_%'", ) as { name: string; type: string }[]; - // Serialize all queries to avoid concurrent wa-sqlite access + // Serialize all queries to avoid concurrent @rivetkit/sqlite access // which can cause "file is not a database" errors. const tableInfos = []; for (const table of tables) { diff --git a/rivetkit-typescript/packages/rivetkit/src/utils.ts b/rivetkit-typescript/packages/rivetkit/src/utils.ts index cecbe71f59..5c1ebaaded 100644 --- a/rivetkit-typescript/packages/rivetkit/src/utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/utils.ts @@ -1,11 +1,23 @@ -export { stringifyError } from "@/common/utils"; -export { assertUnreachable } from "./common/utils"; +import { stringifyError } from "@/common/utils"; +import type { Context as HonoContext, Handler as HonoHandler } from "hono"; +import { stringify as uuidstringify } from "uuid"; +import pkgJson from "../package.json" with { type: "json" }; +import { getLogger } from "./common/log"; +import { assertUnreachable } from "./common/utils"; + +/** @experimental */ +export { stringifyError }; + +/** @experimental */ +export { assertUnreachable }; /** * Joins multiple abort signals into one. * * The returned signal aborts when the first input signal aborts. * Uses `AbortSignal.any(...)` when available, with a runtime fallback. + * + * @experimental */ export function joinSignals( ...signals: Array @@ -63,6 +75,8 @@ export function joinSignals( /** * Returns a promise that resolves after the given number of milliseconds. + * + * @experimental */ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -78,17 +92,13 @@ export function sleep(ms: number): Promise { * if (c.aborted) break; * // ... game logic * } + * + * @experimental */ export function interval(ms: number): () => Promise { return () => sleep(ms); } -import type { Context as HonoContext, Handler as HonoHandler } from "hono"; -import { stringify as uuidstringify } from "uuid"; -import { stringifyError } from "@/common/utils"; -import pkgJson from "../package.json" with { type: "json" }; -import { getLogger } from "./common/log"; - export const VERSION = pkgJson.version; let _userAgent: string | undefined; @@ -97,6 +107,11 @@ function logger() { return getLogger("utils"); } +/** + * Builds the HTTP user agent used by this library. + * + * @experimental + */ export function httpUserAgent(): string { // Return cached value if already initialized if (_userAgent !== undefined) { @@ -122,6 +137,11 @@ export type UpgradeWebSocket = ( export type GetUpgradeWebSocket = () => UpgradeWebSocket; +/** + * Reads an environment variable from Deno or Node runtimes. + * + * @experimental + */ export function getEnvUniversal(key: string): string | undefined { if (typeof Deno !== "undefined") { return Deno.env.get(key); @@ -131,6 +151,11 @@ export function getEnvUniversal(key: string): string | undefined { } } +/** + * Traces a debug value and returns it. + * + * @experimental + */ export function dbg(x: T): T { console.trace(`=== DEBUG ===\n${x}`); return x; @@ -142,6 +167,8 @@ export function dbg(x: T): T { * * @param data - The ArrayBuffer or ArrayBufferView to convert * @returns A Uint8Array view of the data + * + * @experimental */ export function toUint8Array(data: ArrayBuffer | ArrayBufferView): Uint8Array { if (data instanceof Uint8Array) { @@ -175,6 +202,8 @@ export type LongTimeoutHandle = { abort: () => void }; * Polyfill for Promise.withResolvers(). * * This is specifically for Cloudflare Workers. Their implementation of Promise.withResolvers does not work correctly. + * + * @experimental */ export function promiseWithResolvers(onReject: (reason?: any) => void): { promise: Promise; @@ -191,6 +220,11 @@ export function promiseWithResolvers(onReject: (reason?: any) => void): { return { promise, resolve, reject }; } +/** + * Sets a timeout that supports delays larger than the JavaScript timer limit. + * + * @experimental + */ export function setLongTimeout( listener: () => void, after: number, @@ -282,6 +316,11 @@ export class SinglePromiseQueue { } } +/** + * Converts a Buffer or Uint8Array into an ArrayBuffer view. + * + * @experimental + */ export function bufferToArrayBuffer(buf: Buffer | Uint8Array): ArrayBuffer { return buf.buffer.slice( buf.byteOffset, @@ -304,6 +343,8 @@ export function bufferToArrayBuffer(buf: Buffer | Uint8Array): ArrayBuffer { * @param path The path to append to the endpoint (may include query parameters) * @param queryParams Optional additional query parameters to append * @returns The properly combined URL string + * + * @experimental */ export function combineUrlPath( endpoint: string, @@ -342,6 +383,11 @@ export function combineUrlPath( return `${baseUrl.protocol}//${baseUrl.host}${fullPath}${fullQuery}`; } +/** + * Compares two ArrayBuffer values by byte content. + * + * @experimental + */ export function arrayBuffersEqual( buf1: ArrayBuffer, buf2: ArrayBuffer, @@ -365,6 +411,11 @@ export const EXTRA_ERROR_LOG = { export type Runtime = "deno" | "bun" | "node"; +/** + * Detects the current JavaScript runtime from the user agent. + * + * @experimental + */ export function detectRuntime(): Runtime { const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : ""; diff --git a/rivetkit-typescript/packages/sqlite-vfs/package.json b/rivetkit-typescript/packages/sqlite-vfs/package.json index 58edbf192d..5fb868d7d7 100644 --- a/rivetkit-typescript/packages/sqlite-vfs/package.json +++ b/rivetkit-typescript/packages/sqlite-vfs/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@rivetkit/bare-ts": "^0.6.2", - "wa-sqlite": "^1.0.0", + "@rivetkit/sqlite": "^0.1.0", "vbare": "^0.0.4" }, "devDependencies": { diff --git a/rivetkit-typescript/packages/sqlite-vfs/src/vfs.ts b/rivetkit-typescript/packages/sqlite-vfs/src/vfs.ts index ddca21c907..d667421b1e 100644 --- a/rivetkit-typescript/packages/sqlite-vfs/src/vfs.ts +++ b/rivetkit-typescript/packages/sqlite-vfs/src/vfs.ts @@ -6,12 +6,12 @@ * used concurrently with other instances. */ -// Note: wa-sqlite VFS.Base type definitions have incorrect types for xRead/xWrite +// Note: @rivetkit/sqlite VFS.Base type definitions have incorrect types for xRead/xWrite // The actual runtime uses Uint8Array, not the {size, value} object shown in types -import * as VFS from "wa-sqlite/src/VFS.js"; +import * as VFS from "@rivetkit/sqlite/src/VFS.js"; -import SQLiteESMFactory from "wa-sqlite/dist/wa-sqlite-async.mjs"; -import { Factory } from "wa-sqlite"; +import SQLiteESMFactory from "@rivetkit/sqlite/dist/wa-sqlite-async.mjs"; +import { Factory } from "@rivetkit/sqlite"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { CHUNK_SIZE, getMetaKey, getChunkKey } from "./kv"; @@ -57,7 +57,7 @@ function decodeFileMeta(data: Uint8Array): number { /** * SQLite API interface (subset needed for VFS registration) - * This is part of wa-sqlite but not exported in TypeScript types + * This is part of @rivetkit/sqlite but not exported in TypeScript types */ interface SQLite3Api { vfs_register: (vfs: unknown, makeDefault?: boolean) => number; @@ -88,7 +88,7 @@ interface SQLite3Api { /** * Simple async mutex for serializing database operations - * wa-sqlite calls are not safe to run concurrently on one module instance + * @rivetkit/sqlite calls are not safe to run concurrently on one module instance */ class AsyncMutex { #locked = false; @@ -188,7 +188,7 @@ export class Database { } /** - * Get the raw wa-sqlite API (for advanced usage) + * Get the raw @rivetkit/sqlite API (for advanced usage) */ get sqlite3(): SQLite3Api { return this.#sqlite3; @@ -205,7 +205,7 @@ export class Database { /** * SQLite VFS backed by KV storage. * - * Each instance is independent and has its own wa-sqlite WASM module. + * Each instance is independent and has its own @rivetkit/sqlite WASM module. * This allows multiple instances to operate concurrently without interference. */ export class SqliteVfs { @@ -222,7 +222,7 @@ export class SqliteVfs { } /** - * Initialize wa-sqlite and VFS (called once per instance) + * Initialize @rivetkit/sqlite and VFS (called once per instance) */ async #ensureInitialized(): Promise { // Fast path: already initialized @@ -235,10 +235,10 @@ export class SqliteVfs { this.#initPromise = (async () => { // Load WASM binary (Node.js environment) const require = createRequire(import.meta.url); - const wasmPath = require.resolve("wa-sqlite/dist/wa-sqlite-async.wasm"); + const wasmPath = require.resolve("@rivetkit/sqlite/dist/wa-sqlite-async.wasm"); const wasmBinary = readFileSync(wasmPath); - // Initialize wa-sqlite module - each instance gets its own module + // Initialize @rivetkit/sqlite module - each instance gets its own module const module = await SQLiteESMFactory({ wasmBinary }); this.#sqlite3 = Factory(module) as unknown as SQLite3Api; @@ -266,7 +266,7 @@ export class SqliteVfs { // Serialize all open operations within this instance await this.#openMutex.acquire(); try { - // Initialize wa-sqlite and SqliteSystem on first call + // Initialize @rivetkit/sqlite and SqliteSystem on first call await this.#ensureInitialized(); if (!this.#sqlite3 || !this.#sqliteSystem) { diff --git a/rivetkit-typescript/packages/sqlite-vfs/src/wa-sqlite.d.ts b/rivetkit-typescript/packages/sqlite-vfs/src/wa-sqlite.d.ts index 9492d96a6f..eeb81a13b9 100644 --- a/rivetkit-typescript/packages/sqlite-vfs/src/wa-sqlite.d.ts +++ b/rivetkit-typescript/packages/sqlite-vfs/src/wa-sqlite.d.ts @@ -1,8 +1,8 @@ -declare module "wa-sqlite" { +declare module "@rivetkit/sqlite" { export function Factory(module: any): any; } -declare module "wa-sqlite/src/VFS.js" { +declare module "@rivetkit/sqlite/src/VFS.js" { export class Base { handleAsync(fn: () => Promise): number; } @@ -20,7 +20,7 @@ declare module "wa-sqlite/src/VFS.js" { export const SQLITE_OPEN_READWRITE: number; } -declare module "wa-sqlite/dist/wa-sqlite-async.mjs" { +declare module "@rivetkit/sqlite/dist/wa-sqlite-async.mjs" { const factory: (config?: { wasmBinary?: ArrayBuffer }) => Promise; export default factory; } diff --git a/website/src/components/Code.jsx b/website/src/components/Code.jsx index c51a9d5c62..3ff514110d 100644 --- a/website/src/components/Code.jsx +++ b/website/src/components/Code.jsx @@ -1,326 +1,326 @@ -"use client"; -import { Tab } from "@headlessui/react"; -import clsx from "clsx"; -import { - Children, - createContext, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import { create } from "zustand"; - -import { Tag } from "@/components/Tag"; - -const languageNames = { - csharp: "C#", - cpp: "C++", - go: "Go", - js: "JavaScript", - json: "JSON", - php: "PHP", - python: "Python", - ruby: "Ruby", - ts: "TypeScript", - yaml: "YAML", - gdscript: "GDScript", - docker: "Docker", - rust: "Rust", -}; - -function getPanelTitle({ title, language }) { - return title ?? languageNames[language] ?? "Code"; -} - -function ClipboardIcon(props) { - return ( - - ); -} - -function CopyButton({ code }) { - const [copyCount, setCopyCount] = useState(0); - const copied = copyCount > 0; - - useEffect(() => { - if (copyCount > 0) { - const timeout = setTimeout(() => setCopyCount(0), 1000); - return () => { - clearTimeout(timeout); - }; - } - }, [copyCount]); - - return ( - - ); -} - -function CodePanelHeader({ tag, label }) { - if (!tag && !label) { - return null; - } - - return ( -
- {tag && ( -
- {tag} -
- )} - {tag && label && ( - - )} - {label && ( - - {label} - - )} -
- ); -} - -function CodePanel({ tag, label, code, children }) { - if (!children) { - return null; - } - const child = Children.only(children); - - return ( -
- -
-
-					{children}
-				
- -
-
- ); -} - -function CodeGroupHeader({ title, children, selectedIndex }) { - const hasTabs = Children.count(children) > 1; - - if (!title && !hasTabs) { - return null; - } - - return ( -
- {title && ( -

- {title} -

- )} - {hasTabs && ( - - {Children.map(children, (child, childIndex) => { - return ( - - {getPanelTitle(child.props)} - - ); - })} - - )} -
- ); -} - -function CodeGroupPanels({ children, ...props }) { - const hasTabs = Children.count(children) > 1; - - if (hasTabs) { - return ( - - {Children.map(children, (child) => ( - - {child} - - ))} - - ); - } - - return {children}; -} - -function usePreventLayoutShift() { - const positionRef = useRef(); - const rafRef = useRef(); - - useEffect(() => { - return () => { - window.cancelAnimationFrame(rafRef.current); - }; - }, []); - - return { - positionRef, - preventLayoutShift(callback) { - const initialTop = positionRef.current.getBoundingClientRect().top; - - callback(); - - rafRef.current = window.requestAnimationFrame(() => { - const newTop = positionRef.current.getBoundingClientRect().top; - window.scrollBy(0, newTop - initialTop); - }); - }, - }; -} - -const usePreferredLanguageStore = create((set) => ({ - preferredLanguages: [], - addPreferredLanguage: (language) => - set((state) => ({ - preferredLanguages: [ - ...state.preferredLanguages.filter( - (preferredLanguage) => preferredLanguage !== language, - ), - language, - ], - })), -})); - -function useTabGroupProps(availableLanguages) { - const { preferredLanguages, addPreferredLanguage } = - usePreferredLanguageStore(); - const [selectedIndex, setSelectedIndex] = useState(0); - const activeLanguage = [...availableLanguages].sort( - (a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a), - )[0]; - const languageIndex = availableLanguages.indexOf(activeLanguage); - const newSelectedIndex = - languageIndex === -1 ? selectedIndex : languageIndex; - if (newSelectedIndex !== selectedIndex) { - setSelectedIndex(newSelectedIndex); - } - - const { positionRef, preventLayoutShift } = usePreventLayoutShift(); - - return { - as: "div", - ref: positionRef, - selectedIndex, - onChange: (newSelectedIndex) => { - preventLayoutShift(() => - addPreferredLanguage(availableLanguages[newSelectedIndex]), - ); - }, - }; -} - -const CodeGroupContext = createContext(false); - -export function CodeGroup({ children, title, ...props }) { - const languages = Children.map(children, (child) => - getPanelTitle(child.props), - ); - const tabGroupProps = useTabGroupProps(languages); - const hasTabs = Children.count(children) > 1; - const Container = hasTabs ? Tab.Group : "div"; - const containerProps = hasTabs ? tabGroupProps : {}; - const headerProps = hasTabs - ? { selectedIndex: tabGroupProps.selectedIndex } - : {}; - - return ( - - - - {children} - - {children} - - - ); -} - -export function EphermeralTab({ children, ...props }) { - return <>{children}; -} - -export function Code({ children, ...props }) { - const isGrouped = useContext(CodeGroupContext); - - if (isGrouped) { - return ( - - ); - } - - return ( - - {children} - - ); -} - -export function Pre({ children, ...props }) { - const isGrouped = useContext(CodeGroupContext); - - if (isGrouped) { - return children; - } - - return {children}; -} +// "use client"; +// import { Tab } from "@headlessui/react"; +// import clsx from "clsx"; +// import { +// Children, +// createContext, +// useContext, +// useEffect, +// useRef, +// useState, +// } from "react"; +// import { create } from "zustand"; +// +// import { Tag } from "@/components/Tag"; +// +// const languageNames = { +// csharp: "C#", +// cpp: "C++", +// go: "Go", +// js: "JavaScript", +// json: "JSON", +// php: "PHP", +// python: "Python", +// ruby: "Ruby", +// ts: "TypeScript", +// yaml: "YAML", +// gdscript: "GDScript", +// docker: "Docker", +// rust: "Rust", +// }; +// +// function getPanelTitle({ title, language }) { +// return title ?? languageNames[language] ?? "Code"; +// } +// +// function ClipboardIcon(props) { +// return ( +// +// ); +// } +// +// function CopyButton({ code }) { +// const [copyCount, setCopyCount] = useState(0); +// const copied = copyCount > 0; +// +// useEffect(() => { +// if (copyCount > 0) { +// const timeout = setTimeout(() => setCopyCount(0), 1000); +// return () => { +// clearTimeout(timeout); +// }; +// } +// }, [copyCount]); +// +// return ( +// +// ); +// } +// +// function CodePanelHeader({ tag, label }) { +// if (!tag && !label) { +// return null; +// } +// +// return ( +//
+// {tag && ( +//
+// {tag} +//
+// )} +// {tag && label && ( +// +// )} +// {label && ( +// +// {label} +// +// )} +//
+// ); +// } +// +// function CodePanel({ tag, label, code, children }) { +// if (!children) { +// return null; +// } +// const child = Children.only(children); +// +// return ( +//
+// +//
+//
+// 					{children}
+// 				
+// +//
+//
+// ); +// } +// +// function CodeGroupHeader({ title, children, selectedIndex }) { +// const hasTabs = Children.count(children) > 1; +// +// if (!title && !hasTabs) { +// return null; +// } +// +// return ( +//
+// {title && ( +//

+// {title} +//

+// )} +// {hasTabs && ( +// +// {Children.map(children, (child, childIndex) => { +// return ( +// +// {getPanelTitle(child.props)} +// +// ); +// })} +// +// )} +//
+// ); +// } +// +// function CodeGroupPanels({ children, ...props }) { +// const hasTabs = Children.count(children) > 1; +// +// if (hasTabs) { +// return ( +// +// {Children.map(children, (child) => ( +// +// {child} +// +// ))} +// +// ); +// } +// +// return {children}; +// } +// +// function usePreventLayoutShift() { +// const positionRef = useRef(); +// const rafRef = useRef(); +// +// useEffect(() => { +// return () => { +// window.cancelAnimationFrame(rafRef.current); +// }; +// }, []); +// +// return { +// positionRef, +// preventLayoutShift(callback) { +// const initialTop = positionRef.current.getBoundingClientRect().top; +// +// callback(); +// +// rafRef.current = window.requestAnimationFrame(() => { +// const newTop = positionRef.current.getBoundingClientRect().top; +// window.scrollBy(0, newTop - initialTop); +// }); +// }, +// }; +// } +// +// const usePreferredLanguageStore = create((set) => ({ +// preferredLanguages: [], +// addPreferredLanguage: (language) => +// set((state) => ({ +// preferredLanguages: [ +// ...state.preferredLanguages.filter( +// (preferredLanguage) => preferredLanguage !== language, +// ), +// language, +// ], +// })), +// })); +// +// function useTabGroupProps(availableLanguages) { +// const { preferredLanguages, addPreferredLanguage } = +// usePreferredLanguageStore(); +// const [selectedIndex, setSelectedIndex] = useState(0); +// const activeLanguage = [...availableLanguages].sort( +// (a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a), +// )[0]; +// const languageIndex = availableLanguages.indexOf(activeLanguage); +// const newSelectedIndex = +// languageIndex === -1 ? selectedIndex : languageIndex; +// if (newSelectedIndex !== selectedIndex) { +// setSelectedIndex(newSelectedIndex); +// } +// +// const { positionRef, preventLayoutShift } = usePreventLayoutShift(); +// +// return { +// as: "div", +// ref: positionRef, +// selectedIndex, +// onChange: (newSelectedIndex) => { +// preventLayoutShift(() => +// addPreferredLanguage(availableLanguages[newSelectedIndex]), +// ); +// }, +// }; +// } +// +// const CodeGroupContext = createContext(false); +// +// export function CodeGroup({ children, title, ...props }) { +// const languages = Children.map(children, (child) => +// getPanelTitle(child.props), +// ); +// const tabGroupProps = useTabGroupProps(languages); +// const hasTabs = Children.count(children) > 1; +// const Container = hasTabs ? Tab.Group : "div"; +// const containerProps = hasTabs ? tabGroupProps : {}; +// const headerProps = hasTabs +// ? { selectedIndex: tabGroupProps.selectedIndex } +// : {}; +// +// return ( +// +// +// +// {children} +// +// {children} +// +// +// ); +// } +// +// export function EphermeralTab({ children, ...props }) { +// return <>{children}; +// } +// +// export function Code({ children, ...props }) { +// const isGrouped = useContext(CodeGroupContext); +// +// if (isGrouped) { +// return ( +// +// ); +// } +// +// return ( +// +// {children} +// +// ); +// } +// +// export function Pre({ children, ...props }) { +// const isGrouped = useContext(CodeGroupContext); +// +// if (isGrouped) { +// return children; +// } +// +// return {children}; +// } diff --git a/website/src/components/DocsTableOfContents.tsx b/website/src/components/DocsTableOfContents.tsx index 2173ebb3e0..adfcb9751d 100644 --- a/website/src/components/DocsTableOfContents.tsx +++ b/website/src/components/DocsTableOfContents.tsx @@ -5,7 +5,6 @@ import { cn } from "@rivet-gg/components"; import { motion } from "framer-motion"; import { useCallback, useRef, useState } from "react"; import { useEffect } from "react"; -import { Newsletter } from "./Newsletter"; const HEADER_HEIGHT = remToPx(6.5); // const SCROLL_MARGIN = remToPx(9 /* scroll-mt-header-offset */ - HEADER_HEIGHT); @@ -163,12 +162,10 @@ interface DocsTableOfContentsProps { // biome-ignore lint/suspicious/noExplicitAny: FIXME tableOfContents: any; className?: string; - showNewsletter?: boolean; } export function DocsTableOfContents({ tableOfContents: providedToc, className, - showNewsletter = true, }: DocsTableOfContentsProps) { const tableOfContents = providedToc; @@ -200,7 +197,6 @@ export function DocsTableOfContents({
- {showNewsletter && }
diff --git a/website/src/components/Header.jsx b/website/src/components/Header.jsx index ea8d9e3e99..b900fc4fdf 100644 --- a/website/src/components/Header.jsx +++ b/website/src/components/Header.jsx @@ -228,7 +228,7 @@ export const Header = forwardRef(function Header( - Docs + Documentation Changelog diff --git a/website/src/components/MermaidScript.astro b/website/src/components/MermaidScript.astro index db3a67f0f2..d506e92a41 100644 --- a/website/src/components/MermaidScript.astro +++ b/website/src/components/MermaidScript.astro @@ -4,9 +4,45 @@ --- -``` - -### Step 3: Load messages and listen for updates - -Update your init function and add the addMessage helper function: - -```html - -``` - -### Step 4: Handle sending messages - -Add the form submit handler to your init function: - -```html - -``` - - -```html - - - - Rivet Chat Room - - - - -

Rivet Chat Room

-
    -
    - - -
    - - -``` -
    - diff --git a/website/src/content/posts/2025-12-03-ai-generated-backends/page.mdx b/website/src/content/posts/2025-12-03-ai-generated-backends/page.mdx index febded1576..a31c84bfca 100644 --- a/website/src/content/posts/2025-12-03-ai-generated-backends/page.mdx +++ b/website/src/content/posts/2025-12-03-ai-generated-backends/page.mdx @@ -28,7 +28,7 @@ Rivet Actors solve this by unifying state and logic in a single actor definition -```typescript registry.ts +```typescript actors.ts export const user = actor({ // State is defined alongside behavior createState: (c, input) => ({ diff --git a/website/src/layouts/DocsLayout.astro b/website/src/layouts/DocsLayout.astro index 762a09f2f9..945e794b64 100644 --- a/website/src/layouts/DocsLayout.astro +++ b/website/src/layouts/DocsLayout.astro @@ -14,7 +14,7 @@ const { title, description, canonicalUrl } = Astro.props;
    -
    +
    diff --git a/website/src/layouts/LearnLayout.astro b/website/src/layouts/LearnLayout.astro index a6a21b4a3e..d70c62d909 100644 --- a/website/src/layouts/LearnLayout.astro +++ b/website/src/layouts/LearnLayout.astro @@ -69,7 +69,6 @@ const pageTitle = title.replace(/ - Rivet$/, ''); diff --git a/website/src/mdx/rehype.ts b/website/src/mdx/rehype.ts index 2bf134f2e2..ce7d2eb194 100644 --- a/website/src/mdx/rehype.ts +++ b/website/src/mdx/rehype.ts @@ -18,8 +18,34 @@ function rehypeParseCodeBlocks() { node.properties.className[0]?.replace(/^language-/, ""); } - // Parse annotations - supports both old JSON format and new space-separated format - const info = parentNode.properties?.annotation || node.data; + // Parse annotations from either mdx-annotations or plain code fence metastring. + const infoCandidates = [ + parentNode.properties?.annotation, + parentNode.properties?.metastring, + parentNode.properties?.meta, + node.properties?.annotation, + node.properties?.metastring, + node.properties?.meta, + ]; + const nodeData = + node.data && typeof node.data === "object" + ? (node.data as { + meta?: unknown; + metastring?: unknown; + metaString?: unknown; + }) + : null; + if (nodeData) { + infoCandidates.push( + nodeData.meta, + nodeData.metastring, + nodeData.metaString, + ); + } + const info = infoCandidates.find( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ); if (info && typeof info === "string") { const trimmed = info.trim(); @@ -59,6 +85,26 @@ function rehypeParseCodeBlocks() { /** @type {import("shiki").Highlighter} */ let highlighter; +function normalizeClassNames(value: unknown): string[] { + if (typeof value === "string") return value.split(/\s+/).filter(Boolean); + if (Array.isArray(value)) { + return value.flatMap((entry) => normalizeClassNames(entry)); + } + return []; +} + +function looksLikeMermaid(code: string): boolean { + const firstLine = code + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (!firstLine) return false; + + return /^(sequenceDiagram|flowchart|graph|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|mindmap|timeline|gitGraph|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment)\b/.test( + firstLine, + ); +} + function rehypeShiki() { return async (tree) => { highlighter ??= await shiki.getSingletonHighlighter({ @@ -94,6 +140,22 @@ function rehypeShiki() { ) { const codeNode = node.children[0]; const textNode = codeNode.children[0]; + const preClassNames = normalizeClassNames(node.properties.className); + const codeClassNames = normalizeClassNames(codeNode.properties?.className); + const isMermaid = + preClassNames.includes("mermaid") || + codeClassNames.includes("mermaid") || + codeClassNames.includes("language-mermaid") || + looksLikeMermaid(textNode.value); + + if (isMermaid) { + node.properties.language = "mermaid"; + node.properties.className = Array.from( + new Set([...preClassNames, "mermaid"]), + ); + node.properties.code = textNode.value; + return; + } node.properties.code = textNode.value; diff --git a/website/src/mdx/remark.ts b/website/src/mdx/remark.ts index 96ef1b36a5..d38b8e06ac 100644 --- a/website/src/mdx/remark.ts +++ b/website/src/mdx/remark.ts @@ -1,6 +1,7 @@ import { mdxAnnotations } from "mdx-annotations"; import remarkGfm from "remark-gfm"; import { execSync } from "child_process"; +import { visit } from "unist-util-visit"; // Remark plugin to add last modified time from git history function remarkModifiedTime() { @@ -26,4 +27,38 @@ function remarkModifiedTime() { }; } -export const remarkPlugins = [mdxAnnotations.remark, remarkGfm, remarkModifiedTime]; +// Preserve plain code fence metastrings (for example: ```ts registry.ts @hide) on hProperties. +// mdx-annotations only consumes JSON-like annotation blocks, so we bridge remaining metastrings +// using a neutral property that does not go through mdx-annotations' recma parser. +function remarkCodeFenceMetaToAnnotation() { + return (tree: unknown) => { + visit(tree, "code", (node: unknown) => { + const code = node as { + meta?: string | null; + data?: { + hProperties?: Record; + }; + }; + const meta = code.meta?.trim(); + if (!meta) return; + + const data = (code.data ??= {}); + const hProperties = (data.hProperties ??= {}); + const existingMetaString = hProperties.metastring; + + if ( + typeof existingMetaString !== "string" || + existingMetaString.trim().length === 0 + ) { + hProperties.metastring = meta; + } + }); + }; +} + +export const remarkPlugins = [ + mdxAnnotations.remark, + remarkCodeFenceMetaToAnnotation, + remarkGfm, + remarkModifiedTime, +]; diff --git a/website/src/metadata/skill-base-rivetkit-cookbook.md b/website/src/metadata/skill-base-rivetkit-cookbook.md index b9f8f76daf..28b54c347c 100644 --- a/website/src/metadata/skill-base-rivetkit-cookbook.md +++ b/website/src/metadata/skill-base-rivetkit-cookbook.md @@ -1,20 +1,9 @@ -# RivetKit Guide +# -Before you implement anything: +**IMPORTANT: Before doing anything, you MUST read `BASE_SKILL.md` in this skill's directory. It contains essential guidance on debugging, error handling, state management, deployment, and project setup. Those rules and patterns apply to all RivetKit work. Everything below assumes you have already read and understood it.** -1. Ensure the `rivetkit` skill is installed. - - If it is already installed, do not reinstall it. - - If it is not installed, install it non-interactively and only for the current agent: - - `npx --yes skills add rivet-dev/skills --skill rivetkit --agent -y` - - Replace `` with the current agent (e.g. `claude-code`, `cursor`, `windsurf`). -2. Read `rivetkit/SKILL.md` first (and any relevant references). - -If `rivetkit` is not installed, instruct the user to install it, then stop and wait before proceeding. - -## Error Handling Policy + -- Prefer fail-fast behavior by default. -- Avoid `try/catch` unless absolutely needed for recovery, cleanup, or adding context. -- Never swallow errors. If a `catch` exists, handle the error explicitly, at minimum by logging it. +## Reference Map - + diff --git a/website/src/metadata/skills.ts b/website/src/metadata/skills.ts index d08f780aff..0cc2bcd56d 100644 --- a/website/src/metadata/skills.ts +++ b/website/src/metadata/skills.ts @@ -37,6 +37,7 @@ type SkillConfig = { content: SkillContentSource; includeReferences: boolean; includeOpenApi: boolean; + baseSkillId?: SkillId; }; const BASE_SKILL_CONFIGS = { @@ -161,7 +162,7 @@ function cookbookSkillIdFromEntryId(entryId: string) { if (!flattened) { throw new Error(`cookbook entry id resolved to empty slug: ${entryId}`); } - return `rivetkit-${flattened}`; + return flattened; } async function getCookbookSkillConfigs(): Promise> { @@ -189,8 +190,9 @@ async function getCookbookSkillConfigs(): Promise> { collection: "cookbook", docId: entry.id, }, - includeReferences: false, - includeOpenApi: false, + includeReferences: true, + includeOpenApi: true, + baseSkillId: "rivetkit", }); } @@ -243,7 +245,13 @@ export async function listSkillReferences(skillId: SkillId): Promise entry.data.skill); - const references = skillDocs.map((entry) => buildReference(entry)); + const references: SkillReference[] = skillDocs.map((entry) => buildReference(entry)); + + const cookbookEntries = await getCookbook(); + for (const entry of cookbookEntries) { + references.push(buildCookbookReference(entry)); + } + references.sort((a, b) => a.title.localeCompare(b.title)); cachedReferences.set(skillId, references); return references; @@ -275,6 +283,11 @@ export async function renderSkillFile(skillId: SkillId): Promise { let fileBody = base.replace("", content); + if (base.includes("")) { + const title = await resolveContentTitle(config); + fileBody = fileBody.replace("", title); + } + if (base.includes("")) { if (!config.includeReferences) { throw new Error(`skill base for ${config.id} includes a reference index but references are disabled`); @@ -330,6 +343,39 @@ function buildReference(entry: Awaited>[number] }; } +function buildCookbookReference(entry: Awaited>[number]): SkillReference { + const rawSlug = normalizeSlug(entry.id); + const slug = `cookbook/${rawSlug}`; + const fileId = slug; + const canonicalUrl = `https://rivet.dev/cookbook/${rawSlug}`; + const body = entry.body ?? ""; + + return { + slug, + fileId, + title: entry.data.title, + description: entry.data.description, + docPath: `/cookbook/${rawSlug}`, + canonicalUrl, + sourcePath: entry.filePath ?? null, + tags: slug.split("/").filter(Boolean), + markdown: convertDocToReference(body), + }; +} + +async function resolveContentTitle(config: SkillConfig): Promise { + const collection = config.content.collection; + const entries = collection === "docs" ? await getDocs() : await getCookbook(); + const docIds = [config.content.docId, ...(config.content.fallbackDocIds ?? [])]; + const doc = entries.find((entry) => + docIds.some((docId) => entry.id === docId || (docId.includes("/") && entry.id.startsWith(`${docId}/`))), + ); + if (!doc) { + throw new Error(`Doc ${config.content.docId} not found when resolving title.`); + } + return doc.data.title; +} + async function buildSkillContent(config: SkillConfig) { const collection = config.content.collection; const entries = collection === "docs" ? await getDocs() : await getCookbook(); diff --git a/website/src/pages/cookbook/[...slug].astro b/website/src/pages/cookbook/[...slug].astro index 2643acad00..70355898a4 100644 --- a/website/src/pages/cookbook/[...slug].astro +++ b/website/src/pages/cookbook/[...slug].astro @@ -21,7 +21,7 @@ export async function getStaticPaths() { } const { entry } = Astro.props; -const { Content } = await render(entry); +const { Content, headings } = await render(entry); const { title, description, templates } = entry.data as { title: string; @@ -29,8 +29,21 @@ const { title, description, templates } = entry.data as { templates?: string[]; }; +// Build table of contents from headings (same pattern as docs) +const tableOfContents = headings + .filter((h) => h.depth === 2 || h.depth === 3) + .reduce((acc, h) => { + if (h.depth === 2) { + acc.push({ title: h.text, id: h.slug, children: [] }); + } else if (acc.length > 0) { + acc[acc.length - 1].children.push({ title: h.text, id: h.slug, children: [] }); + } + return acc; + }, [] as Array<{ title: string; id: string; children: Array<{ title: string; id: string; children: never[] }> }>); + const slugPath = entry.id.replace(/\/index$/, ""); const canonicalUrl = `https://rivet.dev/cookbook/${slugPath}/`; +const skillId = `rivetkit-${slugPath.replaceAll("/", "-")}`; function resolveTemplates(names?: string[]): Array