Reference

Hearth Project Format (v1)

Edit on GitHub

Every file in a Hearth project and the schema behind it.

A Hearth project is a plain directory of human- and agent-readable JSON plus scripts and assets. Everything is local-first; nothing requires a server.

Agents: prefer hearth CLI commands / MCP tools over editing these files directly; commands validate every change. This document is the contract those tools maintain.

my-game/
├── hearth.json                  # project manifest
├── assets.json                  # asset index
├── scenes/
│   └── level_1.scene.json       # one file per scene
├── assets/
│   ├── sprites/coin.svg         # procedural or imported sprites
│   ├── tiles/grass.svg
│   ├── sounds/coin_sound.wav    # procedural sound effects (16-bit PCM WAV)
│   ├── animations/idle.anim.json
│   └── prefabs/ember_grub.prefab.json  # reusable entity-subtree templates
├── scripts/
│   ├── player-controller.lua    # behavior scripts (Lua by default; .js works too)
│   └── lib/noise.lua            # required helpers; scripts/ is recursive
├── playtests/
│   └── smoke.playtest.json      # headless scripted tests
├── .hearth/
│   ├── agent-config.json        # machine-readable agent entry point
│   ├── baseline.json            # diff baseline (snapshot; gitignored)
│   ├── history/                 # undo/redo entries (gitignored)
│   └── log/
│       └── commands.jsonl       # append-only command journal (gitignored)
├── AGENTS.md                    # generated agent instructions
└── CLAUDE.md                    # pointer for Claude Code

All schemas are Zod definitions in packages/core/src/schema/. That code is normative; this page is descriptive. Every file carries formatVersion: 1.

hearth.json

{
  "formatVersion": 1,
  "hearthVersion": "0.15.0",
  "id": "prj_a1b2c3d4",
  "name": "My Game",
  "description": "",
  "initialScene": "scn_x9y8z7",         // scene id that runs first
  "scenes": [
    { "id": "scn_x9y8z7", "name": "Level 1", "path": "scenes/level_1.scene.json" }
  ],
  "inputMappings": {
    "actions": {                          // action name → KeyboardEvent.code list
      "left": ["ArrowLeft", "KeyA"],
      "jump": ["Space"]
    },
    "gamepadButtons": { "jump": ["a"] },   // action name → named gamepad buttons
    "gamepadAxes": {},                     // action name → digital axis-crossed-threshold binding
    "axes": {                              // virtual analog axis name → ctx.input.axis(name) source
      "horizontal": {
        "gamepadAxis": 0,
        "negativeCodes": ["ArrowLeft", "KeyA"],
        "positiveCodes": ["ArrowRight", "KeyD"]
      }
    },
    "deadzone": 0.15                       // default stick deadzone, 0-1
  },
  "buildSettings": {
    "width": 800, "height": 600,
    "backgroundColor": "#1a1a2e",
    "targetFps": 60, "fixedTimestep": 60, // physics/update Hz
    "title": "My Game",
    "icon": null,                // sprite asset id used as the desktop app icon, or null for the bundled default
    "loading": {                 // what an exported game shows while loading
      "backgroundColor": "#000000",
      "image": null,             // sprite asset id, centered, or null
      "spinner": false           // minimal neutral spinner
    }
  }
}

Change settings with the updateSettings command (partial deep-merge) rather than hand-editing, or the editor’s Game Settings panel for a typed UI over every field above. See export.md#editor-game-settings-panel.

Scene files (scenes/*.scene.json)

Entities are a flat list; hierarchy is parentId (children inherit the parent’s translation at runtime). IDs are stable and prefixed (ent_, scn_, ast_, ptt_, prj_) so they’re self-describing in diffs and logs.

{
  "formatVersion": 1,
  "id": "scn_x9y8z7",
  "name": "Level 1",
  "entities": [
    {
      "id": "ent_k3v9qzpm",
      "name": "Player",
      "parentId": null,
      "enabled": true,
      "tags": ["player"],
      "components": {
        "Transform":      { "position": {"x": 120, "y": 380}, "rotation": 0, "scale": {"x":1,"y":1} },
        "SpriteRenderer": { "assetId": "ast_abc123", "shape": "rectangle", "color": "#ffffff",
                            "width": 32, "height": 48, "opacity": 1, "flipX": false, "flipY": false,
                            "layer": 0, "visible": true },
        "Collider":       { "shape": "box", "width": 28, "height": 46, "radius": 16,
                            "offset": {"x":0,"y":0}, "isTrigger": false },
        "PhysicsBody":    { "bodyType": "dynamic", "velocity": {"x":0,"y":0},
                            "gravityScale": 1, "drag": 0 },
        "Script":         { "scriptPath": "scripts/player-controller.lua",
                            "params": { "speed": 220 } }
      }
      // optional: "prefab": { "asset": "ast_…" } marks this entity as a
      // tracked instance of a prefab asset — see prefabs.md
    }
  ]
}

Components are a map keyed by type: one component per type per entity in format v1 (multi-instance components are the documented extension path via a formatVersion bump). See components.md for every type and property, or run hearth inspect components --json.

Camera.postEffects (a stack of screen-space post-processing filters) and SpriteEffects (per-sprite outline/flash/dissolve) round-trip through scene files like any other component data. See effects.md for the full catalog.

assets.json + asset files

{
  "formatVersion": 1,
  "assets": [
    { "id": "ast_abc123", "name": "coin", "type": "sprite",
      "path": "assets/sprites/coin.svg",
      "metadata": { "procedural": true, "shape": "coin", "color": "yellow" } }
  ]
}

Asset types: sprite, tile, audio, animation, font, prefab, data, other. Procedural assets are deterministic: SVG for sprites/tiles, 16-bit PCM WAV for sounds (createSound; same preset + seed → identical bytes). No AI, no network. Animation assets are JSON: { "frames": ["ast_…", …], "frameDuration": 0.15, "loop": true } referencing sprite assets. Prefab assets are JSON too ({ "name", "entities": [...] }, normalized local ids, root-first). See prefabs.md for the full shape and the four commands that read/write it.

Playtests (playtests/*.playtest.json)

Deterministic headless tests: scripted input + assertions at a fixed timestep.

{
  "formatVersion": 1,
  "id": "ptt_q1w2e3",
  "name": "player-moves-right",
  "scene": "scn_x9y8z7",
  "maxFrames": 300,                       // hard stop
  "steps": [
    { "type": "wait", "frames": 60 },
    { "type": "press", "action": "right", "frames": 45 },
    { "type": "assertProperty", "entity": "Player",
      "property": "Transform.position.x", "greaterThan": 180 },
    { "type": "assertNoErrors" }
  ]
}

Step types: wait, press, release, click (screen coordinates), assertEntityExists, assertProperty (equals / greaterThan / lessThan), assertPositionNear, assertScene, assertNoErrors. Playtests also carry a seed (default 0) for the script RNG (ctx.random / Lua math.random): same seed, same run.

Scripts (scripts/**/*.lua, scripts/**/*.js)

