Build

Asset Pipeline Guide

Edit on GitHub

Importing real art, spritesheets, music, and fonts: slicing, animations, and the playtest step that proves audio happened.

How real art, spritesheets, music, and fonts get into a Hearth project: import, probe, slice, animate, and play, plus the playtest step that proves audio actually happened. Everything here works identically from a human’s editor click, an agent’s CLI/MCP call, or a script’s ctx call; the differences that do exist (a couple of surfaces error where others return null) are called out explicitly below.

Import

importAsset (CLI: hearth import asset <path> [--name n] [--type t]; MCP: import_asset) copies an external file into assets/<kind>/ and registers it in the asset index. The destination kind and asset type are both inferred from the file extension unless you override type:

Extensiontype
png, jpg/jpeg, gif, webp, svgsprite
mp3, wav, ogg, m4aaudio
ttf, otf, woff, woff2font
jsondata
anything elseother

For sprite (and tile) assets, import also probes the image bytes for dimensions: no image library, just a byte-level reader (packages/core/src/assets/imageInfo.ts) that understands PNG, JPEG, GIF, WebP, and SVG (via width/height/viewBox attributes). A successful probe writes metadata.width, metadata.height, and metadata.format; an unrecognized or dimensionless file (e.g. a sourceless SVG) just skips those keys rather than failing the import.

hearth import asset ./art/walk-sheet.png --name walk-sheet --json
{
  "success": true,
  "command": "importAsset",
  "data": {
    "asset": {
      "id": "ast_h22hj7bs",
      "name": "walk-sheet",
      "type": "sprite",
      "path": "assets/sprites/walk-sheet.png",
      "metadata": { "importedFrom": "walk-sheet.png", "width": 64, "height": 16, "format": "png" }
    }
  },
  "errors": [], "warnings": [], "changed": [ /* … */ ], "files": ["hearth.json", "assets.json"], "suggestions": []
}

Bulk import

importAssets (CLI: hearth import asset <path...> [--recursive]; MCP: import_assets) imports several files in one atomic undo/journal step instead of one importAsset call per file. A single path with no --recursive still runs the single-file importAsset command (so --name keeps working); anything else (multiple paths, or a directory with --recursive) expands any directory argument into the files under it (recursively, dotfiles/dot-directories skipped) and runs importAssets as one batch:

hearth import asset ./art/tileset/ --recursive --json
{
  "success": true,
  "command": "importAssets",
  "data": {
    "imported": [
      { "path": "assets/sprites/grass.png", "assetId": "ast_…", "name": "grass", "type": "sprite" }
      // … one entry per successfully imported file
    ],
    "skipped": [
      { "path": "art/tileset/notes.txt", "code": "UNKNOWN_TYPE", "message": "…" }
    ]
  },
  "errors": [], "warnings": [], "changed": [ /* … */ ],
  "files": ["hearth.json", "assets.json"], "suggestions": []
}

Every path is validated up front: a missing file, an unrecognized extension, or a directory passed without --recursive lands in skipped (with a code and message) rather than failing the whole batch. Name/path collisions, including two files in the same batch that would land on the same name, are resolved with an auto-suffix (grass, grass-2, …) instead of erroring. type (when passed) overrides extension-based inference for every file in the batch; --name is only valid for the single-file form. The editor’s Assets panel funnels both its multi-select file picker and whole-panel drag-and-drop (including dropped folders) through this same command. See editor.md.

Slicing a spritesheet

sliceSpritesheet (CLI: hearth create asset slice <asset> --frame-size WxH [--margin N] [--spacing N] [--prefix NAME]; MCP: slice_spritesheet with separate frameWidth/frameHeight numbers instead of a WxH string) cuts an imported sheet into a row-major grid of named frames and writes them into the asset’s own metadata. It creates no new asset and no new file.

ParamDefaultMeaning
assetSheet asset id or name (must be sprite or tile)
frameWidth / frameHeightSize of one frame, pixels
margin0Border skipped on all four edges before the grid starts
spacing0Gap between frames, both axes
namePrefixslug of the asset’s nameFrame names are <prefix>_<index>, row-major (row 0 left-to-right, then row 1, …)

Worked example: a 64×16 PNG with four 16×16 frames in a single row:

