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:
| System | Frame | Used by |
|---|---|---|
| Grid | integer (col, row) | terrain tiles |
| Sub-tile | fractional grid coords | objects within a tile |
| World | metres / feet from world origin | 3D 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:
- Bump
WORLD_SCHEMA_VERSIONinpackages/shared/src/world/version.ts. - Add a migration in
packages/world-engine/src/migrations/<from>-to-<to>.ts. - 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-platformAPI. - 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.