WorldForge Studio

01 — Data Model

The canonical world document is the single source of truth. Every projection (2D, 3D, print, simulation, export) reads from this schema. No subsystem may introduce a parallel "engine-specific" data shape.


1. Top-level shape

{
  "world": {
    "schemaVersion": "0.1.0",          // see packages/shared/src/world/version.ts
    "id": "ancient-jungle",
    "name": "Ancient Khmer Jungle",
    "description": "An overgrown jungle concealing the ruins of a forgotten empire.",
    "createdAt": "2026-07-29T10:00:00Z",
    "updatedAt": "2026-07-29T12:30:00Z",
    "author": "gas://user/kristian"
  },

  "grid": {
    "projection": "hex",                // 'hex' | 'square' | 'freeform'
    "size": { "cols": 64, "rows": 48 }, // ignored when projection === 'freeform'
    "hexSize": 32,                      // pixel radius at zoom 1
    "units": "metres",                  // 'metres' | 'feet'
    "origin": "top-left"
  },

  "elevation": {
    "seaLevel": 0,
    "min": -100,
    "max": 4000
  },

  "terrain": [
    {
      "x": 10, "y": 15,                // grid coordinate (see §3)
      "biome": "jungle",
      "terrain": "dense_jungle",
      "elevation": 30,
      "variant": "ancient"
    }
  ],

  "objects": [
    {
      "id": "obj_01H8X...",
      "x": 10.5, "y": 15.2,             // sub-tile coordinates allowed
      "assetId": "gas://object/palm_tree@1.2.0",
      "variant": "ancient",
      "rotation": 90,                   // degrees
      "scale": 1.2,
      "tags": ["forest", "jungle"],
      "collision": false
    }
  ],

  "regions": [
    {
      "id": "region_01H8X...",
      "kind": "polygon",                // 'polygon' | 'rect' | 'circle' | 'brush'
      "points": [{ "x": 5, "y": 5 }, { "x": 8, "y": 5 }, { "x": 8, "y": 8 }, { "x": 5, "y": 8 }],
      "biome": "dense_jungle",
      "tags": ["dense", "old_growth"]
    }
  ],

  "gameplay": {
    "spawns": [
      {
        "id": "spawn_01H8X...",
        "x": 12, "y": 16,
        "encounter": "gas://encounter/jungle_predators@1.0.0",
        "schedule": { "from": "20:00", "to": "06:00" },
        "density": 0.4
      }
    ],
    "encounters": [],
    "resources": [
      {
        "id": "res_01H8X...",
        "x": 11, "y": 15,
        "kind": "wood",
        "amount": 50
      }
    ],
    "triggers": [
      {
        "id": "trg_01H8X...",
        "kind": "on_enter",
        "region": "region_01H8X...",
        "effect": "spawn_encounter",
        "payload": { "encounterId": "..." }
      }
    ],
    "music": [
      {
        "region": "region_01H8X...",
        "track": "gas://music/jungle_theme@1.0.0",
        "loop": true
      }
    ]
  },

  "npcs": [
    {
      "id": "npc_01H8X...",
      "name": "Captain Arvid",
      "role": "merchant",
      "home": "castle",
      "schedule": [
        { "from": "08:00", "to": "12:00", "at": "market" },
        { "from": "12:00", "to": "14:00", "at": "tavern" },
        { "from": "22:00", "to": "06:00", "at": "home" }
      ],
      "goals": ["sell goods", "find rumours"],
      "dialogue": ["gas://dialogue/arvid@1.0.0"]
    }
  ],

  "quests": [
    {
      "id": "quest_01H8X...",
      "title": "The Lost Temple",
      "stages": [
        { "id": "stage_1", "kind": "find_region", "target": "region_01H8X..." },
        { "id": "stage_2", "kind": "defeat_encounter", "encounter": "gas://encounter/jungle_predators@1.0.0" }
      ]
    }
  ],

  "weather": {
    "default": "gas://weather/jungle_default@1.0.0",
    "overrides": []
  },

  "lighting": {
    "dayNightCycle": true,
    "sunrise": "06:00",
    "sunset": "18:00",
    "seasons": ["spring", "summer", "autumn", "winter"]
  },

  "cameraPaths": [
    {
      "id": "path_01H8X...",
      "name": "Show my kingdom",
      "kind": "flythrough",
      "keyframes": [
        { "t": 0,    "position": { "x": 0,    "y": 500, "z": 0    }, "lookAt": { "x": 0,   "y": 0, "z": 0 }, "fov": 60 },
        { "t": 10,   "position": { "x": 100,  "y": 200, "z": 100  }, "lookAt": { "x": 50,  "y": 0, "z": 50 }, "fov": 50 },
        { "t": 30,   "position": { "x": 200,  "y": 100, "z": 200  }, "lookAt": { "x": 100, "y": 0, "z": 100 }, "fov": 40 }
      ],
      "easing": "ease-in-out"
    }
  ],

  "gas": {
    "biomes": ["gas://biome/jungle@1.4.0", "gas://biome/river@1.0.0"],
    "civilization": "gas://civ/ancient_khmer@1.0.0",
    "themes": ["gas://theme/ancient@1.0.0"],
    "packs": [
      "gas://pack/jungle_adventure@1.2.0",
      "gas://pack/ancient_ruins@0.9.0"
    ],
    "licence": "CC-BY-4.0"
  }
}

