Flame jet
A 32×64 floor trap: a ring of dressed stone blocks round a pit with an iron grate across it; the vent is a 32-pixel floor piece and the canvas is tall so the fire column fits. “idle” (8 frames) has the pit smoulder under the grate while a few embers climb and cool; “burst” (12 frames) sends a roaring column of fire well above the vent, with tongues tearing off the top. All the fire colours, including the glow on the stone and the embers, are one family, so “idle-soul” and “burst-soul” are the same loops in soulfire.
6×
Get the animated sprites: Dungeon Mechanisms on itch.ioVol.2 · 12 traps, doors and breakables
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Flame jet — 32x64 (the vent is a 32px floor piece; the canvas is tall so the fire column fits), top-down 3/4,// key light top-left. A ring of dressed stone blocks round a pit, an iron grate across the pit.// All fire colours are the fl0..3 family, so the whole jet (column, glow on the stone, embers) swaps with swap("fl", ...).// Variants (all 32x64, ground at the bottom so they align):// idle loop 8f — the pit smoulders under the grate, a few embers climb and cool// burst loop 12f — a roaring column of fire rises well above the vent, tongues tear off the top// burst-soul loop 12f — the same in soulfire (fl -> soul)// idle-soul loop 8f — the smouldering vent in soulfire, to pair with burst-soulThe last lines. submit declares the canvas and lists the variants the renderer exports.
submit(FlameJet, { size: [32, 64], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "burst", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true } }, { name: "burst-soul", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true }, repalette: swap("fl", "soul") }, { name: "idle-soul", args: ["idle"], repalette: swap("fl", "soul") }, ],});Show the whole program (193 lines)
// Flame jet — 32x64 (the vent is a 32px floor piece; the canvas is tall so the fire column fits), top-down 3/4,// key light top-left. A ring of dressed stone blocks round a pit, an iron grate across the pit.// All fire colours are the fl0..3 family, so the whole jet (column, glow on the stone, embers) swaps with swap("fl", ...).// Variants (all 32x64, ground at the bottom so they align):// idle loop 8f — the pit smoulders under the grate, a few embers climb and cool// burst loop 12f — a roaring column of fire rises well above the vent, tongues tear off the top// burst-soul loop 12f — the same in soulfire (fl -> soul)// idle-soul loop 8f — the smouldering vent in soulfire, to pair with burst-soulconst CX = 16, GROUND = 61;const RING = { cy: 53, rx: 14.5, ry: 6, side: 2 }; // top face of the stone ring; `side` = height of its outer wallconst HOLE = { cy: 53, rx: 8.5, ry: 3.3 }; // opening in the ring's top faceconst GRATE = { cy: 54, rx: 8, ry: 2.9 }; // the grate sits a little below the top faceconst N_IDLE = 8, N_BURST = 12;const eR = (x, y, cx, cy, rx, ry) => { const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; return Math.sqrt(nx * nx + ny * ny); };const inE = (x, y, cx, cy, rx, ry) => eR(x, y, cx, cy, rx, ry) <= 1;const inRing = (x, y) => inE(x, y, CX, RING.cy, RING.rx, RING.ry);const inHole = (x, y) => inE(x, y, CX, HOLE.cy, HOLE.rx, HOLE.ry);// ---- stone ring ---------------------------------------------------------------------------------const BLOCKS = 8;function blockAt(x, y) { // angle round the ring -> block index and position inside the block (0..1) const a = Math.atan2((y + 0.5 - RING.cy) / RING.ry, (x + 0.5 - CX) / RING.rx); const u = ((a / TAU) * BLOCKS + 0.5 + BLOCKS) % BLOCKS; return [Math.floor(u), u - Math.floor(u)];}function ring(g, hot) { const { cy, rx, ry, side } = RING; g.part("ring", () => { // outer wall (seen on the near half): lit on the left, turning away on the right, a dark foot on the floor g.field({ x: [CX - rx - 1, CX + rx + 1], y: [cy, cy + ry + side + 1] }, (x, y) => { if (inRing(x, y)) return null; let d = 0; for (let k = 1; k <= side; k++) if (inRing(x, y - k)) { d = k; break; } if (!d) return null; const [, f] = blockAt(x, y - d); const nx = (x + 0.5 - CX) / rx; if (f < 0.07 || f > 0.97) return "n0"; // joint between blocks if (d === 1) return nx < -0.45 ? "n3" : nx < 0.4 ? "n2" : "n1"; return nx < -0.45 ? "n1" : "n0"; }); // top face g.field({ x: [CX - rx - 1, CX + rx + 1], y: [cy - ry - 1, cy + ry + 1] }, (x, y) => { if (!inRing(x, y) || inHole(x, y)) return null; const [k, f] = blockAt(x, y); const r = eR(x, y, CX, cy, rx, ry), rh = eR(x, y, CX, HOLE.cy, HOLE.rx, HOLE.ry); const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry; if (hot && rh < 1.16 && ny > -0.55) return "fl2"; // the fire lights the inner lip if (hot && rh < 1.36 && ny > 0.1) return "fl1"; if (f < 0.07 || f > 0.97) return "n1"; // joint if (r > 0.9) return nx + 0.6 * ny < 0.1 ? "n4" : "n3"; // outer arris if (rh < 1.16) return ny > 0 ? "n4" : "n2"; // inner arris: the near lip faces the light return k === 2 || k === 5 ? "n2" : "n3"; // two blocks sit a shade darker }); // chips on a few blocks (countable marks) for (const [x, y, c] of [[6, 50, "n2"], [25, 49, "n2"], [8, 57, "n2"], [24, 57, "n2"], [19, 58, "n2"]]) { if (!hot || !inE(x, y, CX, HOLE.cy, HOLE.rx * 1.36, HOLE.ry * 1.36)) g.pixel(x, y, c); } });}// ---- pit & grate --------------------------------------------------------------------------------function pit(g, glow) { g.part("pit", () => { g.field({ x: [CX - HOLE.rx, CX + HOLE.rx], y: [HOLE.cy - HOLE.ry - 1, HOLE.cy + HOLE.ry + 1] }, (x, y) => { if (!inHole(x, y)) return null; if (!inE(x, y, CX, GRATE.cy, GRATE.rx, GRATE.ry)) return glow(-1, x, y); // far inner wall return glow(eR(x, y, CX, GRATE.cy, GRATE.rx * 0.85, GRATE.ry), x, y); }); });}const isBar = (x, y) => y === GRATE.cy - 1 || y === GRATE.cy + 1 ? 2 : (x - CX + 30) % 3 === 0 ? 1 : 0;function grate(g, hot) { // two bars across and short bars front-to-back, clipped to the grate's ellipse g.part("grate", () => { g.field({ x: [CX - GRATE.rx, CX + GRATE.rx], y: [GRATE.cy - GRATE.ry - 1, GRATE.cy + GRATE.ry + 1] }, (x, y) => { if (!inE(x, y, CX, GRATE.cy, GRATE.rx, GRATE.ry)) return null; const b = isBar(x, y); if (!b) return null; if (hot) return b === 2 && (x - CX + 30) % 3 !== 0 ? "n0" : "ink"; // silhouettes against the fire if (b === 2) return (x - CX + 30) % 3 === 0 ? (x < CX + 2 ? "n3" : "n2") : x < CX - 2 ? "n2" : "n1"; return "n0"; }); });}// ---- fire ---------------------------------------------------------------------------------------// three flames rooted in the pit: a tall centre column and two short flankers leaning out. Each flame has nested// layers (shell, body, bright, core), each layer itself flame-shaped (shorter and narrower).const BASE = GRATE.cy + 1;const FLAMES = [ { name: "flankL", x: 11.5, h: 20, hw: 4.2, lean: -3.5, seed: 0.31 }, { name: "flankR", x: 20.5, h: 24, hw: 4.2, lean: 3.5, seed: 0.64 }, { name: "column", x: 16, h: 49, hw: 7.4, lean: 0, seed: 0.05 },];const LAYERS = [["fl0", 1, 1], ["fl1", 0.92, 0.72], ["fl2", 0.72, 0.48], ["fl3", 0.45, 0.25]];const profile = (k) => (k < 0.12 ? 0.82 + 0.18 * (k / 0.12) : k < 0.42 ? 1 - 0.14 * ((k - 0.12) / 0.3) : 0.86 * Math.pow((1 - k) / 0.58, 0.8));function flameH(f, t) { return f.h * (1 + 0.07 * Math.sin(TAU * (2 * t + f.seed)) + 0.04 * Math.sin(TAU * (3 * t + 2 * f.seed)));}function flameAxis(f, t, k) { return f.x + (f.lean + 1.5 * Math.sin(TAU * (t + f.seed))) * k * k + 1.1 * Math.sin(TAU * (1.3 * k - 2 * t + f.seed)) * k;}function layerOf(f, t, x, y) { // index of the innermost layer of flame f covering pixel (x, y); -1 = outside const k = (BASE + 1 - (y + 0.5)) / flameH(f, t); if (k < 0 || k > 1) return -1; const lick = 1 + 0.18 * Math.sin(TAU * (2.6 * k - 3 * t + 1.7 * f.seed)) + 0.07 * Math.sin(TAU * (5.2 * k - 2 * t)); const dx = Math.abs(x + 0.5 - flameAxis(f, t, k)); let li = -1; for (let j = 0; j < LAYERS.length; j++) { const [, hf, wf] = LAYERS[j], kj = k / hf; if (kj > 1 || dx > f.hw * wf * profile(kj) * lick) break; li = j; } return li;}function fire(g, t) { // the flames merge: each pixel takes the hottest layer any flame puts there, so no shell cuts through a core g.part("fire", () => g.field({ x: [0, 31], y: [0, BASE] }, (x, y) => { if (y >= RING.cy && !inHole(x, y)) return null; // the near half of the ring stands in front of the foot let li = -1; for (const f of FLAMES) li = Math.max(li, layerOf(f, t, x, y)); return li < 0 ? null : LAYERS[li][0]; }));}function tongues(g, t) { // blobs tear off the tip of the column and rise, shrinking const f = FLAMES[2]; g.detached("tongues", () => { for (let i = 0; i < 2; i++) { const p = (2 * t + i / 2) % 1; const tip = BASE - flameH(f, t); const x = Math.round(flameAxis(f, t, 1)) + (i ? 1 : -1); const y = Math.round(tip - p * 8); if (y < 1) continue; if (p < 0.4) { g.ellipse({ x: [x - 1, x + 1], y: [y - 2, y + 1] }, "fl0", { fill: "fl0" }); g.line([x, y - 1], [x, y], "fl1"); } else if (p < 0.7) { g.rect({ x: [x, x + 1], y: [y - 1, y] }, "fl0", { fill: "fl0" }); g.pixel(x, y, "fl1"); } else if (p < 0.9) g.pixel(x, y, "fl0"); } });}function embers(g, t, n, reach, y0, spread) { g.detached("embers", () => { for (let i = 0; i < n; i++) { const p = (t + i / n) % 1, s = hash(i, 5) - 0.5; const x = CX + Math.round(s * spread * (1 + p) + Math.sin(TAU * (p + hash(i, 2))) * 1.3); const y = y0 - Math.round(p * reach * (0.75 + 0.25 * hash(i, 8))); if (p < 0.85 && y >= 0) g.pixel(x, y, p < 0.25 ? "fl3" : p < 0.5 ? "fl2" : p < 0.7 ? "fl1" : "fl0"); } });}// ---- sprite -------------------------------------------------------------------------------------const FlameJet = sprite(pal, (g, t, state) => { const burst = state === "burst"; contactShadow(g, CX, GROUND, 16, 2); g.outlined("ink", () => ring(g, burst)); if (!burst) { // idle: the bed of embers under the grate breathes; a slow pulse travels across it pit(g, (r, x, y) => { const pulse = 0.5 + 0.5 * Math.sin(TAU * (t - x / 12)); if (r < 0) return (x + 0.5 - CX) / HOLE.rx > 0.35 ? "n1" : "n0"; // far inner wall if (r < 0.35) return pulse > 0.7 ? "fl2" : "fl1"; if (r < 0.62) return pulse > 0.45 ? "fl1" : "fl0"; if (r < 0.88) return pulse > 0.6 ? "fl0" : "n0"; return "ink"; }); grate(g, false); embers(g, t, 3, 11, GRATE.cy - 2, 8); return; } pit(g, (r) => (r < 0 ? "fl1" : r < 0.6 ? "fl3" : "fl2")); fire(g, t); grate(g, true); tongues(g, t); embers(g, t, 8, 34, 46, 12);});submit(FlameJet, { size: [32, 64], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "burst", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true } }, { name: "burst-soul", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true }, repalette: swap("fl", "soul") }, { name: "idle-soul", args: ["idle"], repalette: swap("fl", "soul") }, ],});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.