Pixel art as code, line by line.
Four real programs from Aurum Nocturne’s asset packs, each beside the animation it renders. This is what AI pixel art means in Tessarune: an AI coding agent writes a short JavaScript program, and a deterministic renderer turns it into pixels.
The shape of every program
Procedural pixel art in Tessarune comes down to a handful of calls:
- palette({ … })
- Named colours. These packs share one 32-colour palette in four-step ramps: n0–n5 iron and stone, w wood, g brass, r fire, e green, b blue, v violet.
- sprite(pal, (g, t, …args) => …)
- The drawing function. g is the brush; t is time, running from 0 to 1 over the loop; extra arguments choose a state such as lit or unlit.
- g.part(name, …)
- Groups pixels under a name, so a person or an agent can find, check and change one part.
- g.field(box, (x, y) => colour)
- Asks a function for the colour of each pixel in a box. Curved surfaces, flames and light are shaded this way.
- g.outlined("ink", …)
- Draws a 1px outline round everything drawn inside it.
- submit(sprite, { size, animate, variants })
- Declares the canvas, frame rate and loop, and the list of variants to render.
The listings leave out what the packs’ build script puts in front of every program: a one-line runtime header, the shared palette and a few helpers (contactShadow, litIndex, flame, hash, chk, TAU). Line numbers are the real ones. The programs were written by an AI coding agent and revised after reviewing the rendered frames.
Cauldron: shading, a loop and three brews
The reference prop of Arcane Curiosities Vol.1. It shows the idioms every other prop follows: light from the top left, a 1px outline, a dithered contact shadow, and animation that repeats exactly once per loop.
6×


The brief, then the geometry. The opening comment is the request in plain words. Below it, the cauldron is a few ellipses written as numbers, and IRON is the four-step ramp its body uses.
// 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"];Shading a round body. g.field asks for the colour of every pixel in a box. litIndex treats the body as a sphere lit from the top left and returns a step from 0 to 3, which picks the iron colour. The underside takes a warm rim of firelight, and a small ellipse test places the highlight.
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]; });}A loop that closes. Each bubble has a phase, and (t + ph) % 1 runs from 0 to 1 once per loop: a dot, a blob, a ring of spray, then nothing. The last frame flows back into the first with no jump.
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"); } } });}Putting it together. Parts are drawn back to front, the solid ones inside g.outlined("ink"). submit sets the canvas, 10 fps over 1.2 seconds, the anchor at the feet, and three variants: swap("brew", "v") moves the brew colours onto the violet ramp. No new colours, no redrawing.
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") }, ],});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") }, ],});Wall torch: one function, six animations
A wall prop with four flame colours, an unlit state and an ignite animation. The states are arguments to one drawing function, so the torch itself is drawn once.
6×



States in the header. The comment lists every variant and its length. The ignite one-shot ends on the first frame of the lit loop, so a game can play them back to back.
// 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)A flame from four nested teardrops. LAYERS are the shell, body, bright and core. For each pixel, drawFlame measures how far up the flame it is and how far from the swaying axis, and keeps the innermost layer it falls inside. flameH makes the height flicker with two sine waves of t.
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; }));}Ignite as a table. GROW holds the flame’s size in each of the 8 frames: nothing, a spark, growth, a small overshoot, settled. g.depth keeps the nearer parts of the torch in front of the wall plate.
// ---- 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];⋯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); }});Six variants from one program. Fire, soulfire, witchfire and bile are ramp swaps of the fl flame colours; unlit and ignite pass a different state with their own timing.
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 — 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 } }, ],});Teleport rune: runes you can read in the source
A floor rune with dormant, activate and active states. The runes themselves are written as tiny text pictures.
6×


