Tessarune
Dungeon Mechanisms · saw_trap.js

Saw trap

48×32 · 195 lines of JavaScript

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”.

Saw trap — Rendered output6×
Rendered by this program, variant “travel” · 48×32 · 16 frames
Dungeon MechanismsGet 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.js · 1–10
// 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 f0

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

saw_trap.js · 185–195
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.js · 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.