Tessarune
Tavern & Market · roast_spit.js

Roast spit

48×48 · 215 lines of JavaScript

A 48×48 floor prop in top-down 3/4: an open cookfire in a ring of stones, two forked iron stands and an iron spit with a crank, with a twine-trussed ham on it. The ham turns: its scored crackling, twine knots and fat beads travel round the body while the lighting stays fixed, so the beads flash as they pass the highlight. “idle” (16 frames, loop) is one full turn, with the crank handle circling and fat drips falling into the fire to sizzle; “empty” (12 frames, loop) is the bare spit with the fire only.

Roast spit — Rendered output6×
Rendered by this program, variant “idle” · 48×48 · 16 frames
Tavern & MarketGet the animated sprites: Tavern & Market on itch.ioVol.3 · 12 tavern and market props · 32 animations

The header. The comment at the top of the file is the brief: size, view, light direction and every variant.

roast_spit.js · 1–5
// Roast spit — 48x48 floor prop, top-down 3/4, key light top-left.// An open cookfire in a ring of stones, two forked iron stands, and an iron spit with a crank; a twine-trussed ham// roasts on it. The ham TURNS: its scored crackling, twine knots and fat beads travel round the body (lighting stays// fixed, so the beads flash as they pass through the highlight), the crank handle circles, and fat drips fall into// the fire and sizzle. Variants: idle (16f loop, one full turn), empty (12f loop: bare spit, fire only).

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

