openvibers/openvibe.games README.md
README.md
OpenVibe.Games
Browser games and creator worlds — openvibe.games. Play in the browser, with achievements on OpenVibe.Play.
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; help and the developer API.
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@2packs, grants, audit), the
games-quickjs@1server script mods (docs/mods.md) and thegames.*events - the game WebSocket protocol and the portal,
/playand/editorpages - the casual kit (
packages/casual), its server economy (saves, ledger, idempotent actions) and the/play/<game>
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 boundedgames-quickjs@1scripts
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-contractsv0.77.0,openvibe-sdkv0.22.0 andopenvibe-sharedv4.33.0 (pinned by release
tarball inapps/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
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
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'ssubject_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 thegamesOAuth 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, tableevent_outbox):games.player.joined|left,
games.skill.leveled,games.blueprint.unlocked,games.world.saved
(at most one checkpoint perWORLD_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, namespaceMEDIA_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
[email protected](Games validates against the schema in the
installedopenvibe-contractspackage — pin drift is a defect, ADR-0007
decision 12). The content runtime isgames-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 inhello; the server checks it before joining and echoes its digest inwelcome. 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 installgames-quickjs@1script 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 whereGAMES_SCRIPT_MODS=1(budgets
cpuMs2 per tick,memoryMb8,stackKb128 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 withgames.mod.manage). Fresh clients fetch
GET /api/v1/contentbefore the digest handshake so live script changes can be joined. - Status:
GET /api/v1/platformreports 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.savedevents reach OpenVibe.Events; no mod is
installed, no grant exists and no asset has been mirrored to Media yet. - OpenVibe Frame. The portal,
/playand/editorload 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.tscovers 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.
<!-- versions:start -->
- openvibe-contracts: v0.422.0
- openvibe-sdk: v0.161.0
- openvibe-shared: v4.237.0
- openvibe-publishing: v1.7.0
<!-- versions:end -->