hearth create asset slice walk-sheet --frame-size 16x16 --prefix walk --json
{
  "success": true,
  "command": "sliceSpritesheet",
  "data": {
    "assetId": "ast_h22hj7bs",
    "frameCount": 4,
    "columns": 4,
    "rows": 1,
    "frames": ["walk_0", "walk_1", "walk_2", "walk_3"]
  },
  "errors": [], "warnings": [], "changed": [ /* … */ ], "files": ["hearth.json", "assets.json"],
  "suggestions": ["createAnimationFromSheet --sheet walk-sheet --frames walk_0 walk_1 walk_2 walk_3"]
}

asset.metadata afterward carries the typed frame list and the grid that produced it:

{
  "width": 64, "height": 16, "format": "png",
  "frames": [
    { "name": "walk_0", "x": 0,  "y": 0, "width": 16, "height": 16 },
    { "name": "walk_1", "x": 16, "y": 0, "width": 16, "height": 16 },
    { "name": "walk_2", "x": 32, "y": 0, "width": 16, "height": 16 },
    { "name": "walk_3", "x": 48, "y": 0, "width": 16, "height": 16 }
  ],
  "grid": { "frameWidth": 16, "frameHeight": 16, "margin": 0, "spacing": 0 }
}

Frame size must fit the sheet with the given margin/spacing, or the command fails outright (INVALID_INPUT, both CLI and MCP; there’s no partial slice):

{
  "success": false,
  "errors": [{ "code": "INVALID_INPUT", "message": "Frame size 100×100 does not fit in image 64×16 with margin 0 and spacing 0" }]
}

When the frame size doesn’t divide the sheet evenly, slicing still succeeds but leaves a data.warning string (not a top-level warnings[] entry; check data.warning specifically) naming the unused edge:

{ "data": { "frameCount": 3, "columns": 3, "rows": 1, "frames": [/* … */], "warning": "sheet is 64x16; 4px unused on the right" } }

Re-slicing replaces: running sliceSpritesheet again on the same asset with different params overwrites metadata.frames/metadata.grid wholesale. There’s no merge, so any animation referencing frame names that no longer exist will fail validation (see Validation below).

The only sanctioned reader of metadata.frames is getSheetFrames(asset)/findSheetFrame(asset, name) (packages/core/src/assets/sheetFrames.ts). Every consumer (renderer, animator, validator, export bundling) goes through these two functions rather than reading metadata.frames directly, so a malformed or hand-edited frame list degrades to “no frames” instead of throwing.

Animations from a sliced sheet

createAnimationFromSheet (CLI: hearth create asset anim-from-sheet <name> --sheet <asset> --frames a,b,c [--duration SECONDS] [--no-loop]; MCP: create_animation_from_sheet) builds a .anim.json animation asset whose frames array is a list of sheet refs ("<sheetAssetId>#<frameName>") rather than sprite-asset ids. This is the same animation asset type createAnimationAsset produces (still useful for multi-file sprite flipbooks); a sheet-backed one just points at named regions of one image instead of N separate files.

hearth create asset anim-from-sheet walk-cycle --sheet walk-sheet \
  --frames walk_0,walk_1,walk_2,walk_3 --duration 0.12 --json
{
  "success": true,
  "command": "createAnimationFromSheet",
  "data": {
    "asset": {
      "id": "ast_1poxkqpa", "name": "walk-cycle", "type": "animation",
      "path": "assets/animations/walk_cycle.anim.json",
      "metadata": { "frameCount": 4, "frameDuration": 0.12, "loop": true, "sheet": "ast_h22hj7bs" }
    },
    "frames": ["ast_h22hj7bs#walk_0", "ast_h22hj7bs#walk_1", "ast_h22hj7bs#walk_2", "ast_h22hj7bs#walk_3"]
  }
}

Every frame name must already exist on the sheet (sliceSpritesheet run first): an unknown name fails the whole call (INVALID_INPUT, listing every missing name at once) rather than writing a partially-valid animation.