Plain Lua (default) or JavaScript files defining lifecycle hooks (see scripting.md); both languages get the identical ctx API. Scripts are referenced by path from Script components and are the one part of the project agents are encouraged to edit as code (via hearth create script / hearth edit-script).

scripts/ is a tree. store.listScripts(), CLI/MCP script listing, validation, hot reload, and exports all recurse into subdirectories such as scripts/lib/. Script modules are not a separate asset type: a helper is just a .lua or .js script required by another script and, usually, not attached to an entity.

.hearth/log/commands.jsonl

An append-only, newline-delimited JSON log of commands run through any session against this project (CLI, MCP, or the editor), independent of .hearth/history/’s undo/redo stack. Unlike history, the journal also records read-only-but-meaningful runs (runPlaytest, validateProject) and failed commands, and it is never rewound by undo/redo. It’s the data source for hearth log / listJournal / the MCP list_journal tool, and for the editor’s Agent panel activity timeline and external-change detection (see agent-panel.md).

One JSON object per line, oldest first:

{
  "seq": 42,                       // monotonic per project
  "ts": "2026-07-06T20:14:05.108Z",// ISO timestamp
  "source": "cli",                 // "editor" | "cli" | "mcp" | "unknown" (or a session's own label)
  "command": "createEntity",
  "summary": "createEntity Gem",   // human-readable, reuses the command's own summary
  "ok": true,
  "error": "NOT_FOUND",            // present only when ok is false; a stable error code
  "detail": { "errors": 0, "warnings": 1 } // small command-specific facts only; present on a few commands
}

Notes:

  • What gets journaled: every mutates: true command, plus a small fixed allowlist of meaningful non-mutating ones (runPlaytest, validateProject). Plain inspect*/list* reads are not journaled; the editor calls those constantly and they carry no news.
  • No params, no snapshots. detail carries only a few small, defensively-extracted facts (e.g. runPlaytest’s pass/fail and assertion counts, validateProject’s error/warning counts). The journal is a feed, not a backup. Undo history and snapshot/diff/revert remain the separate mechanisms for that.
  • Rotation. Once the file exceeds 4000 lines it’s rewritten down to the newest 2000, preserving seq continuity. A journal-write failure never fails the command it’s recording; it’s isolated exactly like history-record failures.
  • seq is monotonic per project, read from the last line on disk at append time; exact cross-process collisions under concurrent writers are tolerable (the journal is informational, never load-bearing for correctness) and ties break by ts.
  • Joins the .gitignore-managed engine-state convention alongside .hearth/history/ and .hearth/baseline.json. This file is local session state, not part of the project’s tracked format.

Versioning & compatibility

  • formatVersion is a strict literal (1); loaders reject unknown versions rather than guessing.
  • hearthVersion records the Hearth engine version that last wrote the project manifest. It is not a second format counter; it is a compatibility stamp for diagnostics and migrations.
  • Unknown component types are rejected by the scene schema (strict map), which keeps agent-written files honest.
  • IDs must match their prefix patterns (^ent_[a-z0-9]+$ etc.).

Format stability and migrations

The v1.0 promise is that opening a project never writes to disk. Loading an older project is byte-non-destructive: Hearth validates and migrates the in-memory document it needs to run, but it does not rewrite hearth.json, scenes, assets, scripts, or playtests just because they were opened.

The first undoable edit made through the command system advances hearthVersion to the running engine version as part of that edit’s normal save. There is no separate stamp-only write. Undoing that edit restores the previous snapshot, including the older hearthVersion stamp.

Hearth currently verifies this compatibility floor with projects stamped 0.13.0 and 0.14.0: they open without disk writes, validate, and advance to the current stamp on first edit. Projects stamped newer than the running engine fail loudly instead of being silently modified.

After 1.0, project-format changes that would otherwise break old projects must ship with automatic migrations and release notes describing the change.