Witch's broom
A 32×48 floor prop: a besom with a crooked ash handle (a knob, a knot, a snapped-off twig), a violet cord wrapped where the straw is bound on, a second tie lower down and a fanned bundle of straw. The whole broom is one model in its own coordinates, ray-cast per pixel, so every angle of the takeoff is lit the same way. “lean” (12 frames) stands it on its bristles with a glint sliding down the handle; “hover” (16 frames) floats it level above its shadow, bobbing, with violet sparkles falling; “takeoff” (10 frames, once) crouches back, lifts and swings level.
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.
// Witch's broom — 32x48 floor prop, top-down 3/4, key light top-left.// A besom: a crooked ash handle with a knob, a knot and a snapped-off twig, a violet cord wrapped where the straw is// bound on, a second tie lower down, and a fanned bundle of straw. The whole broom is one model in broom space// (s along the axis from the knob, d across it) ray-cast per pixel, so every angle of the takeoff is lit the same way.// Variants:// lean 12f loop: stands on its bristles; a glint slides down the handle and twinkles on the cord// hover 16f loop: floats horizontally above its shadow, bobbing; the straw flutters, violet sparkles fall off// takeoff 10f once: lean -> crouch back -> lifts and swings level with an overshoot; ends on hover frame 0The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(WitchBroom, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LEAN / 10, loop: true }, anchor: { x: 15, y: GROUND }, args: ["lean"], variants: [ { name: "lean", args: ["lean"] }, { name: "hover", args: ["hover"], animate: { fps: 10, duration: N_HOVER / 10, loop: true } }, { name: "takeoff", args: ["takeoff"], animate: { fps: 10, duration: N_TAKE / 10, loop: false } }, ],});Show the whole program (190 lines)
// Witch's broom — 32x48 floor prop, top-down 3/4, key light top-left.// A besom: a crooked ash handle with a knob, a knot and a snapped-off twig, a violet cord wrapped where the straw is// bound on, a second tie lower down, and a fanned bundle of straw. The whole broom is one model in broom space// (s along the axis from the knob, d across it) ray-cast per pixel, so every angle of the takeoff is lit the same way.// Variants:// lean 12f loop: stands on its bristles; a glint slides down the handle and twinkles on the cord// hover 16f loop: floats horizontally above its shadow, bobbing; the straw flutters, violet sparkles fall off// takeoff 10f once: lean -> crouch back -> lifts and swings level with an overshoot; ends on hover frame 0const W = 32, H = 48, GROUND = 45, L = 30;const N_LEAN = 12, N_HOVER = 16, N_TAKE = 10;const BIND0 = 16.5, BIND1 = 19.3, TIE0 = 23.2, TIE1 = 24.3;const WOOD = ["w0", "w1", "w2", "w3"], STRAW = ["w1", "g0", "g1", "g2"], CORD = ["v0", "v1", "v2", "v3"];const LEAN = { th: -6, base: [15, 45.6] };const HOVER = { th: 78, c: [14.5, 26.5] };const rad = (deg) => (deg * Math.PI) / 180;// handle centre line wanders (a crooked branch); the bound part continues straight from where the binding startsconst crook = (s) => 0.7 * Math.sin(Math.min(s, BIND0) * 0.24 + 0.2) + 0.25 * Math.sin(Math.min(s, BIND0) * 0.7);function halfW(s) { if (s < 1.9) return 1.95; // knob if (s < BIND0) return 1.45 - s * 0.012; if (s < BIND1) return 2.5; // cord wrap const u = (s - BIND1) / (L - BIND1); return 2.3 + 4.1 * Math.pow(u, 0.7); // straw fans out}function centreFromBase(th, base, lift) { const dx = Math.sin(rad(th)), dy = Math.cos(rad(th)); return [base[0] - dx * L / 2, base[1] - lift - dy * L / 2];}// one tone function for the whole broom: pose = {th, c:[cx,cy]}, flutter = phase of the straw tips (or null)function broomTone(pose, flutter, glint) { const th = rad(pose.th), dx = Math.sin(th), dy = Math.cos(th), px = Math.cos(th), py = -Math.sin(th); return (x, y) => { const X = x + 0.5 - pose.c[0], Y = y + 0.5 - pose.c[1]; const s = X * dx + Y * dy + L / 2, d = X * px + Y * py; if (s < 0 || s > L + 1) return null; const dd = d - crook(s); let hw = halfW(s); // snapped twig on the shadow side of the handle const twig = s > 9.0 && s < 10.8 && dd > 1.2 && dd < 2.6 + (s - 9.0) * 0.5; if (s >= BIND1) { const dn = dd / hw; const sid = Math.floor((dn + 1) * 5.5); // individual strand tips of uneven length const rag = 0.3 + 2.0 * hash(sid, 3) + (flutter == null ? 0 : 1.1 * (0.5 + 0.5 * Math.sin(TAU * flutter + sid * 1.9))); if (s > L - rag) return null; } if (Math.abs(dd) > hw && !twig) return null; const dn = twig ? 0.2 : Math.max(-1, Math.min(1, dd / hw)); const nz = Math.sqrt(Math.max(0, 1 - dn * dn)); const lit = -0.72 * (px + py) * dn + 0.55 * nz - 0.25; // light from the top-left, a little from the front const lvl = lit > 0.42 ? 3 : lit > 0.05 ? 2 : lit > -0.35 ? 1 : 0; if (twig) return s < 9.7 ? "w2" : "w1"; if (s < BIND0) { if (glint != null && Math.abs(s - glint) < 1.1 && lvl >= 2) return "n5"; if (s > 6.0 && s < 7.0 && Math.abs(dd) < 0.7) return "w0"; // knot if (s > 13.0 && s < 13.9 && dd < -0.3) return "w0"; // a ring of bark if (s < 1.0) return lvl >= 2 ? "w3" : "w1"; // cut end of the knob if (lvl === 3 && ((s > 2 && s < 4.5) || (s > 7.8 && s < 11.5))) return "w4"; return WOOD[lvl]; } if (s < BIND1) { // cord: wraps read as ridges const ridge = Math.floor((s - BIND0) * 1.6) % 2 === 1; return CORD[Math.max(0, Math.min(3, lvl - (ridge ? 1 : 0) - (lvl === 3 && Math.abs(dn) > 0.6 ? 1 : 0)))]; } if (s > TIE0 && s < TIE1) return CORD[Math.max(0, lvl - 1)]; // straw: strands converge on the binding (stripes in normalised width), one step darker every other strand const strand = Math.floor((dn + 1) * 6.5 + 0.5); const sl = lit > 0.22 ? 3 : lit > -0.22 ? 2 : 1; let k = sl - (strand % 3 === 0 ? 1 : 0); const sid = Math.floor((dn + 1) * 5.5); if (s > L - 0.3 - 2.0 * hash(sid, 3) - 1.2 && lvl >= 2 && strand % 3 !== 0) return "w4"; // sunlit strand tips if (s < BIND1 + 1.2 || (s > TIE1 && s < TIE1 + 0.9)) k -= 1; // tucked under the ties return STRAW[Math.max(0, Math.min(3, k))]; };}function drawBroom(g, pose, flutter, glint) { g.outlined("ink", () => g.part("broom", () => g.field({ x: [0, W - 1], y: [0, H - 1] }, broomTone(pose, flutter, glint))));}function endPoint(pose) { // centre of the straw end, in screen space const th = rad(pose.th); return [pose.c[0] + Math.sin(th) * (L / 2 - 1), pose.c[1] + Math.cos(th) * (L / 2 - 1)];}function star(g, x, y, size) { // 4-point twinkle x = Math.round(x); y = Math.round(y); if (size <= 0) return; g.pixel(x, y, size > 1 ? "n5" : "v3"); if (size >= 2) for (const [a, b] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + a, y + b, "v3"); if (size >= 3) for (const [a, b] of [[2, 0], [-2, 0], [0, 2], [0, -2]]) g.pixel(x + a, y + b, "v2");}const bobAt = (t) => Math.round(2 * Math.sin(TAU * t));const hoverPose = (t) => ({ th: HOVER.th, c: [HOVER.c[0], HOVER.c[1] - bobAt(t)] });function trail(g, t, keep) { // sparkles shaken off the straw as it hovers g.detached("sparkles", () => { for (let i = 0; i < 9; i++) { const p = (t + i / 9) % 1; if (p > keep) continue; const e = endPoint(hoverPose(t - p)); // emitted where the straw was at the time const side = ((i * 4) % 9) / 8 - 0.5; const x = e[0] - 1 + side * 5 + p * 2 + Math.sin(TAU * (p + i * 0.37)) * 1.2; const y = e[1] + 2 + side * 3 + p * 14; if (y > GROUND) continue; if (p < 0.12) star(g, x, y, 1); else if (p < 0.3) star(g, x, y, 2); else if (p < 0.45) { g.pixel(Math.round(x), Math.round(y), "v3"); g.pixel(Math.round(x), Math.round(y) - 1, "v2"); } else if (p < 0.75) g.pixel(Math.round(x), Math.round(y), "v2"); else if (chk(Math.round(x), Math.round(y))) g.pixel(Math.round(x), Math.round(y), "v1"); } });}function leanFrame(g, t) { const pose = { th: LEAN.th, c: centreFromBase(LEAN.th, LEAN.base, 0) }; contactShadow(g, 15, GROUND, 5, 1); // a glint slides down the handle in the first half, then the cord twinkles, then a mote off the knob const glint = t < 0.5 ? 1.5 + (t / 0.5) * 13.5 : null; drawBroom(g, pose, null, glint); const th = rad(pose.th), at = (s, d) => [pose.c[0] + Math.sin(th) * (s - L / 2) + Math.cos(th) * d, pose.c[1] + Math.cos(th) * (s - L / 2) - Math.sin(th) * d]; g.detached("glints", () => { const k = Math.round(t * N_LEAN) % N_LEAN; const cord = [0, 0, 0, 0, 0, 0, 1, 2, 3, 2, 1, 0][k]; const tip = [1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0][k]; const [cx, cy] = at(17.9, -3.4); star(g, cx, cy, cord); const [kx, ky] = at(0.2, -2.6); star(g, kx, ky, tip); // two slow motes rising off the straw for (let i = 0; i < 2; i++) { const p = (t + i / 2) % 1; const [mx, my] = at(26 + i * 2, (i ? 4 : -4) + Math.sin(TAU * (p + i * 0.3)) * 1.5); const y = Math.round(my - p * 10), x = Math.round(mx); if (p < 0.5) g.pixel(x, y, "v2"); else if (p < 0.8) g.pixel(x, y, "v1"); } });}function hoverFrame(g, t, keep) { const pose = hoverPose(t); const lift = bobAt(t); contactShadow(g, 15, GROUND, lift > 0 ? 8 : 9, 1); drawBroom(g, pose, (2 * t) % 1, null); trail(g, t, keep);}// takeoff keyframes: tilt (deg), lift off the base (px), blend from base-pivot to the hover centre, extra riseconst TAKE = [[-6, 0, 0, 0], [-13, 0, 0, 0], [2, 2, 0, 0], [26, 5, 0.2, 0], [52, 7, 0.5, 0], [70, 8, 0.8, 0], [84, 0, 1, 2], [80, 0, 1, 1], [77, 0, 1, 0]];function takeoffFrame(g, f) { if (f >= N_TAKE - 1) return hoverFrame(g, 0, 1); if (f === 0) return leanFrame(g, 0); const [th, lift, k, rise] = TAKE[f]; const cb = centreFromBase(th, LEAN.base, lift); const pose = { th, c: [cb[0] + (HOVER.c[0] - cb[0]) * k, cb[1] + (HOVER.c[1] - cb[1]) * k - rise] }; contactShadow(g, 15, GROUND, Math.round(5 + 4 * Math.min(1, f / 5)), 1); drawBroom(g, pose, f >= 3 ? (f * 0.2) % 1 : null, null); // a burst of sparkles where it pushed off, then the trail starts g.detached("burst", () => { if (f >= 2 && f <= 5) { const r = (f - 1) * 3; for (let i = 0; i < 8; i++) { const a = (i / 8) * TAU + 0.3; const x = Math.round(15 + Math.cos(a) * r), y = Math.round(GROUND - 1 + Math.sin(a) * r * 0.35 - (f - 1)); g.pixel(x, y, f < 4 ? "v3" : f < 5 ? "v2" : "v1"); } } }); if (f >= 6) trail(g, 0, (f - 5) / 4);}const WitchBroom = sprite(pal, (g, t, mode) => { if (mode === "takeoff") return takeoffFrame(g, Math.min(N_TAKE - 1, Math.round(t * N_TAKE))); t = ((t % 1) + 1) % 1; if (mode === "hover") return hoverFrame(g, t, 1); leanFrame(g, t);});submit(WitchBroom, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LEAN / 10, loop: true }, anchor: { x: 15, y: GROUND }, args: ["lean"], variants: [ { name: "lean", args: ["lean"] }, { name: "hover", args: ["hover"], animate: { fps: 10, duration: N_HOVER / 10, loop: true } }, { name: "takeoff", args: ["takeoff"], animate: { fps: 10, duration: N_TAKE / 10, loop: false } }, ],});These excerpts omit the boilerplate prepended by the asset pack build script: a single-line runtime header, the shared palette, and common helper utilities (contactShadow, litIndex, flame, hash, chk, TAU). The displayed line numbers match the original source files. Each program was authored by an AI coding agent and adjusted after visual inspection of the rendered frames.
Have your agent write programs like this.
Tessarune gives it the drawing API reference, 25 production manuals and a renderer to check its work against.