This is the canonical shape. The full Zod schema lives in packages/world-engine/src/world.ts (Phase 1). For now, this document is the contract — every package depends on it.


2. Coordinate systems

There are three coordinate systems; conversions are explicit and one-way:

SystemFrameUsed by
Gridinteger (col, row)terrain tiles
Sub-tilefractional grid coordsobjects within a tile
Worldmetres / feet from world origin3D engine, physics, simulation

grid → world is computed by packages/world-engine/src/coords/grid-to-world.ts. world → screen is computed by the active projection's camera.


3. Grid projections

3.1 Hex (axial coordinates)

     / \     / \
    |   |   |   |
     \ /     \ /
     / \     / \
    |   |   |   |
     \ /     \ /
  • Pointy-top axial (q, r) — preferred for continental maps.
  • Flat-top axial (q, r) — preferred for tactical combat.
  • Conversion to world space uses hexSize (radius from centre to corner).

3.2 Square (cartesian)

  • (x, y) integer indices.
  • Optional sub-tile offsets.

3.3 Freeform

  • Tiles are not grid-aligned. Each terrain element carries its own polygon footprint.
  • Used for organic maps and city builder mode.

4. Layer model

The world is a stack of layers, each consumed by the relevant subsystem:

World
 ├── Grid           → world-engine (math)
 ├── Elevation      → world-engine (height map)
 ├── Terrain        → terrain-gen (auto-tile) + asset-pipeline (SVG render)
 ├── Objects        → asset-pipeline (mesh render) + simulation (interactables)
 ├── Regions        → terrain-gen (brushes) + simulation (triggers)
 ├── Gameplay       → simulation + cinematic (encounter, music)
 ├── NPCs           → simulation + cinematic (camera follow)
 ├── Quests         → simulation + export (Adventure Craft)
 ├── Weather        → cinematic + 3D engine
 ├── Lighting       → 3D engine + cinematic
 ├── CameraPaths    → cinematic (timeline)
 └── GAS refs       → asset-pipeline (resolve + licence)

No layer stores data that belongs in another. NPC dialogue lives in npcs[].dialogue (as a GAS ref), not in terrain[]. Encounter tables live in gameplay.encounters (or as a GAS ref), not in terrain[].biome.


5. Terrain entry

interface TerrainEntry {
  x: number;          // grid coord
  y: number;          // grid coord
  biome: BiomeId;     // see packages/shared/src/biomes.ts
  terrain: string;    // pack-specific terrain id, e.g. "dense_jungle"
  elevation: number;  // metres
  variant?: string;   // 'ancient' | 'decayed' | 'winter' | ...
  movement?: number;  // optional override; defaults to pack value
  visibility?: number;// optional override; 0..1
}

biome is the canonical, pack-independent identifier. terrain is the pack-specific visual/semantic identifier. A jungle biome with terrain: "dense_jungle" rendered with the Viking theme becomes a "dense_birch_forest".


6. Object entry

interface ObjectEntry {
  id: string;                       // ULID, unique within the world
  x: number; y: number;             // sub-tile coords
  assetId: GasAssetId;              // gas://object/<name>@<version>
  variant?: string;                 // selects an SVG variant or 3D mesh LOD
  rotation?: number;                // degrees, 0..360
  scale?: number;                   // 0..N, default 1
  tags?: string[];                  // free-form, e.g. ['forest', 'jungle']
  collision?: boolean;              // override pack default
  gameplay?: {
    blocksMovement?: boolean;
    blocksVisibility?: boolean;
    interactable?: boolean;
  };
}

assetId is the only asset reference. Asset geometry, animations, and metadata are resolved by packages/asset-pipeline. Never inline SVG paths or GLB URLs.


7. Region entry

Regions are paintings over the grid, not containers. Use them for biomes, fog of war, weather volumes, music volumes, trigger zones.

interface Region {
  id: string;
  kind: 'polygon' | 'rect' | 'circle' | 'brush';
  points: Array<{ x: number; y: number }>;
  biome?: BiomeId;                  // paint the biome under the region
  tags?: string[];
}

8. Gameplay entry

Spawns, resources, triggers, encounter references, music references. All gameplay data lives here, never on terrain[] or objects[].

interface Spawn {
  id: string;
  x: number; y: number;
  encounter: GasAssetId;            // resolved by the simulation
  schedule?: { from: string; to: string };
  density: number;                  // 0..1
}

interface Trigger {
  id: string;
  kind: 'on_enter' | 'on_exit' | 'on_interact' | 'on_time';
  region: string;                   // Region.id
  effect: 'spawn_encounter' | 'play_music' | 'run_dialogue' | 'set_weather' | 'custom';
  payload: Record<string, unknown>;
}

