Tessarune
Witch's Hut · herb_rack.js

Herb drying rack

48×48 · 288 lines of JavaScript

A 48×48 wall prop with no contact shadow: a rough-hewn beam held to the wall by two iron straps, with a sage bundle, a garlic braid, a lavender bunch, a dried lizard charm with a blood bead and a rowan-berry sprig hanging upside down on twine. Each hanging thing is drawn around its own pivot and swung by a shear, so the whole mass moves and the twine stays attached. All variants are 16-frame loops: “idle” sways them gently out of phase; “breeze” leans them right in a gust and drops a sage leaf and a lavender floret; “dried” shows them browned and withered, barely moving.

Herb drying rack — Rendered output6×
Rendered by this program, variant “breeze” · 48×48 · 16 frames
Witch's HutGet the animated sprites: Witch's Hut on itch.ioVol.5 · 12 Halloween props · 42 animations

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

herb_rack.js · 1–9
// Herb drying rack — 48x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A rough-hewn beam held to the wall by two iron straps; six things hang from it on twine wrapped round the beam:// a sage bundle, a garlic braid, a lavender bunch, a dried lizard charm with a blood bead and a rowan-berry sprig. Every bundle hangs upside down: cut stem ends above the tie, sprigs fanning out below.// Each hanging thing is drawn as a pure function of local (pivot) coordinates and swung by a shear about its pivot,// so the whole mass moves and the twine stays attached.// Variants (16f loops @10fps)://   idle    bundles sway gently, each out of phase//   breeze  a gust leans them to the right and they swing harder; a sage leaf and a lavender floret break off and fall//   dried   the same bundles browned and withered (key remap inside the palette), thinner sprigs, barely moving

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

