Skip to content
Mark Rogers edited this page Oct 27, 2026 · 5 revisions

A gate is a check whose result is in the JSON. When one fails, make and frames still write their files, print "ok": false and exit 1. The skills add one rule on top: a gate that fails twice for the same sprite stops the work, and the agent reports the gate JSON instead of loosening a threshold.

Make gates

Gate Holds when Present
alpha_binary every alpha is 0 or 255 always
colours (a count, not a gate) unique opaque colours in the sprite always
colours_outside_palette 0 colours are outside the palette (an explicit outline colour counts as allowed) with a palette
within_budget colours is at most budget (default: the palette's size) with a palette or a budget
size_ok the canvas is the crop.size asked for with crop.size
seam_ratio, seam_ok the wrap edge's step is at most 1.5 times the mean step inside with tile
aw_pixel artwright's clean_pixel_art is not false always: a verdict, or skipped

A budget of 4 on the mushroom, which kept 7 colours:

{"id": "mushroom", "input": "render.png", "palette": "pico-8", "budget": 4}
  "gates": {
    "alpha_binary": true,
    "colours": 7,
    "colours_outside_palette": 0,
    "within_budget": false,
    "aw_pixel": {
      "skipped": "artwright's aw.py not found (set PIXELWRIGHT_AW)"
    }
  }
}
$ echo $?
1

size_ok can only read true: a sprite that does not fit the size is refused by the crop step with an error before any gate runs (Commands).

artwright's verdict

With artwright installed, make runs aw.py pixel on the sprite and keeps clean_pixel_art, problems, near_duplicate_colour_pairs and orphans. With pixelwright 0.6.1 or later and artwright 0.4.1 or later the Home sprite passes (this run: 0.8.4 and 0.7.0; earlier runs with pixelwright 0.7.0 and artwright 0.4.2 printed the same):

    "aw_pixel": {
      "clean_pixel_art": true,
      "problems": [],
      "near_duplicate_colour_pairs": 0,
      "orphans": {
        "count": 0,
        "first": []
      }
    }
  }
}
$ echo $?
0

With pixelwright 0.6.0 and artwright 0.4.0 the same make failed, with transitional (blend) pixels 1.76%, and so did any sprite with a dark outline against transparency. The cause, shown on a 12x12 box with a one-pixel dark outline around a red fill: pixelwright stores every cleared pixel as RGBA 0, 0, 0, 0, and artwright's blend test reads the colour of transparent pixels as well, so the outline between that black and a bright fill measured as a blend pixel: 11.11% on that box with artwright 0.4.1 and no --scale. Since artwright 0.4.2 a run touching a fully transparent pixel is skipped, so the box comes out clean without --scale:

box-black.png: transparent pixels stored as [0, 0, 0, 0], transitional_pixels 0.0, problems []

The same box with its transparent pixels stored as white had none on any version:

box-white.png: transparent pixels stored as [255, 255, 255, 0], transitional_pixels 0.0, problems []

Since 0.6.1, make calls aw.py pixel --scale 1 (its output is at native size), and artwright 0.4.1 does not fail blend pixels at a stated scale of 1 (it still reports their count; 0.4.1 counted 11.11% here):

box-black.png with --scale 1: transitional_pixels 0.0, clean_pixel_art True, problems []

The sprite make wrote stores its transparent pixels the same way:

out/mushroom.png: 792 transparent pixels, stored as [(0, 0, 0, 0)]

With an older pair (pixelwright 0.6.0, or artwright 0.4.0), or artwright 0.4.1 run by hand without --scale, read problems before changing a sprite: when the only problem is blend pixels and the sprite has a dark outline against transparency, the outline is the likely cause. The full recipe with orphans, thinning and a selective outline also passes with 0.6.1 and 0.4.1 and later; artwright reports 6 orphan pixels there without failing on them:

      "clean_pixel_art": true,
      "problems": [],
      "near_duplicate_colour_pairs": 0,
      "orphans": {
        "count": 6,

The gates are no proof that the input was pixel art. The painted gradient (no grid, scale 1) reduced to 8 colours passed every make gate, artwright's included:

  "gates": {
    "alpha_binary": true,
    "colours": 8,
    "colours_outside_palette": 0,
    "within_budget": true,
    "aw_pixel": {
      "clean_pixel_art": true,
      "problems": [],
      "near_duplicate_colour_pairs": 0,
      "orphans": {
        "count": 0,
        "first": []
      }
    }
  }
}
$ echo $?
0

