# Pixel sprites and sound effects from one spec file, with a five-check quick check (free sample of Game Assets as Code)

Version 1.0, 2026-10-28. By PaperChaseWebb. For any coding agent that can run Python: Grok Bot, Claude Code, Codex or Hermes. One Markdown file and one Python 3 script, standard library only. The script was tested on its worked example (5 findings, then 0), and two builds of the same spec gave identical files. The worked example's PNG and WAV files were loaded in Godot 4.7.2 headless.

Ask an image model for a 16 by 16 coin and you get something that looks like pixel art but is not on a grid, uses forty shades of yellow and comes out different the next time. Ask a coding agent to write the coin as a grid of letters and a palette, and you get exactly 16 by 16, exactly your colors, and the same file every time you build it. This sample is the smallest useful version of that: pixel sprites and short sound effects from one spec file, a manifest with the hash of every file, and a quick check.

## Install in one line

Save this page as FREE-SAMPLE.md, then:

```
python3 -c "import re;t=open('FREE-SAMPLE.md').read();open('gac_mini.py','w').write(re.search(r'~~~python\n(.*?)~~~',t,re.S).group(1))"
```

## The spec

Put mini.spec.json in a folder. A palette maps letters to colors, "." is transparent, and every sprite is a list of rows. Every sound has a seed, a wave, a start and end pitch, a duration and a gain.

```json
{"palette": {"k": "#1a1c2c", "g": "#e8a23c", "w": "#f4f4f4", "t": "#a7613b"},
 "assets": [
  {"name": "spr_coin", "kind": "sprite", "scale": 4, "grid": ["..kk..", ".kggk.", "kggwgk", "kggggk", ".kggk.", "..kk.."]},
  {"name": "spr_chest", "kind": "sprite", "scale": 3, "grid": [".kkkkkk.", "kttttttk", "kkkggkkk", "kttggttk", "kttttttk", ".kkkkkk."]},
  {"name": "sfx_coin", "kind": "sfx", "seed": 1, "wave": "square", "f0": 988, "f1": 1319, "dur": 0.12, "gain": 0.4},
  {"name": "sfx_jump", "kind": "sfx", "seed": 2, "wave": "square", "f0": 220, "f1": 660, "dur": 0.2, "gain": 0.5},
  {"name": "sfx_hurt", "kind": "sfx", "seed": 3, "wave": "sine", "f0": 400, "f1": 90, "dur": 0.25, "gain": 0.6, "noise": 0.5}]}
```

`python3 gac_mini.py build my-art` writes my-art/out/spr_coin.png, the other files and MANIFEST.json, with the sha256 of each file and a hash of its spec entry.

## Five rules for your agent

Paste these into AGENTS.md, CLAUDE.md or your Grok Bot or Hermes instructions:

```
1. Change mini.spec.json, never the files in out/. If a sprite looks wrong, change its grid and rebuild.
2. Use palette letters only, prefix sprites spr_ and sounds sfx_, and give every sound an integer seed.
3. After every build run python3 gac_mini.py check . and paste the findings as printed.
4. Keep sound gain at 0.6 or below so nothing clips.
5. Nothing goes into the game folder or leaves this machine until I have looked at it and said yes.
```

## The five checks

| Check | What it looks for |
|---|---|
| Q1 | a name without spr_ or sfx_ |
| Q2 | a letter in a sprite grid that is not in the palette |
| Q3 | a sound without an integer seed |
| Q4 | an asset not built, or its spec or file changed since the build |
| Q5 | a sound that peaks above -1 dBFS or starts or ends away from zero (a click) |

## Worked example

`python3 gac_mini.py example demo` writes two copies of the spec above: demo/fixed, built and clean, and demo/seeded, with five faults planted the way they happen in real sessions. Then:

```
python3 gac_mini.py check demo/seeded
Q1  coin: a sprite name starts with spr_
Q2  spr_chest: letters ['x'] are not in the palette
Q3  sfx_coin: needs an integer seed
Q4  sfx_jump: spec or file changed since the build
Q5  sfx_hurt: clips above -1 dBFS or clicks at an edge
5 finding(s)
```

Exit 1. What happened: the agent renamed the coin and dropped the prefix, typed an x into the chest's lock, wrote a seed as text, made the jump longer without rebuilding, and turned the hurt sound up to 1.5. The fixes are the same five lines in reverse: name it spr_coin, put g back in the lock, write seed 1 as a number, rebuild, and bring the gain back to 0.6. demo/fixed is the result: 0 findings, exit 0. The check reads only; it never changes your files.

## gac_mini.py

