Cauldron
A 48×48 floor prop: a cauldron of brew over a wood fire, in top-down 3/4 with the key light at the top-left. The loop is 12 frames at 10 fps: bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts and the fire flickers. The green, violet and blue brews are the same loop with one colour ramp swapped, so no new colours are added.
6×
Get the animated sprites: Arcane Curiosities Vol.1 on itch.io12 animated props · 52 animations
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Cauldron over a wood fire — 48x48, top-down 3/4, key light top-left.// Loop: 12 frames @ 10 fps. Bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts, fire flickers.// Variants: brew colour green / violet / blue (ramp swap — no new colours).The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(Cauldron, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: 45 }, variants: [ { name: "idle-green" }, { name: "idle-violet", repalette: swap("brew", "v") }, { name: "idle-blue", repalette: swap("brew", "b") }, ],});Show the whole program (202 lines)
// Cauldron over a wood fire — 48x48, top-down 3/4, key light top-left.// Loop: 12 frames @ 10 fps. Bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts, fire flickers.// Variants: brew colour green / violet / blue (ramp swap — no new colours).const CX = 24, GROUND = 45;const BODY = { cx: 24, cy: 29, rx: 16, ry: 12 };const LIP = { cy: 16, rx: 17, ry: 6 };const MOUTH = { cy: 16, rx: 14, ry: 4 };const IRON = ["n0", "n1", "n2", "n3"];function edgeDist(x, y, cx, cy, rx, ry) { // approximate distance (px) from the ellipse boundary, inside only const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; const r = Math.sqrt(nx * nx + ny * ny); return (1 - r) * Math.min(rx, ry) * 1.15;}function body(g, t) { const { cx, cy, rx, ry } = BODY; g.field({ x: [cx - rx, cx + rx], y: [cy - ry, cy + ry] }, (x, y) => { const i = litIndex(x, y, cx, cy, rx, ry, 4, -0.05); if (i < 0) return null; const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; // warm rim light from the fire along the underside if (ny > 0.5 && Math.abs(nx) < 0.55 && edgeDist(x, y, cx, cy, rx, ry) < 1.2) return Math.abs(nx) < 0.3 ? "r1" : "r0"; // soft specular: a short curved highlight on the upper-left shoulder const sx = nx + 0.5, sy = ny + 0.28; if (sx * sx * 3 + sy * sy * 9 < 0.07) return "n4"; return IRON[i]; });}function band(g, t) { // riveted iron band following the belly's curvature, with a pulsing sigil plate at the front const { cx, cy, rx } = BODY; const yAt = (x) => { const u = (x + 0.5 - cx) / (rx - 0.5); return Math.round(cy - 3 + (1 - Math.sqrt(Math.max(0, 1 - u * u))) * -3); }; g.part("band", () => { for (let x = cx - rx + 1; x <= cx + rx - 1; x++) { const y = yAt(x), u = (x - cx) / rx; g.pixel(x, y, u < -0.3 ? "n3" : u < 0.5 ? "n2" : "n1"); g.pixel(x, y + 1, "n0"); } for (const u of [-0.8, -0.45, 0.45, 0.8]) { const x = Math.round(cx + u * (rx - 1)); g.pixel(x, yAt(x) + 1, u < 0 ? "n4" : "n3"); } }); const pulse = 0.5 + 0.5 * Math.cos(TAU * t); const hot = pulse > 0.66 ? "brew3" : pulse > 0.33 ? "brew2" : "brew1"; const y0 = yAt(cx) - 1; g.part("sigil", () => { g.fillPoly([[cx, y0 - 1], [cx + 3, y0 + 2], [cx, y0 + 5], [cx - 3, y0 + 2]], "n1"); g.poly([[cx, y0 - 1], [cx + 3, y0 + 2], [cx, y0 + 5], [cx - 3, y0 + 2], [cx, y0 - 1]], "g1"); g.pixel(cx - 1, y0, "g2"); g.pixel(cx - 2, y0 + 1, "g2"); g.pixel(cx, y0 + 1, "brew1"); g.pixel(cx - 1, y0 + 2, "brew1"); g.pixel(cx + 1, y0 + 2, "brew1"); g.pixel(cx, y0 + 3, "brew1"); g.pixel(cx, y0 + 2, hot); if (pulse > 0.5) { g.pixel(cx, y0 + 1, "brew2"); g.pixel(cx, y0 + 3, "brew2"); } });}function lip(g) { const { cy, rx, ry } = LIP; g.part("lip", () => { // outer thickness (seen on the near side) then the lit top face g.ellipse({ x: [CX - rx, CX + rx], y: [cy - ry + 1, cy + ry] }, "n1", { fill: "n1" }); g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry - 1] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - (cy - 0.5)) / (ry - 0.5); if (nx * nx + ny * ny > 1) return null; const a = -0.7 * nx - 0.3 * ny; // top-left of the ring catches the light return a > 0.45 ? "n4" : a > 0.05 ? "n3" : a > -0.45 ? "n2" : "n1"; }); }); for (const s of [-1, 1]) { const hx = CX + s * (rx + 1); g.part(s < 0 ? "handleL" : "handleR", () => { g.strokeEllipse({ x: [hx - 2, hx + 2], y: [cy + 4, cy + 9] }, s < 0 ? "n2" : "n1"); g.pixel(hx - 1, cy + 4, s < 0 ? "n4" : "n2"); }); }}function brew(g, t) { const { cy, rx, ry } = MOUTH; const L = { cx: CX, cy: cy + 1, rx: rx - 1, ry: ry - 1 }; g.part("brew", () => { // inner wall of the far side, then the liquid surface g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry; if (nx * nx + ny * ny > 1) return null; const lx = (x + 0.5 - L.cx) / L.rx, ly = (y + 0.5 - L.cy) / L.ry; if (lx * lx + ly * ly > 1) return nx > 0.35 ? "n1" : "n0"; // inner wall: its right side catches light if (ly > 0.55 && Math.abs(lx) < 0.8) return "brew2"; // meniscus along the near edge if (ly < -0.3) return "brew0"; return "brew1"; }); // swirl: two arms, 2-fold symmetric so a half turn per loop closes the loop for (let arm = 0; arm < 2; arm++) { for (let s = 0.25; s < 0.9; s += 0.09) { const a = Math.PI * t + arm * Math.PI + s * 2.8; const x = Math.round(L.cx + Math.cos(a) * s * L.rx); const y = Math.round(L.cy + Math.sin(a) * s * L.ry); g.pixel(x, y, s > 0.6 ? "brew2" : "brew0"); } } g.line([CX - 9, cy], [CX - 6, cy], "brew3"); // glint of the key light });}function bubbles(g, t) { const spots = [[-6, 1, 0.0], [3, 2, 0.34], [8, 0, 0.68]]; g.detached("bubbles", () => { for (const [dx, dy, ph] of spots) { const p = (t + ph) % 1; const x = CX + dx, y = MOUTH.cy + dy; if (p < 0.2) g.pixel(x, y, "brew2"); else if (p < 0.45) { g.rect({ x: [x - 1, x], y: [y - 1, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 1, "brew3"); } else if (p < 0.62) { g.ellipse({ x: [x - 2, x + 1], y: [y - 2, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 2, "brew3"); g.pixel(x - 2, y - 1, "brew3"); g.pixel(x + 1, y, "brew0"); } else if (p < 0.75) { for (const [ox, oy] of [[-3, 0], [2, 0], [-1, -3], [0, 1]]) g.pixel(x + ox, y + oy, "brew3"); } } });}function steam(g, t) { g.detached("steam", () => { for (let i = 0; i < 4; i++) { const p = (t + i / 4) % 1; const x0 = CX + [-6, -1, 4, 8][i] + Math.round(Math.sin(TAU * (p * 0.8 + i * 0.3)) * 2); const y0 = LIP.cy - 4 - Math.round(p * 12); if (p < 0.35) { g.rect({ x: [x0, x0 + 1], y: [y0, y0 + 1] }, "brew3", { fill: "brew3" }); g.pixel(x0 + 1, y0 + 1, "brew2"); } else if (p < 0.7) { g.pixel(x0, y0, "brew2"); g.pixel(x0 + 1, y0 - 1, "brew2"); } else if (chk(x0, Math.floor(t * 12))) g.pixel(x0, y0, "brew1"); } });}function logs(g) { g.part("logs", () => { // back log (angled), front log (level) with end grain facing the viewer g.rect({ x: [18, 31], y: [39, 41] }, "w1", { fill: "w1" }); g.pixel(31, 39, "w3"); g.rect({ x: [15, 34], y: [41, 44] }, "w2", { fill: "w2" }); g.line([16, 41], [33, 41], "w3"); g.line([16, 44], [33, 44], "w1"); g.ellipse({ x: [12, 16], y: [40, 45] }, "w3", { fill: "w3" }); g.strokeEllipse({ x: [12, 16], y: [40, 45] }, "w1"); g.pixel(14, 42, "w1"); g.pixel(13, 41, "w4"); g.line([22, 42], [26, 42], "w1"); g.pixel(30, 43, "w1"); });}function fire(g, t) { g.part("fire", () => { flame(g, 19, 40, 5, 8, t, 0.1, ["r1", "r2", "r3", "g3"]); flame(g, 29, 40, 5, 7, t, 0.55, ["r1", "r2", "r3", "g3"]); flame(g, 24, 41, 7, 11, t, 0.3, ["r1", "r2", "r3", "g3"]); }); g.detached("embers", () => { for (let k = 0; k < 3; k++) { const p = (t * 2 + k / 3) % 1; const x = 24 + Math.round(Math.sin(TAU * (p + k * 0.4)) * 7), y = 37 - Math.round(p * 5); if (p < 0.7) g.pixel(x, y, p < 0.35 ? "g2" : "r2"); } });}function foot(g, fx, lit) { g.part(lit ? "footL" : "footR", () => { g.fillPoly([[fx - 2, 37], [fx + 2, 37], [fx + 3, 43], [fx - 3, 43]], "n1"); g.line([fx - 2, 38], [fx - 3, 42], lit ? "n3" : "n2"); g.line([fx - 3, 43], [fx + 3, 43], "n0"); g.pixel(fx - 1, 43, "n2"); g.pixel(fx + 1, 43, "n2"); // toes });}const Cauldron = sprite(pal, (g, t) => { contactShadow(g, CX, GROUND, 18, 2); g.outlined("ink", () => { foot(g, 12, true); foot(g, 36, false); g.part("body", () => body(g, t)); lip(g); }); band(g, t); brew(g, t); bubbles(g, t); g.outlined("ink", () => { logs(g); fire(g, t); }); steam(g, t);});submit(Cauldron, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: 45 }, variants: [ { name: "idle-green" }, { name: "idle-violet", repalette: swap("brew", "v") }, { name: "idle-blue", repalette: swap("brew", "b") }, ],});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.