snap's scale of 1 is the warning there; see Making pixel art.

Animation gates

Field Holds when
rest_frame_identical the rest frame equals the still in every RGBA byte (null for a loop)
changed_outside_mask 0 pixels change outside the tracks' regions
alpha_changed_outside_alpha_regions 0 alpha values change outside regions marked "alpha": true
new_colours 0 colours appear that are not the still's, extraColours or a track's declared colours
noticeable the peak share of changed pixels is at least 8%, or the peak brightness change in the focal region is at least 40
loop_seam_ok loops only: the last-to-first jump changes no more pixels than the largest step inside
drawn_outside_silhouette since 0.7.2: the most pixels any frame draws where the still is transparent. It fails only an animation with a site track, which must keep it at 0; for others it is a number
flicker_pixels since 0.8.1: unique locations where lone pixels turn on and back off (or the reverse) over three frames with no changed neighbour; since 0.8.4, matched travelling pixels are excluded. It fails only when the animation sets max_flicker_pixels and the count is above it
pass all of the above

changed_share_per_frame and focal_mean_abs_luma_delta are the per-frame numbers behind noticeable; noticeable_thresholds can be changed per brief or per animation ("noticeable": {"share": 0.08, "luma": 40}). aw_pixel_frames is artwright's frame check (aw.py pixel --frames), or skipped; it does not count toward pass.

A pulse too weak to see:

{"use": {"frames": 6, "focal": "fire", "tracks": [
  {"effect": "pulse", "region": "fire", "hot": [255, 250, 220], "strength": 0.05,
   "curve": [0, 0.5, 1, 1, 0.5, 0]}]}}
$ python pxw.py frames brief-weak.json out-weak
{
  "ok": false,
  "id": "forge",
  "gates": "out-weak/forge.gates.json",
  "animations": {
    "use": {
      "pass": false,
      "frames": 6,
      "rest_frame_identical": true,
      "changed_outside_mask": 0,
      "new_colours": 0,
      "noticeable": false,
      "sheet": "out-weak/forge-use-6f.png"
    }
  }
}
$ echo $?
1

A use animation whose first frame is already glowing, so the rest frame is not the still:

{"use": {"frames": 6, "focal": "fire", "tracks": [
  {"effect": "pulse", "region": "fire", "hot": [255, 250, 220], "curve": [1, 1, 0.8, 0.5, 0.2, 0.1]}]}}
$ python pxw.py frames brief-rest.json out-rest
{
  "ok": false,
  "id": "forge",
  "gates": "out-rest/forge.gates.json",
  "animations": {
    "use": {
      "pass": false,
      "frames": 6,
      "rest_frame_identical": false,
      "changed_outside_mask": 0,
      "new_colours": 0,
      "noticeable": true,
      "sheet": "out-rest/forge-use-6f.png"
    }
  }
}
$ echo $?
1

A smoke loop from the chimney that is too faint and does not close:

{"smoke": {"frames": 8, "rest": "none", "focal": "sky", "tracks": [
  {"effect": "puffs", "region": "sky", "source": [21, 4], "bornFrames": [0, 4], "life": 8,
   "colours": [[214, 210, 220], [168, 164, 176]], "seed": 5}]}}
  "changed_share_per_frame": [
    0.0082,
    0.0185,
    0.0226,
    0.0206,
    0.0267,
    0.0247,
    0.0309,
    0.0267
  ],
  "focal_mean_abs_luma_delta": [
    1.3,
    3.8,
    4.6,
    3.9,
    4.6,
    5.0,
    6.1,
    5.2
  ],
  "noticeable_thresholds": {
    "share": 0.08,
    "luma": 40
  },
  "noticeable": false,
  "drawn_outside_silhouette": 15,
  "flicker_pixels": 3,
  "flicker_at": [
    [
      26,
      1
    ],
    [
      25,
      2
    ],
    [
      22,
      3
    ]
  ],
  "loop_seam_changed": 17,
  "loop_seam_ok": false,
  "pass": false,

An effect name frames does not know stops before any frame is made:

{"use": {"frames": 4, "tracks": [{"effect": "wobble", "region": "fire"}]}}
$ python pxw.py frames brief-odd.json out-odd
{
  "ok": false,
  "error": "use: unknown effect(s) ['wobble']; known: ['arc', 'assemble', 'bands', 'bend', 'brighten', 'buildup', 'cart', 'darken', 'drone', 'dust', 'external', 'fill', 'glint', 'model_rotate', 'puffs', 'pulse', 'rotate', 'scroll', 'shift', 'site', 'sparks', 'spill']"
}
$ echo $?
1

