Skip to content

Visual assets

Foray cannot use Pac-Man art, so it ships its own generated pixel-art set: a single sprite-sheet PNG plus an atlas JSON, consumed by the browser replay renderer. This page is the art bible — the dimensions, the required frames, the palette, and the atlas format — so the assets can be generated, reviewed, and regenerated against a fixed spec. The art is original throughout: an ant-colony raiding theme, not a maze-chase homage.

The look is a top-down 16×16 pixel-art colony. A packed sheet keeps the whole case to two committed files, draws as a single texture, and recolours per team by palette swap rather than by shipping duplicate art (see Palette & recolour). Pixel art at a fixed 16-px grid also matches the discrete, tile-locked board exactly — one sprite cell per maze tile.

The renderer holds no game logic (the rules live in foray-core); it only maps the reconstructed tick’s entities onto sprite cells.

The assets live with the case, beside the renderer:

test-cases/adversarial/easy/foray/v1.0.0/replay/
index.html # the [replay] renderer entry
renderer.mjs # canvas drawing: tick interpolation, walk cycles, wall autotiling
assets/
gen-sheet.mjs # the packer: composes source art (+ placeholders) -> sheet.png/json
sheet.png # the packed sprite sheet (RGBA PNG)
sheet.json # the atlas: frames + anims + wall_tiles + border_tiles
palette.json # named color slots + per-team ramps (see below)
source/ # committed finished per-asset art the packer composes in
  • sheet.png — one PNG, cells on a 16×16 grid, frames packed top-left. Plain RGBA (color type 6): the renderer’s per-team recolour matches the neutral ramp by colour value (see Palette & recolour), so the sheet does not need to be indexed-color.

  • sheet.json — the atlas. Named frames map to pixel rectangles, plus the animations (walk cycles the renderer plays as an agent crosses a tile) and the autotile maps the renderer selects board tiles from:

    {
    "cell": 16,
    "frames": {
    "soldier_s_0": { "x": 0, "y": 0, "w": 16, "h": 16 }, // facing-major walk frames
    "raider_e_2": { "x": 96, "y": 16, "w": 16, "h": 16 },
    "raider_laden_e_2": { "x": 96, "y": 32, "w": 16, "h": 16 },
    "seed": { "x": 16, "y": 48, "w": 16, "h": 16 },
    "wall_5": { "x": 0, "y": 80, "w": 16, "h": 16 }, // bitmask-keyed wall tile
    "floor": { "x": 48, "y": 80, "w": 16, "h": 16 }
    // ...
    },
    "anims": {
    // one ordered walk cycle per role+facing; the renderer ties the phase to
    // motion (a frame per quarter-cell) and falls back to fps when free-running.
    "soldier_walk_s": { "frames": ["soldier_s_0","soldier_s_1","soldier_s_2","soldier_s_3"], "fps": 8 },
    "raider_walk_s": { "frames": ["raider_s_0", "..."], "fps": 8 },
    "raider_laden_walk_s": { "frames": ["raider_laden_s_0", "..."], "fps": 6 }
    // ... + _n / _w / _e for each
    },
    "wall_tiles": { "0": "wall_0", "5": "wall_5", "...": "...", "15": "wall_15" }, // N=1,E=2,S=4,W=8
    "border_tiles": { "cap_top": "border_cap_top", "mid": "border_mid", "cap_bottom": "border_cap_bottom" }
    }

The set the renderer needs to draw a match. Team colour comes from the palette swap, so each agent/nest frame is authored once (in a neutral base ramp) and tinted Red or Blue at draw time — never duplicated per team. The moving casts (soldier, raider) are walk cycles: four frames per facing, named <role>_<facing>_<step> and grouped into the atlas’s anims, which the renderer cycles as the agent crosses a tile so motion reads smoothly between ticks.

