Tessarune
Arcane Curiosities Vol.1 · brazier.js

Brazier

48×64 · 190 lines of JavaScript

A 48×64 wrought-iron brazier: a wide riveted bowl of glowing coals on three curved legs, with a big fire made of three overlapping flames and rising embers. In the 12-frame loop the flames lick upward and shed tongues, the cracks in the coal pulse and the embers rise. The flame comes in fire, soul, witch and bile by swapping one ramp, and “unlit” is a single cold still of ash with a few dying embers.

Brazier — Rendered output6×
Rendered by this program, variant “idle-fire” · 48×64 · 12 frames
Arcane Curiosities Vol.1Get 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.

brazier.js · 1–4
// Wrought-iron brazier — 48x64 (tall), top-down 3/4, key light top-left.// A wide riveted bowl of glowing coals on three curved legs, with a big fire (3 overlapping flames) and rising embers.// Loop: 12 frames @ 10 fps: flames lick upward and shed tongues, coal cracks pulse, embers rise.// Variants: flame colour fire / soul / witch / bile (fl ramp swap), plus a cold `unlit` still (ash, a few dying embers).

The last lines. submit declares the canvas and lists the variants the renderer exports.

brazier.js · 178–190
submit(Brazier, {  size: [48, 64], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 24, y: 61 },  args: ["lit"],  variants: [    { name: "idle-fire" },    { name: "idle-soul", repalette: swap("fl", "soul") },    { name: "idle-witch", repalette: swap("fl", "witch") },    { name: "idle-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: false },  ],});
Show the whole program (190 lines)
brazier.js · 190 lines
// Wrought-iron brazier — 48x64 (tall), top-down 3/4, key light top-left.// A wide riveted bowl of glowing coals on three curved legs, with a big fire (3 overlapping flames) and rising embers.// Loop: 12 frames @ 10 fps: flames lick upward and shed tongues, coal cracks pulse, embers rise.// Variants: flame colour fire / soul / witch / bile (fl ramp swap), plus a cold `unlit` still (ash, a few dying embers).const CX = 24, GROUND = 61;const RIM = { cy: 33, rx: 18, ry: 5 };const MOUTH = { cy: 33, rx: 15, ry: 3.2 };const BOWL = { cy: 34, rx: 17, ry: 12 };const IRON = ["n0", "n1", "n2", "n3"];// ---- legs ---------------------------------------------------------------------------------------// three legs at 120 degrees: the front one drops from the boss under the bowl, the two back ones// splay outward from the bowl's flanks; each ends in a scroll where the bar curls up off the floorfunction backLeg(g, name, s) {  const X = (x) => CX + s * x;                       // s = -1 left, +1 right  const lit = s < 0;  const span = (a, b) => [Math.min(X(a), X(b)), Math.max(X(a), X(b))];  g.part(name, () => {    g.sweep([[X(11), 42], [X(15.5), 45], [X(17.5), 49.5], [X(19), 55]], { w0: 1.1, w1: 0.9 },      (u, v) => (v * s > 0.2 ? (lit ? "n3" : "n2") : v * s < -0.4 ? "n0" : "n1"));    g.strokeEllipse({ x: span(19, 21), y: [52, 55] }, "n1");    g.pixel(X(20), 52, lit ? "n3" : "n2");  });}function frontLeg(g) {  g.part("legF", () => {    g.sweep([[24, 45], [25, 49], [24, 54], [23, 58]], { w0: 1.3, w1: 1.0 }, (u, v) => (v < -0.25 ? "n3" : v > 0.4 ? "n0" : "n1"));    g.strokeEllipse({ x: [23, 26], y: [58, 61] }, "n1");                   // scroll foot facing us    g.pixel(23, 59, "n3"); g.pixel(24, 58, "n3");    g.line([25, 61], [26, 60], "n0");  });}// ---- bowl ---------------------------------------------------------------------------------------function bowl(g) {  g.part("bowl", () => {    g.field({ x: [CX - BOWL.rx, CX + BOWL.rx], y: [BOWL.cy, BOWL.cy + BOWL.ry] }, (x, y) => {      const i = litIndex(x, y, CX, BOWL.cy, BOWL.rx, BOWL.ry, 4, 0.15);      if (i < 0) return null;      const nx = (x + 0.5 - CX) / BOWL.rx, ny = (y + 0.5 - BOWL.cy) / BOWL.ry;      const sx = nx + 0.5, sy = ny - 0.5;      if (sx * sx * 3 + sy * sy * 12 < 0.08) return "n4";          // soft specular on the upper-left shoulder      return IRON[i];    });    // a boss under the bowl where the legs meet    g.ellipse({ x: [CX - 3, CX + 3], y: [44, 46] }, "n1", { fill: "n1" });    g.line([CX - 2, 44], [CX, 44], "n2");  });  // riveted strap round the belly, following the curve of the bowl  const yAt = (x) => { const u = (x + 0.5 - CX) / (BOWL.rx - 0.5); return Math.round(BOWL.cy + 6.5 - (1 - Math.sqrt(Math.max(0, 1 - u * u))) * 4); };  g.part("strap", () => {    for (let x = CX - BOWL.rx + 1; x <= CX + BOWL.rx - 1; x++) {      const u = (x - CX) / BOWL.rx, y = yAt(x);      g.pixel(x, y, u < -0.3 ? "n3" : u < 0.4 ? "n2" : "n1");      g.pixel(x, y + 1, "n0");    }    for (const u of [-0.8, -0.48, -0.16, 0.16, 0.48, 0.8]) {      const x = Math.round(CX + u * (BOWL.rx - 1)), y = yAt(x);      g.pixel(x, y, u < 0 ? "n5" : "n4");    }  });}function rim(g, lit) {  const { cy, rx, ry } = RIM;  g.part("rim", () => {    g.ellipse({ x: [CX - rx, CX + rx], y: [cy - ry + 1, cy + ry + 1] }, "n1", { fill: "n1" });   // rolled lip thickness    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 a = -0.75 * nx - 0.35 * ny;      if (lit && ny < -0.2 && ny > -0.75 && Math.abs(nx) > 0.72 && Math.abs(nx) < 0.86) return "fl0";  // coal glow on the inner lip      return a > 0.5 ? "n4" : a > 0.1 ? "n3" : a > -0.4 ? "n2" : "n1";    });  });}// ---- coals --------------------------------------------------------------------------------------const BACK_LUMPS = [[12, 31], [17, 30], [23, 29], [29, 30], [35, 31], [14, 33], [20, 32], [27, 32], [33, 33]];const FRONT_LUMPS = [[11, 34], [16, 35], [21, 35], [27, 35], [32, 35], [37, 34]];function lumps(g, t, lit, list, k0) {  for (let i = 0; i < list.length; i++) {    const [lx, ly] = list[i], id = i + k0;    g.ellipse({ x: [lx - 2, lx + 2], y: [ly - 1, ly + 1] }, "n0", { fill: "n0" });    g.line([lx - 1, ly - 1], [lx + 1, ly - 1], "n1");                              // lit top    if (lit) {      g.pixel(lx + 2, ly, "fl0");                                                   // hot underside edge      const p = 0.5 + 0.5 * Math.sin(TAU * (t + hash(id, 3)));                      // crack pulses on its own phase      if (hash(id, 7) > 0.3) g.line([lx - 1, ly], [lx, ly + (hash(id, 9) > 0.5 ? 1 : 0)], p > 0.72 ? "fl3" : p > 0.36 ? "fl2" : "fl1");    } else {      g.pixel(lx - 1, ly - 1, "n2");                                                // dull charcoal sheen      if (hash(id, 7) > 0.72) g.pixel(lx + 2, ly, "r0");                            // a dying ember in the gap    }  }}function coalBed(g, lit) {  const { cy, rx, ry } = MOUTH;  g.part("coals", () => {    g.field({ x: [CX - rx, CX + rx], y: [cy - ry - 4, cy + ry] }, (x, y) => {      const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry;      const top = -1 - 1.3 * Math.max(0, 1 - nx * nx * 2.2);          // the heap bulges above the rim      if (!(nx * nx + ny * ny <= 1 || (ny < 0 && ny >= top && Math.abs(nx) < 0.68))) return null;      if (!lit) {                                                      // grey ash settled between cold lumps        const ax = nx / 0.5, ay = (y + 0.5 - (cy - 1.5)) / 2.8;        return ax * ax + ay * ay <= 1 ? (ax < -0.2 && ay < -0.1 ? "n3" : "n2") : ny < 0 ? "n2" : "n1";      }      return Math.abs(nx) < 0.3 ? "fl2" : Math.abs(nx) < 0.65 ? "fl1" : "fl0";    });  });}// ---- fire ---------------------------------------------------------------------------------------// each flame: a teardrop with nested inner teardrops (body, bright, core) that are shorter and narrower,// so the inner layers are themselves flame-shaped. Side flames lean outward; the centre one is in front.const FLAMES = [  { name: "flameL", x: 17, base: 32, h: 18, hw: 4.6, lean: -2.5, seed: 0.31 },  { name: "flameR", x: 31, base: 32, h: 20, hw: 4.6, lean: 2.5, seed: 0.64 },  { name: "flameC", x: 24, base: 33, h: 28, hw: 7, lean: 0, seed: 0.05 },];const LAYERS = [["fl0", 1, 1], ["fl1", 0.88, 0.72], ["fl2", 0.62, 0.48], ["fl3", 0.36, 0.26]];   // key, height, widthconst profile = (k) => (k < 0.22 ? 0.7 + 0.3 * Math.sin((k / 0.22) * Math.PI / 2) : Math.pow((1 - k) / 0.78, 0.9));function flameHeight(f, t) {  return f.h * (1 + 0.08 * Math.sin(TAU * (2 * t + f.seed)) + 0.05 * Math.sin(TAU * (3 * t + 2 * f.seed)));}function flame(g, f, t) {  const hh = flameHeight(f, t);  g.part(f.name, () => g.field({ x: [2, 45], y: [f.base - 32, f.base] }, (x, y) => {    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.4 * Math.sin(TAU * (t + f.seed))) * k * k             + 1.0 * 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));        // bulges travel upward    const dx = Math.abs(x + 0.5 - ax);    let key = null;    for (const [c, hf, wf] of LAYERS) {      const kj = k / hf;      if (kj > 1) break;      if (dx <= f.hw * wf * profile(kj) * lick) key = c; else break;    }    return key;  }));}function fire(g, t) {  for (const f of FLAMES) flame(g, f, t);  // tongues tear off the tips and rise; embers drift up and cool  g.detached("tongues", () => {    for (const f of FLAMES) {      const p = (2 * t + f.seed) % 1, tip = f.base - flameHeight(f, t);      const x = f.x + Math.round(f.lean + 1.4 * Math.sin(TAU * (t + f.seed))), y = Math.round(tip - 1 - p * 7);      if (y < 1) 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");    }  });  g.detached("embers", () => {    for (let i = 0; i < 6; i++) {      const p = (t + i / 6) % 1;      const x = CX + Math.round((i - 2.5) * 4 + Math.sin(TAU * (p * 1.5 + hash(i, 1))) * 2.5);      const y = 26 - Math.round(p * 24);      if (p < 0.92 && y >= 0) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : p < 0.8 ? "fl1" : "fl0");    }  });}// ---- sprite -------------------------------------------------------------------------------------const Brazier = sprite(pal, (g, t, state) => {  const lit = state !== "unlit";  contactShadow(g, CX, 58, 21, 3);  g.outlined("ink", () => { backLeg(g, "legL", -1); backLeg(g, "legR", 1); });  g.outlined("ink", () => { bowl(g); rim(g, lit); });  g.outlined("ink", () => { coalBed(g, lit); g.part("coal lumps", () => lumps(g, t, lit, BACK_LUMPS, 0)); });  if (lit) fire(g, t);  g.part("front coals", () => lumps(g, t, lit, FRONT_LUMPS, 20));  g.outlined("ink", () => frontLeg(g));});submit(Brazier, {  size: [48, 64], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 24, y: 61 },  args: ["lit"],  variants: [    { name: "idle-fire" },    { name: "idle-soul", repalette: swap("fl", "soul") },    { name: "idle-witch", repalette: swap("fl", "witch") },    { name: "idle-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: 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.