diff --git a/.agent/friction/rivetkit.md b/.agent/friction/rivetkit.md new file mode 100644 index 0000000000..d384d77bee --- /dev/null +++ b/.agent/friction/rivetkit.md @@ -0,0 +1,14 @@ +# RivetKit Friction Log + +This file tracks recurring friction points encountered while designing and documenting RivetKit game examples. + +## 2026-02-13 + +- `openspec/` exists but contains no example artifacts yet (no existing spec templates to mirror directly). +- Repo guidance says notes should live under `.agents/notes/...`, but this task requires `.agent/notes/...` and `.agent/friction/...`. +- `examples/sqlite-drizzle` appears stubbed/commented; for SQLite persistence examples, `examples/sqlite-raw` is the clearest reference pattern. +- Requirement tension: "players must not be able to cheat location" vs "prefer client-authoritative for fast-paced games". Specs will use client-authoritative movement with simple caps/rate limits and server-side sanity checks, not full anti-cheat. +- Using `c.conn.id` as identity makes \"per-player persistent world\" ambiguous across reconnects; idle spec treats a player as a session (with an optional token extension). +- Requested deletion of `examples/multiplayer-game` while this phase is "specs only" can leave the auto-generated `examples/multiplayer-game-vercel` out of sync until regeneration. +- Local deletion via shell commands was blocked by policy; removed `examples/multiplayer-game` tracked files via patch deletes instead (untracked `node_modules/` may still exist on disk). +- Matchmakers using SQLite introduces the need for stale-entry cleanup (missed close events) and simple indexing queries; specs include `cleanupStale*` scheduled actions to keep tables bounded. diff --git a/.agent/notes/games/game-examples-issues.md b/.agent/notes/games/game-examples-issues.md new file mode 100644 index 0000000000..a54a6c26f1 --- /dev/null +++ b/.agent/notes/games/game-examples-issues.md @@ -0,0 +1,175 @@ +# Game Examples: Issues Encountered + +Last updated: 2026-02-13 + +This is a running log of issues we hit while iterating on the `examples/game-*` projects, plus any context needed to reproduce and fix them. + +## Runtime Issues + +- `examples/game-fps-arena`: blank/black screen when joining match. + - Symptom: UI renders, tick increments, but viewport stays blank and only shows "click to lock". + - Fix applied: ensure the app root fills the viewport (`#root { height: 100% }`) and fix menu/game layering so the WebGL canvas is visible. + +- `examples/game-fps-arena`: input axes mismatch. + - Symptom: forward/backwards inverted, yaw (left/right look) inverted, pitch OK. + - Fix applied: correct forward/right vectors and add a client/server yaw convention conversion. + +- `examples/game-fps-arena`: jerky camera rotation and movement. + - Symptom: client looks jittery as if server corrections are fighting the client. + - Likely causes to revisit: + - too-aggressive server authority without client prediction/smoothing + - sending inputs at a low rate without interpolation + - applying server snapshots directly to the local player without reconciliation + +- `examples/game-fps-arena`: holding space causes repeated hopping. + - Symptom: player auto-hops repeatedly when space is held. + - Fix applied: edge-trigger jump on keydown while grounded, and send `jump` to the server only once per press. + +- `examples/game-fps-arena`: bullets/tracers initially too small and POV-originated. + - Symptom: tracer is hard to see; visually originates from camera center rather than the gun. + - Fix applied: render tracer with a blocky head + long semi-transparent streak, and spawn the visual tracer from a gun muzzle offset from the camera. + - Note: server hit validation remains eye-based hitscan, while visuals originate at the muzzle for game feel. + +- `examples/game-fps-arena`: other players' jumps not visible. + - Symptom: local jump could be client-only; remote players never appear airborne. + - Fix applied: add server-side jump velocity/gravity state and replicate. + +- `examples/game-fps-arena`: obstacle collisions not working. + - Symptom: player passes through boxes or collision differs between client and server. + - Fix applied: shared obstacle layout and shared circle-vs-AABB resolution logic used by both client and server. + +- `examples/game-fps-arena`: map visuals were box/texture-based and did not match the new grid-based collision world. + - Symptom: world was still rendered as colored cubes with prototype textures, while server/client physics had moved to a tile/grid map. + - Fix applied: render the arena using Kenney `prototype-kit` GLB tiles (floors, walls, thick platform tiles, narrow stairs, doorway indicators) driven by `WORLD_GRID`/`WORLD_RAMPS`. + - Notes: + - Prototype textures are no longer used for this example. + - Keep a small negative-Y base plane under the tiles to avoid gaps and z-fighting. + +- `examples/game-fps-arena`: deterministic tests got flaky when the map introduced a raised mid platform. + - Symptom: vitest hitscan test could fail or time out depending on spawn/obstacles (straight-line move could get blocked by the mid platform). + - Fix applied: prefer a set of known-good lane spawn points in `buildSpawn`, keeping the first two spawns on the same flat lane for deterministic hitscan. + +- `examples/game-fps-arena`: single-shot only firing felt wrong for an arena shooter. + - Symptom: holding mouse button did not keep firing. + - Fix applied: client treats the weapon as automatic and repeatedly calls `fire` while LMB is held. The server stays authoritative and rate limits. + +- `examples/game-fps-arena`: remote players appeared half buried in the floor and blaster models were not visible. + - Symptom: other players looked like they were inside the ground; first-person gun stayed as the fallback box/cylinder or was invisible. + - Fix applied: + - offset the capsule fallback avatar so feet sit on y=0 (CapsuleGeometry is centered by default). + - add the camera to the scene graph so the gun (attached to the camera) renders. + - prefer a larger blaster model from Kenney `blaster-kit` (`blaster-r.glb`) for the first-person gun. + - Follow-up fix applied: + - replicate per-player avatar selection via `PlayerPublicState.avatar` and load the correct Kenney blocky character model per remote player. + - attach a third-person blaster model to remote avatars (best-effort mount) so other players visibly hold a weapon. + - add a lightweight walk cycle (limb swing when moving) and head pitch look for models with separate limb nodes. + +- `examples/game-fps-arena`: prototype-kit tiles overlapped and clipped into each other. + - Symptom: some floor pieces or indicators appeared to clip into the floor, especially around ramps/platforms. + - Fix applied: do not place the thin floor tile under platforms or ramps, and lift doorway indicators slightly above the floor. + +- `examples/game-fps-arena`: remote yaw interpolation flips and player facing mismatch. + - Symptom: remote players flip when crossing a critical angle, and some models appear to face backwards. + - Fix applied: rotate Kenney blocky character instances by `PI` to match the example's `-Z` forward convention and interpolate yaw with wrapped angle math (not linear lerp). + +- `examples/game-fps-arena`: stepping off stairs onto adjacent platforms felt blocked. + - Symptom: players can go partially up stairs but cannot smoothly walk onto the raised platform next to the stairs. + - Fix applied: allow a small step-up onto platform surfaces during `resolveObstaclesXZ` when within `STEP_HEIGHT`. + +- `examples/game-fps-arena`: world sometimes appeared empty due to GLB URLs with spaces. + - Symptom: tile world and/or gun model fails to load, leaving a mostly empty scene. + - Fix applied: + - rename Kenney asset folders at download time to remove spaces (e.g. `Models/GLB format` -> `Models/glb`) to avoid static server path decoding issues. + - update runtime paths to use the normalized folder names. + - keep `encodeURI` in `GLTFLoader` URLs as an extra guard. + - Verified: `/tmp/fps-prefab-world.png` + +- `examples/game-fps-arena`: geometry loaded lazily, causing jarring transitions and silent placeholder fallbacks. + - Symptom: menu could show before assets were ready; if GLBs failed to load, the world/gun could silently fall back or appear empty. + - Fix applied: preload required Kenney geometry (prototype-kit tiles + blaster + blocky character) on initial page load, show a blocking loading screen before the menu, and show a fatal error screen if any geometry fails to load. + - Verified: `/tmp/fps-loading-screen.png`, `/tmp/fps-after-loading-menu.png` + +- `examples/game-idle-base`: crashes at runtime on this machine with Node `v24.13.0`. + - Symptom: actor startup fails with `RuntimeError: memory access out of bounds` originating from `wa-sqlite` (`wa-sqlite-async.mjs`). + - Status: still repros as of 2026-02-13. UI loads briefly, then server-side runtime crashes. + - Suspect: Node 24 + WASM sqlite runtime incompatibility/regression. + +- `examples/game-mmo-chunks`: runtime crash on this machine with Node `v24.13.0`. + - Symptom: `Error: Failed to save actor state: BareError: (byte:0) too large buffer`. + - Status: repro’d during `pnpm dev` session. + - Suspect: actor state serialization exceeding limits, or a bug in the driver/storage layer. + +- `examples/game-fps-arena`: dev server crash due to trace storage state growth. + - Symptom: Vite dev session exits with `Failed to save actor state: BareError: (byte:0) too large buffer` originating from `ActorTracesDriver.set`. + - Fix applied: use `createMemoryDriver()` in `src/actors/index.ts` so long dev sessions do not persist large trace KV blobs to disk. + +- `examples/game-fps-arena`: add combat feedback and scoring semantics. + - Change applied: + - award `+10` points per kill (server authoritative) and broadcast a `kill` event to drive UI feedback. + - slow visual tracers so bullets read at short range even though shots are hitscan. + - add bullet-hole decals on world impacts when a shot does not hit a player. + - add hitmarker (`X`) on hit, hurt vignette when the local player is hit, and killfeed/toast messages. + - add Kenney audio (`sci-fi-sounds`) for shooting and getting hit, and use UI pack click sounds for UI interactions. + - Files: + - `examples/game-fps-arena/src/actors/match.ts` + - `examples/game-fps-arena/src/types.ts` + - `examples/game-fps-arena/frontend/App.tsx` + - `examples/game-fps-arena/frontend/game/ThreeFpsView.tsx` + - `examples/game-fps-arena/scripts/assets/download-assets.mjs` + +- `examples/game-fps-arena`: page scroll + GC hitches during extended play. + - Symptom: the page could scroll (unwanted for a fullscreen game) and the client could hitch after sustained firing. + - Fix applied: + - force `overflow: hidden` on `html, body, #root` to prevent scrollbars from appearing. + - replace per-shot tracer mesh/material allocations with a bounded tracer pool and reuse bullet-hole decals with a ring buffer to avoid constant allocation/disposal churn. + - stop deep-cloning resources for the "prop museum" and lane props by using static clones that share geometry/materials, reducing startup memory and GC pressure. + - Files: + - `examples/game-fps-arena/frontend/App.css` + - `examples/game-fps-arena/frontend/game/ThreeFpsView.tsx` + +- Various examples: RivetKit runtime warnings during dev. + - Vite warning: dynamic imports in rivetkit dist cannot be analyzed (`vite:import-analysis`). + - Log noise: `subscription does not exist in persist` warnings. + - `baseline-browser-mapping` warns the mapping data is out of date. + - These did not block the UI checks but they add noise and may hide real issues. + +- `examples/game-fps-arena`: `unhandled actor start promise rejection` about missing actor name `town`. + - Symptom: log says `no actor in registry for name town`. + - Likely cause: stale persisted actor instance from another example or key collision across examples sharing a driver/data dir. + +## Typecheck Issues + +- `examples/game-idle-base`: TypeScript errors. + - Symptom: `.then(setTop)` where the value was inferred as `Promise`, plus `unknown` DB row typing from `c.db.execute`. + - Fix applied: `await` the call and add safe row decoding/casting. + +- `examples/game-io-arena`: TypeScript errors. + - Symptom: `c.state` inferred as `unknown` inside actor actions; missing/insufficient typings for `d3-quadtree`; `.then(setRoomId)` mismatch. + - Fix applied: add explicit `RoomState` return typing on `createState`, add minimal `d3-quadtree` module declaration, and `await`/narrow connections in frontend code. + +## UI/UX Issues + +- Menu alignment and background misalignment complaints (notably in `game-fps-arena`). + - Fix applied: center menu panels; avoid overlapping layers; improve backdrop/scanline effect; ensure Kenney UI Pack texture is used as a subtle motif instead of a misaligned overlay. + +- UI requirements applied across game examples: + - Use Kenney UI Pack for buttons/controls. + - Avoid monospaced fonts in UI chrome. + - Avoid normal casing: UI chrome moved to `text-transform: uppercase`. + +## Agent-Browser Verification Artifacts + +Screenshots captured while verifying UI layouts: + +- `examples/game-fps-arena`: `/tmp/game-fps-arena-ui.png` +- `examples/game-fps-arena` (post world/model update): `/tmp/fps-menu-updated.png` +- `examples/game-fps-arena` (in match, post world/model update): `/tmp/fps-in-match.png` +- `examples/game-fps-arena` (prototype-kit tiles): `/tmp/fps-tiles-menu.png` +- `examples/game-fps-arena` (in match, prototype-kit tiles): `/tmp/fps-tiles-in-match.png` +- `examples/game-fps-arena` (world fallback + encoded GLB URLs): `/tmp/fps-world-not-empty.png` +- `examples/game-idle-base`: `/tmp/game-idle-base-ui.png` +- `examples/game-io-arena`: `/tmp/game-io-arena-ui.png` +- `examples/game-mmo-chunks`: `/tmp/game-mmo-chunks-ui.png` +- `examples/game-npc-town-ai`: `/tmp/game-npc-town-ai-ui.png` +- `examples/game-party-cah`: `/tmp/game-party-cah-ui.png` +- `examples/game-tic-tac-toe`: `/tmp/game-tic-tac-toe-ui.png` diff --git a/.agent/notes/multiplayer-cookbook.md b/.agent/notes/multiplayer-cookbook.md new file mode 100644 index 0000000000..8942ee2004 --- /dev/null +++ b/.agent/notes/multiplayer-cookbook.md @@ -0,0 +1,154 @@ +# Multiplayer Cookbook (RivetKit) + +Game-focused notes to turn into docs. Seeded from `/home/nathan/misc/rork-skill-old.md` and extended while writing game example specs. + +## Architecture Patterns + +- Actor per entity: player, room, chunk, match. +- Coordinator and data actors: use a coordinator (matchmaker/world index) to create/find data actors (rooms/chunks/matches). +- Sharding: split hot state by room, time window, random shard, or grid coordinate. +- Lifecycle: data actors can `c.destroy()` when empty to reduce cost. + +## Matchmaking Patterns + +- Open lobby (io-style): + - Maintain a list of active rooms and route joiners to the most-full room under capacity. + - Auto-create rooms as needed; auto-destroy when empty. +- Filled room queue (turn-based / fixed-size): + - Put players in a queue until exactly `N` are ready. + - Emit realtime queue/ready status events for UI. + - Spawn a match actor when full and hand back the match key to both players. +- Mode-based matchmaking: + - Matchmaker accepts `{ mode }` and routes into separate pools (e.g. `ffa`, `tdm`, `br`). +- Team management (simple): + - Allow manual selection with server validation, or auto-balance on join. + +## Game Loop & Tick Rates + +- Turn-based games: do not run a tick loop. +- Casual realtime: ~10 ticks/sec (100ms). +- Fast-paced realtime: ~20 ticks/sec (50ms). Avoid going below 50ms unless you are intentionally paying for higher tick rates. +- Implement loops with `setInterval` started in `onWake` and tied to `c.abortSignal` so it stops cleanly on actor shutdown. +- For idle/offline progression, prefer `c.schedule.after(...)` to run a coarse recurring tick even when nobody is connected. + - Use a coarse interval (e.g. 5-15 minutes) and apply catch-up based on `Date.now() - lastTickAt`. + +## Realtime Data Model + +- Prefer server-published snapshots/diffs via events for UI rendering. +- Keep events small and typed. For high-frequency updates, batch per tick. +- For canvas rendering, keep game world rendering outside React reconciliation (React for UI only). +- For party/lobby games, consider per-connection redaction (each player sees only their hand/secret info). + +## Interest Management (Simple) + +- Spatial partitioning via chunk actors is a simple form of interest management. +- Clients subscribe only to nearby chunks (e.g. 3x3 around the player) and render only subscribed state. +- For shooters, consider limiting what the client receives by proximity and/or field of view (optional; not required for simple examples). + +## Netcode Options (Document, Even If Examples Stay Simple) + +- Client-authoritative movement with caps and rate limits (smooth, simple, weaker anti-cheat). +- Server-authoritative movement from inputs (more robust, can feel less responsive without prediction). +- Interpolation/smoothing on the client (optional; can be added later when examples need it). + +## Physics And Spatial Indexing + +### Always Use Spatial Indexing + +When entity counts can grow, do not do naive O(n^2) collision checks. Prefer using a spatial indexing library rather than implementing your own broadphase. + +- AABB indexing: `rbush` (dynamic) or `flatbush` (static-ish, rebuilt occasionally) + - Use AABBs even for circles and capsules (insert their bounding boxes). +- Point indexing: `d3-quadtree` for fast nearest/within-radius queries. + +### Prefer Not Using A Full Physics Engine + +Most multiplayer game examples can be implemented with: + +- Kinematic movement rules (speed caps, bounds, simple collision tests). +- Simple primitives (circle/sphere/AABB/capsule) and raycasts. +- Server-owned resolution for game rules (hits, damage, pickups, building placement). + +### If You Need An Engine, Recommend Rapier + +If you hit the threshold where hand-rolled collisions become too complex (joints, stacked bodies, stable contact resolution, lots of dynamic bodies), use Rapier. + +- 2D: `@dimforge/rapier2d` +- 3D: `@dimforge/rapier3d` + +Fallback engines (use only if Rapier does not work for a practical reason): + +- 2D: `planck-js` (Box2D-like), `matter-js` +- 3D: `cannon-es`, `ammo.js` + +Notes: + +- Physics engines are not mutually exclusive with spatial indexing. Use spatial indexing for interest management and broad queries regardless. +- Physics engines are mutually exclusive with each other in practice. Pick one engine per simulation. +- Avoid Three.js on the backend. For 3D hitscan and simple collisions, use analytic math or Rapier. +- `three-mesh-bvh` is optional and mainly useful on the client for fast raycasts against detailed static meshes. Skip it unless you need mesh raycasts. + +## Security & Anti-Cheat (Keep It Simple) + +### Baseline Checklist (All Examples) + +- Identity: + - Use `c.conn.id` as the authoritative identity of the caller. + - Never accept `playerId` (or similar) from the client as the source of truth. +- Authorization: + - Validate that the caller is allowed to mutate the target entity (room membership, turn ownership, host-only actions). +- Input validation: + - Clamp sizes/lengths and validate enums. + - Validate usernames (length, allowed chars; avoid weird/unbounded unicode). +- Rate limiting: + - Per-connection rate limits for spammy actions (chat, join/leave, fire, move updates). +- State integrity: + - Server recomputes derived state (scores, win conditions, placements). + - Avoid client-authoritative changes to inventory/currency/leaderboard totals. + +### Movement Validation (Client-Authoritative Friendly) + +- Clients may send position/rotation updates for smoothness, but the server must: + - Enforce max delta per update (speed cap) based on elapsed time. + - Reject or clamp teleports. + - Enforce world bounds (and basic collision if applicable). + - Rate limit update frequency (e.g. 20Hz max). + +## Rendering Tips + +- Canvas: + - Use `ctx.save()`/`ctx.restore()` and nested transforms for rotation/translation. + - Split entities into small render helpers/classes to avoid monolithic render functions. +- Three.js: + - Keep a minimal scene graph and update transforms per frame. + - Keep network updates decoupled from render framerate (render at RAF, apply latest state). + +## Assets (Kenney) + +- Download Kenney asset packs at build time into a gitignored directory (per-example). +- Vite config should assert assets are present (fail fast with a clear message). +- Recommended pattern: `package.json` has `assets:download` and `predev`/`prebuild` hooks; `vite.config.ts` checks `assets/kenney/` via `fs.existsSync` and throws if missing. + +## Persistence (When To Use SQLite) + +- Actor state is fine for small ephemeral state (rooms, short-lived matches). +- Prefer SQLite (`rivetkit/db`) when state is: + - Large or table-like (tiles/blocks/buildings/inventory). + - Needs queries/indexes beyond key lookups. + - Expected to persist long-term and grow over time. +- Matchmakers/coordinators may also use SQLite when their indexing state can grow large (room registries, matchmaking pools, large queues). + +## NPCs / AI + +- Keep NPC generation simple: + - Assert `OPENAI_API_KEY` is present on backend startup. + - Limit tokens and rate limit per player. + - Keep NPC memory minimal and bounded (short summaries), or skip long-term memory in the example. + +## Example Design Notes (From Rork + Rivet Compat) + +- Prefer talking to actors directly from the frontend (`useActor` / `client`) instead of adding extra HTTP endpoints as a hop. +- Matchmaking should demonstrate open lobby, filled-room queue, and mode-based pools. +- Common pitfalls to guard against: + - Missing tick loop where needed. + - Actions that mutate other players without validating the caller. diff --git a/.agent/specs/games/README.md b/.agent/specs/games/README.md new file mode 100644 index 0000000000..49b2bcb779 --- /dev/null +++ b/.agent/specs/games/README.md @@ -0,0 +1,13 @@ +# Game Example Specs + +These are specs for new RivetKit game examples. This folder is specs only. + +## List + +- `open-lobby-io-style-multiplayer-2d.md` +- `turn-based-tic-tac-toe-2d.md` +- `realtime-multiplayer-fps-3d.md` +- `mmo-chunked-world-building-2-5d.md` +- `idle-base-builder-leaderboard-2-5d.md` +- `party-game-cah-like-2d.md` +- `npc-town-vercel-ai-sdk-2-5d.md` diff --git a/.agent/specs/games/idle-base-builder-leaderboard-2-5d.md b/.agent/specs/games/idle-base-builder-leaderboard-2-5d.md new file mode 100644 index 0000000000..b13cb5dd7f --- /dev/null +++ b/.agent/specs/games/idle-base-builder-leaderboard-2-5d.md @@ -0,0 +1,149 @@ +# Idle Base Builder With Global Leaderboard (2.5D) + +## Summary + +A Clash-of-Clans-inspired idle/base builder where each player has their own world actor and the game exposes a global leaderboard across all players. + +- Frontend: React + canvas (2.5D tile base). +- Backend: one actor per player world; one coordinator actor for leaderboard. + +## Goals + +- Demonstrate "actor per player" pattern for isolated worlds. +- Simple idle loop: gather resources over time, build/upgrade structures. +- Global leaderboard maintained by a coordinator actor. +- Keep it simple and secure: no spoofing other players, server-owned resource math. + +## Non-goals + +- PvP attacks/raids. +- Complex offline progression (e.g. long queues, boosts, or minute-by-minute simulation while away). This example still supports simple offline catch-up. + +## Identity Assumption + +- Caller identity is `c.conn.id`. +- Player identity is a stable `playerId` stored in `localStorage`. + - The client generates a random UUID on first load and persists it. + - The `idleWorld` actor key is `[playerId]`. + - The actor still uses `c.conn.id` to identify the caller connection for rate limiting and per-connection events. + +## UX + +- Landing: enter name, start. +- Base view: grid with a few buildings. +- Actions: + - Place building. + - Upgrade building. + - Collect resources. +- Sidebar: global leaderboard. + +## Actors + +### `idleLeaderboard` (coordinator) + +- Key: `["main"]`. +- State: + - `entries: Array<{ playerId: string; name: string; score: number; updatedAt: number }>` +- Actions: + - `upsertEntry(entry)` called by each player world on changes. + - `getTop(n: number)`. +- Events: + - `leaderboardUpdated(top)`. + +### `idleWorld` (data) + +- Key: `[playerId]` (from `localStorage`). +- DB: `rivetkit/db` raw SQLite for persistent, structured world data (buildings/resources/last tick). +- DB usage: use `onMigrate` to create tables and `c.db.execute(...)` for reads/writes (no ORM). +- State: + - `name: string` (cached for snapshots; source of truth can be SQLite) + - `resources: { gold: number; wood: number }` (cached; source of truth in SQLite) + - `buildings: Array<{ id: string; kind: "hut"|"mine"|"lumber"; level: number; tx: number; ty: number }>` (cached; source of truth in SQLite) + - `lastTickAt: number` (cached; source of truth in SQLite) + - `nextTickAt: number | null` + - `lastActionAtByConnId: Record` +- SQLite schema (per world actor): + - `world(name TEXT, gold INTEGER, wood INTEGER, last_tick_at INTEGER)` + - Single-row table (or a keyed row) storing aggregate world values. + - `buildings(id TEXT PRIMARY KEY, kind TEXT, level INTEGER, tx INTEGER, ty INTEGER)` +- Lifecycle: + - `onWake`: schedule recurring ticks with `c.schedule.after(...)` so the world progresses even when no one is connected. + - `onConnect`: initialize if first time; ensure only the connecting `c.conn.id` can control this world. +- Actions: + - `setName(name: string)`. + - `placeBuilding(req: { kind; tx; ty })`: + - Validate bounds and collisions. + - Deduct cost. + - `upgradeBuilding(buildingId: string)`: + - Validate ownership (implicitly by world actor key). + - Deduct cost and increment level. + - `collect()` (optional): move some resources from "produced" to "available". + - `tick()`: + - Reads `last_tick_at` from SQLite, computes `deltaMs`, and updates `gold/wood/last_tick_at` in SQLite. + - Optionally refreshes cached `resources/lastTickAt` for snapshots. + - Sets `lastTickAt = Date.now()`. + - Schedules the next tick with `c.schedule.after(TICK_INTERVAL_MS, "tick")` and sets `nextTickAt = Date.now() + TICK_INTERVAL_MS`. +- Leaderboard integration: + - After meaningful changes (name, resources, upgrades), compute a simple score and call `idleLeaderboard.upsertEntry`. +- Events: + - `worldSnapshot(state)`. + +## Scoring + +- Simple score formula, e.g. `sum(building.level) + floor(gold / 100) + floor(wood / 100)`. + +## Offline Progression (Using `c.schedule`) + +This example should progress while the user is offline by using scheduled ticks (`c.schedule.after`) to wake the actor periodically. + +Guidelines: + +- Use a coarse `TICK_INTERVAL_MS` (e.g. 5-15 minutes) to keep it cheap. +- In `onWake`, if `nextTickAt` is `null` (or clearly stale), schedule `tick()` soon and set `nextTickAt`. +- `tick()` should always schedule the next tick. + +Catch-up math (inside `tick()`): + +- `deltaMs = Date.now() - lastTickAt` +- `deltaMs = clamp(deltaMs, 0, MAX_OFFLINE_MS)` (e.g. cap at 24h to keep it bounded) +- Compute resource gain from building production rates (from SQLite `buildings`) for `deltaMs`, add to SQLite `world.gold/world.wood`, and set SQLite `world.last_tick_at = Date.now()`. + +## Assets + +Kenney packs (chosen): + +- `isometric-miniature-bases` (base ground tiles) +- `isometric-miniature-farm` (buildings/props that read as a base) +- `ui-pack` (UI) +- `input-prompts` (optional: key prompt icons) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- No spoofing: + - World actor is keyed by server-known identity (`c.conn.id`). + - Actions do not accept player ids. +- Server-owned economy: + - Server computes resource accrual. + - Server validates costs and persists authoritative values in SQLite. +- Rate limit: + - Throttle spam actions (place/upgrade). + +## Testing + +Automated (Vitest): + +- Actor-level tests for `idleWorld` raw SQLite migrations and persistence (buildings/resources/last tick). +- Actor-level tests for scheduled tick catch-up math and caps. +- Actor-level tests for economy invariants (costs validated, no negative resources). +- Actor-level tests for `idleLeaderboard` ordering and updates. + +Manual: + +- Two clients: worlds are isolated. +- Leaderboard updates as buildings upgrade. +- Verify offline progression: build a mine, wait (or advance time if supported), reconnect and confirm resources increased. diff --git a/.agent/specs/games/mmo-chunked-world-building-2-5d.md b/.agent/specs/games/mmo-chunked-world-building-2-5d.md new file mode 100644 index 0000000000..80ac026c12 --- /dev/null +++ b/.agent/specs/games/mmo-chunked-world-building-2-5d.md @@ -0,0 +1,129 @@ +# MMO Chunked World With Building (2.5D) + +## Summary + +A shared-world MMO-like sandbox where the world is split into grid chunks (one chunk = one actor). Players can move around and place/remove blocks that persist for everyone using SQLite storage. + +- Frontend: React + canvas (2.5D top-down / light isometric). +- Backend: chunk actors keyed by chunk coords; per-chunk SQLite via `rivetkit/db` using the raw SQLite API (no ORM). +- No server-side handoff: clients choose which chunks to connect to. + +## Goals + +- Demonstrate spatial sharding via actors: `chunk[x,y]`. +- Shared persistent building that survives restarts. +- Simple movement and visibility limited by chunk subscription. + +## Non-goals + +- Seamless handoff/transfer logic between chunks. +- Complex pathfinding or physics. +- Strong anti-cheat. + +## UX + +- Player spawns near origin. +- View is a 2.5D tile grid. +- Player can: + - Move. + - Place a block (selected type) on a tile. + - Remove a block. +- UI shows current chunk coordinates and which chunks you are subscribed to. + +## World Model + +- Chunk size: `CHUNK_TILES = 32` (32x32 tiles). +- World coordinate system: + - Global tile coords `(tx, ty)`. + - Chunk coords `(cx, cy) = (floor(tx/CHUNK_TILES), floor(ty/CHUNK_TILES))`. + - Local coords `(lx, ly) = (tx mod CHUNK_TILES, ty mod CHUNK_TILES)`. + +## Actors + +### `worldIndex` (optional coordinator) + +- Key: `["main"]`. +- Purpose: + - Provide constants, optional list of active chunks, and an example of a coordinator. +- This can be omitted; clients can compute chunk keys directly. + +### `worldChunk` (data) + +- Key: `[cx, cy]` (as strings or numbers, but stable). +- DB: `rivetkit/db` raw SQLite used for block persistence (use `onMigrate` + `c.db.execute(...)`). +- State: + - `playersByConnId: Record` + - `blocks: Map` cached in memory (key = `${lx},${ly}`) + - `lastMoveAtByConnId: Record` +- Block: + - `type: "wood"|"stone"|"grass"|...` (small enum) + - `placedAt: number` + - `placedByConnId: string` +- DB schema (per chunk): + - `blocks(lx INTEGER, ly INTEGER, type TEXT, placed_at INTEGER, placed_by TEXT, PRIMARY KEY(lx,ly))` +- Lifecycle: + - `onMigrate`: create `blocks` table. + - `onWake`: load all blocks into memory (bounded by chunk size). + - `onConnect`: add player to `playersByConnId`. + - `onDisconnect`: remove player. Keep the actor warm; it will sleep automatically when idle. +- Actions: + - `setName(name: string)` + - `move(update: { tx: number; ty: number; clientAt: number })` + - Client-authoritative but tile-based. + - Server enforces max tile delta per update and bounds for this chunk's responsibility: + - If `tx,ty` leaves the chunk, allow it but do not imply handoff; client should connect to the destination chunk separately. + - `placeBlock(req: { tx: number; ty: number; type: BlockType })` + - Validate tile is within this chunk (based on `tx,ty`). + - Upsert into DB and in-memory cache. + - `removeBlock(req: { tx: number; ty: number })` + - Validate tile is within this chunk. + - Delete from DB and cache. +- Events: + - `chunkSnapshot({ cx, cy, players, blocks })` on connect and periodically. + - `blockPlaced`, `blockRemoved`, `playerMoved`. + +## Client Chunk Subscription + +- Client computes the set of chunks to subscribe to based on player position, e.g. a 3x3 grid around the current chunk. +- Client maintains actor connections to those chunks and renders: + - Blocks in all subscribed chunks. + - Players in all subscribed chunks. + +## Assets + +Kenney packs (chosen): + +- `isometric-blocks` (placeable blocks/tiles) +- `isometric-landscape` (terrain variety) +- `ui-pack` (UI) +- `input-prompts` (optional: key prompt icons) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- Identity: + - Players are keyed by `c.conn.id`. +- Building integrity: + - Server validates target chunk ownership for `placeBlock/removeBlock`. + - Server writes to SQLite; clients cannot directly change persisted data. +- Movement: + - Clamp tile delta per update. + - Rate limit move actions. + +## Testing + +Automated (Vitest): + +- Actor-level tests for `worldChunk` raw SQLite migrations and persistence (place/remove survives restart). +- Actor-level tests for chunk ownership validation (reject place/remove for tiles outside the chunk). +- Actor-level tests for movement clamp/rate limit on tile updates. + +Manual: + +- Two clients in same chunk: place/remove blocks and see updates live. +- Refresh server and verify blocks persist. +- Move across chunk boundary: client should connect to new chunk and see blocks there. diff --git a/.agent/specs/games/npc-town-vercel-ai-sdk-2-5d.md b/.agent/specs/games/npc-town-vercel-ai-sdk-2-5d.md new file mode 100644 index 0000000000..cb732d1fb0 --- /dev/null +++ b/.agent/specs/games/npc-town-vercel-ai-sdk-2-5d.md @@ -0,0 +1,106 @@ +# NPC Town (Pokemon-Style) With Vercel AI SDK (2.5D) + +## Summary + +A small 2.5D town where players can walk around and talk to NPCs. NPC dialog is generated using the Vercel AI SDK with OpenAI. + +- Frontend: React + canvas. +- Backend: a town/world actor plus optional NPC actors. +- AI: OpenAI via Vercel AI SDK. + +## Goals + +- Demonstrate integrating AI-driven NPC dialog into a RivetKit game. +- Keep it simple: no streaming UI required, bounded memory, basic rate limits. +- Assert token availability at backend startup. + +## Non-goals + +- Fully persistent long-term NPC memories. +- Complex tool use. + +## UX + +- Player moves on a tile grid. +- NPCs are stationary. +- Interact key opens a dialog box. +- Player can send short messages; NPC replies. + +## Token Requirement + +- Backend must fail fast on startup if `OPENAI_API_KEY` is missing. + +## Actors + +### `npcTown` (data) + +- Key: `["main"]`. +- State: + - `playersByConnId: Record` + - `npcs: Array<{ id: string; name: string; tx: number; ty: number; persona: string; memorySummary: string }>` + - `lastTalkAtByConnId: Record` +- Actions: + - `setName(name: string)` + - `move(update: { tx: number; ty: number; clientAt: number })` + - Tile-based with simple clamp and rate limit. + - `talk(req: { npcId: string; message: string }): { reply: string }` + - Validate npc exists and caller is near npc. + - Rate limit per connection. + - Use Vercel AI SDK to generate reply using OpenAI. + - Update `memorySummary` with a short bounded summary. +- Events: + - `townSnapshot({ players, npcs })`. + - Optional: `chat({ npcId, reply })`. + +### Optional: `npc` (one actor per NPC) + +- If used, key: `[npcId]`. +- Keeps persona/memory isolated; town actor routes to the correct NPC actor. + +## AI Design + +- Prompt inputs: + - NPC persona. + - NPC memory summary (bounded, short). + - Player message. + - A short system rule set (PG, no unsafe content, stay in character). +- Output constraints: + - Short replies. + - No hidden instructions. + +## Assets + +Kenney packs (chosen, pixel art): + +- `roguelike-rpg-pack` (pixel tiles + characters) +- `ui-pack-pixel-adventure` (pixel UI) +- `input-prompts-pixel-16` (optional: pixel key prompt icons) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- Identity: `c.conn.id` is the only player id. +- Location checks: + - `talk()` requires proximity. +- Rate limits: + - `talk()` per connection to control cost and spam. +- Input constraints: + - Limit message length. + +## Testing + +Automated (Vitest): + +- Actor-level tests for proximity checks and rate limiting in `talk()`. +- Actor-level tests that missing `OPENAI_API_KEY` fails fast at startup (or first talk call, depending on implementation choice). +- Actor-level tests for movement clamps. + +Manual: + +- Verify missing `OPENAI_API_KEY` fails at startup with a clear error. +- Verify you cannot talk to an NPC across the map. +- Verify rate limiting works. diff --git a/.agent/specs/games/open-lobby-io-style-multiplayer-2d.md b/.agent/specs/games/open-lobby-io-style-multiplayer-2d.md new file mode 100644 index 0000000000..7582658bf8 --- /dev/null +++ b/.agent/specs/games/open-lobby-io-style-multiplayer-2d.md @@ -0,0 +1,149 @@ +# Open Lobby IO-Style Multiplayer (2D) + +## Summary + +A drop-in/drop-out arena (Agar.io-inspired) built with a matchmaker coordinator actor that automatically creates and destroys game room actors. + +- Frontend: React + canvas. +- Backend: RivetKit actors, realtime events. +- Assets: Kenney (downloaded at build time, gitignored). + +## Goals + +- Open lobby matchmaking: join the most-full room under capacity; create rooms on demand. +- Room lifecycle: rooms destroy themselves when empty. +- Simple, fun loop: move, eat pellets, grow; collide to consume smaller players. +- Keep it secure-by-default: no spoofing other players; basic movement sanity checks. + +## Non-goals + +- Skill-based matchmaking. +- Advanced netcode (interpolation, prediction rollback). +- Strong anti-cheat beyond caps/rate limits. + +## UX + +- Landing: enter a display name, click Play. +- Game: top-down arena, minimap optional. +- HUD: your mass, leaderboard (top 10), player count. + +## Actors + +### `ioMatchmaker` (coordinator) + +- Key: `["main"]`. +- DB: `rivetkit/db` raw SQLite for room indexing state that may grow large. +- State: + - `rooms: Array<{ roomId: string; players: number; updatedAt: number; }>` +- SQLite schema: + - `rooms(room_id TEXT PRIMARY KEY, players INTEGER NOT NULL, updated_at INTEGER NOT NULL)` +- Responsibilities: + - Choose or create a room for a connecting player. + - Track approximate room population to route players. +- Lifecycle: + - `onMigrate`: create `rooms` table. + - `onWake`: schedule periodic cleanup of stale rooms (e.g. every 5 minutes). +- Actions: + - `findRoom(): { roomId: string }` + - Select the most-full room where `players < ROOM_MAX`. + - Prefer a SQLite query like `SELECT room_id FROM rooms WHERE players < ? ORDER BY players DESC, updated_at DESC LIMIT 1`. + - If none exists, create a new `ioRoom` with a random `roomId` and register it. + - `roomHeartbeat(roomId: string, players: number)` + - Called by room periodically or on membership changes. + - Upsert `rooms` row with `players` and `updated_at = Date.now()`. + - `roomClosed(roomId: string)` + - Called by the room right before it destroys. + - Delete from `rooms`. + - `cleanupStaleRooms()` + - Delete rows where `updated_at < now - STALE_TTL_MS` to handle missed `roomClosed` calls. + - This is defensive; rooms should call `roomClosed` on shutdown, but cleanup handles crashes/network failures. +- Events: + - Optional for UI: `roomsUpdated(rooms)`. + +### `ioRoom` (data) + +- Key: `[roomId]`. +- State: + - `playersByConnId: Record` + - `pellets: Pellet[]` + - `rngSeed: number` + - `lastMoveAtByConnId: Record` + - `lastSeenAtByConnId: Record` +- Player: + - `connId: string` (server-side identity) + - `name: string` + - `x, y: number` + - `radius: number` + - `color: string` + - `score: number` +- Lifecycle: + - `onConnect`: create player using `c.conn.id` as key; spawn at random safe position. + - `onDisconnect`: remove player; if empty, call matchmaker `roomClosed` then `c.destroy()`. + - `onWake`: start a tick loop (10Hz) to spawn pellets and resolve collisions. +- Actions: + - `setName(name: string)` + - `move(update: { x: number; y: number; clientAt: number })` + - Client-authoritative movement. + - Server clamps delta by max speed using `clientAt` (or server time) and rate limits to 10-20Hz. + - Server enforces world bounds. +- Events: + - `snapshot(state)` at 10Hz (players, pellets, leaderboard summary). + - Optional: `playerJoined`, `playerLeft`. + +## Networking + +- Clients never send `playerId`. +- All per-player mutations are keyed by `c.conn.id`. +- Payload size: snapshots should be capped (pellet count, max players). + +## Game Rules + +- Eating pellets increases radius. +- If a larger player overlaps a smaller player by a threshold, smaller is consumed and respawns. +- Pellets spawn to maintain a target density. + +## Persistence + +- Not required. Room state is ephemeral. + +## Assets + +Rendering: + +- Deliberately minimal Agar.io style: raw circles for players/pellets and a simple procedural/grid background. + +Kenney packs (chosen): + +- `ui-pack` (HUD/buttons) +- `game-icons` (optional: icons for UI) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- Identity: + - Use `c.conn.id` as the only player identity. + - Never accept `playerId` from client. +- Movement: + - Clamp max delta per update. + - Rate limit `move`. + - Enforce bounds. +- Gameplay integrity: + - Server owns pellet spawning and growth math. + - Server computes collisions and scoring. + +## Testing + +Automated (Vitest): + +- Actor-level tests for `ioMatchmaker` room selection/upsert/delete + stale cleanup. +- Actor-level tests for `ioRoom` identity (no spoofing), movement clamp/rate limit, and collision scoring rules. + +Manual: + +- Join 2 tabs, verify both appear and cannot rename/move each other. +- Try spamming `move` at 120Hz; server should clamp and/or reject. +- Verify rooms destroy when last player leaves and matchmaker forgets them. diff --git a/.agent/specs/games/party-game-cah-like-2d.md b/.agent/specs/games/party-game-cah-like-2d.md new file mode 100644 index 0000000000..67bb31de4c --- /dev/null +++ b/.agent/specs/games/party-game-cah-like-2d.md @@ -0,0 +1,110 @@ +# Party Game (Cards Against Humanity-Like) (2D) + +## Summary + +A Jackbox-style party game with a lobby code that players join from their phones/browsers. Each round: a prompt is shown, players submit a card, a judge picks the funniest, and scores update. + +- Frontend: React (UI-heavy) + minimal canvas (optional for flair). +- Backend: a single lobby actor per room. +- Deck: small and PG. + +## Goals + +- Demonstrate a lobby actor pattern (one actor per party room). +- Simple real-time state sync with events. +- Secure submissions: players can only submit for themselves; judge rotation enforced by server. + +## Non-goals + +- Large deck tooling. +- Content moderation. + +## UX + +- Create or join lobby by code. +- Lobby shows player list and a Start button. +- Round flow: + - Everyone sees a prompt. + - Each non-judge player picks one card from their hand and submits. + - When all submitted (or timeout), judge sees submissions and picks a winner. + - Scores update and judge rotates. + +## Actors + +### `partyLobby` (data) + +- Key: `[code]`. +- State: + - `phase: "lobby" | "submitting" | "judging" | "reveal"` + - `playersByConnId: Record` + - `judgeConnId: string | null` + - `round: number` + - `prompt: string | null` + - `handsByConnId: Record` (never broadcast) + - `submissionsByConnId: Record` (never broadcast) + - `deckPrompts: string[]` (small PG list) + - `deckAnswers: string[]` (small PG list) + - `lastActionAtByConnId: Record` +- Lifecycle: + - `onConnect`: add player using `c.conn.id`, deal a hand. + - `onDisconnect`: remove player; if lobby empty, `c.destroy()`. +- Actions: + - `setName(name: string)` + - `getMyHand(): string[]`: + - Returns the caller hand only (keyed by `c.conn.id`). + - `startGame()`: + - Only allowed if `phase == "lobby"` and `players >= MIN_PLAYERS`. + - Pick initial judge and start first round. + - `submitCard(card: string)`: + - Only allowed if caller is not judge and `phase == "submitting"`. + - Validate card is in caller hand. + - Remove card from hand and record submission. + - `pickWinner(winnerConnId: string)`: + - Only allowed if caller is judge and `phase == "judging"`. + - Validate winnerConnId is one of the submitters. + - Increment score, rotate judge, start next round. +- Events: + - `lobbyState(state)` should not include hands or per-conn submissions. + - During judging, emit a judge-only event like `judgeSubmissions({ cards: string[] })` (shuffled, anonymized). + - On reveal, emit `roundReveal({ winnerConnId, submissions: Array<{ connId, card }> })`. + +## Matchmaking + +- Minimal: a client creates a random `code` and `partyLobby.create([code])`. +- Optional: add `partyMatchmaker` to allocate codes; not required. + +## Assets + +Kenney packs (chosen): + +- `ui-pack` (UI shell) +- `boardgame-pack` (boardgame-style visuals) +- `playing-cards-pack` (card visuals) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- Identity: `c.conn.id` is the only player id. +- Authorization: + - Only judge can call `pickWinner`. + - Only non-judge can call `submitCard`. + - Submissions must be from caller hand. +- Rate limiting: prevent spam of submit/start. + +## Testing + +Automated (Vitest): + +- Actor-level tests for lobby phase transitions and judge rotation. +- Actor-level tests for authorization (only judge can pick; only non-judge can submit). +- Actor-level tests for `getMyHand()` returning only the caller hand and that hands/submissions are never broadcast in `lobbyState`. + +Manual: + +- 3 clients: verify judge rotates and only judge can pick. +- Try submitting a card not in hand; server rejects. +- Disconnect judge mid-round; server chooses a new judge. diff --git a/.agent/specs/games/realtime-multiplayer-fps-3d.md b/.agent/specs/games/realtime-multiplayer-fps-3d.md new file mode 100644 index 0000000000..9b11e2e5ab --- /dev/null +++ b/.agent/specs/games/realtime-multiplayer-fps-3d.md @@ -0,0 +1,150 @@ +# Realtime Multiplayer FPS (3D) + +## Summary + +A full 3D first-person shooter demonstrating: + +- Mode-based matchmaking: FFA, TDM, Battle Royale. +- A match actor that hosts an arena, validates firing, tracks kills, and ends rounds. +- Client-authoritative movement with simple caps and rate limits (no interpolation). + +Frontend: React + Three.js. + +## Goals + +- A single example that showcases three matchmaking pools and a mode selector. +- Fast-paced feel via client-authoritative movement updates. +- Server-owned combat rules: fire rate, hitscan validation, damage, deaths. +- Basic team management for TDM (auto-balance, optional manual selection). + +## Non-goals + +- Complex physics. +- Advanced lag compensation. +- Anti-cheat beyond simple caps/rate limits. + +## UX + +- Menu: pick mode (FFA/TDM/BR), click Play. +- In match: + - Pointer lock + WASD + mouse look. + - Simple weapon (hitscan rifle). + - HUD: health, ammo (optional), scoreboard. +- End screen: winner/scoreboard, play again. + +## Actors + +### `fpsMatchmaker` (coordinator) + +- Key: `["main"]`. +- DB: `rivetkit/db` raw SQLite for match indexing state that may grow large. +- State: + - `pools: Record<"ffa"|"tdm"|"br", Array<{ matchId: string; players: number; updatedAt: number }>>` +- SQLite schema: + - `matches(mode TEXT NOT NULL, match_id TEXT NOT NULL, players INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(mode, match_id))` +- Actions: + - `findMatch(mode: "ffa"|"tdm"|"br"): { matchId: string }` + - Route to the most-full match under capacity for that mode. + - Create a new `fpsMatch` if none available. + - `matchHeartbeat(mode, matchId, players)` + - `matchClosed(mode, matchId)` + - `cleanupStaleMatches()` + - Delete rows where `updated_at < now - STALE_TTL_MS` to handle missed `matchClosed` calls. + - This is defensive; matches should call `matchClosed` on shutdown, but cleanup handles crashes/network failures. + +### `fpsMatch` (data) + +- Key: `[matchId]`. +- Config: + - `mode: "ffa"|"tdm"|"br"` + - `maxPlayers` (e.g. 12 for FFA/TDM, 24 for BR) +- State: + - `phase: "lobby" | "playing" | "finished"` + - `playersByConnId: Record` + - `teamsByConnId: Record` (tdm only) + - `projectileLog` omitted (hitscan) + - `circle` (br only): center, radius + - `lastMoveAtByConnId: Record` + - `lastFireAtByConnId: Record` +- Player: + - `connId`, `name` + - `pos: { x,y,z }`, `yaw`, `pitch` + - `hp`, `alive`, `kills`, `deaths` +- Lifecycle: + - `onWake`: start 20Hz tick for BR circle + damage + heartbeat; FFA/TDM can be mostly event-driven. + - `onConnect`: spawn player and assign team if mode is TDM. + - `onDisconnect`: remove player; if empty, notify matchmaker then `c.destroy()`. +- Actions: + - `setName(name: string)` + - `setTeam(team: "red"|"blue")` (tdm only): optional; server may override to keep balance. + - `move(update: { pos: {x,y,z}; yaw: number; pitch: number; clientAt: number })` + - Client-authoritative. + - Server rate limits (e.g. 20Hz) and clamps max distance traveled since last update. + - Server enforces arena bounds. + - `fire(req: { yaw: number; pitch: number; clientAt: number })` + - Server rate limits (e.g. 8-10 shots/sec). + - Server raycasts from the shooter's last known pos/aim. + - Server applies damage to the first hit player (excluding same-team in TDM). + - `respawn()` (ffa/tdm only): only if dead. +- Events: + - `snapshot(state)` at ~10-20Hz containing players (pos/aim/hp/alive) and scoreboard. + - `killed({ killerConnId, victimConnId })`. + - `phaseChanged({ phase })`. + +## Game Rules + +- Damage: fixed per shot (e.g. 25), 4 hits to kill. +- FFA: first to X kills or time limit. +- TDM: teams score kills; first to X. +- BR: + - No respawns. + - Circle shrinks every N seconds; outside circle takes periodic damage. + - Last alive wins. + +## Networking + +- No client-provided player identifiers. +- Server events are the only way to learn other players' state. + +## Assets + +Kenney packs (chosen): + +- `prototype-kit` (3D level geometry + props for a simple arena) +- `prototype-textures` (3D materials/textures) +- `ui-pack-sci-fi` (HUD) +- `input-prompts` (optional: control icons for tutorial overlay) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- Identity: all mutations keyed by `c.conn.id`. +- Movement: clamp delta per update; rate limit. +- Combat: + - Server owns fire rate. + - Server computes hits; client cannot claim hits. +- Team: + - Server enforces team selection rules. + +## Testing + +Automated (Vitest): + +- Actor-level tests for `fpsMatchmaker` indexing/heartbeat/cleanup with SQLite-backed `matches` table. +Actor-level tests for `fpsMatch`: + +- Movement rate limit and speed clamp (teleport rejection). +- Fire rate limit and server-owned hit validation. +- TDM friendly-fire prevention and team rules. +- BR circle damage and last-alive win condition. + +Manual: + +- Two clients: verify you cannot damage teammates in TDM. +- Attempt to call `move()` with huge teleport; server clamps/rejects. +- Attempt to spam `fire()`; server rate limits. +- BR: verify circle shrink and last-alive win. diff --git a/.agent/specs/games/turn-based-tic-tac-toe-2d.md b/.agent/specs/games/turn-based-tic-tac-toe-2d.md new file mode 100644 index 0000000000..6d39ea45b2 --- /dev/null +++ b/.agent/specs/games/turn-based-tic-tac-toe-2d.md @@ -0,0 +1,109 @@ +# Turn-Based Multiplayer Tic Tac Toe (2D) + +## Summary + +A simple turn-based game demonstrating filled-room matchmaking (2 players), match creation, and strict server validation of turns. + +- Frontend: React + canvas. +- Backend: RivetKit actors. +- Assets: Kenney UI pack (downloaded at build time, gitignored). + +## Goals + +- Matchmaker queues players until exactly 2 are ready. +- Match actor serves as lobby + game state machine. +- Secure turn enforcement: players cannot play out of turn or spoof the other seat. + +## Non-goals + +- Ranked matchmaking. +- Spectators. + +## UX + +- Landing: enter display name, click Find Match. +- Lobby: shows "waiting for opponent" until matched. +- Game: 3x3 grid, show whose turn, show winner/draw, rematch. + +## Actors + +### `tttMatchmaker` (coordinator) + +- Key: `["main"]`. +- DB: `rivetkit/db` raw SQLite for a potentially large matchmaking queue. +- State: + - `waitingConnIds: string[]` + - `activeMatchIds: string[]` (optional) +- SQLite schema: + - `queue(conn_id TEXT PRIMARY KEY, enqueued_at INTEGER NOT NULL)` +- Lifecycle: + - `onMigrate`: create `queue` table. +- Actions: + - `enqueue(): { status: "queued" } | { status: "matched"; matchId: string; seat: "x" | "o" }` + - Uses `c.conn.id`. + - If queue empty, enqueue and return queued. + - If one waiting, pop and create a new `tttMatch` and return match info. + - `cancelQueue()` removes `c.conn.id` if present. + +Implementation note: + +- Even if `waitingConnIds` exists in memory for convenience, the source of truth should be SQLite (`queue` table) so a restart does not lose the queue. + +### `tttMatch` (data) + +- Key: `[matchId]`. +- State: + - `phase: "lobby" | "playing" | "finished"` + - `players: { x?: { connId: string; name: string }; o?: { connId: string; name: string } }` + - `board: Array` length 9 + - `turn: "x" | "o"` + - `winner: null | "x" | "o" | "draw"` +- Lifecycle: + - `onConnect`: if seat open, assign the connecting `c.conn.id` to the first available seat; otherwise treat as reconnect (same conn id) or reject. + - `onDisconnect`: mark seat disconnected; optionally end match if a player leaves. +- Actions: + - `setName(name: string)` sets name for caller's seat. + - `play(index: number)`: + - Identify caller seat by `c.conn.id`. + - Validate `phase == "playing"`, `turn == callerSeat`, `board[index] == null`. + - Apply move, check winner, advance turn. + - `rematch()`: + - Only allowed when `phase == "finished"` and both players connected. +- Events: + - `stateUpdated(state)` whenever board/phase changes. + +## Networking + +- Client never sends seat or player id. +- Match actor derives caller seat by connection id. + +## Assets + +Kenney packs (chosen): + +- `ui-pack` (buttons/frames) +- `input-prompts` (optional: key prompt icons) + +- Download at build time into `assets/kenney/` (gitignored). +- Vite config must fail fast if `assets/kenney/` is missing. +- `package.json` includes `assets:download` and `predev`/`prebuild` hooks. +- `vite.config.ts` checks for the expected directory/files and throws a clear error if missing. + +## Security Checklist + +- No spoofing: `play()` determines seat by `c.conn.id`. +- Input validation: index in `[0..8]`. +- Rate limit: `play()` at most a few per second per connection. + +## Testing + +Automated (Vitest): + +- Actor-level tests for queue behavior (enqueue/cancel/match pairing) with SQLite-backed queue. +- Actor-level tests for `tttMatch.play()` turn enforcement, invalid moves, winner/draw detection, and rematch reset. + +Manual: + +- Two tabs: confirm only the correct tab can play its own moves. +- Attempt double move in same turn; server rejects. +- Rematch resets state cleanly. diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 0000000000..bf23721dd1 --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,152 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - Context file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read the files listed in `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.