Attach it the normal way: add a SpriteAnimator with assetId set to the animation asset, and a sibling SpriteRenderer. Each fixed frame, SpriteAnimator writes both SpriteRenderer.assetId and SpriteRenderer.frame from the current entry: a plain sprite-id entry clears frame back to null (whole image), a #-ref entry sets frame to the frame name. See components.md and scripting.md for ctx.animate.

Validation of frame refs

hearth validate (and validate_project) checks two things after any slicing/animation edit, both warnings, not errors: a missing frame degrades to drawing the whole texture rather than breaking the game:

  • FRAME_NOT_FOUND: an entity’s SpriteRenderer.frame names a frame that isn’t on the referenced sheet.
  • ANIMATION_FRAME_NOT_FOUND: an animation asset’s <sheet>#<frame> ref doesn’t resolve.

At render time the same lookup (findSheetFrame) that powers validation also drives drawing: an unresolvable SpriteRenderer.frame logs a runtime warning once per assetId#frame pair and falls back to the sheet’s whole texture, rather than drawing nothing.

SpriteRenderer.frame and tint

SpriteRenderer.frame (default null) names a sliced sheet frame to draw a sub-rectangle of assetId’s texture instead of the whole image: set it directly, or let SpriteAnimator drive it. null always means “draw the whole image,” even on an asset that has been sliced.

Every textured SpriteRenderer (an asset, not the shape/color primitive fallback) is now tinted by its own color field, and a color edit takes effect immediately (it’s part of the sprite’s visual identity, so the renderer rebuilds the node with the new tint). The schema default is #ffffff (white), which is a no-op tint: every project that predates this feature renders identically, since an untouched SpriteRenderer.color is still #ffffff. Set color to anything else (#ff8888 for a damage flash, #888888 to dim) to tint real art the same way the primitive fallback has always used color to fill its shape.

Music

ctx.audio.playMusic / stopMusic / setMusicVolume are a separate channel from ctx.audio.play/stop: one shared track for the whole running game (session-scoped, so it survives ctx.scenes.load scene switches), not a pool of independent one-shot playbacks.

CallSignatureBehavior
ctx.audio.playMusic(assetRef, opts?: { volume?, loop?, fadeIn? }) -> string | nullReplaces whatever is currently playing; returns a handle id, or null when the asset doesn’t exist
ctx.audio.stopMusic(opts?: { fadeOut? }) -> voidNo-op when nothing is playing
ctx.audio.setMusicVolume(volume, opts?: { fade? }) -> voidNo-op when nothing is playing
ctx.audio.playMusic("theme", { loop = true, fadeIn = 1 })
ctx.audio.setMusicVolume(0.3, { fade = 0.5 })
ctx.audio.stopMusic({ fadeOut = 1 })

Replace, not stack: calling playMusic while a track is already playing doesn’t layer the new one on top: the old track is recorded as stopped (its fadeOut equal to the new call’s fadeIn) and the new one starts, both ramps running over the same span so the swap crossfades rather than clicking. volume is clamped to 0..1; loop defaults to true (music tracks usually should loop; one-shot SFX via ctx.audio.play default the other way).

Autoplay: AudioSource.music: true (alongside autoplay: true) routes scene-start autoplay onto the music channel instead of a regular one-shot/looping playback. Set it on an entity and no script is needed to start the soundtrack. music: false (the default) is the existing ctx.audio.play-style playback.

In the browser, music streams; it doesn’t decode-then-play. SFX (ctx.audio.play) fetch, fully decodeAudioData, and cache the whole buffer, fine for a two-second sound. Music instead plays through a real <audio> element fed into Web Audio via createMediaElementSource. The browser handles buffering/seeking progressively, so a five-minute loop never blocks on a full decode. Caveat for single-file exports: --single-file embeds every asset as a data: URI baked directly into the HTML text, so by the time the page loads, the whole track is already in memory regardless: “streaming” in that build only means “plays through the same <audio> element,” not “loads progressively over the network.” The folder build (hearth export web, no --single-file) is where streaming actually saves memory and startup time on long tracks.

Run reports record every music play/stop in audioEvents with music: true, exactly like one-shot audio does with music omitted. See assertAudioCount below. Music never enters the same handle table ctx.audio.stop (SFX) reaches into, so stopping SFX (even every playback of an asset at once) can never accidentally kill the soundtrack.

Fonts

