Tavern hearth
A 64×64 wall prop facing the camera, with no contact shadow: a dressed-stone fireplace with an oak mantel beam on stone corbels holding a frothy tankard, a pewter mug and a candle, a voussoir arch with a keystone, two logs on iron andirons, and a flagstone hearth slab in front. The fire warms the jamb reveals, the arch soffit and the slab. “idle” (12 frames, loop) licks the flames and lifts sparks; “low” (12 frames, loop) is embers only, with pulsing cracks and a smoke wisp; “stoke” (8 frames, once) goes from low through a flare to the full fire.
6×
Get the animated sprites: Tavern & Market on itch.ioVol.3 · 12 tavern and market props · 32 animations
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Tavern hearth — 64x64 WALL prop (no contact shadow), top-down 3/4, camera-facing, key light top-left.// A dressed-stone fireplace: chimney breast running up out of frame, an oak mantel beam on stone corbels (a frothy// tankard, a pewter mug and a candle on it), an ashlar surround with a segmental voussoir arch and keystone, a// firebox with splayed firebrick covings, two logs on iron andirons, and a flagstone hearth slab in front.// The fire lights the room: the jamb reveals, the arch soffit and the hearth slab warm up and pulse with the flames.// Variants: idle (12f loop: flames lick, sparks rise, glow on the stones breathes), low (12f loop: embers only —// charred logs with pulsing cracks, a dim glow, a smoke wisp), stoke (8f one-shot: low -> flare -> full; its last// frame == idle frame 0).The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(Hearth, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "low", args: ["low"] }, { name: "stoke", args: ["stoke"], animate: { fps: 10, duration: N / 10, loop: false } }, ],});Show the whole program (404 lines)
// Tavern hearth — 64x64 WALL prop (no contact shadow), top-down 3/4, camera-facing, key light top-left.// A dressed-stone fireplace: chimney breast running up out of frame, an oak mantel beam on stone corbels (a frothy// tankard, a pewter mug and a candle on it), an ashlar surround with a segmental voussoir arch and keystone, a// firebox with splayed firebrick covings, two logs on iron andirons, and a flagstone hearth slab in front.// The fire lights the room: the jamb reveals, the arch soffit and the hearth slab warm up and pulse with the flames.// Variants: idle (12f loop: flames lick, sparks rise, glow on the stones breathes), low (12f loop: embers only —// charred logs with pulsing cracks, a dim glow, a smoke wisp), stoke (8f one-shot: low -> flare -> full; its last// frame == idle frame 0).const W = 64, H = 64, CX = 32, GROUND = 61;const OP = { x0: 19, x1: 44, bot: 53, spring: 36, bx0: 24, bx1: 39, floorY: 46 }; // firebox opening + interiorconst AR = 23.1, AYC = 32 + AR; // arch circle: crown at y=32const RING = 4.3, KEY_UP = 6.4, KEY_HW = 0.115; // voussoir depth, keystoneconst archTop = (x) => AYC - Math.sqrt(Math.max(0, AR * AR - (x + 0.5 - CX) ** 2));const inOpening = (x, y) => x >= OP.x0 && x <= OP.x1 && y <= OP.bot && y + 0.5 >= archTop(x);const covL = (y) => OP.x0 + (OP.bot - y) * (OP.bx0 - OP.x0) / (OP.bot - OP.floorY); // floor's left edge per rowconst covR = (y) => OP.x1 - (OP.bot - y) * (OP.x1 - OP.bx1) / (OP.bot - OP.floorY);const STONE = ["n0", "n1", "n2", "n3", "n4"];const WARM = { n0: "r0", n1: "w1", n2: "w2", n3: "w3", n4: "w4", ink: "ink" };const clamp = (v, a, b) => Math.max(a, Math.min(b, v));// ---- static geometry: one classification per pixel ---------------------------------------------------// surround courses: [y0, y1, block starts across the width] (hand-set so no block is a 1px sliver)const COURSES = [ [26, 30, [8, 15, 23, 36, 42, 49]], [31, 35, [8, 12, 52]], [36, 40, [8, 14, 45, 50]], [41, 45, [8, 12, 45, 52]], [46, 50, [8, 14, 45, 50]], [51, 53, [8, 45]],];const CORBEL = [[9, 13], [9, 13], [10, 12], [10, 12], [10, 12]]; // rows 26..30: a stepped bracket (right is mirrored)const SLAB_J = [4, 14, 25, 38, 50]; // flagstone startsconst idxOf = (starts, x) => { let j = -1; for (const s of starts) if (x >= s) j++; return j; };function classify(x, y) { if (y >= 54 && y <= GROUND && x >= 4 && x <= 59) return { part: "slab", mat: y <= 58 ? "top" : "front", k: idxOf(SLAB_J, x) }; if (y >= 19 && y <= 25 && x >= 5 && x <= 58) return { part: "mantel", mat: y <= 21 ? "mtop" : "mfront" }; if (y <= 18 && x >= 14 && x <= 49) { const c = Math.floor((18 - y) / 4); return { part: "chimney", mat: "stone", k: 500 + c * 16 + Math.floor((x - 14 + (c & 1) * 5) / 9), lift: -0.35 }; } if (y < 26 || y > 53 || x < 8 || x > 55) return null; if (inOpening(x, y)) { if (y >= OP.floorY && x >= covL(y) - 0.01 && x <= covR(y) + 0.01) return { part: "firebox", mat: "floor" }; if (y < OP.floorY && x >= OP.bx0 && x <= OP.bx1) return { part: "firebox", mat: "back" }; return { part: "firebox", mat: "coving", side: x < CX ? -1 : 1 }; } const cr = CORBEL[y - 26]; if (cr && ((x >= cr[0] && x <= cr[1]) || (x >= 63 - cr[1] && x <= 63 - cr[0]))) return { part: "corbels", mat: "stone", k: x < CX ? 900 : 901, lift: 1, corbel: true }; const dx = x + 0.5 - CX, dy = AYC - (y + 0.5), r = Math.hypot(dx, dy), a = Math.atan2(dx, dy); if (y < OP.spring && r >= AR - 0.6) { if (Math.abs(a) < KEY_HW && r <= AR + KEY_UP) return { part: "arch", mat: "stone", k: 700, lift: 1.1, key: true }; if (r <= AR + RING) { const s = Math.sign(a), n = Math.floor((Math.abs(a) - KEY_HW) / 0.205); return { part: "arch", mat: "stone", k: 710 + s * (n + 1), lift: 0.6 }; } } if (y <= 35 && x >= 14 && x <= 49) return { part: "surround", mat: "stone", k: x < CX ? 40 : 41 }; // plain spandrels over the arch for (let c = 0; c < COURSES.length; c++) { const [y0, y1, starts] = COURSES[c]; if (y >= y0 && y <= y1) return { part: "surround", mat: "stone", k: c * 16 + idxOf(starts, x), plinth: c === COURSES.length - 1 }; } return null;}const MAP = [];for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) MAP.push(classify(x, y));const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? null : MAP[y * W + x]);// distance (Chebyshev, capped) from each pixel to the nearest firebox pixel: how exposed a stone is to the fireconst DIST = MAP.map((m, i) => { if (!m || m.part === "firebox") return 0; const x = i % W, y = (i - x) / W; for (let d = 1; d <= 6; d++) for (let v = -d; v <= d; v++) for (let u = -d; u <= d; u++) { const q = at(x + u, y + v); if (q && q.part === "firebox") return d; } return 9;});// ---- lighting -------------------------------------------------------------------------------------------// fire glow: its reach follows the heat and breathes with the flamesconst pulse = (t) => 1 + 0.12 * Math.sin(TAU * t) + 0.05 * Math.sin(TAU * (3 * t) + 1);function glowAt(x, y, L) { // inside the firebox const d = Math.hypot((x + 0.5 - CX) / 1.35, (y + 0.5 - 47) / 1.0); const R = L.R; return d < R * 0.42 ? 3 : d < R * 0.68 ? 2 : d < R ? 1 : 0;}function faceGlow(x, y, L) { // on the surround: only the stones hugging the opening const d = DIST[y * W + x] + (y < 38 ? 1 : 0); return d <= L.face - 1.5 ? 2 : d <= L.face ? 1 : 0;}const WARM1 = { n0: "n0", n1: "w0", n2: "w1", n3: "w2", n4: "w3", ink: "ink" };function warm(key, lvl) { if (lvl >= 2) return WARM[key] || key; if (lvl === 1) return WARM1[key] || key; return key;}function stoneKey(x, y, b, L) { const up = at(x, y - 1), dn = at(x, y + 1), lf = at(x - 1, y), rt = at(x + 1, y); const other = (q) => !q || q.k !== b.k; const fire = (q) => q && q.part === "firebox"; if (fire(rt) || fire(lf) || fire(dn)) { // the reveal facing the fire const gl = glowAt(x, y, L); return gl >= 3 ? "w3" : gl === 2 ? "w2" : gl === 1 ? "w1" : "n1"; } let lv = 2 + (b.lift || 0); const h = hash(b.k * 1.7, 3.1); if (h < 0.22) lv -= 0.6; else if (h > 0.78) lv += 0.6; let key; if (b.part === "arch") { // voussoirs: a band of paler dressed stone, dark radial joints; the keystone stands proud const inward = x < CX ? rt : lf; if (b.key) key = !rt || rt.k !== b.k ? "n1" : !up || up.k !== b.k || !lf || lf.k !== b.k ? "n4" : "n3"; else if (inward && inward.part === "arch" && inward.k !== b.k) key = "n1"; else key = "n3"; return warm(key, faceGlow(x, y, L)); } if (b.corbel) { key = y === 26 ? "n2" : !dn || dn.k !== b.k ? "n1" : !rt || rt.k !== b.k ? "n1" : !lf || lf.k !== b.k ? "n4" : "n3"; return warm(key, faceGlow(x, y, L)); } if ((up && up.corbel) || (lf && lf.corbel)) return "n0"; // the bracket's cast shadow if ((other(rt) && rt && rt.part !== "arch") || (other(dn) && dn && dn.part !== "mantel")) key = "n0"; else { if ((up && other(up) && up.part !== "mantel") || (lf && other(lf))) lv += 1; if (!lf) lv += 0.5; if (!rt) lv -= 1; if (up && up.part === "mantel" && !b.corbel) lv -= 1; // the beam's cast shadow if (b.corbel) { lv = up && up.part === "mantel" ? 2 : lf && lf.k !== b.k ? 4 : 3; if (!dn || dn.k !== b.k) lv = 1; if (!rt || rt.k !== b.k) lv = Math.min(lv, 1); } key = STONE[clamp(Math.floor(lv), 0, 4)]; } if (b.part === "chimney") return key; return warm(key, faceGlow(x, y, L));}function slabKey(x, y, b, L) { const lf = at(x - 1, y); const seam = lf && lf.part === "slab" && lf.k !== b.k; let key; if (b.mat === "top") { if (seam) key = "n1"; else if (y === 54) key = inOpening(x, 53) ? "n2" : "n1"; // the surround's foot shades the slab else if (y === 58) key = "n4"; // lit front lip else key = x === 4 ? "n4" : "n3"; if (y > 54 && y < 58 && !seam && hash(x * 3.1 + y, 1.7) > 0.93 && x > 6) key = "n2"; // pits in the stone // firelight spilling out of the opening: a warm pool with a dithered edge const e = Math.hypot((x + 0.5 - CX) / L.sx, (y + 0.5 - 53) / L.sy); const lvl = e < 0.55 ? 3 : e < 1 ? 2 : 0; if (lvl >= 3) return key === "n1" ? "w2" : key === "n2" ? "w3" : "w4"; if (lvl === 2) return key === "n1" ? "w1" : key === "n2" ? "w2" : key === "n4" ? "w4" : "w3"; return key; } if (seam) return "n0"; key = y === 59 ? "n2" : "n1"; if (x === 4) key = y === 59 ? "n3" : "n2"; return key;}function mantelKey(x, y, b) { if (b.mat === "mtop") return y === 19 ? "w2" : y === 20 ? "w3" : "w4"; if (y === 25) return "w1"; if (x === 5) return "w3"; if (x === 58) return "w1"; // adzed oak: a few long grain lines and two knots const g1 = y === 23 && ((x >= 9 && x <= 17) || (x >= 34 && x <= 45)); const g2 = y === 24 && ((x >= 21 && x <= 30) || (x >= 48 && x <= 55)); if (g1 || g2) return "w1"; if ((x === 27 && y === 23) || (x === 13 && y === 24)) return "w0"; if (y === 22) return "w3"; return "w2";}function fireboxKey(x, y, b, L) { const up = at(x, y - 1), lf = at(x - 1, y), rt = at(x + 1, y); const edge = (q) => !q || q.part !== "firebox"; if (edge(up) || edge(lf) || edge(rt)) return y >= OP.bot - 1 && !edge(up) ? "n1" : "ink"; // the opening's shadowed lip const g = glowAt(x, y, L); if (b.mat === "back") { if (y < 38) return "ink"; // the flue throat const brick = (y - 38) % 3 === 2 || ((x + (Math.floor((y - 38) / 3) & 1) * 2) % 5 === 0); if (brick) return g >= 3 ? "r0" : "ink"; return g >= 3 ? "r1" : g >= 2 ? "r0" : "n0"; } if (b.mat === "coving") { if (y < 37) return "ink"; const brick = (y % 3 === 0); if (brick) return g >= 2 ? "r0" : "ink"; return g >= 3 ? "r1" : g >= 2 ? "w1" : g >= 1 ? "n1" : "n0"; } // floor: ash with a glowing ember bed under the logs if (y === OP.bot) return g >= 2 ? "w2" : "n2"; const hot = Math.abs(x + 0.5 - CX) < 9 && y >= 47 && y <= 51; if (hot) { const p = 0.5 + 0.5 * Math.sin(TAU * (L.t + hash(x, y))); return p > 0.7 && L.heat > 0.15 ? "r2" : "r1"; } return g >= 2 ? "n2" : "n1";}function drawStructure(g, L) { const parts = ["firebox", "slab", "surround", "arch", "corbels", "chimney", "mantel"]; for (const part of parts) { g.part(part, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => { const b = MAP[y * W + x]; if (!b || b.part !== part) return null; if (b.mat === "stone") return stoneKey(x, y, b, L); if (part === "slab") return slabKey(x, y, b, L); if (part === "mantel") return mantelKey(x, y, b); return fireboxKey(x, y, b, L); })); }}// ---- logs and andirons -----------------------------------------------------------------------------------function logs(g, L, which) { const hot = L.heat; const crack = (id) => { const p = 0.5 + 0.5 * Math.sin(TAU * (L.t + hash(id, 2))); return p > 0.66 ? "fl2" : p > 0.3 ? "fl1" : "fl0"; }; if (which === "back") g.part("backLog", () => { g.sweep([[24, 45.5], [40, 45]], { w0: 1.8, w1: 1.7 }, (u, v) => (v < -0.35 ? (hot > 0.5 ? "r1" : "w0") : v > 0.45 ? "w0" : hot > 0.5 ? "w1" : "w0")); g.pixel(29, 45, crack(1)); g.pixel(30, 45, crack(1)); g.pixel(36, 46, crack(2)); }); else g.part("frontLog", () => { g.sweep([[22.5, 50], [43, 48.5]], { w0: 2.2, w1: 2.0 }, (u, v) => (v < -0.45 ? (hot > 0.5 ? "r1" : "n0") : v > 0.5 ? "w0" : hot > 0.5 ? "w2" : "w1")); // end grain toward the viewer's left g.ellipse({ x: [20, 24], y: [48, 52] }, "w3", { fill: "w3" }); g.strokeEllipse({ x: [20, 24], y: [48, 52] }, "w2"); g.pixel(22, 50, "w1"); g.pixel(21, 49, "w4"); // charred patches and glowing cracks g.line([27, 49], [29, 49], "n0"); g.line([35, 48], [37, 48], "n0"); g.line([31, 50], [32, 50], crack(3)); g.pixel(39, 49, crack(4)); g.pixel(26, 50, crack(5)); if (hot < 0.5) { g.line([28, 48], [33, 48], "n0"); g.line([36, 47], [41, 47], "n0"); } });}function andiron(g, x0, s, hot) { // x0 = post's left column; s = -1 left andiron, +1 right (the fire rims the side facing the centre) g.part(s < 0 ? "andironL" : "andironR", () => { g.rect({ x: [x0, x0 + 1], y: [45, 52] }, "n1", { fill: "n1" }); g.pixel(x0, 46, "n2"); g.pixel(x0, 47, "n2"); g.line([x0 + (s < 0 ? 1 : 0), 47], [x0 + (s < 0 ? 1 : 0), 51], hot ? "r1" : "r0"); // firelight on the inner edge g.ellipse({ x: [x0 - 1, x0 + 2], y: [42, 45] }, "n2", { fill: "n2" }); // ball finial g.pixel(x0 + 1, 45, "n1"); g.pixel(x0 + 2, 44, "n1"); g.pixel(x0, 43, "n4"); g.pixel(x0 - 1 + (s < 0 ? 3 : 0), 44, hot ? "r1" : "n1"); g.line([x0 - 1, 53], [x0 + 2, 53], "n1"); g.pixel(x0 - 1, 53, "n2"); // splayed foot });}// ---- flames (layered teardrops, clipped to the opening) --------------------------------------------------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.22 ? 0.72 + 0.28 * Math.sin((k / 0.22) * Math.PI / 2) : Math.pow((1 - k) / 0.78, 0.9));const FLAMES = [ { name: "flameBL", x: 25, base: 47, h: 8, hw: 2.6, lean: -1, seed: 0.83 }, { name: "flameBR", x: 39, base: 47, h: 9, hw: 2.6, lean: 1, seed: 0.21 }, { name: "flameL", x: 27, base: 48, h: 12, hw: 4, lean: -1.4, seed: 0.37 }, { name: "flameR", x: 36.5, base: 48, h: 13, hw: 4.2, lean: 1.3, seed: 0.66 }, { name: "flameC", x: 31.5, base: 49, h: 17, hw: 6, lean: 0, seed: 0.05 },];const flameH = (f, t, s) => s * f.h * (1 + 0.09 * Math.sin(TAU * (2 * t + f.seed)) + 0.06 * Math.sin(TAU * (3 * t + 2 * f.seed)));function drawFlame(g, f, t, s, maxLayer) { const hh = flameH(f, t, s), hwS = f.hw * Math.min(1.15, 0.4 + 0.6 * s); if (hh < 1.5) return; g.part(f.name, () => g.field({ x: [OP.x0, OP.x1], y: [Math.max(0, f.base - 26), f.base] }, (x, y) => { if (!inOpening(x, y)) return null; const k = (f.base + 1 - (y + 0.5)) / hh; if (k < 0 || k > 1) return null; const ax = f.x + 0.5 + (f.lean + 1.2 * Math.sin(TAU * (t + f.seed))) * k * k + 0.9 * Math.sin(TAU * (1.2 * k - 2 * t + f.seed)) * k; const lick = 1 + 0.2 * Math.sin(TAU * (2.3 * k - 2 * t + 1.7 * f.seed)); const dx = Math.abs(x + 0.5 - ax); let key = null; for (let j = 0; j <= maxLayer; j++) { const [c, hf, wf] = LAYERS[j], kj = k / hf; if (kj > 1) break; if (dx <= hwS * wf * profile(kj) * lick) key = c; else break; } return key; }));}function fire(g, t, s) { for (const f of FLAMES) { const small = f.name.startsWith("flameB"); const sf = small ? Math.min(1, s * 1.4) : s; if (sf > 0.05) drawFlame(g, f, t, sf, s < 0.3 ? 1 : s < 0.55 ? 2 : 3); }}// low fire: small dim tongues that come and go on the logsfunction licks(g, t) { for (const [x, ph, h] of [[28, 0, 4], [34, 0.4, 3], [38, 0.7, 3]]) { const p = (t + ph) % 1; if (p > 0.6) continue; const hh = h * Math.sin(Math.PI * p / 0.6); drawFlame(g, { name: "lick", x, base: 48, h: hh, hw: 1.4, lean: 0.3, seed: ph }, t, 1, 1); }}// ---- sparks, smoke ------------------------------------------------------------------------------------function put(g, x, y, key) { if (at(x, y) && at(x, y).part === "firebox") g.pixel(x, y, key); }function sparks(g, t, n, burst) { g.detached("sparks", () => { for (let i = 0; i < n; i++) { const p = (t + i / n) % 1; const x = CX + Math.round((i - (n - 1) / 2) * 3 + Math.sin(TAU * (p * 1.5 + hash(i, 1))) * 2); const y = 42 - Math.round(p * 14); if (p < 0.85) put(g, x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : "fl1"); } if (burst) for (let i = 0; i < 7; i++) { // stoke: a spray of sparks thrown up and out const k = burst - 1; const x = CX + Math.round((i - 3) * (1.2 + k * 1.1) + hash(i, 4) * 2 - 1); const y = 44 - Math.round(3 + k * 3 + hash(i, 6) * 3) + (k > 1 ? Math.round((k - 1) * (i & 1)) : 0); if (y > 31 && x > 15 && x < 48) g.pixel(x, y, k < 1 ? "fl3" : k < 2 ? "fl2" : "fl1"); } });}function smoke(g, t) { g.detached("smoke", () => { for (let y = 46; y >= 33; y--) { const k = (46 - y) / 13; const x = CX + Math.round(Math.sin(TAU * (k * 1.2 - t)) * (0.5 + 2 * k)); const puff = (k * 2 - t + 2) % 1; if (puff > 0.7) continue; if (k > 0.6 && chk(x, y)) continue; put(g, x, y, k < 0.35 ? "n3" : "n2"); } });}// ---- mantel dressing -------------------------------------------------------------------------------------function tankard(g) { g.part("tankard", () => { g.rect({ x: [8, 13], y: [15, 21] }, "w2", { fill: "w2" }); g.line([8, 15], [8, 21], "w3"); g.line([13, 15], [13, 21], "w1"); g.line([11, 16], [11, 20], "w1"); // stave joint for (const y of [16, 20]) { g.line([8, y], [13, y], "n2"); g.pixel(8, y, "n3"); g.pixel(13, y, "n1"); } g.strokeRect({ x: [14, 15], y: [16, 19] }, "w1"); // handle g.pixel(14, 16, "w2"); g.ellipse({ x: [7, 14], y: [12, 15] }, "n4", { fill: "n4" }); // froth heaped over the rim g.line([8, 12], [11, 12], "n5"); g.pixel(8, 13, "n5"); g.pixel(13, 14, "n3"); g.pixel(12, 15, "n3"); });}function pewter(g) { g.part("pewter", () => { g.rect({ x: [17, 21], y: [16, 21] }, "n3", { fill: "n3" }); g.line([17, 16], [17, 21], "n4"); g.line([21, 16], [21, 21], "n2"); g.pixel(18, 18, "n5"); g.line([17, 16], [21, 16], "n2"); g.line([18, 16], [20, 16], "n0"); // rim + dark mouth g.line([17, 20], [21, 20], "n2"); // turned foot ring g.strokeRect({ x: [22, 23], y: [17, 19] }, "n2"); });}function candle(g, t) { g.part("candle", () => { g.ellipse({ x: [48, 55], y: [19, 21] }, "g1", { fill: "g1" }); // brass dish g.line([49, 19], [52, 19], "g3"); g.line([50, 21], [54, 21], "g0"); g.rect({ x: [50, 53], y: [12, 19] }, "n4", { fill: "n4" }); g.line([50, 12], [50, 19], "n5"); g.line([53, 13], [53, 19], "n3"); g.pixel(51, 13, "n5"); g.line([50, 14], [50, 16], "n5"); // wax drip g.pixel(53, 12, "n3"); g.pixel(52, 11, "n0"); // melted crown + wick });}function candleFlame(g, t) { g.detached("candleFlame", () => { const f = Math.floor(t * 12) % 4, lean = [0, 0, 1, 0][f], tall = [3, 4, 3, 4][f]; for (let i = 0; i < tall; i++) g.pixel(52 + (i >= 2 ? lean : 0), 10 - i, i === 0 ? "fl3" : i === tall - 1 ? "fl0" : "fl1"); g.pixel(51, 9, f & 1 ? "fl0" : "fl1"); });}// ---- sprite ---------------------------------------------------------------------------------------------const N = 8;const HEAT = [0.25, 0.4, 0.62, 0.85, 1, 1, 1, 1];const GROW = [0.12, 0.3, 0.6, 0.95, 1.22, 1.12, 1.04, 1];const Hearth = sprite(pal, (g, t, state) => { let heat = 1, s = 1, ft = t, burst = 0; if (state === "low") { heat = 0.2; s = 0; } if (state === "stoke") { const f = Math.min(N - 1, Math.round(t * N)); heat = HEAT[f]; s = GROW[f]; ft = ((f - (N - 1)) / 12 + 1) % 1; // same 10 fps clock as idle: the last frame is idle t=0 burst = f >= 3 && f <= 5 ? f - 2 : 0; } const flare = s > 1 ? 1 + (s - 1) * 0.8 : 1, P = pulse(ft) * flare; const L = { heat, t: ft, R: (7 + 10 * heat) * P, face: heat * 3.4 * P, sx: (3 + 13 * heat) * P, sy: (1.5 + 3.5 * heat) * P }; g.outlined("ink", () => drawStructure(g, L)); g.outlined("ink", () => logs(g, L, "back")); if (s > 0) fire(g, ft, s); else licks(g, ft); g.outlined("ink", () => { logs(g, L, "front"); andiron(g, 22, -1, heat > 0.5); andiron(g, 40, 1, heat > 0.5); }); if (state === "low") smoke(g, t); else sparks(g, ft, s >= 0.9 ? 5 : 0, burst); g.outlined("ink", () => { tankard(g); pewter(g); candle(g, ft); }); candleFlame(g, ft);});submit(Hearth, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "low", args: ["low"] }, { name: "stoke", args: ["stoke"], animate: { fps: 10, duration: N / 10, loop: false } }, ],});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.