GroupFramesNotes
Soldier (defender)soldier_{s,n,w,e}_{0..3} (16)Mandibled, angular silhouette; a 4-step walk cycle per facing → anims.soldier_walk_{s,n,w,e}.
Raider (forager, empty)raider_{s,n,w,e}_{0..3} (16)Lighter, leaner silhouette; 4-step walk cycle per facing → anims.raider_walk_*.
Raider, ladenraider_laden_{s,n,w,e}_{0..3} (16)The same cycles carrying a seed — slower, heavier: the carry-weight tell → anims.raider_laden_walk_*.
Immune ant(no frame)Not a sheet frame: the renderer draws a breathing additive cyan aura procedurally over any agent with immune_ticks > 0 (jelly active), pulsed by the tick clock so it haloes the sprite. Note an immune soldier is reachable — a raider that eats jelly and runs home keeps its window — so the aura is not raider-only.
Seed cacheseedThe ordinary scorable resource, worth 1.
Large seedlarge_seedWorth and weighing three ordinary seeds. The only fixture that moves — it drifts a tile at a time toward the border — so it is drawn per frame from the snapshot’s large_seeds, not as a static fixture. Deliberately not interpolated: a drift step is a discrete hop every few hundred ticks, not a walk, and sliding it would read as a rendering fault.
Royal jellyjelly_active, jelly_spentActive = glowing node; spent = dimmed husk after it is eaten. The husk is temporary: a node regrows at the same tile after its respawn window, so the renderer flips it back automatically (it derives “spent” as any jelly_nodes tile absent from the frame’s active jelly list, and holds no rules of its own).
Maze wallswall_{0..15}A 4-neighbor autotile set; the frame index is the N=1/E=2/S=4/W=8 connection bitmask, mapped in wall_tiles. The renderer picks each board cell’s tile from its wall neighbors, so walls render as a connected pac-man-style maze.
Boundary seamborder_{cap_top,mid,cap_bottom}The no-man’s-land divider down the middle, in border_tiles; capped top/bottom, tileable middle.
FloorfloorDug-tunnel ground the maze sits on.
NestnestOne frame, tinted per team, marking each spawn.

The agent walk cycles and the wall/boundary/floor tiles are produced by their own asset-generation cases (next section); facing is still decorative (the rules are direction-agnostic), and the renderer falls back to a single static frame name if a walk anim is ever absent, so the contract tolerates a partial set.

palette.json defines named colour slots and the per-team ramps that fill them, so the two colonies are one art set rendered twice:

{
"slots": ["body_dark", "body_mid", "body_light", "accent", "carried_seed"],
"shared": {
"soil_dark": "#241a12", "soil_mid": "#3a2a1c", "floor": "#1b1410",
"border": "#4a3f2a", "seed": "#e8c14a", "jelly": "#7be0a0"
},
"teams": {
"red": { "body_dark": "#5a1410", "body_mid": "#a83228", "body_light": "#e8635a", "accent": "#ffb0a0" },
"blue": { "body_dark": "#0f2a5a", "body_mid": "#2a5aa8", "body_light": "#5a8fe8", "accent": "#a0c8ff" }
}
}

At draw time the renderer (renderer.mjs, loadSheet) bakes one tinted copy of the sheet per team by matching the four neutral grey ramp values by colour and rewriting them to that team’s ramp — so the swap needs no indexed palette, just an RGBA sheet. Seeds, jelly, and the soil tiles use the shared ramp and are not tinted: they deliberately avoid the four neutral greys, so the value-match leaves them untouched. The board reads as earthy soil + dug tunnels, with the two colonies the only saturated colour — so attacker/defender allegiance is legible at a glance, and a laden raider’s carried seed (carried_seed / shared seed gold) pops against either colony.

The finished art is itself produced by The Test Cabinet: each piece of the sheet is the output of an asset-generation case drawn against its own brief —

AssetCase
Nestforay-nest (single sprite)
Seed cacheforay-seed (single sprite)
Royal jelly (active + spent)foray-jelly (2-frame sheet)
Soldier walk cyclesforay-soldier (16-frame sheet)
Raider walk cycles, empty + ladenforay-raider (32-frame sheet)
Maze wall autotile + boundary + floorforay-walls (20-frame sheet)

A run’s regenerated frames are committed under replay/assets/source/ (named for the atlas frame they fill — see source/README.md), and a reviewer can eyeball them or the packed sheet.png directly; a regeneration is an ordinary diff.

A committed Node packer, replay/assets/gen-sheet.mjs, composes the sheet — run node gen-sheet.mjs from replay/assets/. For every frame in the frame list it blits the finished art from source/<name>.png when present and otherwise draws a structured placeholder glyph, then writes the RGBA sheet.png and the sheet.json atlas (frames + anims + wall_tiles + border_tiles), kept in sync with palette.json. So finished and not-yet-generated frames coexist in one committed sheet the renderer consumes unchanged; re-run the packer whenever source/, the spec, or the palette changes.