Repository navigation
Gates
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.
| 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).
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.
| 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 $?
1A 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 $?
1A 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 $?
1Two 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 $?
1From 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.
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 $?
1For animations, read <id>.gates.json the same way: each animation is a key holding pass.
This wiki describes pixelwright 0.8.4 and was last updated on 2026-10-26. The plugin is MIT licensed. Report problems in the issues. On GitHub: release v0.8.4.
Using it
The releases
Contributing
Elsewhere