openvibers/openvibe.games ARCHITECTURE.md

ARCHITECTURE.md

main 8.3 KB · 156 lines · markdown

Architecture

Principles

  1. The server is the game. Clients render, predict, and express intent;
    every gameplay-relevant decision (movement, inventory, crafting, placement,
    physgun, damage, economy) is validated or simulated server-side. No client
    field is ever trusted.
  2. Domain logic is engine-free. @openvibe/gameplay has no Babylon, Rapier,
    DOM, or network imports; it runs identically in vitest, on the server, and
    inside client prediction. Engines live behind adapters.
  3. Data-driven content. Items, recipes, and worlds are declarative
    definitions validated at startup (@openvibe/content). Adding content never
    requires engine changes. Stable ids everywhere; display names are cosmetic.
  4. Persistent state is explicit. DTOs in @openvibe/persistence are the disk
    format. Runtime objects (meshes, bodies, live inventories) are transient
    projections rebuilt from them.
  5. Sleep is a first-class citizen. Settled physics objects cost nothing on
    the wire (excluded from snapshots) and nothing per-tick; they exist as
    pinned state until something wakes them.

Package dependency graph

          shared
         /  |   \
  protocol content physics ──(rapier adapter: deterministic Rapier wasm)
         \  |  /
        gameplay
            |
       persistence (DTOs; PostgreSQL impl behind repository interfaces; migrations)
            |
   apps/server        apps/client
   (authoritative)    (Babylon render + prediction + HUD)

Only apps/client knows Babylon exists: ADR-0007 decision 2 removed the engine
from the authoritative server, and an architecture audit
(apps/server/src/architectureAudit.audit.ts) fails if apps/server imports
@babylonjs/*. Only apps/server and packages/persistence know PostgreSQL
(openvibe-sdk/db) exists. packages/* never import browser-only APIs: the
physics adapter is Rapier, which runs identically in Node and the browser with no
scene or engine, so there is no Node-only path to keep separate.

Simulation model

  • Fixed 30 Hz tick on the server (FixedTimestep also drives client
    prediction). Snapshots at 15 Hz. Rendering is decoupled and interpolated.
  • Movement is a kinematic Source/Quake controller (gameplay/movement):
    explicit velocity + capsule sweeps + clip-plane sliding + step-up, with
    friction / ground-accelerate / air-accelerate (air-strafing works). The
    player is not a dynamic rigid body; a kinematic Rapier capsule mirrors the
    player so props collide with them. The controller consumes a
    CollisionQueries interface — the Rapier adapter provides it on both sides
    (one implementation, no scene), the tests provide analytic worlds.
  • Client prediction: every input command (seq-stamped) is sent and applied
    locally; snapshots carry the last processed seq; the client rewinds to the
    authoritative state and replays unacked inputs. Remote entities render from
    ~130 ms interpolation buffers.
  • Physics: Rapier (@dimforge/rapier3d-deterministic-compat, one pinned
    build on both sides) behind PhysicsWorld (@openvibe/physics). Stepping is
    always manual (world.step(dt) from the fixed loop) — never coupled to render
    frames. Collision layers: Static / Prop / Player, filtered at the shape level
    for rays and sweeps.

Networking

  • Explicit protocol package with a version constant; inbound messages are
    zod-validated (range-clamped axes, size caps) and malformed traffic
    disconnects. JSON encoding today behind an encode/decode boundary sized for
    a binary codec swap.
  • Interest management: per-session cell subscriptions and known-entity
    sets built from the spatial hash. A precise radius check filters each cell's
    candidates; crossing cells sends spawn/despawn differences with exit-radius
    hysteresis. Snapshots contain only relevant players + awake relevant bodies.
  • Sleep networking: when a prop settles, one final entity pin is
    broadcast and it drops out of snapshots; on wake it re-enters. Settled
    props also mark themselves dirty exactly once for the persistence flush.
  • Wire-level rate limiting (messages/sec, payload bytes) plus a bounded input
    queue per session (max 2 catch-up sims/tick) prevent client-driven speedup.

Server systems (per tick)

  1. Drain input queues → movement simulation per session (never client
    positions; silent clients coast to a stop after 3 bridged ticks)
  2. Physgun drive: held bodies pulled toward the holder's view ray via
    velocity control (never teleported) with proportional angular control
  3. physics.step(dt)
  4. Sync awake prop transforms into entity records; settle/wake transitions
  5. Crafting queues (tick-based completion, output-space aware)
  6. Replication (every 2nd tick): interest diff + snapshot per session
  7. Periodic write-behind checkpoint (a dirty snapshot handed to the flusher; the tick never awaits I/O)

Persistence

PostgreSQL 18 through openvibe-sdk/db, behind PersistenceStore repositories (the SDK's async handle
and sql fragments; migrations in packages/persistence/migrations/0001_initial.sql applied at boot
by the SDK migrator as the owner role). The schema: places, world_meta, world_entities
(props/resources/NPCs with motion + kind-specific state JSON), world_constraints, characters
(inventory, skills, friends, reputation, unlocks, stats, armor, active job), mods, mod_grants,
mod_placements, mod_audit, media_mirrors, identity_adoptions, account_data_events, and the
SDK's event_outbox and token_revocations. JSON columns are jsonb and round-trip as objects; a
local guest account is keyed by guest: + sha256(their token), never the raw token.

The tick never awaits I/O. It takes a copy-on-write snapshot of the dirty rows
(world.takeDirty(), npcs.takeDirty(), dirty sessions, env/markets) and the write-behind flusher
(apps/server/src/game/flusher.ts) persists it in one transaction, one flush in flight per instance.
A checkpoint while one is running is coalesced (the dirty flags stay set), a failed transaction
restores the snapshot's dirtiness and is retried, and shutdown drains the queue before the database
closes. Batch writes are multi-row INSERT … ON CONFLICT in chunks of at most 500 rows. First boot
seeds from the world definition; afterwards the database is the world's source of truth. The
simulation never reads the platform tables.

M4 runs several isolated simulations of the same place in one process. A PostgreSQL
game_instances claim has a renewable lease version; state writes lock and verify
that claim in their transaction. Later rooms keep separate world rows while
characters and their inventory remain shared. Each character keeps a last
position per room. See docs/instances.md for transfer and the process limit.

Platform boundary

Adapters to the OpenVibe platform live in apps/server/src/platform and
apps/server/src/mods (ADR-0006): canonical subject accounts, the games
service principal, the events outbox, the Media mirror, and the mod registry
with its runtime seam (ModApi: capability-checked bindings, the only thing a
mod can reach). No package under packages/ imports any of it.

Zones

Declarative rule volumes (pvp, build, physgun) resolved by position;
systems ask rulesAt(pos) — the protected city is future content, not code.

Entities

GameEntity is a small record with optional capability components (prop,
resource, owner, …) in an EntityStore with kind indexes. Deliberately not a
full archetype ECS yet: systems only touch query methods, so storage can be
migrated if profiling demands it. The same composition philosophy governs item
definitions (world, placeable, workstation capability blocks).

Observability

ServerMetrics sampled by the tick loop (tick/physics ms EMA, awake/settled
bodies, sessions, bytes out, snapshot size, RSS), exposed at /metrics,
summarized to structured JSON logs periodically. Logs carry system/player/
entity context fields and never spam per-tick.

Security posture

Assume hostile clients: protocol validation at the edge, range checks on every
interaction (gather, place, physgun grab/unfreeze), zone rules, ownership
recorded on placement, inventory ops validated against authoritative state,
one live session per identity token, guest tokens restricted to a format
that can never name an account key. Client-side rays
exist purely for UX.