~~~python
#!/usr/bin/env python3
"""gac_mini.py: pixel sprites and sound effects from one spec file, plus a five-check quick check.
Python 3.8+, standard library only. Free sample of Game Assets as Code by PaperChaseWebb.

  python3 gac_mini.py build PROJECT      PROJECT/mini.spec.json -> PROJECT/out/*.png, *.wav, MANIFEST.json
  python3 gac_mini.py check PROJECT      read-only, five checks, exit 0 clean, 1 findings, 2 no folder
  python3 gac_mini.py example DIR        writes DIR/seeded (5 planted faults) and DIR/fixed to try the check on

Same spec and seed give the same bytes, so a hash in the manifest means something."""
import sys, os, json, hashlib, struct, zlib, math, random, wave

def sha(p): return hashlib.sha256(open(p, "rb").read()).hexdigest()
def ahash(a): return hashlib.sha256(json.dumps(a, sort_keys=True).encode()).hexdigest()

def png(path, grid, palette, scale):
    rows = []
    for line in grid:
        px = b"".join((bytes.fromhex(palette[c][1:]) + b"\xff") if c != "." else b"\0\0\0\0" for c in line for _ in range(scale))
        rows += [b"\0" + px] * scale
    w, h = len(grid[0]) * scale, len(grid) * scale
    ck = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
    open(path, "wb").write(b"\x89PNG\r\n\x1a\n" + ck(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + ck(b"IDAT", zlib.compress(b"".join(rows), 9)) + ck(b"IEND", b""))

def sfx(path, a):
    rnd = random.Random(a["seed"]); sr = 44100; n = int(sr * a["dur"]); f0, f1 = a["f0"], a.get("f1", a["f0"]); ph = 0.0; out = bytearray()
    for i in range(n):
        ph += f0 * (f1 / f0) ** (i / n) / sr; p = ph % 1
        s = (1.0 if p < 0.5 else -1.0) if a.get("wave", "square") == "square" else math.sin(2 * math.pi * p)
        s = s * (1 - a.get("noise", 0)) + (rnd.random() * 2 - 1) * a.get("noise", 0)
        e = min(1.0, i / (0.005 * sr), (n - 1 - i) / (a.get("release", 0.05) * sr))
        out += struct.pack("<h", int(max(-1, min(1, s * e * a.get("gain", 0.5))) * 32767))
    w = wave.open(path, "wb"); w.setnchannels(1); w.setsampwidth(2); w.setframerate(sr); w.writeframes(bytes(out)); w.close()

def build(proj):
    spec = json.load(open(os.path.join(proj, "mini.spec.json"))); out = os.path.join(proj, "out"); os.makedirs(out, exist_ok=True); man = {}
    for a in spec["assets"]:
        if a["kind"] == "sprite": fn = a["name"] + ".png"; png(os.path.join(out, fn), a["grid"], spec["palette"], a.get("scale", 1))
        else: fn = a["name"] + ".wav"; sfx(os.path.join(out, fn), a)
        man[a["name"]] = {"file": fn, "sha256": sha(os.path.join(out, fn)), "spec": ahash(a)}; print("built", fn, man[a["name"]]["sha256"][:12])
    json.dump(man, open(os.path.join(out, "MANIFEST.json"), "w"), indent=1, sort_keys=True)

def check(proj):
    if not os.path.isdir(proj): print("no project folder:", proj); return 2
    F = []; spec = json.load(open(os.path.join(proj, "mini.spec.json"))); out = os.path.join(proj, "out")
    try: man = json.load(open(os.path.join(out, "MANIFEST.json")))
    except OSError: man = {}
    for a in spec["assets"]:
        n = a["name"]; pre = "spr_" if a["kind"] == "sprite" else "sfx_"
        if not n.startswith(pre): F.append(f"Q1  {n}: a {a['kind']} name starts with {pre}")
        if a["kind"] == "sprite":
            bad = sorted({c for line in a["grid"] for c in line if c != "." and c not in spec["palette"]})
            if bad: F.append(f"Q2  {n}: letters {bad} are not in the palette")
        elif not isinstance(a.get("seed"), int): F.append(f"Q3  {n}: needs an integer seed")
        m = man.get(n)
        if not m or not os.path.exists(os.path.join(out, m["file"])): F.append(f"Q4  {n}: not built"); continue
        if m["spec"] != ahash(a) or sha(os.path.join(out, m["file"])) != m["sha256"]: F.append(f"Q4  {n}: spec or file changed since the build")
        if a["kind"] == "sfx":
            w = wave.open(os.path.join(out, m["file"])); d = w.readframes(w.getnframes()); w.close()
            s = struct.unpack(f"<{len(d) // 2}h", d)
            if max(map(abs, s)) > 29204 or abs(s[0]) > 1000 or abs(s[-1]) > 1000: F.append(f"Q5  {n}: clips above -1 dBFS or clicks at an edge")
    for f in F: print(f)
    print(f"{len(F)} finding(s)" if F else "0 findings. Clean.")
    return 1 if F else 0

DEMO = {"palette": {"k": "#1a1c2c", "g": "#e8a23c", "w": "#f4f4f4", "t": "#a7613b"},
        "assets": [{"name": "spr_coin", "kind": "sprite", "scale": 4, "grid": ["..kk..", ".kggk.", "kggwgk", "kggggk", ".kggk.", "..kk.."]},
                   {"name": "spr_chest", "kind": "sprite", "scale": 3, "grid": [".kkkkkk.", "kttttttk", "kkkggkkk", "kttggttk", "kttttttk", ".kkkkkk."]},
                   {"name": "sfx_coin", "kind": "sfx", "seed": 1, "wave": "square", "f0": 988, "f1": 1319, "dur": 0.12, "gain": 0.4},
                   {"name": "sfx_jump", "kind": "sfx", "seed": 2, "wave": "square", "f0": 220, "f1": 660, "dur": 0.2, "gain": 0.5},
                   {"name": "sfx_hurt", "kind": "sfx", "seed": 3, "wave": "sine", "f0": 400, "f1": 90, "dur": 0.25, "gain": 0.6, "noise": 0.5}]}

def example(d):
    for sub in ("fixed", "seeded"):
        p = os.path.join(d, sub); os.makedirs(p, exist_ok=True); json.dump(DEMO, open(os.path.join(p, "mini.spec.json"), "w"), indent=1); build(p)
    s = json.loads(json.dumps(DEMO)); p = os.path.join(d, "seeded")
    s["assets"][0]["name"] = "coin"                                 # Q1 wrong prefix
    s["assets"][1]["grid"][2] = "kkkxxkkk"                          # Q2 letter not in the palette
    s["assets"][2]["seed"] = "1"                                    # Q3 seed is text
    s["assets"][3]["dur"] = 0.3                                     # Q4 edited, not rebuilt
    s["assets"][4]["gain"] = 1.5; sfx(os.path.join(p, "out", "sfx_hurt.wav"), s["assets"][4])
    man = json.load(open(os.path.join(p, "out", "MANIFEST.json"))); man["sfx_hurt"].update(sha256=sha(os.path.join(p, "out", "sfx_hurt.wav")), spec=ahash(s["assets"][4]))
    man["coin"] = man.pop("spr_coin"); man["coin"]["spec"] = ahash(s["assets"][0]); man["spr_chest"]["spec"] = ahash(s["assets"][1]); man["sfx_coin"]["spec"] = ahash(s["assets"][2])
    json.dump(man, open(os.path.join(p, "out", "MANIFEST.json"), "w"), indent=1, sort_keys=True)   # Q5 sfx_hurt clips
    json.dump(s, open(os.path.join(p, "mini.spec.json"), "w"), indent=1)
    print(f"made {d}/fixed and {d}/seeded; now run: python3 gac_mini.py check {d}/seeded")

if __name__ == "__main__":
    if len(sys.argv) != 3: print(__doc__); sys.exit(2)
    c, p = sys.argv[1:]
    if c == "build": build(p)
    elif c == "check": sys.exit(check(p))
    elif c == "example": example(p)
    else: print(__doc__); sys.exit(2)
~~~

## What the full pack adds

Game Assets as Code ($29) adds four more lanes (seeded symmetric sprite variants, seamless tiles, SVG icons and low-poly Blender props built headless, tested on Blender 5.2.3 with a triangle cap), a review page after each lane, owner approvals tied to file hashes, a promote step that never overwrites, a separate release yes for anything that leaves your machine, a Godot 4 load test, a skill file for Grok Bot, Claude Code, Codex and Hermes, and a fifteen-check read-only checker tested on a seeded fixture. If you want it set up in your game for your team, there is a $5,000 Install sprint and $5,000/mo Manage. Book a call from paperchasewebb.ai.

Credit: the idea of game assets as agent skills owes a lot to uilianks's pxl, vex, kiln, loom and chirp skills (https://github.com/uilianks/pxl) and crsolver's claude-pxl (https://github.com/crsolver/claude-pxl). Nothing here is copied from them.

PaperChaseWebb 💸
