Harvest bonfire
A 64×64 festival bonfire: a teepee of logs inside a ring of fieldstones, straw kindling at its base and two tied sheaves at its foot. “idle” (16 frames) has tall flames, rising sparks and heat shimmer; “embers” (16 frames) is the burned-down fire on pulsing coals; “ignite” (10 frames, once) starts from a spark in the straw and ends on the first idle frame; “unlit” (8 frames) is fresh logs with a wisp of smoke. The fire is one colour family (fl0..3), so a single ramp swap recolours it.
6×
Get the animated sprites: Harvest Festival on itch.ioVol.6 · 12 harvest-fair props · 37 animations
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Harvest bonfire — 64x64 floor prop (large), top-down 3/4, key light top-left.// A big festival bonfire on open ground: a teepee of split and bark-on logs (they cross and stick out at the apex)// stacked on an ash bed inside a ring of fieldstones, a tuft of straw kindling at its base, and two tied straw// sheaves standing at its foot. The fire is the fl0..3 family, so swap("fl","soul") etc. would recolour it.// Variants:// idle 16f loop: tall flames lick up through the logs and shed tongues, sparks rise and cool, heat shimmer// wavers above the tips, the ash bed and the stones facing the fire glow and breathe// embers 16f loop: burned down — charred stubs and two fallen logs on a heap of glowing coals whose cracks// pulse, small low flames, thin smoke curling up, a rare spark// ignite 10f once: a spark in the straw kindling, the straw catches, flames climb the logs, flare and settle;// frame 0 equals unlit frame 0, the last frame equals idle frame 0// unlit 8f loop: fresh logs and straw on old ash, a faint wisp of smoke from the kindlingThe last lines. submit declares the canvas and lists the variants the renderer exports.
submit(Bonfire, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "embers", args: ["embers"] }, { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: N_IGN / 10, loop: false } }, { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: N_UNLIT / 10, loop: true } }, ],});Show the whole program (370 lines)
// Harvest bonfire — 64x64 floor prop (large), top-down 3/4, key light top-left.// A big festival bonfire on open ground: a teepee of split and bark-on logs (they cross and stick out at the apex)// stacked on an ash bed inside a ring of fieldstones, a tuft of straw kindling at its base, and two tied straw// sheaves standing at its foot. The fire is the fl0..3 family, so swap("fl","soul") etc. would recolour it.// Variants:// idle 16f loop: tall flames lick up through the logs and shed tongues, sparks rise and cool, heat shimmer// wavers above the tips, the ash bed and the stones facing the fire glow and breathe// embers 16f loop: burned down — charred stubs and two fallen logs on a heap of glowing coals whose cracks// pulse, small low flames, thin smoke curling up, a rare spark// ignite 10f once: a spark in the straw kindling, the straw catches, flames climb the logs, flare and settle;// frame 0 equals unlit frame 0, the last frame equals idle frame 0// unlit 8f loop: fresh logs and straw on old ash, a faint wisp of smoke from the kindlingconst W = 64, H = 64, CX = 32, GROUND = 61;const N_LOOP = 16, N_UNLIT = 8, N_IGN = 10;const BED = { cx: 32, cy: 53, rx: 18, ry: 5 };const RING = { cx: 32, cy: 53.5, rx: 23, ry: 6 };const APEX = { x: 32, y: 31 };const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];const dith = (x, y, dens) => BAYER[(y & 3) * 4 + (x & 3)] / 16 < dens;// ---- logs ------------------------------------------------------------------------------------------------------// a log is a tapered capsule A (foot) -> B (top), shaded as a cylinder across its width with the light top-left.// style: "split" (pale split face on the lit half, bark on the shaded half) | "bark" (bark all round) | "char"const SPLIT = ["w1", "w3", "w3", "w4"], BARK = ["w0", "w1", "w2", "w3"];function logField(g, L, glow, pulse) { const { A, B, r0, r1, style } = L; const dx = B[0] - A[0], dy = B[1] - A[1], len = Math.hypot(dx, dy); const nx = -dy / len, ny = dx / len; // across-normal for s > 0 const lsign = -0.7 * nx - 0.7 * ny >= 0 ? 1 : -1; // which side faces the key light const pad = Math.max(r0, r1) + 1; g.field({ x: [Math.floor(Math.min(A[0], B[0]) - pad), Math.ceil(Math.max(A[0], B[0]) + pad)], y: [Math.floor(Math.min(A[1], B[1]) - pad), Math.ceil(Math.max(A[1], B[1]) + pad)] }, (x, y) => { const px = x + 0.5 - A[0], py = y + 0.5 - A[1]; const u = (px * dx + py * dy) / (len * len); if (u < 0 || u > 1) return null; const r = r0 + (r1 - r0) * u; const s = px * nx + py * ny; if (Math.abs(s) > r) return null; const q = (s / r) * lsign; // +1 lit edge .. -1 shaded edge const endGrain = (1 - u) * len < 1.6; // the sawn top end catches the light if (style === "char") { if (endGrain) return pulse > 0.5 ? "r2" : "r1"; // the burning broken end if (glow > 0 && q < -0.35 && Math.round(u * len) % 3 === 0) return pulse > 0.6 ? "r2" : "r1"; // glowing checks return q > 0.45 ? "n2" : q > -0.2 ? "n1" : "n0"; } if (endGrain) return Math.abs(s) < r * 0.45 ? "w3" : "w4"; if (glow > 0 && u < 0.42 && q < -0.55) return glow > 0.7 ? "r2" : "r1"; // firelight on the edge facing the fire if (style === "split") { if (q > 0.6) return "w4"; // sharp lit arris of the split face if (q > -0.1) return "w3"; // flat split face return q > -0.6 ? "w1" : "w0"; // bark } return BARK[q > 0.6 ? 2 : q > -0.15 ? 1 : 0]; }); // countable marks: grain along the split face, bark ridges const mark = (uu, ss, k) => g.pixel(Math.round(A[0] + dx * uu + nx * ss * lsign - 0.5), Math.round(A[1] + dy * uu + ny * ss * lsign - 0.5), k); if (style === "split") { mark(0.3, 0.5, "w2"); mark(0.55, 0.3, "w2"); mark(0.72, 0.55, "w2"); } else if (style === "bark") { mark(0.3, 0.1, "w0"); mark(0.5, -0.1, "w2"); mark(0.7, 0.1, "w0"); }}// teepee: feet on an inner ellipse around the bed, all leaning into the apex and crossing past itfunction teepee() { const feet = [[214, "bark", 0.1], [270, "split", -0.6], [326, "bark", 0.5], [14, "split", 0.3], [62, "bark", -0.4], [118, "split", 0.4], [166, "bark", -0.3]]; return feet.map(([deg, style, jit], i) => { const a = (deg * Math.PI) / 180; const A = [32 + 17.5 * Math.cos(a), 53 + 4.6 * Math.sin(a)]; const top = [APEX.x + jit * 2, APEX.y + jit]; const B = [top[0] + (top[0] - A[0]) * 0.24, top[1] + (top[1] - A[1]) * 0.24]; return { i, A, B, r0: 2.6, r1: 1.8, style, back: Math.sin(a) < 0.05 }; });}const LOGS = teepee();// burned down: four charred stubs still leaning in, two logs fallen across the coalsconst STUBS = [ { A: [16, 53], B: [24, 44], r0: 2.4, r1: 1.9, style: "char", back: true }, { A: [46, 51], B: [39, 43], r0: 2.4, r1: 1.9, style: "char", back: true }, { A: [29, 50], B: [33, 41], r0: 2.2, r1: 1.7, style: "char", back: true }, { A: [15, 55], B: [47, 52], r0: 2.4, r1: 2.2, style: "char", back: false }, { A: [21, 58], B: [44, 56], r0: 2.3, r1: 2.0, style: "char", back: false },];// ---- ground: ash bed, stones, straw ---------------------------------------------------------------------------function bed(g, glow, heap, t) { const { cx, cy, rx, ry } = BED; g.part("bed", () => g.field({ x: [cx - rx, cx + rx], y: [cy - ry - 3, cy + ry] }, (x, y) => { const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; const bump = heap * Math.max(0, 1 - nx * nx * 1.6) * 0.75; // a coal heap mounds up in the middle const r = Math.sqrt(nx * nx + (ny + bump * (ny < 0 ? 1 : 0)) * (ny + bump * (ny < 0 ? 1 : 0))); if (nx * nx + ny * ny > 1 && !(ny < 0 && r <= 1)) return null; const rr = Math.sqrt(nx * nx + ny * ny * 0.8); if (glow > 0) { const g2 = glow * (0.9 + 0.1 * Math.sin(TAU * 2 * t)); if (rr < 0.38 * g2) return "fl3"; if (rr < 0.62 * g2) return "fl2"; if (rr < 0.85 * g2) return "fl1"; return rr < 0.95 ? "r0" : "n0"; } if (rr > 0.86) return "n0"; return nx < -0.25 && ny < 0.2 ? "n2" : "n1"; // old grey ash }));}function coals(g, t) { // lumps on the heap, each crack pulsing on its own phase const LUMPS = [[22, 52], [27, 50], [33, 49], [39, 50], [44, 52], [25, 54], [31, 53], [37, 54], [19, 55], [42, 56]]; g.part("coals", () => LUMPS.forEach(([lx, ly], i) => { g.ellipse({ x: [lx - 2, lx + 1], y: [ly - 1, ly] }, "n0", { fill: "n0" }); g.line([lx - 1, ly - 1], [lx, ly - 1], "n1"); const p = 0.5 + 0.5 * Math.sin(TAU * (t + hash(i, 3))); g.pixel(lx + (i % 2), ly, p > 0.7 ? "fl3" : p > 0.35 ? "fl2" : "fl1"); }));}const STONES = Array.from({ length: 15 }, (_, i) => { const a = TAU * (i / 15) + 0.12; const w = 3 + Math.round(hash(i, 1) * 1.6), h = 2 + Math.round(hash(i, 2)); return { i, a, x: RING.cx + RING.rx * Math.cos(a), y: RING.cy + RING.ry * Math.sin(a), w, h, tone: i % 4 === 1 ? 1 : 0 };});function stone(g, s, warm) { const { x: cx, y: cy, w, h, i } = s; const RAMP = s.tone ? ["n1", "n2", "n3", "n4"] : ["n0", "n1", "n2", "n3"]; const facesFire = Math.sin(s.a) < -0.2; // back stones show the camera their fire side g.outlined("ink", () => g.part("stone" + i, () => g.field({ x: [Math.floor(cx - w - 1), Math.ceil(cx + w + 1)], y: [Math.floor(cy - h - 1), Math.ceil(cy + h)] }, (x, y) => { const k = litIndex(x, y, cx, cy, w, h, 4, 0.05); if (k < 0) return null; const ny = (y + 0.5 - cy) / h; if (warm > 0 && facesFire && ny > 0.15) return warm > 0.75 ? "r2" : "r1"; return RAMP[k]; }))); g.pixel(Math.round(cx - w * 0.4), Math.round(cy - h * 0.5), s.tone ? "n5" : "n4"); // a glint if (i % 5 === 2) g.pixel(Math.round(cx + 1), Math.round(cy - h * 0.6), "e1"); // a fleck of moss}function sheaf(g, name, cx, base, h, flip) { const tie = base - Math.round(h * 0.42); const hw = (y) => { if (y >= tie) return 1.8 + (y - tie) / (base - tie) * 2.6; // splays to the ground const k = (tie - y) / (h - (base - tie)); return 1.8 + Math.sqrt(Math.max(0, k)) * 3.9; // fans out to the ears }; g.outlined("ink", () => g.part(name, () => { g.field({ x: [cx - 6, cx + 6], y: [base - h - 2, base] }, (x, y) => { const d = x + 0.5 - cx, w = hw(y); if (Math.abs(d) > w) return null; const col = Math.round(d); const topY = base - h + (Math.abs(col) * 0.5 | 0) + (hash(col + cx, 5) > 0.55 ? 1 : 0); // ragged ear line if (y < topY) return null; const u = d / w; // -1 lit .. +1 shaded if (y <= topY + 1) return u < -0.2 ? "g2" : u < 0.5 ? "g1" : "g0"; // grain heads if (u > 0.55) return "g0"; if (u < -0.55) return (col & 1) ? "g2" : "g3"; return (col & 1) ? "g1" : "g2"; // straw stalks: alternating stripes }); // the twine band around the waist for (let x = Math.ceil(cx - 2.2); x <= Math.floor(cx + 2.2); x++) { g.pixel(x, tie, x < cx - 1 ? "w3" : x < cx + 1 ? "w2" : "w1"); g.pixel(x, tie + 1, "w1"); } g.pixel(Math.round(cx + (flip ? -3 : 3)), tie + 2, "w2"); // twine end const top = base - h; // a few stray stalks poke out of the ears for (const [dx, dy, k] of [[-5, -1, "g1"], [-2, -2, "g2"], [3, -2, "g1"], [5, 0, "g0"]]) { const sx = Math.round(cx + dx * 0.6), sy = top + 2; g.line([sx, sy], [Math.round(cx + dx * (flip ? 0.9 : 1)), top + dy], k); } }));}function kindling(g) { g.part("kindling", () => { const tips = [[-5, -3, "g1"], [-3, -5, "g2"], [-1, -6, "g1"], [1, -5, "g2"], [3, -4, "g1"], [5, -2, "g0"], [0, -3, "g3"], [-4, -1, "g0"]]; for (const [dx, dy, k] of tips) g.line([32 + Math.sign(dx), 56], [32 + dx, 56 + dy], k); g.line([28, 56], [36, 56], "g0"); });}// ---- fire ------------------------------------------------------------------------------------------------------const LAYERS = [["fl0", 1, 1], ["fl1", 0.86, 0.72], ["fl2", 0.6, 0.48], ["fl3", 0.34, 0.26]];const profile = (k) => (k < 0.2 ? 0.72 + 0.28 * Math.sin((k / 0.2) * Math.PI / 2) : Math.pow((1 - k) / 0.8, 0.85));const FLAMES = [ { name: "flameFarL", x: 21, base: 53, h: 15, hw: 3.6, lean: -2.5, seed: 0.83 }, { name: "flameFarR", x: 43, base: 53, h: 17, hw: 3.6, lean: 2.5, seed: 0.47 }, { name: "flameL", x: 25, base: 53, h: 34, hw: 7, lean: -4, seed: 0.31 }, { name: "flameR", x: 39, base: 53, h: 37, hw: 7, lean: 4, seed: 0.64 }, { name: "flameC", x: 32, base: 54, h: 50, hw: 10.5, lean: 0, seed: 0.05 },];const LOW = [ { name: "lowL", x: 25, base: 52, h: 8, hw: 2.6, lean: -1, seed: 0.2 }, { name: "lowC", x: 32, base: 51, h: 11, hw: 3.2, lean: 0.5, seed: 0.55 }, { name: "lowR", x: 39, base: 52, h: 7, hw: 2.4, lean: 1, seed: 0.9 },];const fh = (f, t, s) => s * f.h * (1 + 0.08 * Math.sin(TAU * (2 * t + f.seed)) + 0.05 * Math.sin(TAU * (3 * t + 2 * f.seed)));const fax = (f, t, k) => f.x + 0.5 + (f.lean + 1.6 * Math.sin(TAU * (t + f.seed))) * k * k + 1.1 * Math.sin(TAU * (1.2 * k - 2 * t + f.seed)) * k;function tongue(g, f, t, s) { const hh = fh(f, t, s); if (hh < 2) return; const ws = Math.min(1, 0.45 + 0.55 * s); g.part(f.name, () => g.field({ x: [0, W - 1], y: [Math.max(0, Math.floor(f.base - hh - 1)), f.base] }, (x, y) => { const k = (f.base + 1 - (y + 0.5)) / hh; if (k < 0 || k > 1) return null; const lick = 1 + 0.2 * Math.sin(TAU * (2.3 * k - 2 * t + 1.7 * f.seed)); // bulges travel upward const dx = Math.abs(x + 0.5 - fax(f, t, k)); let key = null; for (const [c, hf, wf] of LAYERS) { const kj = k / hf; if (kj > 1) break; if (dx <= f.hw * ws * wf * profile(kj) * lick) key = c; else break; } return key; }));}function tongues(g, list, t, s) { // tongues tear off the tips and rise g.detached("tongues", () => { for (const f of list) { const p = (2 * t + f.seed) % 1, tip = f.base - fh(f, t, s); const x = Math.round(fax(f, t, 1) - 0.5), y = Math.round(tip - 1 - p * 8); if (y < 1 || s < 0.5) continue; if (p < 0.4) { g.rect({ x: [x, x + 1], y: [y, y + 1] }, "fl1", { fill: "fl1" }); g.pixel(x + 1, y, "fl0"); g.pixel(x, y + 1, "fl0"); } else if (p < 0.7) g.pixel(x, y, "fl0"); } });}function sparks(g, t, n, amt) { g.detached("sparks", () => { for (let i = 0; i < n; i++) { const p = (t * (1 + (i % 2)) + i / n) % 1; if (p > amt) continue; const x0 = 32 + (hash(i, 4) - 0.5) * 22; const x = Math.round(x0 + Math.sin(TAU * (p * 1.3 + hash(i, 6))) * 3 + (x0 - 32) * p * 0.4); const y = Math.round(30 - hash(i, 8) * 8 - p * 30); if (y < 0) continue; if (p < 0.25) { g.pixel(x, y, "fl3"); g.pixel(x, y + 1, "fl2"); } else if (p < 0.55) g.pixel(x, y, "fl2"); else if (p < 0.8) g.pixel(x, y, "fl1"); else g.pixel(x, y, "fl0"); } });}function shimmer(g, t, amt) { // heat haze: three wavering ribbons of dotted light climbing above the flame tips if (amt <= 0) return; g.detached("shimmer", () => { for (const [cx, y0, y1, ph] of [[27, 2, 15, 0.1], [33, 0, 9, 0.45], [39, 3, 14, 0.75]]) { for (let y = y0; y <= y1; y++) { if ((y + Math.floor(t * N_LOOP)) % 3 !== 0) continue; const x = Math.round(cx + 1.4 * Math.sin(y * 0.7 - TAU * 2 * t + ph * TAU)); g.pixel(x, y, y < y0 + 4 ? "fl0" : "fl1"); } } });}function pool(g, t, amt) { // firelight spilling on the ground around the ring (dithered, so it sits on any floor) if (amt <= 0) return; const R = 1 + 0.04 * Math.sin(TAU * 2 * t); g.detached("firelight", () => g.field({ x: [0, W - 1], y: [44, 63] }, (x, y) => { const nx = (x + 0.5 - 32) / (31 * R), ny = (y + 0.5 - 55) / (8.5 * R); const r = Math.sqrt(nx * nx + ny * ny); if (r > 1) return null; return dith(x, y, 0.42 * amt * (1 - r)) ? (r < 0.75 ? "fl1" : "fl0") : null; }));}function smoke(g, t, n, x0, y0, rise, amt) { g.detached("smoke", () => { for (let i = 0; i < n; i++) { const p = (t + i / n) % 1; const x = Math.round(x0 + Math.sin(TAU * (p * 1.2 + i * 0.37)) * (1 + p * 3) + p * 3); const y = Math.round(y0 - p * rise); if (y < 0) continue; if (p < 0.35) { g.rect({ x: [x, x + 1], y: [y, y + 1] }, "n3", { fill: "n3" }); g.pixel(x, y, "n4"); g.pixel(x + 1, y, "n4"); } else if (p < 0.7) { g.pixel(x, y, "n4"); g.pixel(x + 1, y, "n3"); g.pixel(x + 1, y - 1, "n3"); } else if (p < amt) { g.pixel(x, y, "n3"); if (chk(x, y)) g.pixel(x + 1, y - 1, "n2"); } } });}// ---- scenes ----------------------------------------------------------------------------------------------------// st: { t, fire (flame height scale), glow (bed + stone firelight 0..1), sparkAmt, shimmer, pool }function stones(g, back, warm) { for (const s of STONES) if ((Math.sin(s.a) < 0) === back) stone(g, s, warm);}function teepeeScene(g, st) { const { t, fire, glow } = st; const pulse = 0.5 + 0.5 * Math.sin(TAU * 2 * t); pool(g, t, st.pool); contactShadow(g, CX, 59, 29, 3); stones(g, true, glow); bed(g, glow, 0, t); for (const L of LOGS) if (L.back) g.outlined("ink", () => g.part("log" + L.i, () => logField(g, L, 0, pulse))); if (fire < 0.9) kindling(g); for (const f of FLAMES) tongue(g, f, t, fire); for (const L of LOGS) if (!L.back) g.outlined("ink", () => g.part("log" + L.i, () => logField(g, L, glow, pulse))); stones(g, false, 0); sheaf(g, "sheafL", 8, 61, 16, false); sheaf(g, "sheafR", 57, 61, 12, true); if (fire > 0.4) tongues(g, FLAMES.slice(2), t, fire); sparks(g, t, 10, st.sparkAmt); shimmer(g, t, st.shimmer);}function emberScene(g, t) { const pulse = 0.5 + 0.5 * Math.sin(TAU * 2 * t); pool(g, t, 0.45); contactShadow(g, CX, 59, 29, 3); stones(g, true, 0.5); bed(g, 0.7, 1, t); coals(g, t); for (const L of STUBS) if (L.back) g.outlined("ink", () => g.part("stub", () => logField(g, L, 1, (pulse + hash(L.A[0], 1)) % 1))); for (const f of LOW) tongue(g, f, t, 1); for (const L of STUBS) if (!L.back) g.outlined("ink", () => g.part("fallen", () => logField(g, L, 1, (pulse + hash(L.A[0], 2)) % 1))); stones(g, false, 0); sheaf(g, "sheafL", 8, 61, 16, false); sheaf(g, "sheafR", 57, 61, 12, true); sparks(g, t, 3, 0.7); smoke(g, t, 8, 32, 42, 40, 0.95);}function unlitScene(g, t) { teepeeScene(g, { t, fire: 0, glow: 0, sparkAmt: -1, shimmer: 0, pool: 0 }); smoke(g, t, 3, 32, 47, 36, 0.9);}const IGN_FIRE = [0, 0, 0.12, 0.24, 0.38, 0.55, 0.75, 0.98, 1.1, 1];const IGN_GLOW = [0, 0, 0.2, 0.35, 0.5, 0.65, 0.8, 1, 1, 1];function igniteFrame(g, f) { if (f >= N_IGN - 1) return idleFrame(g, 0); if (f === 0) return unlitScene(g, 0); const t = f / N_LOOP; teepeeScene(g, { t, fire: IGN_FIRE[f], glow: IGN_GLOW[f], sparkAmt: f >= 6 ? (f - 5) / 3 : -1, shimmer: f >= 7 ? 1 : 0, pool: IGN_GLOW[f] }); if (f <= 3) g.detached("catch", () => { // the spark in the straw, flaring const k = ["fl3", "fl3", "fl2"][f - 1]; if (f === 1) { // a bright four-point spark for (const [ox, oy] of [[1, 0], [-1, 0], [0, -1], [0, 1]]) g.pixel(32 + ox, 53 + oy, "fl2"); for (const [ox, oy] of [[2, -2], [-2, -2], [2, 1], [-2, 1]]) g.pixel(32 + ox, 53 + oy, "fl1"); } g.pixel(32, 53, k); if (f > 1) for (const [ox, oy] of [[-2, -1], [2, -2], [0, -3], [-3, 1]]) g.pixel(32 + ox * f, 53 + oy * f, "fl1"); });}function idleFrame(g, t) { teepeeScene(g, { t, fire: 1, glow: 1, sparkAmt: 1, shimmer: 1, pool: 1 });}const Bonfire = sprite(pal, (g, t, mode) => { if (mode === "ignite") return igniteFrame(g, Math.min(N_IGN - 1, Math.round(t * N_IGN))); t = ((t % 1) + 1) % 1; if (mode === "embers") return emberScene(g, t); if (mode === "unlit") return unlitScene(g, t); idleFrame(g, t);});submit(Bonfire, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "embers", args: ["embers"] }, { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: N_IGN / 10, loop: false } }, { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: N_UNLIT / 10, loop: true } }, ],});These excerpts omit the boilerplate prepended by the asset pack build script: a single-line runtime header, the shared palette, and common helper utilities (contactShadow, litIndex, flame, hash, chk, TAU). The displayed line numbers match the original source files. Each program was authored by an AI coding agent and adjusted after visual inspection of the rendered frames.
Have your agent write programs like this.
Tessarune gives it the drawing API reference, 25 production manuals and a renderer to check its work against.