Flicker and cadence

Two reports came with 0.8.1. Neither fails an animation by default, and neither changes a sheet.

flicker_at is present only when flicker_pixels is nonzero. It samples up to 8 unique [x, y] pairs in row order, top to bottom then left to right. There is no frame index: a location can blink in more than one three-frame window, and is counted once. Inspect the frames to find the blink. Loops include the last-to-first seam.

flicker_pixels counts single pixels that blink in for one frame, the thing the timing reference warns about at game size. On the forge's use animation it still found 3 in the 0.8.4 archive run, all in the sky, where the sparks fly; the forge passed. The motion heuristic does not match these locations. With "max_flicker_pixels": 0 in the animation, the same tracks still fail:

{"use": {"frames": 10, "focal": "fire", "fps": 12, "max_flicker_pixels": 0, "tracks": [
  {"effect": "pulse", "region": "fire", "hot": [255, 250, 220],
   "curve": [0, 0.5, 1, 1, 0.8, 0.6, 0.4, 0.2, 0.1, 0]},
  {"effect": "sparks", "region": "sky", "source": [19, 3, 24, 4], "bornFrames": [1, 2, 3],
   "colours": [[255, 250, 220], [255, 150, 40], [200, 70, 20]]}]}}
$ python pxw.py frames brief-flicker.json out-flicker
{
  "ok": false,
  "id": "forge",
  "gates": "out-flicker/forge.gates.json",
  "animations": {
    "use": {
      "pass": false,
      "frames": 10,
      "rest_frame_identical": true,
      "changed_outside_mask": 0,
      "new_colours": 0,
      "noticeable": true,
      "sheet": "out-flicker/forge-use-10f.png"
    }
  }
}
$ echo $?
1

From the gates file:

{
  "flicker_pixels": 3,
  "flicker_at": [
    [
      26,
      0
    ],
    [
      17,
      1
    ],
    [
      24,
      3
    ]
  ],
  "pass": false
}

In 0.8.1 to 0.8.3, a spark jumping more than one pixel per frame could be counted because it left no changed neighbour. Since 0.8.4 the gate matches incoming and outgoing motion steps over three frames, allowing a 1 px difference for rounding or acceleration. Over transparent sky it matches alpha, so the spark may change colour; over opaque art it matches RGBA.

This is a motion heuristic, not particle tracking. Births, deaths, sharp turns and other unmatched motion may still count. Review an animation before setting its cap, and leave room for intentional blinks.

cadence_uneven appears when a scroll or shift track with an even curve moves in whole-pixel steps of different sizes, which judders. 8 pixels over 6 frames:

{"slide": {"frames": 6, "focal": "fire", "tracks": [
  {"effect": "shift", "part": "fire", "region": "front", "by": [8, 0], "curve": [0, 0.2, 0.4, 0.6, 0.8, 1]}]}}
$ python pxw.py frames brief-cadence.json out-cadence
{
  "ok": true,
  "id": "forge",
  "gates": "out-cadence/forge.gates.json",
  "animations": {
    "slide": {
      "pass": true,
      "frames": 6,
      "rest_frame_identical": true,
      "changed_outside_mask": 0,
      "new_colours": 0,
      "noticeable": true,
      "sheet": "out-cadence/forge-slide-6f.png"
    }
  }
}
$ echo $?
0
{
  "cadence_uneven": [
    {
      "track": 0,
      "effect": "shift",
      "steps": [
        2,
        1,
        2,
        1,
        2
      ]
    }
  ],
  "pass": true
}

The steps are 2, 1, 2, 1, 2 pixels. The timing reference's advice is to pick a frame count that divides the distance or the period. The report does not touch pass.

A check in CI

The reports are plain JSON, so a build can fail on them. This script reads every .make.json in two of the run's output folders:

import json
import sys
from pathlib import Path

failed = []
for path in sorted(Path("out-set").glob("*.make.json")) + sorted(Path("out-budget").glob("*.make.json")):
    report = json.loads(path.read_text(encoding="utf-8"))
    if not report["ok"]:
        failed.append(path.as_posix())
print("failed:", failed or "none")
sys.exit(1 if failed else 0)
$ python check_makes.py
failed: ['out-budget/mushroom.make.json']
$ echo $?
1

For animations, read <id>.gates.json the same way: each animation is a key holding pass.

Clone this wiki locally