openvibers/openvibe.games README.md
README.md
| 1 |  |
| 2 | |
| 3 | # OpenVibe.Games |
| 4 | |
| 5 | **Browser games and creator worlds** — [openvibe.games](https://openvibe.games). Play in the browser, with achievements on [OpenVibe.Play](https://play.openvibe.network). |
| 6 | |
| 7 | **Live site:** https://openvibe.games · **API docs:** https://openvibe.services · **For AI:** https://openvibe.games/llms.txt |
| 8 | **Run it:** `pnpm install && pnpm dev` (Node 22 or newer) · **Tests:** `npm test` |
| 9 | |
| 10 | Part of the [OpenVibe network](https://openvibe.network); [help](https://openvibe.help) and the [developer API](https://openvibe.services). |
| 11 | |
| 12 | ## Purpose |
| 13 | |
| 14 | OpenVibe.Games hosts Scraplandia at https://openvibe.games and https://play.openvibe.games. |
| 15 | Scraplandia's extraction events double as its scored "Haul" mode: securing at-risk valuables at a |
| 16 | recovery bird earns haul points, with its own all-time/this-week leaderboard and achievements. |
| 17 | The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own |
| 18 | place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host. |
| 19 | See [docs/games.md](docs/games.md) for both games and local progression. |
| 20 | Server-run Vibe Heroes AI draft matches and Bench scoring are described in [docs/ai-matches.md](docs/ai-matches.md). |
| 21 | Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join |
| 22 | (sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds). |
| 23 | It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with |
| 24 | **Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md). |
| 25 | The player pages of OpenVibe.Play (the game services behind Games) live here too: `/achievements`, `/leaderboards` |
| 26 | and the API guide for game makers at `/developers`. They read Play's public API server-side; the service and its API |
| 27 | stay at play.openvibe.network, whose old pages 301 here. |
| 28 | |
| 29 | A persistent multiplayer physics sandbox for the browser: Source-style movement, |
| 30 | physgun-driven building from crafted physical objects, gathering, crafting, and |
| 31 | extraction risk — running against an authoritative dedicated server. |
| 32 | |
| 33 | **Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) · |
| 34 | Rapier physics (`@dimforge/rapier3d-deterministic-compat`, one pinned build |
| 35 | headless on the server and in client prediction) · |
| 36 | WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo. |
| 37 | |
| 38 | ## Owns |
| 39 | |
| 40 | - the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and |
| 41 | props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under |
| 42 | `packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O) |
| 43 | - the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit), the |
| 44 | `games-quickjs@1` server script mods (docs/mods.md) and the `games.*` events |
| 45 | - the game WebSocket protocol and the portal, `/play` and `/editor` pages |
| 46 | - the casual kit (`packages/casual`), its server economy (saves, ledger, idempotent actions) and the `/play/<game>` |
| 47 | pages |
| 48 | |
| 49 | ## Does not own |
| 50 | |
| 51 | - identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the local |
| 52 | copy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events) |
| 53 | - mod grants' authority (Network keeps them, `mods.grant.manage`) and general-purpose executable |
| 54 | mods (OpenVibe.Host Stage C); Games runs its own bounded `games-quickjs@1` scripts |
| 55 | |
| 56 | ## Depends on |
| 57 | |
| 58 | - OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary` |
| 59 | user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror) |
| 60 | - `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v4.33.0 (pinned by release |
| 61 | tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in |
| 62 | development), Valkey (`iovalkey`, optional) |
| 63 | |
| 64 | ## Repository layout |
| 65 | |
| 66 | ``` |
| 67 | apps/ |
| 68 | client/ Browser client: rendering, prediction, input, HUD |
| 69 | server/ Dedicated authoritative server: simulation, networking, persistence |
| 70 | packages/ |
| 71 | shared/ Math, ids, events, logging facade, fixed-timestep primitives |
| 72 | protocol/ Versioned wire messages + codec (zod-validated inbound) |
| 73 | content/ Data-driven item/recipe/world definitions + validation registry |
| 74 | gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities |
| 75 | physics/ PhysicsWorld abstraction + Rapier adapter (@openvibe/physics/rapier) |
| 76 | persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations |
| 77 | casual/ The casual kit: rules, auto-battle and the Vibe Heroes pack (engine-free) |
| 78 | docs/adr/ Architecture decision records |
| 79 | ``` |
| 80 | |
| 81 | See [ARCHITECTURE.md](ARCHITECTURE.md) for boundaries and data flow, |
| 82 | [ROADMAP.md](ROADMAP.md) for what exists and what is planned. |
| 83 | |
| 84 | ## Prerequisites |
| 85 | |
| 86 | - Node.js >= 22 |
| 87 | - pnpm 10 (`corepack enable`) |
| 88 | |
| 89 | ## Development |
| 90 | |
| 91 | ```bash |
| 92 | pnpm install |
| 93 | |
| 94 | # Terminal 1 — authoritative server on :8000 (tsx watch) |
| 95 | pnpm dev:server |
| 96 | |
| 97 | # Terminal 2 — client with hot reload on :5173 (proxies /ws to :8000) |
| 98 | pnpm dev:client |
| 99 | ``` |
| 100 | |
| 101 | Open http://localhost:5173 in two browser windows to see multiplayer. |
| 102 | |
| 103 | **Controls:** WASD move · Space jump (hold to bunnyhop) · Shift sprint · |
| 104 | 1-6 hotbar · Tab menu (inventory / crafting / skills / players) · G drop |
| 105 | held item · E gather / pick up props. |
| 106 | **Physgun (GMod-style):** hold LMB grab (grabbing a frozen prop unfreezes |
| 107 | it) · release to let go · RMB freeze · wheel push/pull · hold E rotate |
| 108 | like a globe (Shift+E snaps) · hold Shift for grid-lock. |
| 109 | **Building:** craft pieces → drop them (G or drag out of the inventory) → |
| 110 | position with the physgun → freeze. Dropping IS placement; E picks any |
| 111 | prop back up into your inventory. |
| 112 | |
| 113 | **The loop:** spawn in Scrap City (safe city — no PvP, no building, no |
| 114 | physgun), gather branches/stones/scrap outside the gates, craft tools, |
| 115 | harvest the forest (axe/woodcutting) and quarry (pickaxe/mining), craft |
| 116 | building pieces, place them in the wilds, position with the physgun and |
| 117 | freeze. Props are protected: only you and players you trust can move them. |
| 118 | |
| 119 | ## Commands |
| 120 | |
| 121 | | Command | Purpose | |
| 122 | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------ | |
| 123 | | `pnpm dev` | Server + client dev processes in parallel | |
| 124 | | `pnpm test` | All unit tests (vitest, through `test/run.js`) | |
| 125 | | `pnpm typecheck` | Strict TypeScript across every package | |
| 126 | | `pnpm lint` | ESLint (typescript-eslint, no-explicit-any) | |
| 127 | | `pnpm format` | Prettier write | |
| 128 | | `pnpm build` | Production build of all packages + client bundle | |
| 129 | | `tsx apps/server/scripts/sliceTest.ts` | End-to-end vertical-slice test (boots a real server, drives protocol clients, restarts, asserts persistence) | |
| 130 | |
| 131 | ## Production |
| 132 | |
| 133 | ```bash |
| 134 | pnpm build |
| 135 | DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/client/dist PORT=8000 pnpm --filter @openvibe/server start |
| 136 | ``` |
| 137 | |
| 138 | `DATABASE_URL` (pooled, through PgBouncer) serves; migrations run at boot on `DATABASE_DIRECT_URL` |
| 139 | (the owner role, a direct session). With neither set and `NODE_ENV` not `production`, the server uses |
| 140 | an embedded PGlite database under `DATABASE_DIR` (default `data/`) — development only, one process. |
| 141 | `GAMES_PLACE_ID` (default `scraplandia`) selects the default place for older clients and the |
| 142 | edited map. Both built-in places have separate world rows and instance leases. `VALKEY_URL` |
| 143 | (optional) makes the per-actor limits and the WebSocket upgrade tickets shared across instances |
| 144 | (issue is `SET … PX … NX`, consume is `GETDEL`, so a ticket is single-use cluster-wide). |
| 145 | `GAMES_INSTANCES_PER_PLACE` (default `1`) enables several room simulations for a place in this process; |
| 146 | `MAX_PLAYERS` is per-room capacity. PostgreSQL records each room and its renewable fenced lease. |
| 147 | `GAMES_INTEREST_CELL` (default `16`) and `GAMES_INTEREST_RADIUS` (default `80`) tune spatial replication. |
| 148 | See [docs/instances.md](docs/instances.md) for transfer, lease expiry and the one-process limit. |
| 149 | `TRUST_PROXY` (optional) says which proxies may set the client-address headers: unset means the TCP |
| 150 | peer is the client and `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` are ignored (they are |
| 151 | client-supplied and would otherwise let a caller mint fresh rate-limit buckets); `true` means the |
| 152 | server is only reachable through the proxy; otherwise a comma-separated list of trusted proxy |
| 153 | IPs/CIDRs (behind nginx, which sets `X-Real-IP` from `$remote_addr`). See [.env.example](.env.example). |
| 154 | |
| 155 | The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game |
| 156 | WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is |
| 157 | the game and `/play` redirects there. Any other path that is neither a file of the build nor a route |
| 158 | answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text |
| 159 | for a missing asset), never the portal with a 200. `/api/ready` is 200 only while the database answers a real round trip |
| 160 | (`db.ready()`) and the simulation |
| 161 | has ticked within the last 5 s, otherwise 503 naming the failed check (`status` |
| 162 | `ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `STATIC_DIR`, |
| 163 | `MAX_PLAYERS`, `LOG_LEVEL`, `GAMES_PLACE_ID`, the database variables above (platform variables below). |
| 164 | |
| 165 | Deployed at https://openvibe.games (nginx TLS termination → server on 127.0.0.1:8000, |
| 166 | systemd unit `openvibe-games.service` from [deploy/openvibe-games.service](deploy/openvibe-games.service), |
| 167 | env file `/etc/openvibe/games.env` (which holds `DATABASE_URL`, `DATABASE_DIRECT_URL` and `VALKEY_URL`); |
| 168 | play.openvibe.games Host-routes to the same process). |
| 169 | |
| 170 | Deploy on the host with `sudo /opt/openvibe.games/deploy/scripts/deploy.sh`, which runs |
| 171 | `ovhost deploy games` (OpenVibe.Host, strategy `pnpm-build`; roadmap WS-N task 11): a fast-forward pull as |
| 172 | the checkout owner, `pnpm install --frozen-lockfile`, every workspace package's dependencies checked, `pnpm build`, |
| 173 | the restart (the SDK migrator applies any new migration as the owner before serving), `/api/ready`, and on failure the checkout |
| 174 | restored, reinstalled and rebuilt and the server restarted again. Players online are reported and |
| 175 | reconnect; `--wait-idle` holds the restart until nobody plays, `--rollback` runs `ovhost rollback games`, |
| 176 | `DRY_RUN=1` prints `ovhost plan games`. Do not pull by hand first (ovhost would find nothing new; pass |
| 177 | `--restart` if you did). The wrapper exits with an error if ovhost is missing. |
| 178 | |
| 179 | Rollback: `sudo deploy/scripts/deploy.sh --rollback` (`ovhost rollback games`), which rebuilds the previous |
| 180 | commit. Migrations are additive and applied at boot, so an older build starts on the newer schema; the SDK |
| 181 | migrator refuses a migration file that was edited after it ran (write a new one instead). |
| 182 | |
| 183 | ## Platform integration (OpenVibe network) |
| 184 | |
| 185 | Games integrates with the rest of OpenVibe at its service boundary only |
| 186 | (`apps/server/src/platform`, `apps/server/src/mods`); the simulation packages |
| 187 | do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects-and-platform-boundary.md). |
| 188 | |
| 189 | - **Identity.** Signed-in players are keyed by their canonical |
| 190 | openvibe.network subject (`usr_…`/`gst_…`, the token's `subject_id`); a |
| 191 | token without one cannot name an account. Guest tokens that look like |
| 192 | account keys (`usr_…`) are refused. |
| 193 | - **Service principal.** Calls to Events, Media and Network identity carry a |
| 194 | client-credentials token of the `games` OAuth client (same client id and |
| 195 | secret as SSO), one per audience. There are no shared keys. The SSO calls |
| 196 | made for a player (`/api/auth/me`, code and FedCM exchanges) keep using the |
| 197 | player's own token. |
| 198 | - **Events** (OpenVibe.Events, through a transactional outbox in |
| 199 | PostgreSQL, table `event_outbox`): `games.player.joined|left`, |
| 200 | `games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved` |
| 201 | (at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and |
| 202 | `games.mod.installed|enabled|disabled|grants_changed|revoked`. Achievements are |
| 203 | not published as events: an unlock is stored in Games (its achievement rules read |
| 204 | those tables) and sent to OpenVibe.Play over Play's API (`GAMES_PLAY_URL`), which |
| 205 | is the network-wide read surface for titles, scores and unlocks. |
| 206 | Progression events come from what a save actually wrote, in the same |
| 207 | transaction. |
| 208 | - **Inventory.** Six built-in achievements give Live items: Scraplandia First Haul (Toxic Name), Big Haul (Golden Name), Haul Week (Cowboy Hat); Salvage Rush First Haul (Neon Name), Big Haul (Flames), Arena Winner (Stars). Games grants them after unlock or guest adoption using its own OAuth client; failed grants retry. Inventory items are free, never sold or traded. |
| 209 | - **Media.** Map-editor assets (textures, paint masks, glb models; the only |
| 210 | uploaded files Games has) are still served from local disk and are also |
| 211 | copied into Media objects (`kind: asset`, namespace `MEDIA_NAMESPACE`), with |
| 212 | a restart-safe queue (`media_mirrors`). There are no screenshots or blueprint |
| 213 | files to upload: blueprints are per-player recipe unlocks. |
| 214 | - **Mods** (ADR-013 in OpenVibe.Contracts). Manifests follow |
| 215 | `[email protected]` (Games validates against the schema in the |
| 216 | installed `openvibe-contracts` package — pin drift is a defect, ADR-0007 |
| 217 | decision 12). The content runtime is `games-content@2`: |
| 218 | declarative data packs checked against `@openvibe/content` (announcements; |
| 219 | inert, mod-owned props; definitions — items, recipes, crops, NPC archetypes — |
| 220 | merged into the content registry at boot, so they take effect on the next |
| 221 | restart). The client sends its definition digest in `hello`; the server checks it before joining and echoes its digest in `welcome`. A mismatch is rejected so installed definitions cannot silently desync clients. Scraplandia's default blank-slate map is supplied by the built-in pack. Each install stores the approved subset of its |
| 222 | requested capabilities; every runtime binding checks it at call time, a |
| 223 | revoked install or capability stops affecting the world on the next tick, |
| 224 | and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are |
| 225 | metadata only. Staff can install `games-quickjs@1` script packs through the mods API; |
| 226 | built-in packs can carry scripts too. Scripts are pinned by the def-set digest and run |
| 227 | in a per-mod QuickJS sandbox only where `GAMES_SCRIPT_MODS=1` (budgets |
| 228 | `cpuMs` 2 per tick, `memoryMb` 8, `stackKb` 128 by default; over budget |
| 229 | disables the mod for the instance; host API and limits in docs/mods.md). API: `GET /api/v1/mods`, |
| 230 | `GET /api/v1/mods/:id` (public); `POST /api/v1/mods`, |
| 231 | `POST /api/v1/mods/:id/enable|disable|revoke|grants|reload`, |
| 232 | `DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit` |
| 233 | (owner/admin session, or a principal token with `games.mod.manage`). Fresh clients fetch |
| 234 | `GET /api/v1/content` before the digest handshake so live script changes can be joined. |
| 235 | - **Status:** `GET /api/v1/platform` reports whether events and the Media |
| 236 | mirror are on, the outbox backlog and mirror counts (no secrets). |
| 237 | - **In production (2026-09-23, release `f11e21c`):** events and the Media |
| 238 | mirror are on. `games.world.saved` events reach OpenVibe.Events; no mod is |
| 239 | installed, no grant exists and no asset has been mirrored to Media yet. |
| 240 | - **OpenVibe Frame.** The portal, `/play` and `/editor` load the OpenVibe Frame |
| 241 | (navbar, footer, theme loader) from this server's own pinned copy at `/shared/` |
| 242 | (`apps/server/src/net/sharedAssets.ts`), so they keep their frame while Network is down. |
| 243 | - **Persistence proof.** `apps/server/src/game/platformIntegration.test.ts` |
| 244 | boots the real server twice on one database and checks characters, world |
| 245 | props, the world clock and the mod registry survive the restart; |
| 246 | `sliceTest.ts` covers the gameplay state end to end. |
| 247 | |
| 248 | Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`): |
| 249 | |
| 250 | | Variable | Purpose | |
| 251 | | ---------------------------------- | ------------------------------------------------------------------------------------------ | |
| 252 | | `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) | |
| 253 | | `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call | |
| 254 | | `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) | |
| 255 | | `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) | |
| 256 | | `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) | |
| 257 | | `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) | |
| 258 | | `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset | |
| 259 | | `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local | |
| 260 | | `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) | |
| 261 | | `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) | |
| 262 | | `GAMES_CREATOR_WORLDS_MAX` | Creator worlds running at once (default 2; 0 stops new ones starting) | |
| 263 | | `GAMES_CREATOR_WORLDS_PER_PERSON` | Worlds one account keeps (default 5) | |
| 264 | | `GAMES_CREATOR_WORLD_IDLE_SECONDS` | An empty creator world stops after this long (default 180) | |
| 265 | | `GAMES_CREATOR_WORLD_PLAYERS` | Players per creator world (default 16) | |
| 266 | | `GAMES_CREATOR_SCRIPTS` | `1` runs creator worlds' own scripts in their rooms (QuickJS sandbox; off by default) | |
| 267 | |
| 268 | ## Capabilities |
| 269 | |
| 270 | Implemented here (the service manifest's `capabilities`): `games.mod.manage` and `games.mod.read` (the |
| 271 | mods API, for a principal token or an owner/admin session); `games.world.announce`, |
| 272 | `games.prop.place`, and `games.def.define` for declarative packs; and |
| 273 | `games.script.run`, `games.players.read`, `games.player.message`, |
| 274 | `games.world.emit`, and `games.storage.kv` for script packs. Script bindings |
| 275 | are absent unless approved for that install. |
| 276 | |
| 277 | Service grants the `games` principal needs: |
| 278 | `inventory.item.grant` (openvibe.inventory), `events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account, |
| 279 | revocation and mod-grant subscriptions), `media.object.upload` |
| 280 | (openvibe.media, namespace `games`), `identity.subject.resolve` |
| 281 | (openvibe.network), `network.modules.write` on `games.progress.summary`, and `mods.grant.manage` |
| 282 | (Network's mod grants, roadmap WS-M task 3). |
| 283 | |
| 284 | ## Tests |
| 285 | |
| 286 | `pnpm test` runs every vitest file (unit tests of the packages and the server, including |
| 287 | `apps/server/src/game/platformIntegration.test.ts`, which boots the real server twice on one database); |
| 288 | it goes through `test/run.js`, which maps the flat `test/*.test.js` pointers back to the colocated |
| 289 | TypeScript tests so `ov test` can select and shard them (vitest reads the real files, so their |
| 290 | `@vitest-environment` docblocks and imports are untouched). After adding or moving a `*.test.ts`, |
| 291 | run `node test/run.js --index` to regenerate the pointers — a missing or stale pointer fails the run. |
| 292 | `pnpm typecheck` and `pnpm lint` keep strict TypeScript and no `any`; |
| 293 | `tsx apps/server/scripts/sliceTest.ts` drives protocol clients against a real server across a restart. |
| 294 | |
| 295 | ## Security |
| 296 | |
| 297 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). The server is authoritative: inbound protocol |
| 298 | messages are validated (zod), the WebSocket has per-socket flood control, and HTTP writes that cross a |
| 299 | capability boundary (editor saves and uploads, the mods API) have per-actor limits (openvibe-sdk/limits). |
| 300 | Players are keyed by Network subjects; guest tokens that look like account keys are refused. |
| 301 | Script mods run in isolated QuickJS sandboxes with only their approved bindings. |
| 302 | nginx keeps `/api/ready` off the public vhosts and `/metrics` answers direct loopback callers only. `OV_OAUTH_CLIENT_SECRET`, `GAMES_EVENTS_SECRET` (signed |
| 303 | Events deliveries) and `OV_NETWORK_AUTH_URL` live in `/etc/openvibe/games.env`, by name only; there are no shared keys |
| 304 | between services. |
| 305 | |
| 306 | <!-- versions:start --> |
| 307 | - openvibe-contracts: v0.422.0 |
| 308 | - openvibe-sdk: v0.166.0 |
| 309 | - openvibe-shared: v4.241.0 |
| 310 | - openvibe-publishing: v1.7.0 |
| 311 | <!-- versions:end --> |
| 312 |