Witch's mortar table
A 48×48 floor prop: a small rough worktable of three planks on splayed legs, holding a heavy stone mortar with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a rune, and loose sage leaves and a lavender sprig. The objects cast short shadows down-right onto the planks. “idle” (16 frames) flickers the candle under a faint steam curl; “grind” (12 frames) has the pestle grind round the bowl by itself while green dust puffs out and the rune pulses; “brewed” (8 frames, once) flares the paste and bursts a violet puff that breaks into sparks.
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 mortar table — 48x48 floor prop, top-down 3/4, key light top-left.// A small rough worktable (three planks, a front apron, splayed legs on a stretcher) holding a heavy stone mortar// with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a// rune, and a few loose sage leaves and a lavender sprig. Objects cast short shadows down-right onto the planks.// Variants:// idle 16f loop: candle flickers, a faint steam curl rises from the mortar// grind 12f loop: the pestle grinds round the bowl by itself, green dust puffs from the mouth, the rune pulses// brewed 8f once : the paste flares, a violet puff bursts from the mortar, swells, breaks into sparks and settles;// the last frame is idle frame 0The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(MortarTable, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "grind", args: ["grind"], animate: { fps: 10, duration: N_GRIND / 10, loop: true } }, { name: "brewed", args: ["brewed"], animate: { fps: 10, duration: N_BREW / 10, loop: false } }, ],});Show the whole program (334 lines)
// Witch's mortar table — 48x48 floor prop, top-down 3/4, key light top-left.// A small rough worktable (three planks, a front apron, splayed legs on a stretcher) holding a heavy stone mortar// with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a// rune, and a few loose sage leaves and a lavender sprig. Objects cast short shadows down-right onto the planks.// Variants:// idle 16f loop: candle flickers, a faint steam curl rises from the mortar// grind 12f loop: the pestle grinds round the bowl by itself, green dust puffs from the mouth, the rune pulses// brewed 8f once : the paste flares, a violet puff bursts from the mortar, swells, breaks into sparks and settles;// the last frame is idle frame 0const W = 48, H = 48, CX = 24, GROUND = 45;const MX = 15, RIM_Y = 18; // mortar axis and rim centre rowconst RIM = { rx: 6.5, ry: 2.6 }, MOUTH = { rx: 4.6, ry: 1.6 };const CANDLE_X = 36;const N_IDLE = 16, N_GRIND = 12, N_BREW = 8;const grainAt = (x, y) => hash(x, y * 3.1) > 0.82;// ---- table ------------------------------------------------------------------------------------------------function table(g) { g.part("tabletop", () => g.field({ x: [5, 42], y: [22, 31] }, (x, y) => { if ((x === 5 || x === 42) && y === 22) return null; // knocked-off corners if (x === 42 && y === 29) return null; // a chip out of the edge if (y === 25 || y === 28) return x % 9 === 3 ? "w2" : "w1"; // plank seams const u = (x - 5) / 37; if (y === 22) return u < 0.4 ? "w4" : u < 0.8 ? "w3" : "w2"; // lit back edge if (x === 5) return "w4"; if (x === 42) return "w2"; if (x === 14 && y === 30) return "w1"; // knot if ((x === 13 || x === 15) && y === 30) return "w2"; // grain dashes, fixed per plank const plank = y < 25 ? 0 : y < 28 ? 1 : 2; const gx = (x + plank * 7) % 13; if (y === [23, 26, 29][plank] && gx > 2 && gx < 7) return "w2"; if (y === [24, 27, 30][plank] && gx > 8 && gx < 11) return u < 0.5 ? "w2" : "w1"; return u < 0.7 ? "w3" : "w2"; })); g.part("apron", () => g.field({ x: [5, 42], y: [32, 35] }, (x, y) => { if (y === 32) return x < 14 ? "w3" : "w2"; if (y === 35) return x < 7 || x > 40 ? null : "w0"; return x === 20 && y === 33 ? "w0" : "w1"; })); g.part("legs", () => { // back legs (farther: higher feet, darker), then the stretcher, then the front legs for (const lx of [11, 35]) { g.rect({ x: [lx, lx + 1], y: [36, 41] }, "w0", { fill: "w0" }); g.pixel(lx, 36, "w1"); } g.rect({ x: [9, 38], y: [40, 41] }, "w1", { fill: "w1" }); g.line([9, 40], [24, 40], "w2"); for (const lx of [6, 39]) { g.rect({ x: [lx, lx + 2], y: [36, 44] }, "w2", { fill: "w2" }); g.line([lx, 36], [lx, 44], "w3"); g.line([lx + 2, 36], [lx + 2, 44], "w1"); g.pixel(lx + 1, 38, "w1"); // peg through the joint } });}// cast shadows on the planks (down-right of each object), darker wood onlyfunction tableShadows(g) { g.part("castShadows", () => g.field({ x: [6, 41], y: [23, 31] }, (x, y) => { const m = ((x + 0.5 - (MX + 4)) / 7) ** 2 + ((y + 0.5 - 29.2) / 1.6) ** 2 < 1; const ts = ((x + 0.5 - 41) / 2.5) ** 2 + ((y + 0.5 - 31) / 0.9) ** 2 < 1; const c = ((x + 0.5 - (CANDLE_X + 3)) / 4.5) ** 2 + ((y + 0.5 - 25.5) / 1.2) ** 2 < 1; const p = x >= 24 && x <= 33 && y === 31; if (!(m || c || p || ts)) return null; if (y === 25 || y === 28) return "w0"; return "w1"; }));}// ---- mortar & pestle -------------------------------------------------------------------------------------const halfW = (y) => { // bowl silhouette half-width per row if (y < RIM_Y) return 0; if (y >= 27) return y === 28 ? 4 : 3.6; // foot const k = (y - RIM_Y) / 9; return 6.5 * Math.sqrt(Math.max(0, 1 - (k * 0.8) ** 2));};function mortar(g, paste) { g.part("mortarBody", () => g.field({ x: [MX - 8, MX + 8], y: [RIM_Y, 28] }, (x, y) => { const dx = x + 0.5 - (MX + 0.5), hw = halfW(y); if (Math.abs(dx) > hw) return null; if (y >= 27) return dx < -2 ? "n2" : dx < 2 ? "n1" : "n0"; // foot, in the bowl's shadow let i = litIndex(x, y, MX + 0.5, RIM_Y + 1, 6.6, 8.5, 4, 0.05); if (i < 0) i = 0; if ((y === 21 && (x === MX - 3 || x === MX + 3)) || (y === 24 && x === MX + 1)) i = Math.max(0, i - 1); // chisel pocks if (y === RIM_Y + 2 && dx < -3 && dx > -5.5) return "n5"; // shoulder highlight return ["n1", "n2", "n3", "n4"][i]; })); g.part("mortarRim", () => g.field({ x: [MX - 7, MX + 7], y: [RIM_Y - 3, RIM_Y + 3] }, (x, y) => { const ox = (x + 0.5 - (MX + 0.5)) / RIM.rx, oy = (y + 0.5 - (RIM_Y + 0.5)) / RIM.ry; if (ox * ox + oy * oy > 1) return null; const ix = (x + 0.5 - (MX + 0.5)) / MOUTH.rx, iy = (y + 0.5 - (RIM_Y + 0.5)) / MOUTH.ry; if (ix * ix + iy * iy <= 1) { // the hollow: far wall in shadow, paste pooled in the near half if (iy < -0.2) return ix > 0.3 ? "n1" : "n0"; return paste(x, y, ix); } const a = -0.75 * ox - 0.45 * oy; if (a > 0.55 && oy < 0) return "n5"; // one hard glint on the lit lip return a > 0.1 ? "n4" : a > -0.45 ? "n3" : "n2"; }));}// rim front edge of the mouth: pestle pixels below it are hidden inside the bowlconst mouthFront = (x) => { const ix = (x + 0.5 - (MX + 0.5)) / MOUTH.rx; return Math.abs(ix) >= 1 ? RIM_Y - 1 : RIM_Y + 0.5 + MOUTH.ry * Math.sqrt(1 - ix * ix) - 0.5;};function pestle(g, a, lift) { // the pestle leans out of the bowl; its top traces an ellipse as it grinds (a = lean direction) const B = [MX + 0.5 + Math.cos(a) * 0.8, RIM_Y + 4 - lift]; const T = [MX + 0.5 + Math.cos(a) * 7, RIM_Y - 9 + Math.sin(a) * 2.5 - lift]; const len = Math.hypot(T[0] - B[0], T[1] - B[1]); const nx = -(T[1] - B[1]) / len, ny = (T[0] - B[0]) / len; // across-direction const lit = nx * -0.7 + ny * -0.7 > 0 ? 1 : -1; // which side faces the top-left light g.part("pestle", () => g.field({ x: [MX - 9, MX + 10], y: [RIM_Y - 13, RIM_Y + 3] }, (x, y) => { if (y > mouthFront(x)) return null; const px = x + 0.5 - B[0], py = y + 0.5 - B[1]; const k = (px * (T[0] - B[0]) + py * (T[1] - B[1])) / (len * len); const v = (px * nx + py * ny) * lit; // + = lit side const kx = B[0] + (T[0] - B[0]) * 0.94, ky = B[1] + (T[1] - B[1]) * 0.94; const knob = Math.hypot(x + 0.5 - kx, y + 0.5 - ky) <= 1.6; // a rounded knob at the handle end if (!knob && (k < 0 || k > 0.9 || Math.abs(v) > 1.05)) return null; return v > 0.4 ? "w4" : v > -0.5 ? "w3" : "w2"; }));}// ---- candle, toadstools, parchment, loose herbs -----------------------------------------------------------function candle(g) { g.part("dish", () => { g.ellipse({ x: [CANDLE_X - 4, CANDLE_X + 4], y: [21, 25] }, "g1", { fill: "g1" }); g.line([CANDLE_X - 3, 21], [CANDLE_X + 1, 21], "g3"); g.line([CANDLE_X - 4, 22], [CANDLE_X - 4, 23], "g2"); g.line([CANDLE_X - 2, 25], [CANDLE_X + 3, 25], "g0"); g.pixel(CANDLE_X + 4, 23, "g0"); g.line([CANDLE_X + 5, 22], [CANDLE_X + 6, 21], "g1"); g.pixel(CANDLE_X + 6, 20, "g2"); // finger loop }); g.part("candle", () => { g.rect({ x: [CANDLE_X - 2, CANDLE_X + 1], y: [15, 23] }, "n4", { fill: "n4" }); g.line([CANDLE_X - 2, 16], [CANDLE_X - 2, 23], "n5"); g.line([CANDLE_X + 1, 16], [CANDLE_X + 1, 23], "n3"); g.line([CANDLE_X - 2, 15], [CANDLE_X + 1, 15], "n5"); // melted top g.pixel(CANDLE_X + 1, 15, "n4"); g.pixel(CANDLE_X - 1, 16, "n3"); g.pixel(CANDLE_X, 16, "n3"); // the well round the wick g.line([CANDLE_X + 2, 16], [CANDLE_X + 2, 19], "n4"); // a long drip g.pixel(CANDLE_X + 2, 20, "n3"); g.pixel(CANDLE_X - 3, 22, "n5"); g.pixel(CANDLE_X - 3, 23, "n4"); // wax pooled on the dish });}function candleFlame(g, t) { g.part("wick", () => g.pixel(CANDLE_X - 1, 14, "n0")); g.part("flame", () => flame(g, CANDLE_X - 1, 13, 3, 6, t, 0.2, ["fl0", "fl1", "fl2", "fl3"]));}function toadstools(g) { g.part("toadstools", () => { // big one, front right g.rect({ x: [39, 40], y: [28, 31] }, "n4", { fill: "n4" }); g.pixel(39, 29, "n5"); g.pixel(40, 31, "n3"); g.ellipse({ x: [36, 42], y: [25, 28] }, "r1", { fill: "r1" }); g.line([37, 25], [39, 25], "r2"); g.line([37, 26], [38, 26], "r2"); g.pixel(37, 25, "r3"); g.line([37, 28], [41, 28], "r0"); // gills in shadow under the cap g.pixel(38, 26, "n5"); g.pixel(40, 25, "n5"); g.pixel(41, 27, "n4"); // warts // small one, leaning in from the left g.line([35, 29], [35, 31], "n4"); g.pixel(34, 31, "n5"); g.rect({ x: [33, 36], y: [28, 29] }, "r1", { fill: "r1" }); g.pixel(33, 28, "r3"); g.pixel(34, 28, "r2"); g.pixel(36, 29, "r0"); g.pixel(35, 28, "n5"); });}// parchment: a slightly skewed sheet with a curled back corner; the rune is drawn by runeKeyconst SHEET = [[21, 27], [29, 25], [31, 30], [23, 32]];function parchment(g) { g.part("parchment", () => g.field({ x: [21, 31], y: [25, 32] }, (x, y) => { const P = [x + 0.5, y + 0.5]; let inside = true; for (let i = 0; i < 4; i++) { const [ax, ay] = SHEET[i], [bx, by] = SHEET[(i + 1) % 4]; if ((bx - ax) * (P[1] - ay) - (by - ay) * (P[0] - ax) < 0) { inside = false; break; } } if (!inside) return null; if (x >= 28 && y <= 26) return "n3"; // curled corner, its underside if (x === 27 && y === 26) return "n4"; const u = (x - 21) / 10 + (y - 25) / 7; return u > 1.2 ? "n4" : "n5"; }));}const RUNE = [[24, 27], [26, 27], [28, 27], [25, 28], [26, 28], [27, 28], [26, 29], [25, 30], [26, 30]];function rune(g, key) { g.part("rune", () => { for (const [x, y] of RUNE) g.pixel(x, y, key); }); }function looseHerbs(g) { g.part("looseHerbs", () => { // a sage sprig (leaves either side of a stem) and a lavender sprig on the front plank, with their shadows g.line([7, 30], [11, 28], "w1"); g.pixel(8, 30, "e3"); g.pixel(9, 30, "e2"); g.pixel(9, 29, "e3"); g.pixel(10, 29, "e2"); g.pixel(11, 28, "e2"); g.pixel(10, 28, "e3"); g.pixel(8, 29, "e2"); g.pixel(11, 29, "e1"); g.pixel(12, 28, "e1"); g.pixel(10, 30, "e1"); g.line([6, 26], [9, 24], "e1"); g.pixel(7, 25, "v2"); g.pixel(8, 24, "v3"); g.pixel(9, 23, "v2"); g.pixel(8, 25, "v1"); g.pixel(12, 31, "e2"); g.pixel(19, 30, "e2"); g.pixel(21, 31, "e1"); // crushed bits });}// ---- effects ----------------------------------------------------------------------------------------------function steam(g, t) { g.detached("steam", () => { for (let i = 0; i < 2; i++) { const p = (t + i / 2) % 1; const x = MX - 1 + i * 2 + Math.round(Math.sin(TAU * (p * 0.9 + i * 0.35)) * 1.5); const y = RIM_Y - 2 - Math.round(p * 11); if (p < 0.4) { g.pixel(x, y, "n4"); g.pixel(x + 1, y - 1, "n3"); } else if (p < 0.75) { if (chk(x, y)) g.pixel(x, y, "n3"); g.pixel(x + 1, y - 1, "n3"); } else if (chk(x, Math.floor(t * 16))) g.pixel(x, y, "n2"); } });}function dustPuffs(g, t, a) { // a puff is kicked out of the mouth on the side the pestle is sweeping past, twice a revolution g.detached("dust", () => { for (let i = 0; i < 4; i++) { const p = (t * 2 + i / 4) % 1; const side = i % 2 ? 1 : -1; const x = Math.round(MX + 0.5 + side * (3 + p * 6)); const y = Math.round(RIM_Y - 1 - p * 8 + p * p * 5); if (p < 0.3) { g.pixel(x, y, "e3"); g.pixel(x + side, y, "e2"); g.pixel(x, y - 1, "e2"); g.pixel(x + side, y - 1, "e3"); } else if (p < 0.6) { g.pixel(x, y, "e2"); g.pixel(x + side, y - 1, "e2"); if (chk(x, y)) g.pixel(x - side, y - 1, "e1"); } else if (p < 0.85) { if (chk(x, y + i)) g.pixel(x, y, "e1"); g.pixel(x + side, y - 1, "e1"); } } });}function runeGlow(g, t, k) { // at the peak of each pulse two motes lift off the rune g.detached("runeMotes", () => { const p = (t * 2) % 1; if (p > 0.45) return; const y = 26 - Math.round(p * 10); g.pixel(26, y, p < 0.2 ? "e3" : "e2"); g.pixel(28, y + 2, p < 0.25 ? "e2" : "e1"); });}// violet puff for the brewed one-shot: frame f in 1..6function puff(g, f) { g.detached("puff", () => { if (f === 1) { // a bright jet out of the mouth for (let y = RIM_Y - 7; y <= RIM_Y; y++) { const w = y > RIM_Y - 3 ? 1 : 0; for (let x = MX - w; x <= MX + 1 + w; x++) g.pixel(x, y, y < RIM_Y - 5 ? "v2" : x === MX || x === MX + 1 ? "v3" : "v2"); } for (const [x, y, k] of [[MX - 3, RIM_Y - 2, "v3"], [MX + 4, RIM_Y - 3, "v3"], [MX - 2, RIM_Y - 5, "v2"], [MX + 3, RIM_Y - 6, "v2"], [MX - 4, RIM_Y, "v1"], [MX + 5, RIM_Y - 1, "v1"], [MX, RIM_Y - 9, "v1"]]) g.pixel(x, y, k); return; } const R = [0, 0, 3.5, 5, 6, 6.5, 6.5][f], cy = [0, 0, RIM_Y - 5, RIM_Y - 7, RIM_Y - 8.5, RIM_Y - 9.5, RIM_Y - 10][f]; const fill = [0, 0, 1, 1, 0.97, 0.62, 0][f]; // cloud density (thins as it breaks up) // billows, back to front: [dx, dy, r] in units of R const BIL = [[0, -0.75, 0.62], [-0.8, -0.05, 0.55], [0.85, 0.05, 0.55], [0.05, 0.35, 0.62]]; if (fill > 0) g.field({ x: [MX - 10, MX + 11], y: [0, RIM_Y] }, (x, y) => { let best = -1, dmin = 9; for (const [bx, by, br] of BIL) { const cx = MX + 0.5 + bx * R * (1 + (f - 2) * 0.08), cyy = cy + by * R, r = br * R; const i = litIndex(x, y, cx, cyy, r, r * 0.9, 4, 0.15); if (i < 0) continue; if (best < 0 || i > best) best = i; dmin = Math.min(dmin, Math.hypot(x + 0.5 - cx, (y + 0.5 - cyy) / 0.9) / r); } if (best < 0) return null; const key = ["v1", "v2", "v2", "v3"][best]; if (fill < 1 && (dmin > fill || (dmin > fill - 0.3 && !chk(x, y)))) return null; // billows shrink to wisps return key; }); // sparks thrown out from the cloud for (let s = 0; s < 7; s++) { const a = (s / 7) * TAU + 0.4; const d = R + 1 + (f - 2) * 1.6; const x = Math.round(MX + 0.5 + Math.cos(a) * d * 1.1); const y = Math.round(cy + Math.sin(a) * d * 0.8 + (f >= 5 ? (f - 4) * 2 : 0)); if (y < 0 || y > GROUND) continue; g.pixel(x, y, f >= 5 ? (s % 2 ? "v2" : "v1") : s % 2 ? "v3" : "v2"); if (f <= 4) g.pixel(x, y + 1, "v1"); } });}// ---- assembly ---------------------------------------------------------------------------------------------const MortarTable = sprite(pal, (g, t, mode) => { t = ((t % 1) + 1) % 1; let ti = t, a = -0.6, lift = 0, runeK = "w1", glowK = 0, f = -1; if (mode === "grind") { a = -0.6 + TAU * t; // one revolution per loop glowK = 0.5 + 0.5 * Math.cos(TAU * 2 * t); runeK = glowK > 0.8 ? "e3" : glowK > 0.45 ? "e2" : glowK > 0.15 ? "e1" : "w1"; } else if (mode === "brewed") { f = Math.min(N_BREW - 1, Math.round(t * N_BREW)); ti = (((f - (N_BREW - 1)) / N_IDLE) % 1 + 1) % 1; // hands back to idle frame 0 lift = f === 1 ? 2 : f === 2 ? 1 : 0; // the pestle jumps in the blast } // paste in the bowl: green, flaring violet in the brewed blast const flare = f >= 0 && f <= 3; const paste = (x, y, ix) => { if (flare) return f === 1 ? "v3" : ix < 0 ? "v2" : "v1"; if (mode === "grind" && hash(x, Math.floor(t * N_GRIND)) > 0.7) return "e3"; return ix < -0.2 ? "e2" : "e1"; }; contactShadow(g, CX, GROUND, 20, 2); g.outlined("ink", () => table(g)); tableShadows(g); g.outlined("ink", () => { looseHerbs(g); parchment(g); candle(g); toadstools(g); mortar(g, paste); pestle(g, a, lift); }); rune(g, runeK); candleFlame(g, ti); if (mode === "grind") { dustPuffs(g, t, a); runeGlow(g, t, glowK); } if (mode === "idle" || (mode === "brewed" && (f === 0 || f >= 6))) steam(g, ti); if (mode === "brewed" && f >= 1 && f <= 6) puff(g, f);});submit(MortarTable, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "grind", args: ["grind"], animate: { fps: 10, duration: N_GRIND / 10, loop: true } }, { name: "brewed", args: ["brewed"], animate: { fps: 10, duration: N_BREW / 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.