Herb drying rack
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.
6×
Get 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 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 movingThe last lines. submit declares the canvas and lists the variants the renderer exports.
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 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.