roast_spit.js · 206–215
submit(Roast, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "empty", args: ["empty"], animate: { fps: 10, duration: 1.2, loop: true } },  ],});
Show the whole program (215 lines)
roast_spit.js · 215 lines
// Roast spit — 48x48 floor prop, top-down 3/4, key light top-left.// An open cookfire in a ring of stones, two forked iron stands, and an iron spit with a crank; a twine-trussed ham// roasts on it. The ham TURNS: its scored crackling, twine knots and fat beads travel round the body (lighting stays// fixed, so the beads flash as they pass through the highlight), the crank handle circles, and fat drips fall into// the fire and sizzle. Variants: idle (16f loop, one full turn), empty (12f loop: bare spit, fire only).const W = 48, H = 48, CX = 24, GROUND = 44;const AXIS = 20.5;                                  // spit axis (screen y)const HAM = { x0: 10, x1: 33, r: 7.2, shank: 2.6 };const PIT = { cx: 24, cy: 40, rx: 13, ry: 4 };const MEAT = ["w0", "w1", "w2", "w3", "w4"];// ham half-height along the spit: a round rump on the left tapering to the shank on the rightfunction hamR(x) {  const u = (x + 0.5 - HAM.x0) / (HAM.x1 - HAM.x0);  if (u < 0 || u > 1) return -1;  if (u < 0.42) return HAM.r * Math.sqrt(Math.max(0, 1 - ((0.42 - u) / 0.42) ** 2));  return HAM.shank + (HAM.r - HAM.shank) * (1 - Math.pow((u - 0.42) / 0.58, 1.7));}// ---- fire pit -------------------------------------------------------------------------------------------const STONES = [];for (let i = 0; i < 11; i++) {  const a = (i / 11) * TAU + 0.2;  STONES.push({ x: Math.round(PIT.cx + Math.cos(a) * PIT.rx), y: Math.round(PIT.cy + Math.sin(a) * PIT.ry), front: Math.sin(a) > 0.05, id: i });}function stone(g, s) {  const w = s.front ? 3 : 2, hgt = s.front ? 2 : 1.5;  g.field({ x: [s.x - w, s.x + w], y: [Math.floor(s.y - hgt - 1), Math.ceil(s.y + hgt)] }, (x, y) => {    const i = litIndex(x, y, s.x + 0.5, s.y, w + 0.5, hgt + 0.6, 4, 0.1 + (hash(s.id, 2) - 0.5) * 0.3);    return i < 0 ? null : ["n1", "n2", "n2", "n3"][i];  });}function pitBed(g, lit, t) {  g.part("ashBed", () => {    g.ellipse({ x: [PIT.cx - 11, PIT.cx + 11], y: [PIT.cy - 3, PIT.cy + 3] }, "n1", { fill: "n1" });    g.ellipse({ x: [PIT.cx - 8, PIT.cx + 8], y: [PIT.cy - 2, PIT.cy + 2] }, lit ? "r0" : "n0", { fill: lit ? "r0" : "n0" });    if (lit) for (let i = 0; i < 7; i++) {      const x = PIT.cx - 6 + i * 2, y = PIT.cy - 1 + (i % 3 === 1 ? 1 : 0);      const p = 0.5 + 0.5 * Math.sin(TAU * (t + hash(i, 5)));      g.pixel(x, y, p > 0.65 ? "r2" : "r1");    }  });}function logs(g, t) {  const crack = (id) => (0.5 + 0.5 * Math.sin(TAU * (t + hash(id, 3))) > 0.5 ? "fl2" : "fl1");  g.part("logs", () => {    g.sweep([[14, 42], [31, 37]], { w0: 1.6, w1: 1.3 }, (u, v) => (v < -0.3 ? "w2" : v > 0.45 ? "w0" : "w1"));    g.sweep([[34, 42], [17, 37]], { w0: 1.6, w1: 1.3 }, (u, v) => (v < -0.3 ? "w2" : v > 0.45 ? "w0" : "w1"));    for (const [x, y] of [[14, 42], [34, 42]]) { g.ellipse({ x: [x - 1, x + 1], y: [y - 1, y + 1] }, "w3", { fill: "w3" }); g.pixel(x, y, "w1"); }    g.pixel(19, 40, crack(1)); g.pixel(20, 40, crack(1)); g.pixel(29, 40, crack(2)); g.pixel(24, 38, "n0"); g.pixel(26, 39, "n0");  });}// ---- flames (layered teardrops) ----------------------------------------------------------------------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: "flameL", x: 18.5, base: 40, h: 9, hw: 3.4, lean: -1.2, seed: 0.37 },  { name: "flameR", x: 29.5, base: 40, h: 10, hw: 3.4, lean: 1.2, seed: 0.68 },  { name: "flameC", x: 24, base: 41, h: 12, hw: 5, lean: 0, seed: 0.05 },];function drawFlame(g, f, t, boost) {  const hh = f.h * (1 + 0.1 * Math.sin(TAU * (2 * t + f.seed)) + 0.06 * Math.sin(TAU * (3 * t + 2 * f.seed)) + boost);  g.part(f.name, () => g.field({ x: [6, 42], y: [Math.max(0, f.base - 22), 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.1 * 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 <= f.hw * wf * profile(kj) * lick) key = c; else break;    }    return key;  }));}// ---- stands, spit, crank -------------------------------------------------------------------------------function stand(g, x, name) {  // x = left column of the 2px pole  g.part(name, () => {    g.line([x, 22], [x, 38], "n3"); g.line([x + 1, 22], [x + 1, 38], "n1");    g.line([x, 21], [x - 2, 17], "n2"); g.line([x + 1, 21], [x + 3, 17], "n1");   // fork    g.pixel(x - 2, 17, "n3"); g.pixel(x + 3, 17, "n2");    g.line([x, 36], [x - 3, 43], "n2"); g.line([x + 1, 36], [x + 4, 43], "n1");    // splayed legs    g.pixel(x - 3, 43, "n3"); g.pixel(x + 4, 43, "n2");    g.pixel(x, 30, "n4");                                                          // a glint on the lit side  });}function spit(g) {  g.part("spit", () => {    g.line([3, 20], [42, 20], "n3"); g.line([3, 21], [42, 21], "n1");    g.pixel(2, 20, "n2"); g.pixel(2, 21, "n1"); g.pixel(3, 20, "n4");  });}function crank(g, th) {  const ps = th + Math.PI / 2;                        // the arm's angle round the axis  const ey = Math.round(AXIS + 4 * Math.sin(ps)), near = Math.cos(ps) > -0.2;  g.part("crank", () => {    g.line([43, 20], [43, 21], "n2");    const y0 = Math.min(ey, 21), y1 = Math.max(ey, 20);    g.line([43, y0], [43, y1], near ? "n2" : "n1");    g.line([44, ey], [46, ey], near ? "w2" : "w1");                                // wooden grip    g.pixel(44, ey, near ? "w3" : "w2");  });}// ---- the ham ---------------------------------------------------------------------------------------------// every visible pixel maps to a point on the turning surface: a = its screen angle round the axis, phi = a - thconst TWINE = [15, 21, 27];function ham(g, th) {  g.part("ham", () => g.field({ x: [HAM.x0, HAM.x1], y: [12, 29] }, (x, y) => {    const r = hamR(x);    if (r < 0) return null;    const v = (y + 0.5 - AXIS) / r;    if (Math.abs(v) > 1) return null;    const a = Math.asin(v), phi = a - th;    // fixed key light from the top-left; the rump end is rounded so it turns toward the light    const u = (x + 0.5 - HAM.x0) / (HAM.x1 - HAM.x0);    const nx = u < 0.42 ? -Math.sqrt(Math.max(0, 1 - (r / HAM.r) ** 2)) : 0.18;    const ny = v * Math.sqrt(1 - nx * nx), nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));    const d = -0.5 * nx - 0.55 * ny + 0.67 * nz;    let lv = d > 0.86 ? 4 : d > 0.58 ? 3 : d > 0.18 ? 2 : d > -0.25 ? 1 : 0;    if (v > 0.8 && Math.abs(x + 0.5 - CX) < 8) return v > 0.93 && Math.abs(x + 0.5 - CX) < 4 ? "r2" : "r1";        // firelight on the underside    if (TWINE.includes(x)) return v < -0.5 ? "n4" : v < 0.4 ? "n3" : "n2";    // scored crackling: a diamond lattice that travels round the ham as it turns    const s = (phi * 4) / TAU;    const l1 = (((x / 7 + s) % 1) + 1) % 1, l2 = (((x / 7 - s) % 1) + 1) % 1;    if ((l1 < 0.12 || l2 < 0.12) && lv >= 2) lv -= 1;    return MEAT[Math.max(0, Math.min(4, lv))];  }));  // twine knots and fat beads riding the surface: drawn only on the half facing us  g.part("glaze", () => {    const onSurface = (x, ph, cb) => {      const psi = ph + th, c = Math.cos(psi);      const wrapped = Math.atan2(Math.sin(psi), c);      if (c < 0.15) return;      const r = hamR(x);      cb(Math.round(AXIS - 0.5 + Math.sin(wrapped) * r), wrapped);    };    for (const [i, x] of TWINE.entries()) onSurface(x, i * 2.1, (y) => { g.pixel(x, y, "n5"); g.pixel(x + 1, y, "n4"); });    const BEADS = [[13, 0.3], [18, 2.2], [24, 4.1], [29, 1.2], [16, 5.2], [23, 3.0]];    for (const [x, ph] of BEADS) onSurface(x, ph, (y, a) => {      const inHi = a < -0.25 && a > -1.1;                                  // passing through the highlight: it flashes      g.pixel(x, y, inHi ? "g3" : "g2");      if (inHi) g.pixel(x - 1, y, "g2");    });  });}function bone(g) {  g.part("bone", () => {    g.rect({ x: [33, 36], y: [19, 21] }, "n4", { fill: "n4" });    g.line([33, 19], [36, 19], "n5"); g.line([33, 21], [36, 21], "n3");    g.ellipse({ x: [36, 39], y: [17, 22] }, "n4", { fill: "n4" });           // the knuckle    g.pixel(37, 18, "n5"); g.pixel(38, 21, "n3"); g.pixel(39, 20, "n3");  });}// ---- drips, sparks --------------------------------------------------------------------------------------const DRIPS = [[14, 0.0], [21, 0.5], [27, 0.25], [18, 0.75]];function drips(g, t) {  g.detached("drips", () => {    for (const [x, ph] of DRIPS) {      const p = (t * 2 + ph) % 1;      const y0 = Math.round(AXIS + hamR(x));      if (p < 0.3) g.pixel(x, y0 + 1, p < 0.15 ? "g1" : "g2");                    // a bead swells on the underside      else if (p < 0.6) { const y = y0 + 2 + Math.round(((p - 0.3) / 0.3) ** 2 * 12); g.pixel(x, y, "g2"); g.pixel(x, y - 1, "g1"); }      else if (p < 0.72) {                                                       // it hits the fire: a sizzle pop        const y = 35;        g.pixel(x, y, "fl3"); g.pixel(x - 1, y - 1, "fl2"); g.pixel(x + 1, y - 1, "fl2"); g.pixel(x, y - 2, "fl1");      }    }  });}function embers(g, t, full) {  g.detached("embers", () => {    for (let i = 0; i < 4; i++) {      const p = (t * 2 + i / 4) % 1;      const x = CX + Math.round((i - 1.5) * 5 + Math.sin(TAU * (p + hash(i, 1))) * 2);      const y = 30 - Math.round(p * 18);      if (p < 0.8 && (!full || y < 12 || y > 29 || x < HAM.x0 - 1 || x > 40)) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.55 ? "fl2" : "fl1");    }  });}// ---- sprite ---------------------------------------------------------------------------------------------const Roast = sprite(pal, (g, t, state) => {  const full = state !== "empty";  const th = full ? TAU * t : 0;  contactShadow(g, CX, GROUND - 1, 21, 3);  g.outlined("ink", () => { pitBed(g, true, t); for (const s of STONES) if (!s.front) g.part("stones", () => stone(g, s)); });  g.outlined("ink", () => logs(g, t));  g.outlined("ink", () => { stand(g, 6, "standL"); stand(g, 40, "standR"); });  let boost = 0;  if (full) for (const [, ph] of DRIPS) { const p = (t * 2 + ph) % 1; if (p >= 0.6 && p < 0.8) boost = 0.12; }  for (const f of FLAMES) drawFlame(g, f, t, f.name === "flameC" ? boost : 0);  g.outlined("ink", () => { spit(g); crank(g, th); });  if (full) g.outlined("ink", () => { ham(g, th); bone(g); });  g.outlined("ink", () => { for (const s of STONES) if (s.front) g.part("stones", () => stone(g, s)); });  if (full) drips(g, t);  embers(g, t, full);});submit(Roast, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "empty", args: ["empty"], animate: { fps: 10, duration: 1.2, loop: true } },  ],});

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.