Glyphs as 3×3 text. Each rune is three strings, where x is a lit pixel. Twelve slots repeat four glyphs, so turning the band by four slots (120°) lands on the same picture — that is how the active loop closes.
// ---- runes: twelve slots, four glyphs repeating, so turning by four slots (120 deg) closes the loop ------const GLYPHS = [ ["xx.", "x.x", "x.."], // thorn ["x.x", ".x.", ".x."], // elk [".x.", "x.x", ".x."], // ing ["x.x", "xxx", "x.x"], // hagal];const runeAt = (k, off) => { const a = (k * 30 + off) * DEG; return { x: Math.round(CX + Math.cos(a) * RX * S_MID), y: Math.round(CY + Math.sin(a) * RY * S_MID), a: ((k * 30 + off) % 360 + 360) % 360 };};Stamping them. For each slot, runeAt finds its place on the ellipse, and the glyph’s x’s become pixels in whatever colour the current state gives.
function runes(g, off, lit) { g.part("runes", () => { for (let k = 0; k < 12; k++) { const r = runeAt(k, off), gl = GLYPHS[k % 4], key = lit(r.a, k); if (!key) continue; for (let j = 0; j < 3; j++) for (let i = 0; i < 3; i++) if (gl[j][i] === "x") g.pixel(r.x - 1 + i, r.y - 1 + j, key); } });}Light as a set of rules. The active state is an object of small functions: which colour the channels, the sigil and the runes take at a given angle and time. Two pulses ride round with the runes, half a turn apart.
function activeLight(t) { const p1 = (90 + 120 * t) % 360, p2 = (p1 + 180) % 360; // two pulses ride round with the runes return { groove: (x, y, a, c) => { if (c === G2) return adist(a, (270 - 120 * t + 360) % 360) < 20 ? "glow3" : "glow2"; return adist(a, p1) < 14 || adist(a, p2) < 14 ? "glow3" : "glow2"; }, sigil: () => "glow3", rune: () => "glow3", spill: true, pool: ["glow1", "glow2"], };}Choosing the state. Dormant, active and the frame-by-frame activation all feed the same dais, runes, light column and motes. The activation’s last frame hands over to the active loop.
const Rune = sprite(pal, (g, t, state) => { let L, off = 0, ct = t, colH = 0, bright = false, age = 1; if (state === "dormant") { L = dormantLight(t); age = -1; } else if (state === "active") { L = activeLight(t); off = 120 * t; colH = 34; } else { // activate: frame-indexed const f = Math.round(t * ACT); ct = ((f - (ACT - 1)) / 12 + 1) % 1; // same 10 fps clock as the active loop off = SPIN[f] % 360; if (f >= ACT - 1) L = activeLight(0); else L = activateLight(f); colH = [0, 0, 0, 0, 0, 22, 40, 36, 34, 34][f]; bright = f === 5 || f === 6; age = f >= 7 ? (f - 6) * 0.34 : -1; if (f < ACT - 1) spark(g, f); } contactShadow(g, CX, GROUND, 23, 2); g.outlined("ink", () => dais(g, L)); curbMarks(g); runes(g, off, (a) => L.rune(a)); if (colH > 0) column(g, ct, colH, bright); if (age > 0) motes(g, ct, age);});Show the whole program (254 lines)
// Teleport rune — 48x48 floor prop, top-down 3/4, key light top-left.// A low round dais of flagstones: a ring of curb stones, a carved channel inlaid with light, a recessed band of// twelve runes, an inner channel, and a compass-star sigil cut through the four inner flagstones.// Variants (all light in glow0..3; violet = swap("glow","v")):// dormant 12f loop runes barely lit; a slow shimmer walks once round the outer channel// activate 10f once a spark races round the channel lighting every rune, the runes whirl up, the sigil flares// and a column of light bursts up; ends on active frame 0// active 12f loop bright channels, the rune band turns (4 runes per loop), motes rise through a light columnconst W = 48, H = 48, CX = 24, CY = 30, RX = 22, RY = 13, GROUND = 45;const S_G1 = 0.87, S_G2 = 0.46; // outer / inner channel (ellipse scale)const S_MID = (S_G1 + S_G2) / 2; // rune band centrelineconst DEG = Math.PI / 180;// ---- static per-pixel map -----------------------------------------------------------------------------const NONE = 0, FACE = 1, CURB = 2, G1 = 3, BAND = 4, G2 = 5, INNER = 6;const rOf = (x, y) => Math.hypot((x - CX) / RX, (y - CY) / RY);const inS = (x, y, s) => rOf(x, y) <= s - 0.01; // the margin trims the 1px nubs at the polesconst ringPx = (x, y, s) => inS(x, y, s) && [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => !inS(x + a, y + b, s));const code = new Uint8Array(W * H), ang = new Float32Array(W * H);for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = y * W + x; ang[i] = ((Math.atan2((y - CY) / RY, (x - CX) / RX) / DEG) + 360) % 360; // 0 = right, 90 = front if (!inS(x, y, 1)) { code[i] = y > CY && (inS(x, y - 1, 1) || inS(x, y - 2, 1)) ? FACE : NONE; continue; } code[i] = ringPx(x, y, S_G1) ? G1 : inS(x, y, S_G1) === false ? CURB : ringPx(x, y, S_G2) ? G2 : inS(x, y, S_G2) ? INNER : BAND;}const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? NONE : code[y * W + x]);// band pixels touching a channel: they catch spill light when active; the channel's upper-left lip shades the bandconst nearG = (x, y) => [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => { const c = at(x + a, y + b); return c === G1 || c === G2; });// angular distance (deg, 0..180)const adist = (a, b) => { const d = Math.abs(((a - b) % 360 + 360) % 360); return Math.min(d, 360 - d); };// swept(a, from, len): is angle a inside the arc that starts at `from` and runs `len` degrees clockwise?const swept = (a, from, len) => ((a - from) % 360 + 360) % 360 <= len;// ---- runes: twelve slots, four glyphs repeating, so turning by four slots (120 deg) closes the loop ------const GLYPHS = [ ["xx.", "x.x", "x.."], // thorn ["x.x", ".x.", ".x."], // elk [".x.", "x.x", ".x."], // ing ["x.x", "xxx", "x.x"], // hagal];const runeAt = (k, off) => { const a = (k * 30 + off) * DEG; return { x: Math.round(CX + Math.cos(a) * RX * S_MID), y: Math.round(CY + Math.sin(a) * RY * S_MID), a: ((k * 30 + off) % 360 + 360) % 360 };};// ---- stone -------------------------------------------------------------------------------------------const stoneHash = (a) => hash(Math.floor(((a + 15) % 360) / 30), 5); // one value per curb stonefunction dais(g, L) { g.part("dais", () => g.field({ x: [1, 46], y: [15, 46] }, (x, y) => { const c = at(x, y), i = y * W + x, a = ang[i]; const nx = (x - CX) / RX; if (c === NONE) return null; if (c === FACE) { // the dais' side: lit on the left, falling away on the right const top = !inS(x, y - 1, 1) ? false : true; if (nx < -0.62) return top ? "n2" : "n1"; if (nx > 0.55) return "n0"; return top ? "n1" : "n0"; } if (c === CURB) { const edge = !inS(x - 1, y, 1) || !inS(x, y - 1, 1) || !inS(x + 1, y, 1) || !inS(x, y + 1, 1); if (edge && a > 150 && a < 290) return "n4"; // outer lip catches the light (upper left) if (edge && a > 10 && a < 110) return "n2"; // lower right lip in shade return stoneHash(a) < 0.3 ? "n2" : "n3"; } if (c === G1 || c === G2) return L.groove(x, y, a, c); if (c === BAND) { if (L.spill && nearG(x, y)) return "glow0"; // carved band: the upper-left wall of the channel shades the band just inside it const shade = at(x, y - 1) === G1 || (at(x - 1, y) === G1 && a > 180); return shade ? "n0" : "n1"; } // INNER: four flagstones cut by a glowing cross; a diamond sigil at the centre const dx = x - CX, dy = y - CY, r = rOf(x, y); const ring = ringPx(x, y, 0.24) && !(dx === 0 && dy === 0); const spoke = r > 0.26 && Math.abs(Math.abs(dx) / RX - Math.abs(dy) / RY) < 0.024; // the flagstone seams if (dx === 0 && dy === 0) return L.sigil(x, y, true); if (ring || spoke) return L.sigil(x, y, false); if (L.pool) return r < 0.24 ? L.pool[1] : L.pool[0]; // flat flagstones; the lower-right lip of each cut seam faces the light const cut = (u, v) => ringPx(u, v, 0.24) || (rOf(u, v) > 0.26 && Math.abs(Math.abs(u - CX) / RX - Math.abs(v - CY) / RY) < 0.024) || at(u, v) === G2; return cut(x - 1, y) || cut(x, y - 1) ? "n3" : "n2"; }));}function curbMarks(g) { g.part("curbSeams", () => { for (let j = 0; j < 12; j++) { const a = (j * 30 + 15) * DEG, c = Math.cos(a), s = Math.sin(a); const p0 = [Math.round(CX + c * RX * 0.92), Math.round(CY + s * RY * 0.92)]; const p1 = [Math.round(CX + c * RX * 0.99), Math.round(CY + s * RY * 0.99)]; g.line(p0, p1, "n1"); if (s > 0.2) { const fx = Math.round(CX + c * RX); const fy = Math.round(CY + s * RY) + 1; g.line([fx, fy], [fx, fy + 1], "ink"); } } g.pixel(8, 23, "n4"); g.pixel(37, 20, "n2"); g.pixel(41, 38, "n1"); g.pixel(12, 40, "n4"); // chips });}// ---- light layers --------------------------------------------------------------------------------------function runes(g, off, lit) { g.part("runes", () => { for (let k = 0; k < 12; k++) { const r = runeAt(k, off), gl = GLYPHS[k % 4], key = lit(r.a, k); if (!key) continue; for (let j = 0; j < 3; j++) for (let i = 0; i < 3; i++) if (gl[j][i] === "x") g.pixel(r.x - 1 + i, r.y - 1 + j, key); } });}const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];function column(g, t, hgt, bright) { // an opaque shaft of light standing on the inner disc: hot core, softer body, dim edges; bright bands (seen as // near arcs of a ring) climb it, a few sparks streak up, and the top dissolves in a dither. Periodic in t. const RC = bright ? 9 : 7; const TONE = ["glow1", "glow2", "glow3", "n5"]; g.detached("column", () => g.field({ x: [CX - 10, CX + 10], y: [0, CY + 6] }, (x, y) => { const h0 = CY - y; // height above the disc centre const wob = 1 + 0.04 * Math.sin(TAU * (h0 / 9 - 2 * t)); // the shaft breathes as it rises const dx = Math.abs(x - CX) / (RC * wob); if (dx > 1) return null; const arc = Math.sqrt(Math.max(0, 1 - dx * dx)); const yb = CY + Math.round(4.8 * arc); // front edge of the base const h = yb - y; // height above the floor if (h < 0 || h > hgt) return null; const top = hgt - h; // distance below the column's top const fade = top / (hgt * 0.45); // ordered-dither dissolve over the top 45% if (fade < 1 && BAYER[(y & 3) * 4 + (x & 3)] / 16 >= fade * fade * 0.9 + 0.05) return null; let k = dx < 0.3 ? 2 : dx < 0.72 ? 1 : 0; if (bright) k = dx < 0.8 ? 2 : 1; if (h === 0) k = Math.max(k, 2); // hot seam where the beam meets the floor for (let j = 0; j < 3; j++) { // bright bands climbing, curved like the near arc of a ring const hr = ((t + j / 3) % 1) * (hgt - 2); const d = y - (CY - Math.round(hr) + Math.round(2 * arc)); if (d === 0 || d === 1) { k += 1; break; } } if (!bright && dx > 0.3 && dx < 0.72 && hash(x, 11) > 0.5) { // sparks streaking up the body const s = ((y + Math.round(t * 24) + Math.floor(hash(x, 13) * 12)) % 12 + 12) % 12; if (s < 2) k = Math.max(k, 2); } return TONE[Math.min(3, k)]; }));}function motes(g, t, maxAge) { g.detached("motes", () => { for (let i = 0; i < 9; i++) { const p = (t * (i % 3 === 0 ? 2 : 1) + hash(i, 21)) % 1; if (p > maxAge) continue; const a = (hash(i, 23) * 360) * DEG, rr = 0.55 + 0.35 * hash(i, 29); const x = Math.round(CX + Math.cos(a) * RX * rr + Math.sin(TAU * (p + hash(i, 31))) * 1.2); const y = Math.round(CY + Math.sin(a) * RY * rr - p * 22); if (y < 0) continue; const key = p < 0.35 ? "glow3" : p < 0.7 ? "glow2" : "glow1"; g.pixel(x, y, key); if (p < 0.15) { g.pixel(x, y + 1, "glow2"); } } });}// ---- states --------------------------------------------------------------------------------------------function dormantLight(t) { const head = (90 + 360 * t) % 360; // the shimmer walks once round per loop return { groove: (x, y, a, c) => { if (c === G2) return "glow0"; const d = ((head - a) % 360 + 360) % 360; // how far behind the shimmer's head return d < 6 ? "glow3" : d < 22 ? "glow2" : d < 45 ? "glow1" : "glow0"; }, sigil: (x, y, centre) => (centre ? "glow1" : "glow0"), rune: (a) => (adist(a, head - 12) < 16 ? "glow2" : "glow1"), spill: false, pool: null, };}function activeLight(t) { const p1 = (90 + 120 * t) % 360, p2 = (p1 + 180) % 360; // two pulses ride round with the runes return { groove: (x, y, a, c) => { if (c === G2) return adist(a, (270 - 120 * t + 360) % 360) < 20 ? "glow3" : "glow2"; return adist(a, p1) < 14 || adist(a, p2) < 14 ? "glow3" : "glow2"; }, sigil: () => "glow3", rune: () => "glow3", spill: true, pool: ["glow1", "glow2"], };}const ACT = 10;const SPIN = [0, 5, 15, 30, 50, 70, 88, 102, 112, 120]; // rune whirl during activation (ends = 4 slots)function activateLight(f) { const d = dormantLight(0); const len = Math.min(360, 72 * (f + 1)); // the racing spark's swept arc from the front const head = (90 + len) % 360; const lit = (a) => len >= 360 || swept(a, 90, len); return { groove: (x, y, a, c) => { if (c === G2) return f >= 5 ? "glow2" : f === 4 ? "glow1" : "glow0"; if (len < 360 && adist(a, head) < 9) return "glow3"; if (f === 4 && len >= 360) return "glow3"; // the ring closes with a flash return lit(a) ? "glow2" : d.groove(x, y, a, c); }, sigil: (x, y, centre) => (f >= 5 ? "glow3" : f === 4 ? "glow2" : d.sigil(x, y, centre)), rune: (a) => (len < 360 && adist(a, head) < 20 ? "glow3" : lit(a) ? (f >= 4 ? "glow3" : "glow2") : "glow1"), spill: f >= 5, pool: f >= 6 ? ["glow1", "glow2"] : f === 5 ? ["glow0", "glow1"] : null, };}function spark(g, f) { // the spark at the head of the racing light: a small star if (f > 3) return; const a = (90 + 72 * (f + 1)) * DEG; const x = Math.round(CX + Math.cos(a) * RX * S_G1), y = Math.round(CY + Math.sin(a) * RY * S_G1); g.detached("spark", () => { for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "glow3"); g.pixel(x, y - 2, "glow2"); g.pixel(x, y, "n5"); });}const Rune = sprite(pal, (g, t, state) => { let L, off = 0, ct = t, colH = 0, bright = false, age = 1; if (state === "dormant") { L = dormantLight(t); age = -1; } else if (state === "active") { L = activeLight(t); off = 120 * t; colH = 34; } else { // activate: frame-indexed const f = Math.round(t * ACT); ct = ((f - (ACT - 1)) / 12 + 1) % 1; // same 10 fps clock as the active loop off = SPIN[f] % 360; if (f >= ACT - 1) L = activeLight(0); else L = activateLight(f); colH = [0, 0, 0, 0, 0, 22, 40, 36, 34, 34][f]; bright = f === 5 || f === 6; age = f >= 7 ? (f - 6) * 0.34 : -1; if (f < ACT - 1) spark(g, f); } contactShadow(g, CX, GROUND, 23, 2); g.outlined("ink", () => dais(g, L)); curbMarks(g); runes(g, off, (a) => L.rune(a)); if (colH > 0) column(g, ct, colH, bright); if (age > 0) motes(g, ct, age);});submit(Rune, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: CX, y: GROUND }, args: ["dormant"], variants: [ { name: "dormant" }, { name: "activate", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false } }, { name: "active", args: ["active"] }, { name: "dormant-violet", repalette: swap("glow", "v") }, { name: "activate-violet", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false }, repalette: swap("glow", "v") }, { name: "active-violet", args: ["active"], repalette: swap("glow", "v") }, ],});Spike trap: animation as a table of numbers
Eight spikes that are one rig: each spike is the same shaded cone at a different visible length.
6×

Four states, one rig. Idle, trigger, armed and retract are the same eight spikes at different heights.
// Spike trap — 32x32 floor tile, top-down 3/4, key light top-left.// A riveted iron plate set into the floor: a lit frame round a recessed panel pierced by 8 holes (3-2-3, staggered).// The spikes are one rig: each spike is a shaded cone whose visible length h slides out of its hole, so every// state is the same spikes at a different h, occluded back row to front row.// Variants:// idle loop 8f — spikes hidden; a faint glint winks from hole to hole// trigger one-shot 8f — spikes shoot up past full height, dust puffs out from the plate, spikes settle (== armed f0)// armed loop 8f — spikes up; a glint runs along the tips left to right// retract one-shot 6f — spikes sink back into the holes (== idle f0)const CX = 16, GROUND = 29;const PLATE = { x0: 2, x1: 30, y0: 8, y1: 26 }; // top face; the front face is 2px below itconst FULL = 7; // full spike length (px above the hole)const N_LOOP = 8, N_TRIG = 8, N_RET = 6;The motion is a list. TRIG holds the spike height and dust stage for each of the 8 trigger frames: 0, then 9 — past the full height of 7 — back to 6, up to 8, settling on 7. RET sinks them again. Tuning the snap means editing these numbers.
// ---- states -------------------------------------------------------------------------------------// trigger: spikes (h), dust stage per frameconst TRIG = [[0, 0], [9, 1], [6, 2], [8, 3], [7, 4], [7, 0], [7, 0], [7, 0]];const RET = [7, 6, 4, 1, 0, 0];States in the drawing function. Each state picks a frame from t, sets the spike heights and adds the glint or the dust. submit gives the two loops and the two one-shots their own timing.
const SpikeTrap = sprite(pal, (g, t, state) => { contactShadow(g, CX, GROUND, 15, 2); g.outlined("ink", () => plate(g)); if (state === "idle") { const f = Math.round(t * N_LOOP) % N_LOOP; holes(g, holeGlint(f)); return; } holes(g, null); if (state === "armed") { const f = Math.round(t * N_LOOP) % N_LOOP; // frame 0 rests; frames 1..7 the glint walks the tips by rank const lit = (i) => f >= 1 && sweep(RANK[i]) === f; spikes(g, () => FULL, (i) => (lit(i) ? "n5" : null)); g.detached("glint", () => { HOLES.forEach((h, i) => { if (f >= 1 && sweep(RANK[i]) === f && (RANK[i] === 7 || sweep(RANK[i] + 1) !== f)) star(g, h.x, h.y + 1 - (FULL - 1), 1); }); }); return; } if (state === "trigger") { const f = Math.min(N_TRIG - 1, Math.round(t * N_TRIG)); const [h, d] = TRIG[f]; spikes(g, () => h); dust(g, d); return; } const f = Math.min(N_RET - 1, Math.round(t * N_RET)); spikes(g, () => RET[f]);});submit(SpikeTrap, { size: [32, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "trigger", args: ["trigger"], animate: { fps: 10, duration: N_TRIG / 10, loop: false } }, { name: "armed", args: ["armed"] }, { name: "retract", args: ["retract"], animate: { fps: 10, duration: N_RET / 10, loop: false } }, ],});Show the whole program (207 lines)
// Spike trap — 32x32 floor tile, top-down 3/4, key light top-left.// A riveted iron plate set into the floor: a lit frame round a recessed panel pierced by 8 holes (3-2-3, staggered).// The spikes are one rig: each spike is a shaded cone whose visible length h slides out of its hole, so every// state is the same spikes at a different h, occluded back row to front row.// Variants:// idle loop 8f — spikes hidden; a faint glint winks from hole to hole// trigger one-shot 8f — spikes shoot up past full height, dust puffs out from the plate, spikes settle (== armed f0)// armed loop 8f — spikes up; a glint runs along the tips left to right// retract one-shot 6f — spikes sink back into the holes (== idle f0)const CX = 16, GROUND = 29;const PLATE = { x0: 2, x1: 30, y0: 8, y1: 26 }; // top face; the front face is 2px below itconst FULL = 7; // full spike length (px above the hole)const N_LOOP = 8, N_TRIG = 8, N_RET = 6;// holes: [cx, top row y] back row first; 3-2-3 staggeredconst ROWS = [ { y: 11, xs: [8, 16, 24] }, { y: 16, xs: [12, 20] }, { y: 21, xs: [8, 16, 24] },];const HOLES = [];for (const r of ROWS) for (const x of r.xs) HOLES.push({ x, y: r.y });// rank of each hole in a diagonal sweep from the top-left (glint order)const RANK = new Array(HOLES.length);HOLES.map((h, i) => [h.x + 1.4 * h.y, i]).sort((a, b) => a[0] - b[0]).forEach((a, r) => { RANK[a[1]] = r; });// ---- plate --------------------------------------------------------------------------------------function plate(g) { const { x0, x1, y0, y1 } = PLATE; g.part("plate", () => { g.field({ x: [x0, x1], y: [y0, y1 + 2] }, (x, y) => { if (y > y1) { // front face (thickness): lit at the left end if (y === y1 + 2) return x < x0 + 3 ? "n1" : "n0"; return x < x0 + 3 ? "n2" : "n1"; } const dx0 = x - x0, dx1 = x1 - x, dy0 = y - y0, dy1 = y1 - y; if (dx0 < 2 || dx1 < 2 || dy0 < 2 || dy1 < 2) { // raised frame: top & left bands face the light, the right and front bands turn away if (dy0 === 0) return dx1 < 2 ? "n3" : "n4"; if (dx0 === 0) return dy1 < 2 ? "n3" : "n4"; if (dx1 === 0) return "n1"; if (dy1 === 0) return "n1"; if (dx0 === 1 || dy0 === 1) return dx1 < 2 ? "n2" : "n3"; return "n2"; } // recessed panel: the frame shades its top and left walls, the lower lip catches the light if (dy0 === 2) return "n0"; if (dx0 === 2) return "n0"; if (dy1 === 2) return "n3"; if (dx1 === 2) return "n2"; // a soft falloff: the panel is a touch brighter toward the top-left return dx0 + 1.4 * dy0 < 15 ? "n2" : "n1"; }); // rivets on the frame corners and mid-sides for (const [x, y] of [[x0 + 1, y0 + 1], [x1 - 2, y0 + 1], [x0 + 1, y1 - 1], [x1 - 2, y1 - 1], [CX - 1, y0 + 1], [CX - 1, y1 - 1]]) { g.pixel(x, y, x < CX && y < y1 - 1 ? "n5" : "n4"); g.pixel(x + 1, y, "n1"); } }); g.part("stain", () => { // old blood dried round one hole on the front row for (const [x, y, k] of [[17, 25, "r0"], [18, 25, "w0"], [14, 25, "w0"], [19, 22, "r0"], [19, 23, "w0"]]) g.pixel(x, y, k); });}function holes(g, glint) { g.part("holes", () => { HOLES.forEach((h, i) => { const { x, y } = h; g.pixel(x - 1, y, "ink"); g.pixel(x, y, "ink"); g.pixel(x + 1, y, "n0"); g.pixel(x - 1, y + 1, "n0"); g.pixel(x, y + 1, "ink"); g.pixel(x + 1, y + 1, "n0"); g.line([x - 1, y + 2], [x + 1, y + 2], "n3"); // lower lip catches the light const k = glint ? glint(i) : 0; // a hidden tip catching the light if (k > 0) g.pixel(x, y + 1, k > 1 ? "n4" : "n3"); }); });}// ---- spikes -------------------------------------------------------------------------------------// cone profile from the tip down: keys for columns cx-1, cx, cx+1 (null = empty); lit on the leftfunction spikeRow(k) { if (k === 0) return [null, "n5", null]; if (k === 1) return [null, "n4", null]; if (k < 3) return ["n4", "n2", null]; return ["n4", "n3", "n1"];}function spikePixels(cx, hy, h, tip, blood) { // h = visible length above the hole; hy = the hole's top row (the spike stands in its lower row) const base = hy + 1, n = Math.round(h), out = []; for (let r = 0; r < n; r++) { const y = base - (n - 1) + r, row = spikeRow(r); const collar = r === n - 1 && n > 2; // where it leaves the hole: a darker band for (let c = 0; c < 3; c++) { let key = row[c]; if (!key) continue; if (collar) key = c === 0 ? "n2" : c === 1 ? "n1" : "n0"; if (r <= 1 && tip) key = tip; if (blood && r >= 2 && r <= 3) key = c === 0 ? "r1" : "r0"; if (blood && r === 4 && c === 1) key = "r0"; out.push([cx + c - 1, y, key]); } } return { base, px: out };}function spikes(g, hs, tipAt) { // each spike carries its own ink outline, drawn back row to front row, so a front spike is cut out against the // ones behind it (g.outlined only rings empty pixels; here the spikes stand over the plate) g.part("spikes", () => HOLES.forEach((hole, i) => { const h = hs(i); if (Math.round(h) <= 0) return; const { base, px } = spikePixels(hole.x, hole.y, h, tipAt ? tipAt(i) : null, i === 6); const has = new Set(px.map(([x, y]) => x + "," + y)); for (const [x, y] of px) for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const qx = x + dx, qy = y + dy; if (qy > base || has.has(qx + "," + qy)) continue; g.pixel(qx, qy, "ink"); } for (const [x, y, k] of px) g.pixel(x, y, k); }));}function star(g, x, y, size) { g.pixel(x, y, "n5"); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + dx, y + dy, size > 1 ? "n5" : "n4"); if (size > 1) for (const [dx, dy] of [[2, 0], [-2, 0], [0, -2]]) g.pixel(x + dx, y + dy, "n4");}// ---- dust ---------------------------------------------------------------------------------------// puffs thrown out from under the plate's edges; stage 1..4: burst, billow, thin out, last motesconst PUFFS = [[3, 21, -1], [28, 19, 1], [7, 28, -1], [24, 28, 1]];const CLOUD = [ null, [" 54 ", " 4443"], [" 544 ", "544443", " 3443 "], [" 4 4 ", "4 4 43", " 3 3 "], [" 3 ", "3 3", " 3 "],];function dust(g, stage) { if (stage <= 0 || stage > 4) return; const rows = CLOUD[stage]; g.detached("dust", () => { for (const [px, py, s] of PUFFS) { const w = rows[0].length, x0 = Math.max(0, Math.min(31 - w, px + s * (stage - 1) - (w >> 1))); const y0 = py - rows.length + 1 - Math.floor((stage - 1) / 2); rows.forEach((row, j) => { for (let i = 0; i < row.length; i++) { const c = row[i]; if (c !== " ") g.pixel(x0 + i, y0 + j, "n" + c); } }); } });}// ---- states -------------------------------------------------------------------------------------// trigger: spikes (h), dust stage per frameconst TRIG = [[0, 0], [9, 1], [6, 2], [8, 3], [7, 4], [7, 0], [7, 0], [7, 0]];const RET = [7, 6, 4, 1, 0, 0];const sweep = (rank) => 1 + Math.floor((rank * 7) / HOLES.length); // frame (1..7) in which a rank is litfunction holeGlint(f) { // frame 0 rests (so the one-shots can join it); frames 1..7 the glint crosses the holes diagonally if (f < 1) return null; return (i) => (sweep(RANK[i]) === f ? 2 : sweep(RANK[i]) === f - 1 ? 1 : 0);}const SpikeTrap = sprite(pal, (g, t, state) => { contactShadow(g, CX, GROUND, 15, 2); g.outlined("ink", () => plate(g)); if (state === "idle") { const f = Math.round(t * N_LOOP) % N_LOOP; holes(g, holeGlint(f)); return; } holes(g, null); if (state === "armed") { const f = Math.round(t * N_LOOP) % N_LOOP; // frame 0 rests; frames 1..7 the glint walks the tips by rank const lit = (i) => f >= 1 && sweep(RANK[i]) === f; spikes(g, () => FULL, (i) => (lit(i) ? "n5" : null)); g.detached("glint", () => { HOLES.forEach((h, i) => { if (f >= 1 && sweep(RANK[i]) === f && (RANK[i] === 7 || sweep(RANK[i] + 1) !== f)) star(g, h.x, h.y + 1 - (FULL - 1), 1); }); }); return; } if (state === "trigger") { const f = Math.min(N_TRIG - 1, Math.round(t * N_TRIG)); const [h, d] = TRIG[f]; spikes(g, () => h); dust(g, d); return; } const f = Math.min(N_RET - 1, Math.round(t * N_RET)); spikes(g, () => RET[f]);});submit(SpikeTrap, { size: [32, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "trigger", args: ["trigger"], animate: { fps: 10, duration: N_TRIG / 10, loop: false } }, { name: "armed", args: ["armed"] }, { name: "retract", args: ["retract"], animate: { fps: 10, duration: N_RET / 10, loop: false } }, ],});Want your agent to write programs like these?
Tessarune gives it the drawing API reference, 25 production manuals and a renderer to check its work against.