Saw trap
A 48×32 floor prop: a patch of flagstones split by an iron-lined slot, with a toothed steel disc standing half sunk in it and spinning clockwise. The disc has a polished rim lit on its top-left arc, 8 teeth leaning the way it spins, and short bright arc streaks that turn with it, so the spin reads at 1×. “spin” (8 frames) turns it a quarter turn with sparks where the teeth leave the slot; “travel” (16 frames) runs it from the left end of the slot to the right end and back; “hidden” (8 frames) shows the empty slot; “emerge” (6 frames, once) raises the spinning blade, ending on the first frame of “spin”.
6×
Get the animated sprites: Dungeon Mechanisms on itch.ioVol.2 · 12 traps, doors and breakables
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Saw trap — 48x32 floor prop, top-down 3/4, key light top-left.// A patch of flagstones split by an iron-lined slot running left-right; a toothed steel disc stands in the slot,// half sunk, spinning clockwise (its top runs right). The disc faces the camera, so it reads as a full circle cut by// the near lip of the slot. Polished rim ring lit on its top-left arc, darker inner disc, 8 sawtooth teeth all leaning// the spin direction, and short bright arc streaks on the face that turn with it, so the spin reads at 1x.// Variants:// spin loop, 8f — blade turns 90° (2 teeth) per loop, sparks spray where the teeth leave the slot// travel loop, 16f — blade runs from the left end of the slot to the right end and back, still spinning// hidden loop, 8f — empty slot, a glint runs along the far iron lip// emerge one-shot, 6f — blade rises spinning out of the slot, overshoots 1px; ends == spin f0The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(SawTrap, { size: [W, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_SPIN / 10, loop: true }, anchor: { x: 24, y: GROUND }, variants: [ { name: "spin", args: ["spin"] }, { name: "travel", args: ["travel"], animate: { fps: 10, duration: N_TRAVEL / 10, loop: true } }, { name: "hidden", args: ["hidden"] }, { name: "emerge", args: ["emerge"], animate: { fps: 10, duration: N_EMERGE / 10, loop: false } }, ],});Show the whole program (195 lines)
// Saw trap — 48x32 floor prop, top-down 3/4, key light top-left.// A patch of flagstones split by an iron-lined slot running left-right; a toothed steel disc stands in the slot,// half sunk, spinning clockwise (its top runs right). The disc faces the camera, so it reads as a full circle cut by// the near lip of the slot. Polished rim ring lit on its top-left arc, darker inner disc, 8 sawtooth teeth all leaning// the spin direction, and short bright arc streaks on the face that turn with it, so the spin reads at 1x.// Variants:// spin loop, 8f — blade turns 90° (2 teeth) per loop, sparks spray where the teeth leave the slot// travel loop, 16f — blade runs from the left end of the slot to the right end and back, still spinning// hidden loop, 8f — empty slot, a glint runs along the far iron lip// emerge one-shot, 6f — blade rises spinning out of the slot, overshoots 1px; ends == spin f0const W = 48, GROUND = 30;const N_SPIN = 8, N_TRAVEL = 16, N_HIDDEN = 8, N_EMERGE = 6;const STEP = TAU / 32; // blade turn per frame (11.25°): 1/4 of a tooth, reads as forwardconst SLOT = { x0: 3, x1: 44, lip: 15, wall: [16, 19], void: 20, near: 21 };const BLADE = { cy: 20.5, rb: 10, th: 2.8, teeth: 8 };const TRAVEL = { mid: 24, amp: 6 };// ---- flagstones ------------------------------------------------------------------------------------------// Irregular flagstones: cells around hand-placed seeds, measured with y stretched (the floor is foreshortened),// mortar where two cells meet. The patch edge follows the cells, so it is ragged and sits on any floor.const SEEDS = [ [3, 9], [9, 5], [14, 11], [21, 6], [27, 11], [33, 5], [39, 11], [45, 7], [3, 26], [9, 30], [13, 25], [20, 29], [26, 24], [32, 29], [38, 25], [44, 29],];const PALE = new Set([0, 7, 9, 15]);const FY = 1.9;function cellAt(x, y) { let a = -1, d1 = 1e9, d2 = 1e9; for (let i = 0; i < SEEDS.length; i++) { const dx = x + 0.5 - SEEDS[i][0], dy = (y + 0.5 - SEEDS[i][1]) * FY, d = Math.hypot(dx, dy); if (d < d1) { d2 = d1; d1 = d; a = i; } else if (d < d2) d2 = d; } return { id: a, gap: d2 - d1 };}function inPatch(x, y, id) { // a rounded-rectangle patch whose edge steps in and out stone by stone const k = 0.86 + 0.14 * hash(id, 3.7); const nx = Math.abs(x + 0.5 - 24) / (24 * (0.93 + 0.07 * k)), ny = Math.abs(y + 0.5 - 16.5) / (14.5 * k); return nx ** 4 + ny ** 4 <= 1;}function stoneTone(x, y) { const c = cellAt(x, y); if (!inPatch(x, y, c.id)) return null; if (c.gap < 1.1) return "n0"; // mortar const other = (xx, yy) => { const o = cellAt(xx, yy); return o.id !== c.id || o.gap < 1.1 || !inPatch(xx, yy, o.id); }; const pale = PALE.has(c.id); const [hi, base, lo] = pale ? ["n3", "n2", "n1"] : ["n2", "n1", "n0"]; if (other(x, y - 1) || other(x - 1, y)) return hi; // far and left bevels face the light if (other(x, y + 1) || other(x + 1, y)) return pale ? lo : "n1"; // near and right bevels in shade return base;}function flagstones(g) { g.part("flagstones", () => { g.field({ x: [0, W - 1], y: [2, 31] }, stoneTone); // countable marks: a crack, a chip, moss in two joints g.line([27, 26], [29, 27], "n0"); g.pixel(30, 27, "n0"); g.pixel(8, 24, "e1"); g.pixel(7, 24, "e2"); g.pixel(36, 13, "e1"); g.pixel(35, 13, "e2"); });}function slot(g) { const { x0, x1, lip, wall } = SLOT; g.part("slot", () => { // far lip: top of the iron angle that lines the slot, lit toward the left g.line([x0, lip], [x1, lip], "n3"); g.line([x0, lip], [x0 + 14, lip], "n4"); // far inner wall: iron plate facing the camera, turned away from the light; plate seams and rivets g.rect({ x: [x0 + 1, x1 - 1], y: [wall[0], wall[1]] }, "n0", { fill: "n0" }); g.line([x0 + 1, wall[0]], [x1 - 1, wall[0]], "n1"); for (let x = x0 + 7; x < x1 - 2; x += 9) { g.line([x, wall[0] + 1], [x, wall[1]], "ink"); g.pixel(x - 2, wall[0] + 1, "n2"); g.pixel(x + 2, wall[0] + 1, "n1"); } g.line([x0 + 1, SLOT.void], [x1 - 1, SLOT.void], "ink"); for (const x of [x0 + 5, x1 - 11]) { g.pixel(x, wall[0] + 1, "w1"); g.pixel(x, wall[0] + 2, "w0"); } // rust weeping from the rivets // end walls: the left one faces right (shade), the right one faces left (lit) g.line([x0, lip + 1], [x0, SLOT.void], "n1"); g.line([x1, lip + 1], [x1, SLOT.void], "n2"); });}function nearLip(g, glintX) { const { x0, x1, near } = SLOT; g.part("nearLip", () => { g.line([x0, near], [x1, near], "n3"); // edge of the near angle, catching the light g.line([x0, near], [x0 + 18, near], "n4"); g.line([x0, near + 1], [x1, near + 1], "n1"); for (let x = x0 + 3; x < x1; x += 6) g.pixel(x, near + 1, "n3"); // rivet heads }); if (glintX != null) g.detached("glint", () => { // a glint slides along the far lip; at its brightest it flares into a small star for (let dx = -1; dx <= 1; dx++) { const x = glintX + dx; if (x >= x0 + 1 && x <= x1 - 1) g.pixel(x, SLOT.lip, "n5"); } if (glintX > x0 + 10 && glintX < x1 - 10) { g.pixel(glintX, SLOT.lip - 1, "n5"); g.pixel(glintX, SLOT.lip + 1, "n4"); } });}// ---- blade ------------------------------------------------------------------------------------------------function bladeTone(x, y, cx, cy, rot) { const dx = x + 0.5 - cx, dy = y + 0.5 - cy, r = Math.hypot(dx, dy); const { rb, th, teeth } = BLADE; if (r > rb + th + 0.3) return null; const a = Math.atan2(dy, dx) - rot; // pattern angle (turns with the blade) const nx = dx / (r || 1), ny = dy / (r || 1); const lit = -0.6 * nx - 0.8 * ny; // how much this part of the rim faces the key light if (r > rb) { // sawtooth: each tooth rises along the spin direction and drops sheer at its tip; a flat gullet between const s = (((a / TAU) * teeth) % 1 + 1) % 1; const h = s < 0.45 ? 0 : (th * (s - 0.45)) / 0.55; if (r > rb + h + 0.3) return null; if (s > 0.8) return lit > -0.3 ? "n5" : "n4"; // sheer front face, honed return lit > -0.3 ? "n3" : "n2"; // back slope } // polished rim ring: bright on the top-left arc, falling into shade on the bottom-right if (r > rb - 1.6) return lit > 0.7 ? "n5" : lit > 0.15 ? "n4" : lit > -0.45 ? "n3" : "n1"; if (r > rb - 2.4) return "n1"; // shadowed step down to the inner disc // brass arbor nut, 2x2, at the axle (sits right on the slot line) if (Math.abs(dx) < 1 && dy > -1.5 && dy < 0.5) return dx < 0 && dy < -0.5 ? "g3" : dx > 0 && dy > -0.5 ? "g0" : "g1"; if (Math.abs(dx) < 2 && dy > -2.5 && dy < 0.5) return "n1"; // its washer // motion streaks: four bright arcs riding the face (only the upper ones are ever out of the slot) for (let k = 0; k < 4; k++) { let d = a - (k * TAU) / 4; d = Math.atan2(Math.sin(d), Math.cos(d)); if (r > 4.4 && r < 6.1 && d > -0.95 && d < 0) return d > -0.28 ? "n5" : d > -0.6 ? "n4" : "n3"; } return "n2";}function blade(g, cx, cy, rot, clipY) { // own 1px ink outline (g.outlined only rims empty pixels, and this blade stands over stone and iron) const R = BLADE.rb + BLADE.th + 1; const tone = (x, y) => bladeTone(x, y, cx, cy, rot); g.part("blade", () => g.field({ x: [Math.floor(cx - R), Math.ceil(cx + R)], y: [Math.floor(cy - R), clipY] }, (x, y) => { const k = tone(x, y); if (k) return k; return tone(x - 1, y) || tone(x + 1, y) || tone(x, y - 1) || tone(x, y + 1) ? "ink" : null; }));}function sparks(g, cx, t, rate, n) { // hot chips thrown up-left where the teeth rise out of the slot (and a few down-right where they bite in) g.detached("sparks", () => { for (let i = 0; i < n; i++) { const p = (t * rate + i / n) % 1; const left = i % 3 !== 2; const ox = left ? cx - 11 : cx + 11, oy = SLOT.void - 1; const vx = left ? -(5 + (i % 2) * 3) : 4, vy = left ? -(6 + (i % 2) * 2) : -3; const x = Math.round(ox + vx * p), y = Math.round(oy + vy * p + 9 * p * p); if (p > 0.7) continue; g.pixel(x, y, p < 0.25 ? "g3" : p < 0.5 ? "r3" : "r2"); if (p < 0.35) g.pixel(x - Math.sign(vx), y + 1, "r3"); } });}const SawTrap = sprite(pal, (g, t, state) => { g.outlined("ink", () => { flagstones(g); slot(g); }); if (state === "hidden") { const f = Math.round(t * N_HIDDEN) % N_HIDDEN; nearLip(g, f < 6 ? SLOT.x0 + 2 + f * 7 : null); return; } let cx = TRAVEL.mid, cy = BLADE.cy, rot = 0, sp = null; if (state === "spin") { const f = Math.round(t * N_SPIN) % N_SPIN; rot = f * STEP; sp = [t, 2, 6]; } if (state === "travel") { const f = Math.round(t * N_TRAVEL) % N_TRAVEL; rot = f * STEP; cx = Math.round(TRAVEL.mid - TRAVEL.amp * Math.cos(TAU * t)); sp = [t, 4, 6]; } if (state === "emerge") { const f = Math.min(N_EMERGE - 1, Math.round(t * N_EMERGE)); const sink = [12, 8, 4, 1, -1, 0][f]; cy = BLADE.cy + sink; rot = (f - (N_EMERGE - 1)) * STEP; if (f === 0) { nearLip(g, SLOT.x0 + 2); return; } sp = f < 5 ? [f / 8, 1, 6] : [0, 2, 6]; // last frame == spin f0, sparks included } blade(g, cx, cy, rot, SLOT.void); nearLip(g, null); if (sp) sparks(g, cx, sp[0], sp[1], sp[2]);});submit(SawTrap, { size: [W, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_SPIN / 10, loop: true }, anchor: { x: 24, y: GROUND }, variants: [ { name: "spin", args: ["spin"] }, { name: "travel", args: ["travel"], animate: { fps: 10, duration: N_TRAVEL / 10, loop: true } }, { name: "hidden", args: ["hidden"] }, { name: "emerge", args: ["emerge"], animate: { fps: 10, duration: N_EMERGE / 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.