IGAH specification

This is the working spec. It started from the Google Doc "Isometric Game Asset Harness & Roadmap" and corrects it where the doc was wrong or couldn't work. Each difference is listed at the end.

Projection standard

ItemValue
Projection2:1 dimetric ("pixel-art isometric"). Ground lines run at atan(½) ≈ 26.565°.
CameraOrthographic, 30° above the ground plane, 45° azimuth
Base tile64 × 32 px by default. 128 × 64 and 256 × 128 are supported. Width must be a multiple of 4.
World units1 unit = 1 tile edge = tileW / √2 px along the ground. Height: tileW / √2 × cos 30° px per unit (39.19 px at 64).
Screen mappingsx = (x − z) · tileW/2, sy = (x + z) · tileW/4 − y · tileW/√2 · cos 30°
Key lightScreen top-left, direction (-0.62, 1, 0.38) toward the light. Fixed to the camera, so all four rotations match.
Shadingambient + (1 − ambient) · q(max(0, n·L) / L.y), with ambient 0.5 (0.4 crushed dark materials to near-black). q posterizes into shadeLevels steps (default 6, 0 = continuous), so a curved model gives a few clean shades instead of hundreds. Flat tops = 1.0, left faces ≈ 0.7, right faces 0.5.
ShadowsHard, cast onto the ground and onto the object itself. They fall bottom-right.
RotationsN = as authored; E, S, W each turn the model a further 90° clockwise seen from above. Footprint [x, z] swaps on E and W.

Stage 1: Blueprint

A blueprint is JSON. The footprint is centred on the origin; tiles span x ∈ [−fx/2, fx/2], z ∈ [−fz/2, fz/2]; y is up.

{
  "id": "oak_tree",            // snake_case, used for file names
  "name": "Oak tree",
  "category": "scenery",
  "footprint": [1, 1],         // [x, z] whole tiles, 1..8
  "occupancy": [[1]],          // optional; rows along z, columns along x
  "parts": [
    { "shape": "cylinder", "size": [0.16, 0.55, 0.16], "at": [0, 0, 0], "color": "#5e4030", "segments": 8 }
  ]
}

Editors can validate and autocomplete against schema/blueprint.schema.json. Add "$schema": "../../schema/blueprint.schema.json" at the top of a blueprint.

Part fields: shape, size [width, height, depth] in tiles, at = bottom-centre position, color #rrggbb, optional rotate (degrees about y).

ShapeNotes
box
pyramidtop: top scale, 0 gives a point
cylindersegments (default 12)
conesegments, top (0 gives a point)
gableRoof; the ridge runs along x
wedgeRamp: full height at −x, ground at +x
stairssteps (default 4), climbing toward −x
archRound arch in the x–y plane, open underneath; thickness, segments

Non-convex shapes (stairs, arch) are built from convex pieces, so face orientation stays correct.

Models. { "shape": "model", "src": "assets/models/x.glb", "size": [w, h, d] } imports a binary glTF. The model is scaled uniformly to fit the size box, stands on y = 0 and is centred on x/z, then at and rotate apply as for any part. src is relative to the repo root (the CLI reads it from disk; the sandbox fetches it). Supported: the scene graph (matrix or TRS, mirrored nodes), indexed or plain triangles, COLOR_0, baseColorFactor, and baseColorTexture with TEXCOORD_n (nearest sampling, wrapping). Materials with alphaMode MASK (or BLEND, treated as a 0.5 cut-off) drop transparent texels, so leaf cards and fences work. Hosts decode the images: the sandbox handles any browser format; the Node CLI decodes PNG and falls back to the base colour, with a warning, for JPEG. An optional color overrides the whole model's colour, or tints its texture.

Each pixel takes the colour covering most of its samples (mode, not mean), so textures stay crisp and face edges never blend. tools/blender/make_statue.py shows the Blender route; run it through blenderq.

Groups and repeats. groups maps a name to a list of parts. A part { "use": "lamp", "at": […], "rotate": … } places the whole group, and groups can use other groups (cycles are rejected). Any part or use can carry "repeat": { "count": n, "step": [x, y, z] }. The step is measured in the parent's frame; the part's own rotate turns each copy, not the spacing.

