Tessarune
Dungeon Mechanisms · wall_torch.js

Wall torch

32×48 · 195 lines of JavaScript

A 32×48 wall prop in 3/4 view with no contact shadow: a riveted iron wall plate and a collar ring holding a wooden torch, its tarred, cloth-wrapped head set in a three-prong iron cup, with a living flame of layered tongues and embers on top. “lit-fire”, “lit-soul”, “lit-witch” and “lit-bile” are 12-frame loops made by swapping one fire ramp; “unlit” (8 frames) sends a thin smoke wisp up from the charred head; “ignite” (8 frames, once) catches a spark, grows the flame and ends on the first frame of “lit-fire”.

Wall torch — Rendered output6×
Rendered by this program, variant “ignite” · 32×48 · 8 frames
Dungeon MechanismsGet 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.

wall_torch.js · 1–5
// Wall torch — 32x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A riveted iron wall plate; a collar ring holds a wooden torch whose tarred, cloth-wrapped head sits in a// three-prong iron cup. A living flame (layered teardrops + side tongues + embers) burns on top.// Variants: lit-fire / lit-soul / lit-witch / lit-bile (12f loop, fl ramp swap), unlit (8f loop: a thin smoke// wisp from the charred head), ignite (8f one-shot: a spark catches, the flame grows; ends on lit-fire frame 0).

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

