openvibers/openvibe.games ARCHITECTURE.md
ARCHITECTURE.md
| 1 | # Architecture |
| 2 | |
| 3 | ## Principles |
| 4 | |
| 5 | 1. **The server is the game.** Clients render, predict, and express intent; |
| 6 | every gameplay-relevant decision (movement, inventory, crafting, placement, |
| 7 | physgun, damage, economy) is validated or simulated server-side. No client |
| 8 | field is ever trusted. |
| 9 | 2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Rapier, |
| 10 | DOM, or network imports; it runs identically in vitest, on the server, and |
| 11 | inside client prediction. Engines live behind adapters. |
| 12 | 3. **Data-driven content.** Items, recipes, and worlds are declarative |
| 13 | definitions validated at startup (`@openvibe/content`). Adding content never |
| 14 | requires engine changes. Stable ids everywhere; display names are cosmetic. |
| 15 | 4. **Persistent state is explicit.** DTOs in `@openvibe/persistence` are the disk |
| 16 | format. Runtime objects (meshes, bodies, live inventories) are transient |
| 17 | projections rebuilt from them. |
| 18 | 5. **Sleep is a first-class citizen.** Settled physics objects cost nothing on |
| 19 | the wire (excluded from snapshots) and nothing per-tick; they exist as |
| 20 | pinned state until something wakes them. |
| 21 | |
| 22 | ## Package dependency graph |
| 23 | |
| 24 | ``` |
| 25 | shared |
| 26 | / | \ |
| 27 | protocol content physics ──(rapier adapter: deterministic Rapier wasm) |
| 28 | \ | / |
| 29 | gameplay |
| 30 | | |
| 31 | persistence (DTOs; PostgreSQL impl behind repository interfaces; migrations) |
| 32 | | |
| 33 | apps/server apps/client |
| 34 | (authoritative) (Babylon render + prediction + HUD) |
| 35 | ``` |
| 36 | |
| 37 | Only `apps/client` knows Babylon exists: ADR-0007 decision 2 removed the engine |
| 38 | from the authoritative server, and an architecture audit |
| 39 | (`apps/server/src/architectureAudit.audit.ts`) fails if `apps/server` imports |
| 40 | `@babylonjs/*`. Only `apps/server` and `packages/persistence` know PostgreSQL |
| 41 | (openvibe-sdk/db) exists. `packages/*` never import browser-only APIs: the |
| 42 | physics adapter is Rapier, which runs identically in Node and the browser with no |
| 43 | scene or engine, so there is no Node-only path to keep separate. |
| 44 | |
| 45 | ## Simulation model |
| 46 | |
| 47 | - Fixed 30 Hz tick on the server (`FixedTimestep` also drives client |
| 48 | prediction). Snapshots at 15 Hz. Rendering is decoupled and interpolated. |
| 49 | - **Movement** is a kinematic Source/Quake controller (`gameplay/movement`): |
| 50 | explicit velocity + capsule sweeps + clip-plane sliding + step-up, with |
| 51 | friction / ground-accelerate / air-accelerate (air-strafing works). The |
| 52 | player is _not_ a dynamic rigid body; a kinematic Rapier capsule mirrors the |
| 53 | player so props collide with them. The controller consumes a |
| 54 | `CollisionQueries` interface — the Rapier adapter provides it on both sides |
| 55 | (one implementation, no scene), the tests provide analytic worlds. |
| 56 | - **Client prediction:** every input command (seq-stamped) is sent and applied |
| 57 | locally; snapshots carry the last processed seq; the client rewinds to the |
| 58 | authoritative state and replays unacked inputs. Remote entities render from |
| 59 | ~130 ms interpolation buffers. |
| 60 | - **Physics:** Rapier (`@dimforge/rapier3d-deterministic-compat`, one pinned |
| 61 | build on both sides) behind `PhysicsWorld` (`@openvibe/physics`). Stepping is |
| 62 | always manual (`world.step(dt)` from the fixed loop) — never coupled to render |
| 63 | frames. Collision layers: Static / Prop / Player, filtered at the shape level |
| 64 | for rays and sweeps. |
| 65 | |
| 66 | ## Networking |
| 67 | |
| 68 | - Explicit protocol package with a version constant; inbound messages are |
| 69 | zod-validated (range-clamped axes, size caps) and malformed traffic |
| 70 | disconnects. JSON encoding today behind an encode/decode boundary sized for |
| 71 | a binary codec swap. |
| 72 | - **Interest management:** per-session cell subscriptions and known-entity |
| 73 | sets built from the spatial hash. A precise radius check filters each cell's |
| 74 | candidates; crossing cells sends spawn/despawn differences with exit-radius |
| 75 | hysteresis. Snapshots contain only relevant players + _awake_ relevant bodies. |
| 76 | - **Sleep networking:** when a prop settles, one final `entity` pin is |
| 77 | broadcast and it drops out of snapshots; on wake it re-enters. Settled |
| 78 | props also mark themselves dirty exactly once for the persistence flush. |
| 79 | - Wire-level rate limiting (messages/sec, payload bytes) plus a bounded input |
| 80 | queue per session (max 2 catch-up sims/tick) prevent client-driven speedup. |
| 81 | |
| 82 | ## Server systems (per tick) |
| 83 | |
| 84 | 1. Drain input queues → movement simulation per session (never client |
| 85 | positions; silent clients coast to a stop after 3 bridged ticks) |
| 86 | 2. Physgun drive: held bodies pulled toward the holder's view ray via |
| 87 | velocity control (never teleported) with proportional angular control |
| 88 | 3. `physics.step(dt)` |
| 89 | 4. Sync awake prop transforms into entity records; settle/wake transitions |
| 90 | 5. Crafting queues (tick-based completion, output-space aware) |
| 91 | 6. Replication (every 2nd tick): interest diff + snapshot per session |
| 92 | 7. Periodic write-behind checkpoint (a dirty snapshot handed to the flusher; the tick never awaits I/O) |
| 93 | |
| 94 | ## Persistence |
| 95 | |
| 96 | PostgreSQL 18 through openvibe-sdk/db, behind `PersistenceStore` repositories (the SDK's async handle |
| 97 | and `sql` fragments; migrations in `packages/persistence/migrations/0001_initial.sql` applied at boot |
| 98 | by the SDK migrator as the owner role). The schema: `places`, `world_meta`, `world_entities` |
| 99 | (props/resources/NPCs with motion + kind-specific state JSON), `world_constraints`, `characters` |
| 100 | (inventory, skills, friends, reputation, unlocks, stats, armor, active job), `mods`, `mod_grants`, |
| 101 | `mod_placements`, `mod_audit`, `media_mirrors`, `identity_adoptions`, `account_data_events`, and the |
| 102 | SDK's `event_outbox` and `token_revocations`. JSON columns are jsonb and round-trip as objects; a |
| 103 | local guest account is keyed by `guest:` + sha256(their token), never the raw token. |
| 104 | |
| 105 | **The tick never awaits I/O.** It takes a copy-on-write snapshot of the dirty rows |
| 106 | (`world.takeDirty()`, `npcs.takeDirty()`, dirty sessions, env/markets) and the write-behind flusher |
| 107 | (`apps/server/src/game/flusher.ts`) persists it in one transaction, one flush in flight per instance. |
| 108 | A checkpoint while one is running is coalesced (the dirty flags stay set), a failed transaction |
| 109 | restores the snapshot's dirtiness and is retried, and shutdown drains the queue before the database |
| 110 | closes. Batch writes are multi-row `INSERT … ON CONFLICT` in chunks of at most 500 rows. First boot |
| 111 | seeds from the world definition; afterwards the database is the world's source of truth. The |
| 112 | simulation never reads the platform tables. |
| 113 | |
| 114 | M4 runs several isolated simulations of the same place in one process. A PostgreSQL |
| 115 | `game_instances` claim has a renewable lease version; state writes lock and verify |
| 116 | that claim in their transaction. Later rooms keep separate world rows while |
| 117 | characters and their inventory remain shared. Each character keeps a last |
| 118 | position per room. See `docs/instances.md` for transfer and the process limit. |
| 119 | |
| 120 | ## Platform boundary |
| 121 | |
| 122 | Adapters to the OpenVibe platform live in `apps/server/src/platform` and |
| 123 | `apps/server/src/mods` (ADR-0006): canonical subject accounts, the `games` |
| 124 | service principal, the events outbox, the Media mirror, and the mod registry |
| 125 | with its runtime seam (`ModApi`: capability-checked bindings, the only thing a |
| 126 | mod can reach). No package under `packages/` imports any of it. |
| 127 | |
| 128 | ## Zones |
| 129 | |
| 130 | Declarative rule volumes (`pvp`, `build`, `physgun`) resolved by position; |
| 131 | systems ask `rulesAt(pos)` — the protected city is future _content_, not code. |
| 132 | |
| 133 | ## Entities |
| 134 | |
| 135 | `GameEntity` is a small record with optional capability components (prop, |
| 136 | resource, owner, …) in an `EntityStore` with kind indexes. Deliberately not a |
| 137 | full archetype ECS yet: systems only touch query methods, so storage can be |
| 138 | migrated if profiling demands it. The same composition philosophy governs item |
| 139 | definitions (`world`, `placeable`, `workstation` capability blocks). |
| 140 | |
| 141 | ## Observability |
| 142 | |
| 143 | `ServerMetrics` sampled by the tick loop (tick/physics ms EMA, awake/settled |
| 144 | bodies, sessions, bytes out, snapshot size, RSS), exposed at `/metrics`, |
| 145 | summarized to structured JSON logs periodically. Logs carry system/player/ |
| 146 | entity context fields and never spam per-tick. |
| 147 | |
| 148 | ## Security posture |
| 149 | |
| 150 | Assume hostile clients: protocol validation at the edge, range checks on every |
| 151 | interaction (gather, place, physgun grab/unfreeze), zone rules, ownership |
| 152 | recorded on placement, inventory ops validated against authoritative state, |
| 153 | one live session per identity token, guest tokens restricted to a format |
| 154 | that can never name an account key. Client-side rays |
| 155 | exist purely for UX. |
| 156 |