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
| Item | Value |
|---|---|
| Projection | 2:1 dimetric ("pixel-art isometric"). Ground lines run at atan(½) ≈ 26.565°. |
| Camera | Orthographic, 30° above the ground plane, 45° azimuth |
| Base tile | 64 × 32 px by default. 128 × 64 and 256 × 128 are supported. Width must be a multiple of 4. |
| World units | 1 unit = 1 tile edge = tileW / √2 px along the ground. Height: tileW / √2 × cos 30° px per unit (39.19 px at 64). |
| Screen mapping | sx = (x − z) · tileW/2, sy = (x + z) · tileW/4 − y · tileW/√2 · cos 30° |
| Key light | Screen top-left, direction (-0.62, 1, 0.38) toward the light. Fixed to the camera, so all four rotations match. |
| Shading | ambient + (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. |
| Shadows | Hard, cast onto the ground and onto the object itself. They fall bottom-right. |
| Rotations | N = 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).
| Shape | Notes |
|---|---|
box | |
pyramid | top: top scale, 0 gives a point |
cylinder | segments (default 12) |
cone | segments, top (0 gives a point) |
gable | Roof; the ridge runs along x |
wedge | Ramp: full height at −x, ground at +x |
stairs | steps (default 4), climbing toward −x |
arch | Round 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
- A software rasterizer in
src/core/render.js. It doesn't use WebGL, and its output is byte-identical everywhere. - Edge sampling: each pixel takes
ss × sssamples (default 4 × 4). A pixel is solid when at least half its samples hit geometry, and it takes the colour of the face covering most of it. Edges stay crisp, with no blended colours. - The canvas is sized so the pivot lands on an integer pixel with a 2 px margin for the outline.
- Looks.
lookpicks a lighting preset (--lookin the CLI, Lighting in the sandbox).rich(the default) adds, after each pixel's colour is chosen: - Ambient occlusion, in screen space against each pixel's own surface plane: neighbours within
max(2, tileW / 24)px that stand in front of the plane (corners, recesses, under eaves, things against a wall) darken it, and walls darken over the lowest tenth of a tile where they meet the ground. - Lit edges: a pixel on the brighter face of a convex edge (a neighbour falls away behind its plane) is drawn one step brighter, so ridges, corners, sills and hoops catch the light.
- Material ramps in the cleaner (below).
flatis the original look: one shade per face, nearest palette colour. The renderer also has an optionalhueShift(cool shadows, warm lights) that no preset uses yet: with 32 colours it pushed shaded browns to grey, so it waits for a larger palette.
Stage 3: Cleaner
- 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.autoruns 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 therichlook,igah-32also 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#111116and shadow#262433are reserved in addition. - Shadow.
checker(50% dither, parity anchored to the pivot so adjacent tiles line up),solidornone. - Outline. A 1 px exterior outline: 8-connected (square, matches a 3 × 3 dilation) or 4-connected (soft corners).
- 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]]
}
}
}
pivot: px from the frame's top-left. In an engine, draw the frame so that the pivot lands on the footprint centre's screen position.pivot_normalized: 0..1 with y from the bottom, the convention a Unity sprite pivot uses.
Stickers (assets/stickers/)
Sticker art has to stay exactly as drawn, so it doesn't go through the 3D pipeline.
- Locked masters.
stickers.jsonlists each master SVG with its sha256. The SVGs are byte-exact copies of the approved art (terry_tface.svg,terry_grin.svg). A test and the build fail if a master changes. Replace one only on purpose, updating its hash in the same commit. - Exact large sizes.
src/core/svg.jsis a small built-in rasterizer: paths with every command (including arcs), polygons, rects, circles, solid fills, nonzero/evenodd, exact area coverage. It reports anything it doesn't support (transforms, strokes, styles) instead of guessing.tools/dev/sticker-verify.cjschecks it against Chrome. At 1024 and 256 px both masters agree to within one pixel everywhere, with total ink within 0.25% (the remaining difference is edge anti-aliasing).dieCut()puts the art, untouched, on a white backing that follows its outline atsize / 28px. Gaps inside the outline are filled the way a die cutter would, and the backing gets a thin grey edge line and an optional drop shadow. The 512, 256 and 128 px renders are for UI, print and inventory. - Approved pixel sizes. In the world a sticker is 12–32 px, where no automatic reduction keeps hand-drawn linework.
pixelCandidate()makes hard-pixel candidates in four colours (ink#1c1a22, backing#f0f0f4, outline#111116): standard: a pixel is ink at 40% coverage.bold: ink at 15% coverage, which keeps thin lines.
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.
- Wall decals.
wallDecal(img, 'left'|'right')shears a pixel sticker onto a wall face by shifting each column half a pixel per column, the 2:1 slope. It never resamples, so every approved pixel survives exactly. npm run stickerswrites everything togallery/stickers/with anindex.json(igah.stickers/1) listing the files and which sizes are approved.
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:
| Animation | Columns | fps | Notes |
|---|---|---|---|
idle | 0 | 1 | Standing |
walk | 1–6 | 10 | Legs ±28°, arms ±24°, feet planted |
run | 7–12 | 14 | Legs ±42°, with airborne frames between strides |
wave | 13–16 | 6 | Right arm raised and swaying |
reach | 17–20 | 6 | Both 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.
| Kind | Options | Animations (columns in order) | Speeds in the manifest |
|---|---|---|---|
person (default) | as above | idle, walk ×6, run ×6, wave ×4, reach ×4 | walk 1.2, run 2.6 tiles/s |
dog | size, ears, tail; coat, markings, collar (style + colour); nose | idle, walk ×6 (diagonal pairs), run ×6 (gallop, airborne), sniff ×4 | walk 1.3, run 3.4 |
bird | species (pigeon, sparrow, crow, gull); body, wings; head, beak | idle, walk ×4 (head bob), peck ×4, fly ×4 (airborne) | walk 0.6, fly 4 |
vehicle | style (hatchback, sedan, van, pickup); paint (solid, two-tone), extras (roof rack, light bar, ladder); glass | idle, drive ×2 | drive 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:
| File | Encoding | Use |
|---|---|---|
<id>_ground.png (maps.ground_y) | R·256 + G = round(4·groundY) + 32768; A = 0 where empty | groundY 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 empty | Picking: 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.
| Gate | Checks |
|---|---|
calibration | A flat tile renders to exactly tileW × tileW/2, rows growing by 4 px (2:1 stair steps) |
tile-seam | Nine tiles placed by pivot cover the centre tile exactly once: no gaps, no overlap |
views | All four rotations rendered and non-empty |
pivot | Integer pivot inside every frame |
alpha-binary | No semi-transparent pixels |
palette | Only palette colours, and no more than the limit |
shadow-direction | Shadow centroid lies to the bottom-right of the object centroid |
maps | Every object/outline pixel has a part and a ground depth; empty and shadow pixels have neither |
Differences from the original Google Doc
| Doc | This repo | Why |
|---|---|---|
| "True isometric (~26.565°)" | 2:1 dimetric | 26.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 view | Render from 3D blueprints | Diffusion can't give four rotations of the same object or an exact angle. See ADR 0001. |
#00FF00 chroma key | Real alpha from the renderer | The HSV key would also delete greenery (trees, grass) and leaves a green fringe. |
| Nearest-neighbour shrink of the whole canvas | Render at target size with 4 × 4 edge sampling | Shrinking 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 gate | The doc's script never quantized. |
Pivot hard-coded (0.5, 0.85) | Computed per view from the footprint centre | Hard-coded pivots drift with every asset height. |
| Full occupancy, dimensions from the N view only | Occupancy and footprint rotated per view | 2 × 1 assets have different E/W frames. |
| Power-of-2 frame sizes | Tight frames plus atlas packing (M9) | Engines don't need power-of-2 frames, and padding wastes space. |
| QA checklist by eye | Seven automated gates | Checks that run on every build. |
| Makefile orchestration | npm run build / npm run gallery | No Python or OpenCV toolchain needed. The same code drives the sandbox. |