Skip to content

Multi-root games & play control

One Game root mounts a world list as layers — threejs and pixijs canvases and a react DOM layer, each first-party world with its own ComponentManager, under one shared loop and play control
One Game, many worlds: layered host stack, one loop, per-world ComponentManagers.

A game is a manifest-backed root list. The manifest composer mounts every entry onto one Game — any number of threejs, pixijs, and react adapters, stacked as layers in a single host container (per-root zOrder), with one shared loop and a delegating input router. Each first-party world carries its own ComponentManager; the Game spans them at query time (below) rather than merging them into one:

vgai.game.json
{
"manifestVersion": 2,
"name": "my-game",
"roots": [
{ "id": "main", "adapter": "threejs", "scene": "scenes/level.vscn.json" },
{ "id": "overlay", "adapter": "pixijs", "scene": "hud.scn2d.json", "zOrder": 10 }
]
}
  • The native node is the entity for gameplay adapters: Object3D in Three.js roots, PIXI.Container in pixijs worlds; react worlds render from state and host no components.
  • Cross-world queries: game.queryByComponent(Cls) spans every first-party world in one call (narrow with {worldId} or {kind}).
  • In the editor, the hierarchy presents native Three.js, PixiJS, and React roots; selection and inspection work in every root (the 3D transform gizmo operates in threejs worlds only).

Game.play is the one game-level control surface (D10/T7.6), with per-world pausable semantics — a menu or HUD world declaring "pausable": false keeps ticking while everything else freezes:

  • pause() — freezes every pausable world’s simulation. The loop itself never stops: a frozen world still runs its render phase every frame with dt = 0 (the screen doesn’t go black; time-based render effects don’t creep), and worlds that can’t be gated (self-driven mounts with no setPaused capability) are reported loudly, once, instead of silently ignored. Audio is muted per-world the same honest way.
  • resume() — the inverse; both are idempotent.
  • step(dt?) — advances exactly the frozen worlds by one fixed substep (real dt). Non-pausable worlds are untouched — the still-running loop already ticks them — and while the game isn’t paused, step() is a no-op.
  • React HUDs stay honest while paused: Game.state only notifies subscribers on frames where something actually advanced, so a fully-frozen game produces no notifications, and a step() that advances a host-driven world produces exactly one. (One recorded edge: a paused game whose only frozen worlds are self-driven — mounts the host doesn’t tick — advances them on step() without a notification; such worlds report state through their own observe bridge, not Game.state.)

The editor’s play bar (pause/step buttons) and the CLI (vgai pause / vgai resume / vgai step) both drive this same surface.