9. NPC entry

interface NPC {
  id: string;
  name: string;
  role: string;
  home: string;                     // Region.id or place name
  schedule: Array<{ from: string; to: string; at: string }>;
  goals: string[];
  dialogue: GasAssetId[];           // resolved by the simulation
  faction?: string;
  stats?: Record<string, number>;
}

Schedules are clock-driven; the simulation runtime advances the clock and resolves the NPC's at location at any moment.


10. Quest entry

interface Quest {
  id: string;
  title: string;
  description?: string;
  stages: Array<{
    id: string;
    kind: 'find_region' | 'defeat_encounter' | 'collect_resource' | 'talk_to_npc' | 'visit_tile' | 'custom';
    target: string;                 // id of the relevant entity
    payload?: Record<string, unknown>;
  }>;
  rewards?: Array<{
    kind: 'xp' | 'gold' | 'item' | 'faction_rep';
    amount?: number;
    target?: string;
  }>;
}

11. Weather & lighting

interface WeatherConfig {
  default: GasAssetId;              // resolved at runtime
  overrides: Array<{
    region: string;                 // Region.id
    weather: GasAssetId;
  }>;
}

interface LightingConfig {
  dayNightCycle: boolean;
  sunrise: string;                  // 'HH:mm'
  sunset: string;
  seasons?: Array<'spring' | 'summer' | 'autumn' | 'winter'>;
  ambient?: { color: string; intensity: number };
  fog?: { color: string; near: number; far: number };
}

12. Camera path entry

interface CameraPath {
  id: string;
  name: string;
  kind: 'flythrough' | 'orbit' | 'follow_npc' | 'cutscene';
  keyframes: Array<{
    t: number;                      // seconds
    position: { x: number; y: number; z: number };
    lookAt?: { x: number; y: number; z: number };
    fov?: number;
    roll?: number;
    aperture?: number;              // depth-of-field
    focusDistance?: number;
    easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier';
  }>;
  duration: number;                 // seconds, derived from last t
  loop?: boolean;
}

Camera paths are owned by the world document so they can be authored in the timeline UI without depending on the 3D engine. The Cinematic Director projects them into Three.js scenes.


13. GAS metadata

Every world carries GAS metadata so the marketplace and licensing layer can reason about it without parsing the document body.

interface GasMetadata {
  biomes: GasAssetId[];
  civilization?: GasAssetId;
  themes: GasAssetId[];
  packs: GasAssetId[];
  licence: string;
}

See packages/shared/src/gas.ts for the shared types.


14. Versioning & migrations

world.schemaVersion is mandatory. On every breaking change:

  1. Bump WORLD_SCHEMA_VERSION in packages/shared/src/world/version.ts.
  2. Add a migration in packages/world-engine/src/migrations/<from>-to-<to>.ts.
  3. Migration runs on file load — never silently rewrite on save.

Non-breaking additions (new optional fields) do not bump the version; downstream projections must tolerate missing fields.


15. Storage

15.1 Local

  • IndexedDB (via y-indexeddb) — primary.
  • File-system export (.gas-world) — for sharing and VCS.

15.2 Cloud (optional, Phase 4+)

  • Hetzner Postgres via game-asset-platform API.
  • Yjs over WebSocket for live collaboration.

15.3 Chunking strategy

Phase 1 stores terrain[] as a flat array; this scales fine for continent-sized maps (≤ 3,000 entries at the default 64×48 hex grid). When worlds exceed that budget — or once the 3D engine needs neighbour streaming — the storage migrates to chunked form, matching the convention used by adventure-craft:

World
 ├── Chunk (0,0)  → { terrain: TerrainEntry[], objects: ObjectEntry[], ... }
 ├── Chunk (0,1)
 ├── Chunk (1,0)
 ├── Chunk (1,1)
 └── ...

A chunk is CHUNK_SIZE × CHUNK_SIZE tiles (default 16×16 = 256 tiles per chunk). Chunk keys are derived from (x, y) by floor(x / CHUNK_SIZE) and the same for y. Unloaded chunks exist as tombstones in the world document (unloaded metadata only — terrain for empty regions is implicit).

The 2D editor uses chunks for:

  • Frustum culling — paint, erase, and select only consider visible chunks.
  • Viewport rendering — only rendered chunks enter the React tree.
  • Undo/redo granularity — chunks are the atomic unit (matches the Yjs document model we'll adopt in Phase 5).
  • Asset streaming — textural and GLB assets load per-chunk from the asset pipeline once the cache hits a miss.

The flat-array form remains valid in World.json; a migration in packages/world-engine/src/migrations flips between flat and chunked when a document crosses the size threshold (10,000 terrain entries). The schema version bumps to 0.2.0 when chunks become the canonical form.


16. Validation

Every read goes through Zod. Schemas live in packages/world-engine/src/world.ts. Invalid worlds are rejected at the boundary with a structured error so the editor can show the user what is broken and where.

The schema is intentionally closed — no Record<string, any> fields. Add a field deliberately, with a migration, or not at all.