herb_rack.js · 278–288
submit(HerbRack, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: 24, y: 10 },  args: ["idle"],  variants: [    { name: "idle" },    { name: "breeze", args: ["breeze"] },    { name: "dried", args: ["dried"] },  ],});
Show the whole program (288 lines)
herb_rack.js · 288 lines
// Herb drying rack — 48x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A rough-hewn beam held to the wall by two iron straps; six things hang from it on twine wrapped round the beam:// a sage bundle, a garlic braid, a lavender bunch, a dried lizard charm with a blood bead and a rowan-berry sprig. Every bundle hangs upside down: cut stem ends above the tie, sprigs fanning out below.// Each hanging thing is drawn as a pure function of local (pivot) coordinates and swung by a shear about its pivot,// so the whole mass moves and the twine stays attached.// Variants (16f loops @10fps)://   idle    bundles sway gently, each out of phase//   breeze  a gust leans them to the right and they swing harder; a sage leaf and a lavender floret break off and fall//   dried   the same bundles browned and withered (key remap inside the palette), thinner sprigs, barely movingconst W = 48, H = 48, PY = 11;                       // PY: first row under the beam (twine leaves the beam here)const BX0 = 2, BX1 = 45;                             // beam spanlet MODE = "idle";// withered remap: greens go to wood browns, violet greys out, berries shrivel darkconst DRY = { e0: "w0", e1: "w1", e2: "w2", e3: "w3", v0: "w0", v1: "v0", v2: "v1", v3: "n3",  r1: "r0", r2: "r1", r3: "w2" };const K = (k) => (MODE === "dried" && DRY[k]) || k;const ramp4 = (l, a, b, c, d) => K(l > 0.55 ? d : l > 0.05 ? c : l > -0.45 ? b : a);// ---- shared shape helpers (local coords: lx right, ly down from the pivot) ------------------------------------// a fan of sprigs hanging from a tie at ly=T; returns the sprig hit with the smallest |across| (front sprigs win)function fan(lx, ly, F) {  const dy = ly - F.T + 0.5;  if (dy < 0) return null;  const r = Math.hypot(lx, dy), a = Math.atan2(lx, dy);  let best = null;  for (let j = 0; j < F.n; j++) {    const aj = -F.spread + (2 * F.spread * j) / (F.n - 1);    const along = r * Math.cos(a - aj), perp = r * Math.sin(a - aj);    const L = F.len[j] * (MODE === "dried" ? 0.92 : 1);    if (along < 0 || along > L) continue;    const w = F.width(along, L, j) * (MODE === "dried" ? 0.8 : 1);    if (w <= 0.2) continue;    const f = perp / w;    if (Math.abs(f) > 1) continue;    const score = Math.abs(f) + Math.abs(aj) * 0.6;    if (!best || score < best.score) best = { j, d: along, L, f, score, aj };  }  return best;}const sprigPos = (F, j, d) => {  const aj = -F.spread + (2 * F.spread * j) / (F.n - 1);  return [Math.sin(aj) * d, F.T - 0.5 + Math.cos(aj) * d];};// cut stem ends above the tie, and the twine wrap of the tiefunction tieAndStems(lx, ly, T, hw, stemA, stemB) {  if (ly >= T && ly <= T + 1 && Math.abs(lx) <= hw) return K(ly === T ? (lx < 0 ? "w4" : "w3") : "w2");  if (ly >= T - 2 && ly < T && Math.abs(lx) <= hw - 1) {    if (ly === T - 2) return K(lx < 0 ? stemB : lx > 0 ? stemA : "e2");    // cut ends catch the light    return K((lx + hw) % 2 ? "e0" : lx < 0 ? "e2" : stemA);                 // separate stem stubs  }  return null;}function inPoly(x, y, P) {  let c = false;  for (let i = 0, j = P.length - 1; i < P.length; j = i++) {    const [xi, yi] = P[i], [xj, yj] = P[j];    if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;  }  return c;}function segDist(x, y, [ax, ay], [bx, by]) {  const dx = bx - ax, dy = by - ay, k = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)));  return Math.hypot(x - ax - k * dx, y - ay - k * dy);}function ball(lx, ly, cx, cy, r, keys) {  const i = litIndex(lx, ly, cx - 0.5, cy - 0.5, r, r, 4, 0.05);  return i < 0 ? null : K(keys[i]);}// ---- the six hanging things ---------------------------------------------------------------------------------// each: { px pivot column, str twine length, ph sway phase, amp, fn(lx,ly) }const SAGE = { T: 3, n: 5, spread: 0.24, len: [18, 22, 25, 21, 17],  width: (d, L, j) => {    const leaf = 0.6 + 0.4 * Math.abs(Math.sin((Math.PI * (d + j * 1.3)) / 3.4));   // leaf pairs scallop the edge    return Math.min(1.7, 0.8 + d * 0.35) * Math.min(1, (L - d) / 3.5 + 0.1) * leaf;  } };function sage(lx, ly) {  const tie = tieAndStems(lx, ly, SAGE.T, 2, "e1", "e3");  if (tie) return tie;  const s = fan(lx, ly, SAGE);  if (!s) return null;  if (s.f > 0.55 && s.d > 2.5) return K("e0");                              // each sprig's shaded flank separates it  let l = -0.6 * s.f - 2 * s.aj + 0.05;  if (s.f < -0.55 && s.d > 2.5) l += 0.5;                                   // lit flank  if (s.d < 3) l -= 0.6;                                                    // shadow under the tie  if (s.d > s.L - 2.5) l += 0.3;                                            // soft grey-green tips  return ramp4(l, "e0", "e1", "e2", "e3");}const BULBS = [[-1, 6], [2, 10], [-1, 14], [2, 18], [0, 22], [1, 26]];function garlic(lx, ly) {  for (let i = BULBS.length - 1; i >= 0; i--) {    const [cx, cy] = BULBS[i];    const nx = (lx - cx) / 2.5, ny = (ly - cy) / 2.3;    if (nx * nx + ny * ny <= 1) {      let k = litIndex(lx + 0.5, ly + 0.5, cx + 0.5, cy + 0.5, 2.5, 2.3, 4, 0.1);      if (Math.abs(Math.abs(lx - cx) - 1) < 0.5 && ny > -0.6) k = Math.max(0, k - 1);   // clove seams      if (i % 2 === 1 && lx - cx === 1 && ny > -0.3 && ny < 0.7) return K("v1");          // purple streak      if (i % 2 === 0 && lx - cx === 0 && ny > 0 && ny < 0.6) return K("v2");      return ["n2", "n3", "n4", "n5"][k];    }    if (ly >= cy - 4 && ly < cy - 2 && Math.abs(lx - cx * 0.6) < 0.8) return ly === cy - 4 ? "w4" : "w3";   // neck into braid    if (i === BULBS.length - 1 && ly > cy + 2 && ly <= cy + 4 && Math.abs(lx - cx) <= 1 && (lx + ly) % 2 === 0) return "w3";  // roots  }  if (ly >= 2 && ly <= 25 && Math.abs(lx) <= 1) {                           // plaited stalks    const k = (ly + (lx < 0 ? 0 : 1) + (lx > 0 ? 1 : 0)) % 3;    return lx > 0 ? (k === 0 ? "w1" : "w2") : (k === 0 ? "w2" : k === 1 ? "w4" : "w3");  }  if (ly >= 0 && ly < 2 && Math.abs(lx) <= 1) return lx < 0 ? "w4" : "w2";  // braid knot at the twine  return null;}const LAV = { T: 4, n: 5, spread: 0.26, len: [15, 18, 21, 19, 16],  width: (d, L) => (d < L * 0.42 ? 0.55 + (d < 2 ? 1 : 0) : (Math.floor(d) % 2 ? 1.05 : 0.65) * Math.min(1, (L - d) / 2 + 0.3)) };function lavender(lx, ly) {  const tie = tieAndStems(lx, ly, LAV.T, 2, "e1", "e2");  if (tie) return tie;  const s = fan(lx, ly, LAV);  if (!s) return null;  const l = -0.7 * s.f - 2 * s.aj + (Math.floor(s.d) % 2 ? 0.2 : -0.2);  if (s.d < s.L * 0.42) return K(l > 0 ? "e2" : "e1");                      // bare stems  if (s.d > s.L - 1.5) return K(l > 0 ? "v2" : "v1");                       // closed bud at the tip  if (s.f > 0.5) return K("v0");  return ramp4(l, "v0", "v1", "v2", "v3");}// a dried lizard hung by its tail as a charm, legs splayed, a blood bead threaded on the twine above itconst LIMBS = [  [[-1, 13], [-3.5, 11.5], [-4, 9.5]], [[1, 13], [3.5, 11.5], [4, 9.5]],      // hind legs reach up  [[-1, 19], [-3.5, 20.5], [-4, 22.5]], [[1, 19], [3.5, 20.5], [4, 22.5]],    // fore legs reach down];function lizard(lx, ly) {  if (ly <= 2 && Math.abs(lx) <= 1) {                                      // bead    if (ly === 0 && lx === -1) return K("r3");    return ly === 2 || lx === 1 ? K("r1") : K("r2");  }  const tx = Math.round(Math.sin((ly - 3) * 0.55) * 1.2);                  // curled, stiff dried tail  if (ly >= 3 && ly <= 11 && Math.abs(lx - tx) <= (ly > 8 ? 1 : 0)) return lx - tx < 0 || ly < 9 ? "w2" : "w1";  // body  const bi = litIndex(lx + 0.5, ly + 0.5, 0.5, 16.5, 2.0, 4.6, 4, 0);  if (bi >= 0) {    if (lx === 0 && ly % 2 === 0 && ly > 12 && ly < 20) return "n4";        // dorsal ridge scales    return ["w1", "w2", "w3", "w4"][bi];  }  // head: pointing down, a dull bead eye  const hi = litIndex(lx + 0.5, ly + 0.5, 0.5, 23, 1.7, 2.4, 3, 0);  if (hi >= 0) {    if (lx === -1 && ly === 22) return K("g2");    return ["w1", "w2", "w3"][hi];  }  for (const [a, b, c] of LIMBS) {    if (segDist(lx, ly, a, b) < 0.55 || segDist(lx, ly, b, c) < 0.55) return lx < 0 ? "w3" : "w2";    if (Math.abs(lx - c[0]) <= 1 && Math.abs(ly - c[1]) <= 0 && (lx + ly) % 2 === 0) return "w1";  // toes  }  return null;}const ROWAN = { T: 4, n: 3, spread: 0.22, len: [11, 14, 12], width: (d) => (d < 2 ? 1.2 : 0.55) };function rowan(lx, ly) {  const tie = tieAndStems(lx, ly, ROWAN.T, 1, "w2", "w3");  if (tie) return tie;  // berries: 2x2 drupes (lit corner, shaded corner) clustered at each sprig tip, front ones first  const CL = [[[0, 0], [-2, -2], [1, -3]], [[0, 0], [2, -2], [-1, -3], [1, 2]], [[0, 0], [-2, -1], [1, -3]]];  for (let j = 0; j < ROWAN.n; j++) {    const L = ROWAN.len[j] * (MODE === "dried" ? 0.92 : 1);    const [bx, by] = sprigPos(ROWAN, j, L);    for (const [ox, oy] of CL[j]) {      const cx = Math.round(bx + ox) - 1, cy = Math.round(by + oy) - 1;      const u = lx - cx, v = ly - cy;      if (u < 0 || u > 1 || v < 0 || v > 1) continue;      return K(u + v === 0 ? "r3" : u + v === 1 ? "r2" : "r1");    }  }  // two leaflets either side of the tie  for (const [cx, cy, s] of [[-2.6, ROWAN.T + 2, -1], [2.8, ROWAN.T + 3, 1]]) {    const u = (lx - cx) / 2.2, v = (ly - cy - u * s * 0.8) / 1.1;    if (u * u + v * v <= 1) return K(v < 0 && s < 0 ? "e3" : v < 0 ? "e2" : "e1");  }  const s = fan(lx, ly, ROWAN);  if (!s) return null;  return K(s.f < 0 ? "w2" : "w1");}const ITEMS = [  { name: "sage", px: 8, str: 2, ph: 0.0, fn: sage, bot: 28 },  { name: "garlicBraid", px: 16, str: 1, ph: 0.37, fn: garlic, bot: 31 },  { name: "lavender", px: 24, str: 6, ph: 0.71, fn: lavender, bot: 26 },  { name: "lizardCharm", px: 31, str: 3, ph: 0.18, fn: lizard, bot: 26 },  { name: "rowan", px: 40, str: 4, ph: 0.55, fn: rowan, bot: 18 },];// shear per item (px of sideways travel per px of drop): periodic in t so the loop closesfunction sway(it, t) {  const k = 1.4 / (it.bot + it.str);                                        // longer things swing through a smaller angle  if (MODE === "breeze") return 2.2 * k + 1.9 * k * Math.sin(TAU * (2 * t - it.ph * 0.5));  if (MODE === "dried") return 0.6 * k * Math.sin(TAU * (t + it.ph));  return 1.1 * k * Math.sin(TAU * (t + it.ph));}function hanging(g, it, s, twine) {  const x0 = Math.max(0, it.px - 14), x1 = Math.min(W - 1, it.px + 14);  g.field({ x: [x0, x1], y: [PY, Math.min(H - 1, PY + it.str + it.bot + 3)] }, (x, y) => {    const ly = y - PY, lx = x - it.px - Math.round(s * ly);    if (twine) return ly < it.str && lx === 0 ? (ly % 2 ? "w3" : "w4") : null;    if (ly < it.str) return null;    return it.fn(lx, ly - it.str);  });}// ---- the beam ----------------------------------------------------------------------------------------------function beam(g) {  g.part("beam", () => g.field({ x: [BX0, BX1], y: [4, 10] }, (x, y) => {    // hewn ends: the top corners are knocked off    if ((x === BX0 || x === BX1) && y === 4) return null;    if (y <= 5) return x < 16 && y === 4 ? "w4" : "w3";                     // top face, brighter toward the light    if (y === 10) return "w1";    if (x === BX0) return "w3";                                             // lit left end    if (x === BX1) return "w1";    // adze marks and grain    if (y === 7 && ((x > 9 && x < 14) || (x > 26 && x < 30))) return "w1";    if (y === 8 && ((x > 18 && x < 24) || (x > 36 && x < 41))) return "w1";    if (y === 6) return "w3";    return "w2";  }));  g.part("straps", () => {    for (const sx of [4, 43]) {      g.line([sx, 1], [sx, 10], "n2");      g.line([sx + 1, 1], [sx + 1, 10], "n1");      g.pixel(sx, 4, "n4"); g.pixel(sx, 5, "n3"); g.pixel(sx + 1, 4, "n3");      g.pixel(sx, 1, "n4");                                                  // nail head into the wall      g.pixel(sx, 8, "n3"); g.pixel(sx + 1, 8, "n0");                        // rivet    }  });  g.part("twineWraps", () => {    for (const it of ITEMS) {      for (let y = 4; y <= 10; y++) g.pixel(it.px, y, y <= 5 ? "w4" : y % 2 ? "w4" : "w3");      g.pixel(it.px + 1, 6, "w1");                                          // wrap's shadow on the beam    }  });}// ---- breeze: things that break off ---------------------------------------------------------------------------function fallers(g, t) {  g.detached("fallers", () => g.outlined("ink", () => {    const drops = [      { it: ITEMS[0], ly: 2 + 25, ph: 0, keys: ["e3", "e2", "e1"] },      { it: ITEMS[2], ly: 6 + 23, ph: 0.5, keys: ["v3", "v2", "v1"] },    ];    for (const d of drops) {      const p = (t - d.ph + 1) % 1;      if (p > 0.85) continue;      const q = p / 0.85;      const s0 = sway(d.it, t);      const x0 = d.it.px + s0 * d.ly, y0 = PY + d.ly;      const x = Math.round(x0 + q * 15 + 2 * Math.sin(TAU * q * 2));      const y = Math.round(y0 + q * (H + 2 - y0));      if (y > H - 3) continue;      const flip = Math.floor(q * 8) % 4;                                    // tumbling leaf: flat, tilted, edge-on, tilted back      const [k0, k1, k2] = d.keys;      if (flip === 0) { g.pixel(x - 1, y, k0); g.pixel(x, y, k1); g.pixel(x + 1, y, k1); g.pixel(x, y + 1, k2); g.pixel(x + 1, y + 1, k2); }      else if (flip === 1) { g.pixel(x - 1, y - 1, k0); g.pixel(x, y - 1, k1); g.pixel(x, y, k1); g.pixel(x + 1, y, k2); g.pixel(x + 1, y + 1, k2); }      else if (flip === 2) { g.pixel(x, y - 1, k0); g.pixel(x, y, k1); g.pixel(x, y + 1, k2); }      else { g.pixel(x + 1, y - 1, k0); g.pixel(x, y - 1, k1); g.pixel(x, y, k1); g.pixel(x - 1, y, k2); g.pixel(x - 1, y + 1, k2); }    }  }));}const HerbRack = sprite(pal, (g, t, mode) => {  t = ((t % 1) + 1) % 1;  MODE = mode;  g.outlined("ink", () => beam(g));  const S = ITEMS.map((it) => sway(it, t));  g.outlined("ink", () => ITEMS.forEach((it, i) => g.part(it.name, () => hanging(g, it, S[i], false))));  g.link("twine", () => ITEMS.forEach((it, i) => hanging(g, it, S[i], true)));  if (mode === "breeze") fallers(g, t);});submit(HerbRack, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: 24, y: 10 },  args: ["idle"],  variants: [    { name: "idle" },    { name: "breeze", args: ["breeze"] },    { name: "dried", args: ["dried"] },  ],});

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.