Skip to content

Worlds and roots

A vgai game is a set of roots. There is no single “scene file” that is the game, and for a 3D world there is no scene format at all — the TSX you write is the document.

vgai.project.json carries a roots array. It is required, it must be non-empty, and it is the only place roots come from — nothing is discovered by scanning folders.

{
"manifestVersion": 2,
"name": "My Game",
"roots": [
{ "id": "world", "adapter": "three", "entry": "src/world.tsx" },
{ "id": "ui", "adapter": "dom", "entry": "src/ui/game.tsx", "zOrder": 100 }
]
}
A game mounts declared Three, Canvas, and DOM roots as sibling layers under one host
One game, declared roots: layered host stack, shared control, per-root lifecycles.

Each root gets its own document, its own lifecycle, and its own render layer, z-ordered as siblings. That manifest is the actual starter: a 3D world and a React UI layer, running side by side.

The root’s adapter declaration is the sole discriminator. There is no separate kind field and no implicit default root.

What each adapter means — and what it hands you

Section titled “What each adapter means — and what it hands you”

The adapter name describes what the host provides, not which library you use.

adapterThe host gives youThe document is
threethe one shared three instanceyour TSX/R3F source
canvasa canvas elementthe root’s own source
doma DOM containeryour JSX — JSX is truth
{ module, surface } / { ingest, surface }the declared surface plus adapter implementationthe game’s own source

The surface name is the host’s promise, not a library allegiance. First-party Canvas roots use PixiJS; Phaser and Babylon currently enter through their own ingest adapters.

For a three root the entry TSX file is not a description of the world that something else loads. It is the world. The editor’s hierarchy, gizmo and inspector drive the live fiber scene and write literal props back into that same file surgically — drag a mesh in the viewport and its position={[…]} tuple updates in your source.

That has two consequences worth internalising:

  • A prefab is a React component. Extract some JSX into a function and reuse it: <Crate position={[2, 0, 0]} />. Sharing a material is sharing props or a module. There is no separate reusable-entity file format, because ordinary composition already does the job.
  • Behavior is a hook, not a class. A component calls useFrame((state, dt) => …) and runs under the engine loop, so pausing freezes it. Its props are its config, typed from the JSX literal, so the inspector can edit them without a schema you had to declare separately.

The source is the document on every first-party surface — there is no scene file format. The manifest schema rejects an unrecognized key loudly rather than quietly ignoring it: Zod’s default behaviour would otherwise strip the unknown key and leave you debugging an empty world.

Metadata lives in each object’s userData, and helpers walk the live tree:

  • queryByTag(root, tag)
  • queryByName(root, name)

These work on the running Three.js graph rather than a serialized scene document.