wall_torch.js · 182–195
submit(WallTorch, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 16, y: 46 },  args: ["lit"],  variants: [    { name: "lit-fire" },    { name: "lit-soul", repalette: swap("fl", "soul") },    { name: "lit-witch", repalette: swap("fl", "witch") },    { name: "lit-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 0.8, loop: true } },    { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: 0.8, loop: false } },  ],});
Show the whole program (195 lines)
wall_torch.js · 195 lines
// Wall torch — 32x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A riveted iron wall plate; a collar ring holds a wooden torch whose tarred, cloth-wrapped head sits in a// three-prong iron cup. A living flame (layered teardrops + side tongues + embers) burns on top.// Variants: lit-fire / lit-soul / lit-witch / lit-bile (12f loop, fl ramp swap), unlit (8f loop: a thin smoke// wisp from the charred head), ignite (8f one-shot: a spark catches, the flame grows; ends on lit-fire frame 0).const W = 32, H = 48, CX = 16;const HEAD = { top: 19, bot: 27, hw0: 4.9, hw1: 2.5 };   // cloth head flares upward: half-width at top / bottomconst BASE = 19;                                          // flame root (head top)// ---- iron wall plate (a small shield-shaped escutcheon behind the collar) --------------------------const PLATE = [[11, 29], [21, 29], [22, 30], [22, 39], [16, 45], [10, 39], [10, 30]];function plate(g) {  g.part("plate", () => {    g.fillPoly(PLATE, "n2");    g.line([11, 29], [20, 29], "n4"); g.line([10, 30], [10, 38], "n3");       // lit bevel, top and left    g.line([22, 30], [22, 38], "n1"); g.line([21, 40], [17, 44], "n1");       // shaded bevel, right and lower right    g.line([11, 40], [15, 44], "n3");    g.line([18, 34], [18, 40], "n1");                                         // the handle's cast shadow    for (const [x, y] of [[12, 31], [20, 31], [16, 42]]) { g.pixel(x, y, "n4"); g.pixel(x + 1, y + 1, "n0"); }    g.pixel(13, 37, "n1"); g.pixel(20, 36, "n3");                            // a pit and a scuff  });}// ---- torch ----------------------------------------------------------------------------------------function handle(g) {  g.part("handle", () => {    g.sweep([[16, 27], [16, 43]], { w0: 1.9, w1: 1.2 }, (u, v) => (v < -0.35 ? "w3" : v > 0.35 ? "w1" : "w2"));    g.pixel(16, 38, "w1"); g.pixel(16, 39, "w1"); g.pixel(15, 36, "w4");      // grain, a lit knot  });}function ringBack(g) {  g.part("collarBack", () => { g.line([13, 31], [19, 31], "n1"); g.pixel(12, 32, "n2"); g.pixel(20, 32, "n1"); });}function ringFront(g) {  g.part("collar", () => {    g.line([12, 33], [20, 33], "n2");    g.line([13, 34], [19, 34], "n1");    g.line([12, 33], [14, 33], "n4"); g.pixel(15, 34, "n3");                   // lit shoulder    g.pixel(19, 34, "n0"); g.pixel(20, 33, "n1");  });}function head(g, lit, t) {  const hwAt = (y) => HEAD.hw0 + (HEAD.hw1 - HEAD.hw0) * Math.pow((y - HEAD.top) / (HEAD.bot - HEAD.top), 0.8);  const TWINE = [22, 25];  g.part("head", () => g.field({ x: [10, 22], y: [HEAD.top, HEAD.bot] }, (x, y) => {    const hw = hwAt(y), u = (x + 0.5 - CX) / hw;    if (Math.abs(u) > 1) return null;    if (y === HEAD.top) {                                       // charred crown where the fire sits      if (!lit) return Math.abs(u) < 0.6 ? "n0" : "w0";      return Math.abs(u) < 0.7 ? "fl1" : "fl0";    }    if (TWINE.includes(y)) return u < -0.4 ? "w4" : u < 0.35 ? "w3" : "w2";  // twine bindings    // tarred cloth wrapped in slanted strips, the cone lit from the left    const s = (y - HEAD.top + 1.3 * u) / 1.6;    const seam = s - Math.floor(s) < 0.34;    let k = u < -0.4 ? 2 : u < 0.45 ? 1 : 0;    if (seam) k = Math.max(0, k - 1);    if (y === HEAD.top + 1 && lit && Math.abs(u) < 0.95) k = Math.min(3, k + 1);   // underglow from the flame    return ["w0", "w1", "w2", "w3"][k];  }));  if (lit) g.part("headEmbers", () => {                        // embers smoulder in the top wrap    for (const [x, ph] of [[13, 0.1], [16, 0.45], [19, 0.8]]) {      const p = 0.5 + 0.5 * Math.sin(TAU * (t + ph));      g.pixel(x, HEAD.top + 1, p > 0.55 ? "fl2" : "fl0");    }  });  else g.part("ash", () => { g.pixel(14, HEAD.top, "n2"); g.pixel(18, HEAD.top, "n1"); g.pixel(17, HEAD.top + 1, "n0"); });}// ---- flame (layered teardrops, like the brazier) ---------------------------------------------------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: "tongueL", x: 13.2, base: BASE, h: 7, hw: 1.9, lean: -1.6, seed: 0.37 },  { name: "tongueR", x: 18.8, base: BASE, h: 8, hw: 1.9, lean: 1.6, seed: 0.71 },  { name: "flame", x: 16, base: BASE + 1, h: 18, hw: 4.8, lean: 0, seed: 0.08 },];const flameH = (f, t, s) => s * f.h * (1 + 0.09 * Math.sin(TAU * (2 * t + f.seed)) + 0.05 * Math.sin(TAU * (3 * t + 2 * f.seed)));function drawFlame(g, f, t, s) {  const hh = flameH(f, t, s), hwS = f.hw * Math.min(1, 0.45 + 0.55 * s);  if (hh < 1.5) return;  g.part(f.name, () => g.field({ x: [2, 29], y: [Math.max(0, f.base - 24), 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.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 (const [c, hf, wf] of LAYERS) {      const 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) if (s > 0.55 || f.name === "flame") drawFlame(g, f, t, f.name === "flame" ? s : (s - 0.55) / 0.45);  const main = FLAMES[2];  g.detached("flicks", () => {                                  // tongues tear off the tip and rise    if (s < 0.8) return;    const p = (2 * t + 0.3) % 1, tip = main.base - flameH(main, t, s);    const x = main.x + Math.round(1.2 * Math.sin(TAU * (t + main.seed))), y = Math.round(tip - 1 - p * 5);    if (y < 0) return;    if (p < 0.4) { g.pixel(x, y, "fl1"); g.pixel(x, y - 1, "fl0"); }    else if (p < 0.7) g.pixel(x, y, "fl0");  });  g.detached("embers", () => {    if (s < 0.6) return;    for (let i = 0; i < 3; i++) {      const p = (t + i / 3) % 1;      const x = CX + Math.round((i - 1) * 3 + Math.sin(TAU * (p * 1.3 + hash(i, 4))) * 2);      const y = BASE - 6 - Math.round(p * 14);      if (p < 0.85 && y >= 0) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : "fl1");    }  });}// ---- smoke wisp (unlit) -----------------------------------------------------------------------------function smoke(g, t, fade) {  g.detached("smoke", () => {    for (let y = BASE - 1; y >= 3; y--) {      const k = (BASE - 1 - y) / 15;                            // 0 at the head, 1 at the top      if (k > fade) continue;      const x = CX + Math.round(Math.sin(TAU * (k * 1.4 - t)) * (0.4 + 2.2 * k) + 1.5 * k);      const puff = (k * 2 - t + 2) % 1;                        // puffs travel upward; gaps between them      if (puff > 0.78 && k > 0.15) continue;      if (k > 0.55 && chk(x, y)) continue;                     // the upper wisp thins to a dither      g.pixel(x, y, k < 0.3 ? "n3" : "n2");    }  });}// ---- ignite: spark -> ember -> flame grows (frame-indexed; the last frame is lit-fire t=0) -----------const IGN = 8;const GROW = [0, 0.14, 0.3, 0.5, 0.72, 1.12, 1.04, 1];function spark(g, f) {  g.detached("spark", () => {    if (f === 0) {      const [x, y] = [18, 16];      for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "fl2");      g.pixel(x + 2, y, "fl0"); g.pixel(x - 2, y, "fl0"); g.pixel(x, y - 2, "fl0");      g.pixel(x, y, "fl3");      g.pixel(20, 14, "fl1"); g.pixel(22, 12, "fl0");           // the trail it came in on    } else if (f <= 3) {                                       // kindling sparks spit upward      for (let i = 0; i < 3; i++) {        const x = CX + [-3, 1, 3][i] + (f - 1) * [-1, 0, 1][i], y = BASE - 3 - f * 3 - i * 2;        g.pixel(x, y, f < 3 ? "fl2" : "fl1");      }    }  });}const WallTorch = sprite(pal, (g, t, state) => {  let lit = state === "lit", grow = 1, ft = t, f = 0;  if (state === "ignite") {    f = Math.round(t * IGN);    grow = GROW[f];    lit = f >= 1;    ft = ((f - (IGN - 1)) / 12 + 1) % 1;                        // same 10 fps clock as the lit loop  }  // nearer parts first at depth -1, so their ink outline survives the plate drawn behind them  g.depth(-1, () => {    g.outlined("ink", () => {      g.depth(1, () => ringBack(g));      handle(g);      ringFront(g);      head(g, lit, ft);    });  });  g.depth(1, () => g.outlined("ink", () => plate(g)));  if (lit) g.depth(-2, () => fire(g, ft, grow));  if (state === "unlit") smoke(g, t, 1);  if (state === "ignite") {    if (f <= 2) smoke(g, 0, [1, 0.6, 0.3][f]);    spark(g, f);  }});submit(WallTorch, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 16, y: 46 },  args: ["lit"],  variants: [    { name: "lit-fire" },    { name: "lit-soul", repalette: swap("fl", "soul") },    { name: "lit-witch", repalette: swap("fl", "witch") },    { name: "lit-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 0.8, loop: true } },    { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: 0.8, 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.