Import a .ttf/.otf/.woff/.woff2 file (importAsset, which infers type: 'font' from the extension) and reference it from Text.fontFamily by the asset’s name, verbatim. At mount, the browser host (packages/runtime/src/pixi/fonts.ts) loads every font-type asset with new FontFace(asset.name, url(...)) and registers it on document.fonts under exactly that name, so there’s no CSS @font-face block to write and no guessing at font-family strings. A single font’s load failure logs a warning and does not block the rest (headless Node hosts skip this entirely; there’s no document/FontFace to touch there, so a Node-only playtest run never depends on fonts loading).

hearth import asset ./fonts/press-start-2p.ttf --name press-start-2p --json
hearth set "Title Scene" HUD Text.fontFamily press-start-2p

A font asset’s name is fixed at import (there is no asset-rename operation), so choose it deliberately (--name): it is the font-family string every Text will reference. Need a different name later? Remove the asset (removeAsset) and re-import the file under the new name, then update each Text.fontFamily to match (fontFamily matches by name, so the old value would otherwise silently fall back to a system font).

In the editor, Text.fontFamily is a picker grouping your project’s font assets with the built-in generic families, not a raw text field. Pick a project font once it’s imported and every Text using it re-renders live in the preview.

Testing audio: assertAudioCount

A playtest step that counts matching entries in the run’s audioEvents, filterable by any combination of asset, action (play/stop), and music (true/false), checked against equals/min/max (at least one required):

{ "type": "assertAudioCount", "music": true, "action": "play", "equals": 1 }
hearth create playtest boot --scene "Level 1" --steps-file steps.json --json
hearth playtest boot --json
{
  "success": true,
  "data": {
    "passed": true,
    "steps": [
      { "index": 1, "type": "assertAudioCount", "passed": true, "message": "action \"play\", music true count 1 OK" }
    ],
    "audioEvents": [ { "frame": 0, "assetId": "ast_7eo3i5ys", "action": "play", "music": true } ]
  }
}

An asset filter that doesn’t resolve to a real asset fails the step outright (assertAudioCount: asset not found: …) rather than silently counting zero. A typo’d asset name can’t accidentally read as “this sound never played.”

Agent walkthrough: import to a green playtest

The full loop, CLI-first (every step also has an MCP tool with identical params: import_asset, slice_spritesheet, create_animation_from_sheet, create_entity, create_playtest, run_playtest):

hearth import asset ./art/walk-sheet.png --name walk-sheet --json
hearth create asset slice walk-sheet --frame-size 16x16 --prefix walk --json
hearth create asset anim-from-sheet walk-cycle --sheet walk-sheet \
  --frames walk_0,walk_1,walk_2,walk_3 --duration 0.12 --json

hearth create entity "Level 1" Player \
  --components '{"SpriteRenderer":{},"SpriteAnimator":{"assetId":"walk-cycle","playing":true}}' --json

hearth import asset ./audio/theme.wav --name theme --json
hearth create entity "Level 1" Music \
  --components '{"AudioSource":{"assetId":"theme","autoplay":true,"loop":true,"music":true,"volume":0.6}}' --json

hearth create playtest boot --scene "Level 1" --steps-file steps.json --json
hearth playtest boot --json   # green: passed: true
hearth validate --json         # no FRAME_NOT_FOUND / ANIMATION_FRAME_NOT_FOUND warnings

steps.json for the last playtest:

[
  { "type": "wait", "frames": 10 },
  { "type": "assertAudioCount", "music": true, "action": "play", "equals": 1 },
  { "type": "assertNoErrors" }
]

Over MCP, the same sequence is import_assetslice_spritesheetcreate_animation_from_sheetcreate_entity (×2) → create_playtestrun_playtest, each call taking the same parameters shown above (MCP’s slice_spritesheet takes numeric frameWidth/frameHeight instead of the CLI’s single --frame-size WxH string; see mcp.md). All of it requires only the asset-edit and safe-edit permission modes; nothing here needs build.

See packages/examples/sky-courier for a complete, playtested example built exactly this way: an imported PNG spritesheet sliced into a walk cycle and idle clip, a streamed WAV music loop on an AudioSource.music entity, and an imported .ttf fixture font used by the HUD text.