![OpenVibe.Games](https://openvibe.games/shared/social/games.jpg) # OpenVibe.Games **Browser games and creator worlds** — [openvibe.games](https://openvibe.games). Play in the browser, with achievements on [OpenVibe.Play](https://play.openvibe.network). **Live site:** https://openvibe.games · **API docs:** https://openvibe.services · **For AI:** https://openvibe.games/llms.txt **Run it:** `pnpm install && pnpm dev` (Node 22 or newer) · **Tests:** `npm test` Part of the [OpenVibe network](https://openvibe.network); [help](https://openvibe.help) and the [developer API](https://openvibe.services). ## Purpose OpenVibe.Games hosts Scraplandia at https://openvibe.games and https://play.openvibe.games. Scraplandia's extraction events double as its scored "Haul" mode: securing at-risk valuables at a recovery bird earns haul points, with its own all-time/this-week leaderboard and achievements. The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host. See [docs/games.md](docs/games.md) for both games and local progression. Server-run Vibe Heroes AI draft matches and Bench scoring are described in [docs/ai-matches.md](docs/ai-matches.md). Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join (sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds). It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with **Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md). The player pages of OpenVibe.Play (the game services behind Games) live here too: `/achievements`, `/leaderboards` and the API guide for game makers at `/developers`. They read Play's public API server-side; the service and its API stay at play.openvibe.network, whose old pages 301 here. A persistent multiplayer physics sandbox for the browser: Source-style movement, physgun-driven building from crafted physical objects, gathering, crafting, and extraction risk — running against an authoritative dedicated server. **Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) · Rapier physics (`@dimforge/rapier3d-deterministic-compat`, one pinned build headless on the server and in client prediction) · WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo. ## Owns - the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under `packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O) - the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit), the `games-quickjs@1` server script mods (docs/mods.md) and the `games.*` events - the game WebSocket protocol and the portal, `/play` and `/editor` pages - the casual kit (`packages/casual`), its server economy (saves, ledger, idempotent actions) and the `/play/` pages ## Does not own - identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the local copy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events) - mod grants' authority (Network keeps them, `mods.grant.manage`) and general-purpose executable mods (OpenVibe.Host Stage C); Games runs its own bounded `games-quickjs@1` scripts ## Depends on - OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary` user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror) - `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v4.33.0 (pinned by release tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in development), Valkey (`iovalkey`, optional) ## Repository layout ``` apps/ client/ Browser client: rendering, prediction, input, HUD server/ Dedicated authoritative server: simulation, networking, persistence packages/ shared/ Math, ids, events, logging facade, fixed-timestep primitives protocol/ Versioned wire messages + codec (zod-validated inbound) content/ Data-driven item/recipe/world definitions + validation registry gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities physics/ PhysicsWorld abstraction + Rapier adapter (@openvibe/physics/rapier) persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations casual/ The casual kit: rules, auto-battle and the Vibe Heroes pack (engine-free) docs/adr/ Architecture decision records ``` See [ARCHITECTURE.md](ARCHITECTURE.md) for boundaries and data flow, [ROADMAP.md](ROADMAP.md) for what exists and what is planned. ## Prerequisites - Node.js >= 22 - pnpm 10 (`corepack enable`) ## Development ```bash pnpm install # Terminal 1 — authoritative server on :8000 (tsx watch) pnpm dev:server # Terminal 2 — client with hot reload on :5173 (proxies /ws to :8000) pnpm dev:client ``` Open http://localhost:5173 in two browser windows to see multiplayer. **Controls:** WASD move · Space jump (hold to bunnyhop) · Shift sprint · 1-6 hotbar · Tab menu (inventory / crafting / skills / players) · G drop held item · E gather / pick up props. **Physgun (GMod-style):** hold LMB grab (grabbing a frozen prop unfreezes it) · release to let go · RMB freeze · wheel push/pull · hold E rotate like a globe (Shift+E snaps) · hold Shift for grid-lock. **Building:** craft pieces → drop them (G or drag out of the inventory) → position with the physgun → freeze. Dropping IS placement; E picks any prop back up into your inventory. **The loop:** spawn in Scrap City (safe city — no PvP, no building, no physgun), gather branches/stones/scrap outside the gates, craft tools, harvest the forest (axe/woodcutting) and quarry (pickaxe/mining), craft building pieces, place them in the wilds, position with the physgun and freeze. Props are protected: only you and players you trust can move them. ## Commands | Command | Purpose | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `pnpm dev` | Server + client dev processes in parallel | | `pnpm test` | All unit tests (vitest, through `test/run.js`) | | `pnpm typecheck` | Strict TypeScript across every package | | `pnpm lint` | ESLint (typescript-eslint, no-explicit-any) | | `pnpm format` | Prettier write | | `pnpm build` | Production build of all packages + client bundle | | `tsx apps/server/scripts/sliceTest.ts` | End-to-end vertical-slice test (boots a real server, drives protocol clients, restarts, asserts persistence) | ## Production ```bash pnpm build DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/client/dist PORT=8000 pnpm --filter @openvibe/server start ``` `DATABASE_URL` (pooled, through PgBouncer) serves; migrations run at boot on `DATABASE_DIRECT_URL` (the owner role, a direct session). With neither set and `NODE_ENV` not `production`, the server uses an embedded PGlite database under `DATABASE_DIR` (default `data/`) — development only, one process. `GAMES_PLACE_ID` (default `scraplandia`) selects the default place for older clients and the edited map. Both built-in places have separate world rows and instance leases. `VALKEY_URL` (optional) makes the per-actor limits and the WebSocket upgrade tickets shared across instances (issue is `SET … PX … NX`, consume is `GETDEL`, so a ticket is single-use cluster-wide). `GAMES_INSTANCES_PER_PLACE` (default `1`) enables several room simulations for a place in this process; `MAX_PLAYERS` is per-room capacity. PostgreSQL records each room and its renewable fenced lease. `GAMES_INTEREST_CELL` (default `16`) and `GAMES_INTEREST_RADIUS` (default `80`) tune spatial replication. See [docs/instances.md](docs/instances.md) for transfer, lease expiry and the one-process limit. `TRUST_PROXY` (optional) says which proxies may set the client-address headers: unset means the TCP peer is the client and `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` are ignored (they are client-supplied and would otherwise let a caller mint fresh rate-limit buckets); `true` means the server is only reachable through the proxy; otherwise a comma-separated list of trusted proxy IPs/CIDRs (behind nginx, which sets `X-Real-IP` from `$remote_addr`). See [.env.example](.env.example). The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is the game and `/play` redirects there. Any other path that is neither a file of the build nor a route answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text for a missing asset), never the portal with a 200. `/api/ready` is 200 only while the database answers a real round trip (`db.ready()`) and the simulation has ticked within the last 5 s, otherwise 503 naming the failed check (`status` `ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `STATIC_DIR`, `MAX_PLAYERS`, `LOG_LEVEL`, `GAMES_PLACE_ID`, the database variables above (platform variables below). Deployed at https://openvibe.games (nginx TLS termination → server on 127.0.0.1:8000, systemd unit `openvibe-games.service` from [deploy/openvibe-games.service](deploy/openvibe-games.service), env file `/etc/openvibe/games.env` (which holds `DATABASE_URL`, `DATABASE_DIRECT_URL` and `VALKEY_URL`); play.openvibe.games Host-routes to the same process). Deploy on the host with `sudo /opt/openvibe.games/deploy/scripts/deploy.sh`, which runs `ovhost deploy games` (OpenVibe.Host, strategy `pnpm-build`; roadmap WS-N task 11): a fast-forward pull as the checkout owner, `pnpm install --frozen-lockfile`, every workspace package's dependencies checked, `pnpm build`, the restart (the SDK migrator applies any new migration as the owner before serving), `/api/ready`, and on failure the checkout restored, reinstalled and rebuilt and the server restarted again. Players online are reported and reconnect; `--wait-idle` holds the restart until nobody plays, `--rollback` runs `ovhost rollback games`, `DRY_RUN=1` prints `ovhost plan games`. Do not pull by hand first (ovhost would find nothing new; pass `--restart` if you did). The wrapper exits with an error if ovhost is missing. Rollback: `sudo deploy/scripts/deploy.sh --rollback` (`ovhost rollback games`), which rebuilds the previous commit. Migrations are additive and applied at boot, so an older build starts on the newer schema; the SDK migrator refuses a migration file that was edited after it ran (write a new one instead). ## Platform integration (OpenVibe network) Games integrates with the rest of OpenVibe at its service boundary only (`apps/server/src/platform`, `apps/server/src/mods`); the simulation packages do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects-and-platform-boundary.md). - **Identity.** Signed-in players are keyed by their canonical openvibe.network subject (`usr_…`/`gst_…`, the token's `subject_id`); a token without one cannot name an account. Guest tokens that look like account keys (`usr_…`) are refused. - **Service principal.** Calls to Events, Media and Network identity carry a client-credentials token of the `games` OAuth client (same client id and secret as SSO), one per audience. There are no shared keys. The SSO calls made for a player (`/api/auth/me`, code and FedCM exchanges) keep using the player's own token. - **Events** (OpenVibe.Events, through a transactional outbox in PostgreSQL, table `event_outbox`): `games.player.joined|left`, `games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved` (at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and `games.mod.installed|enabled|disabled|grants_changed|revoked`. Achievements are not published as events: an unlock is stored in Games (its achievement rules read those tables) and sent to OpenVibe.Play over Play's API (`GAMES_PLAY_URL`), which is the network-wide read surface for titles, scores and unlocks. Progression events come from what a save actually wrote, in the same transaction. - **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. - **Media.** Map-editor assets (textures, paint masks, glb models; the only uploaded files Games has) are still served from local disk and are also copied into Media objects (`kind: asset`, namespace `MEDIA_NAMESPACE`), with a restart-safe queue (`media_mirrors`). There are no screenshots or blueprint files to upload: blueprints are per-player recipe unlocks. - **Mods** (ADR-013 in OpenVibe.Contracts). Manifests follow `mods.mod-manifest@1.1.0` (Games validates against the schema in the installed `openvibe-contracts` package — pin drift is a defect, ADR-0007 decision 12). The content runtime is `games-content@2`: declarative data packs checked against `@openvibe/content` (announcements; inert, mod-owned props; definitions — items, recipes, crops, NPC archetypes — merged into the content registry at boot, so they take effect on the next 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 requested capabilities; every runtime binding checks it at call time, a revoked install or capability stops affecting the world on the next tick, and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are metadata only. Staff can install `games-quickjs@1` script packs through the mods API; built-in packs can carry scripts too. Scripts are pinned by the def-set digest and run in a per-mod QuickJS sandbox only where `GAMES_SCRIPT_MODS=1` (budgets `cpuMs` 2 per tick, `memoryMb` 8, `stackKb` 128 by default; over budget disables the mod for the instance; host API and limits in docs/mods.md). API: `GET /api/v1/mods`, `GET /api/v1/mods/:id` (public); `POST /api/v1/mods`, `POST /api/v1/mods/:id/enable|disable|revoke|grants|reload`, `DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit` (owner/admin session, or a principal token with `games.mod.manage`). Fresh clients fetch `GET /api/v1/content` before the digest handshake so live script changes can be joined. - **Status:** `GET /api/v1/platform` reports whether events and the Media mirror are on, the outbox backlog and mirror counts (no secrets). - **In production (2026-09-23, release `f11e21c`):** events and the Media mirror are on. `games.world.saved` events reach OpenVibe.Events; no mod is installed, no grant exists and no asset has been mirrored to Media yet. - **OpenVibe Frame.** The portal, `/play` and `/editor` load the OpenVibe Frame (navbar, footer, theme loader) from this server's own pinned copy at `/shared/` (`apps/server/src/net/sharedAssets.ts`), so they keep their frame while Network is down. - **Persistence proof.** `apps/server/src/game/platformIntegration.test.ts` boots the real server twice on one database and checks characters, world props, the world clock and the mod registry survive the restart; `sliceTest.ts` covers the gameplay state end to end. Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`): | Variable | Purpose | | ---------------------------------- | ------------------------------------------------------------------------------------------ | | `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) | | `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call | | `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) | | `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) | | `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) | | `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) | | `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset | | `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local | | `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) | | `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) | | `GAMES_CREATOR_WORLDS_MAX` | Creator worlds running at once (default 2; 0 stops new ones starting) | | `GAMES_CREATOR_WORLDS_PER_PERSON` | Worlds one account keeps (default 5) | | `GAMES_CREATOR_WORLD_IDLE_SECONDS` | An empty creator world stops after this long (default 180) | | `GAMES_CREATOR_WORLD_PLAYERS` | Players per creator world (default 16) | | `GAMES_CREATOR_SCRIPTS` | `1` runs creator worlds' own scripts in their rooms (QuickJS sandbox; off by default) | ## Capabilities Implemented here (the service manifest's `capabilities`): `games.mod.manage` and `games.mod.read` (the mods API, for a principal token or an owner/admin session); `games.world.announce`, `games.prop.place`, and `games.def.define` for declarative packs; and `games.script.run`, `games.players.read`, `games.player.message`, `games.world.emit`, and `games.storage.kv` for script packs. Script bindings are absent unless approved for that install. Service grants the `games` principal needs: `inventory.item.grant` (openvibe.inventory), `events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account, revocation and mod-grant subscriptions), `media.object.upload` (openvibe.media, namespace `games`), `identity.subject.resolve` (openvibe.network), `network.modules.write` on `games.progress.summary`, and `mods.grant.manage` (Network's mod grants, roadmap WS-M task 3). ## Tests `pnpm test` runs every vitest file (unit tests of the packages and the server, including `apps/server/src/game/platformIntegration.test.ts`, which boots the real server twice on one database); it goes through `test/run.js`, which maps the flat `test/*.test.js` pointers back to the colocated TypeScript tests so `ov test` can select and shard them (vitest reads the real files, so their `@vitest-environment` docblocks and imports are untouched). After adding or moving a `*.test.ts`, run `node test/run.js --index` to regenerate the pointers — a missing or stale pointer fails the run. `pnpm typecheck` and `pnpm lint` keep strict TypeScript and no `any`; `tsx apps/server/scripts/sliceTest.ts` drives protocol clients against a real server across a restart. ## Security Reporting a vulnerability: [SECURITY.md](SECURITY.md). The server is authoritative: inbound protocol messages are validated (zod), the WebSocket has per-socket flood control, and HTTP writes that cross a capability boundary (editor saves and uploads, the mods API) have per-actor limits (openvibe-sdk/limits). Players are keyed by Network subjects; guest tokens that look like account keys are refused. Script mods run in isolated QuickJS sandboxes with only their approved bindings. nginx keeps `/api/ready` off the public vhosts and `/metrics` answers direct loopback callers only. `OV_OAUTH_CLIENT_SECRET`, `GAMES_EVENTS_SECRET` (signed Events deliveries) and `OV_NETWORK_AUTH_URL` live in `/etc/openvibe/games.env`, by name only; there are no shared keys between services. - openvibe-contracts: v0.422.0 - openvibe-sdk: v0.161.0 - openvibe-shared: v4.237.0 - openvibe-publishing: v1.7.0