Detail kit. Every blueprint can also use built-in groups from src/core/kit.js: kit/window, kit/window_shuttered, kit/window_planter, kit/door, kit/lantern, kit/hanging_sign, kit/barrel, kit/crate, kit/bench, kit/flower_pot and kit/chimney. Wall pieces face +z and have at at their bottom centre on the wall face, so you place one at the face of a wall (and rotate it for other walls). A use may add "scale": s (uniform; sizes, offsets and spacing all scale) and "recolor": { "#from": "#to" } (applies to everything inside; an outer use's map applies after an inner one). Group names starting with kit/ are reserved. Layered small parts (frames, sills, hoops, caps) are what make a building read as detailed, because occlusion and lit edges pick each one out.

Patterns. Any primitive part may set "pattern" to brick, clapboard, planks, shingles, paving, cobble, foliage, speckle or ripple (src/core/patterns.js). A pattern is drawn on the face's own plane, sized in output pixels (a brick course is 3 px at every tile size), and anchored to the part's authoring-space position, so it stays put as the views rotate. Patterns use whole palette-ramp steps (dark ×0.66, base, light ×1.4), not small tints, because the palette would snap small tints back to one colour. Ground tiles repeat, so their patterns avoid rare bright features that would read as stripes.

Render options. A blueprint may set "render": { "outline": 0|4|8, "shadow": "checker"|"solid"|"none", "shadeLevels": n, "look": "rich"|"flat" }. These belong to the asset and win over CLI and sandbox options. Ground tiles use { "outline": 0, "shadow": "none" } so they butt together seamlessly; a flat tile is exactly one tile (48×24 at --tile 48).

Variants. "patternSeed": n on a part shifts its pattern by whole 12 px steps. Every pattern grid (2, 3, 4, 6 px) divides 12, so lines still meet at tile edges while the random parts (clumps, ripples, stones) differ. ground_grass_b/_c, ground_water_b, ground_cobble_b and ground_road_b are variants made this way.

Transitions. ground_shore (straight), ground_shore_out (land corner) and ground_shore_in (water corner) are single blueprints placed in four rotations. Rotation decides which side the water is on: @N +z, @E −x, @S −z, @W +x. ground_road_crosswalk has zebra bars parallel to a road that runs along x.

Scenes. assets/scenes/*.json lay out ground tiles (one character per tile) and props (by footprint corner and view) for review. A legend value can be "id", "id@E" (a rotation) or a list of those (the tool picks one per tile by a stable position hash). node tools/dev/scene.js <scene> composites them with per-pixel occlusion from the ground-depth maps, the same rule an engine would use. The outputs are mock-ups for review, not a runtime.

Anchors. Optional anchors names ground points in authoring space, for example "anchors": { "door": [0.45, 0.52] }, where a walker stops to enter. The manifest copies them to anchors and, for every view, gives views.<V>.anchors.<name> as ground ([x, z] rotated like occupancy) and px (the point in the frame, from its top-left, with sub-pixel precision). Engines should path to ground and never guess a door from the sprite.

Occupancy. Optional occupancy lists footprint[1] rows (z) of footprint[0] values (x), each 0 or 1. The gift shop sample is L-shaped: [[1, 1], [1, 0]]. The manifest rotates it for every view.

Rendering

Stage 3: Cleaner

  1. Palette. The default, igah-32, snaps every asset to one fixed 32-colour set, so a whole scene shares colours. It's built as ramps (greys, timber, foliage, brick, water, brass, fairground) with a warm near-black at the bottom, so heavily shaded colours don't jump to another hue. auto runs median cut over all four views of one asset (default 24 colours), weighted by the log of each colour's pixel count so small accents keep their own entry. Matching uses OKLab with hue/chroma weighted 3× over lightness. With the rich look, igah-32 also defines material ramps (grey, timber, foliage, brick, water, brass, plum, teal; dark → light, sharing a near-black where it suits). The renderer records each pixel's unshaded material colour; the cleaner finds the ramp holding that material's nearest palette colour and snaps the pixel within that ramp by lightness. So lighting only ever moves a surface up or down its own ramp: shaded clapboard stays timber and a teal sign stays teal. Outline #111116 and shadow #262433 are reserved in addition.
  2. Shadow. checker (50% dither, parity anchored to the pivot so adjacent tiles line up), solid or none.
  3. Outline. A 1 px exterior outline: 8-connected (square, matches a 3 × 3 dilation) or 4-connected (soft corners).
  4. Crop. Tight around visible pixels, but never inside the base diamond, so every frame contains its whole footprint and the pivot.

Stage 4: Packager

The sheet is one row of frames in N, E, S, W order. The manifest (igah.sprite/1):

{
  "schema": "igah.sprite/1",
  "asset_id": "coaster_station", "name": "Coaster station", "category": "rides",
  "tile": { "width": 64, "height": 32 },
  "footprint": { "x": 2, "z": 1 },
  "sheet": { "file": "coaster_station_sheet.png", "width": 520, "height": 96 },
  "palette": { "name": "auto", "outline": "#111116", "shadow": "#262433", "colors": ["#…"] },
  "views": {
    "N": {
      "frame": { "x": 0, "y": 0, "w": 130, "h": 96 },
      "pivot": { "x": 50, "y": 68 },
      "pivot_normalized": { "x": 0.3846, "y": 0.2917 },
      "footprint": { "x": 2, "z": 1 },
      "occupancy": [[1, 1]]
    }
  }
}

Stickers (assets/stickers/)

Sticker art has to stay exactly as drawn, so it doesn't go through the 3D pipeline.

The silhouette is worked out at 8× from the real art, so even sub-pixel strokes shape the sticker. A person picks one per size with node tools/approve-sticker.js <id> <size> standard|bold|edited.png. That copies it to approved/ and locks its hash. From then on, builds use that exact file and fail if it changes.

Characters (igah.character/1)

A separate format from props (igah.sprite/1 is unchanged). A character spec (assets/characters/*.json, schema schema/character.schema.json) sets build (slim, average, broad), height (tiles; 0.36 matches the scene), skin, and { style, color } for hair, hat, top, bottom and accessory, plus shoes. src/core/character.js turns a spec into a posed blueprint: limbs are ordinary parts hinged with pitch and pivotY. A walk swings legs ±28° and arms ±24° in opposition, and drops the body by L·(1 − cos θ) so the feet stay planted.

Options: face (glasses, sunglasses, beard, mustache); top (tee, hoodie, jacket, coat, apron, hi-vis, dress); bottom (pants, shorts, skirt); accessory (backpack, shoulder bag, cane, paint can, umbrella, box). An umbrella holds the right arm up, and a box holds both arms in front, in every animation.

The sheet has 8 rows, one per screen direction (S, SW, W, NW, N, NE, E, SE; S faces the viewer), and one column per animation frame:

AnimationColumnsfpsNotes
idle01Standing
walk1–610Legs ±28°, arms ±24°, feet planted
run7–1214Legs ±42°, with airborne frames between strides
wave13–166Right arm raised and swaying
reach17–206Both arms out at chest height (placing something on a wall)

Columns are only ever appended, so idle and walk never move. Every cell has the same size and the same feet pivot. Cell size and pivot depend on the character (a raised arm or umbrella makes cells taller), so always read them from the manifest. The manifest gives cell, pivot, directions[] (each with its row and on-screen screen_vector), animations (columns, fps, loop), walk_speed_tiles_per_second, run_speed_tiles_per_second, and the source spec.

Gates: hard alpha, palette, every frame drawn, and feet on the ground (see "Planting" below).

Build with npm run characters (48 px into gallery/characters/, plus a 96 px showcase in gallery/characters/2x/). Design characters live in sandbox/characters.html. Scenes can place them with "characters": [{ "character", "tile", "direction", "frame" }].

Kinds. A spec's optional kind picks what it is. Each kind lives in src/core/kinds/ and supplies its options, colours, animations and pose; the engine in character.js does everything else.

KindOptionsAnimations (columns in order)Speeds in the manifest
person (default)as aboveidle, walk ×6, run ×6, wave ×4, reach ×4walk 1.2, run 2.6 tiles/s
dogsize, ears, tail; coat, markings, collar (style + colour); noseidle, walk ×6 (diagonal pairs), run ×6 (gallop, airborne), sniff ×4walk 1.3, run 3.4
birdspecies (pigeon, sparrow, crow, gull); body, wings; head, beakidle, walk ×4 (head bob), peck ×4, fly ×4 (airborne)walk 0.6, fly 4
vehiclestyle (hatchback, sedan, van, pickup); paint (solid, two-tone), extras (roof rack, light bar, ladder); glassidle, drive ×2drive 4

Planting. After posing, the engine shifts every frame so the model's lowest point sits exactly at the pose's intended lift: 0 on the ground, the jump height when airborne. Swung limbs are boxes, and their corners dip below any centreline estimate, so this keeps contact exact for every kind. The foot gate then checks contact in 3D (grounded frames at 0, airborne frames above it, because feet can be hidden in some views) and checks pixels for sinking.

Posable parts. Any primitive part may set pitch (degrees; its lower end swings forward, +z), roll (degrees; it tips toward +x) and pivotY (the hinge height above the part's base). They aren't allowed on group uses.

Maps for engines: occlusion and picking

Every build also writes two PNGs, laid out exactly like the sprite sheet (same size and frames), and names them in manifest.maps:

FileEncodingUse
<id>_ground.png (maps.ground_y)R·256 + G = round(4·groundY) + 32768; A = 0 where emptygroundY is the screen y, in px from the frame's pivot, of the ground point under the visible surface. Draw a character behind a pixel when the character's foot y (relative to the same pivot) is less than groundY. This handles multi-tile buildings, which centre-pivot painter sorting can't.
<id>_parts.png (maps.parts)R = index into maps.parts.labels + 1; A = 0 where emptyPicking: which part was clicked (roof, wall, door, …). A = 0 means empty ground, so the click passes through.

Outline pixels carry the part and depth of the object pixel they border. Shadow pixels carry neither. Part labels come from each part's optional name, then the name of the group use that placed it, then the shape. The maps gate checks that every object and outline pixel has both values and that nothing else does.

Gates

A build ships only if every gate passes. The CLI exits 1 otherwise.

GateChecks
calibrationA flat tile renders to exactly tileW × tileW/2, rows growing by 4 px (2:1 stair steps)
tile-seamNine tiles placed by pivot cover the centre tile exactly once: no gaps, no overlap
viewsAll four rotations rendered and non-empty
pivotInteger pivot inside every frame
alpha-binaryNo semi-transparent pixels
paletteOnly palette colours, and no more than the limit
shadow-directionShadow centroid lies to the bottom-right of the object centroid
mapsEvery object/outline pixel has a part and a ground depth; empty and shadow pixels have neither

Differences from the original Google Doc

DocThis repoWhy
"True isometric (~26.565°)"2:1 dimetric26.565° is the 2:1 line angle. True isometric is 30° lines from a 35.264° camera. Pixel-art games use 2:1.
Stable Diffusion generates each viewRender from 3D blueprintsDiffusion can't give four rotations of the same object or an exact angle. See ADR 0001.
#00FF00 chroma keyReal alpha from the rendererThe HSV key would also delete greenery (trees, grass) and leaves a green fringe.
Nearest-neighbour shrink of the whole canvasRender at target size with 4 × 4 edge samplingShrinking the full canvas without cropping makes the object tiny, and nearest-neighbour from about 1024 px throws away detail at random.
16–32 colour palette (stated, never applied)Median cut or fixed preset, enforced by a gateThe doc's script never quantized.
Pivot hard-coded (0.5, 0.85)Computed per view from the footprint centreHard-coded pivots drift with every asset height.
Full occupancy, dimensions from the N view onlyOccupancy and footprint rotated per view2 × 1 assets have different E/W frames.
Power-of-2 frame sizesTight frames plus atlas packing (M9)Engines don't need power-of-2 frames, and padding wastes space.
QA checklist by eyeSeven automated gatesChecks that run on every build.
Makefile orchestrationnpm run build / npm run galleryNo Python or OpenCV toolchain needed. The same code drives the sandbox.