Tessarune
Harvest Festival · festival_lanterns.js

Festival lanterns

64×48 · 197 lines of JavaScript

This 64×48 floor prop features two wooden posts holding a sagging rope with four coloured paper lanterns and five cloth bunting flags. In the 16-frame idle loop, the candles flicker with dithered halos as the lanterns sway and flags flutter. The 12-frame windy loop whips the tassels and rope in a breeze, and the 16 frames unlit variant is daytime, with the paper lanterns dark and gently swaying.

Festival lanterns — Rendered output6×
Rendered by this program, variant “idle” · 64×48 · 16 frames
Harvest FestivalGet the animated sprites: Harvest Festival on itch.ioVol.6 · 12 harvest-fair props · 37 animations

The header. The comment at the top of the file is the brief: size, view, light direction and every variant.

festival_lanterns.js · 1–12
// Festival lanterns — 64x48 floor prop, top-down 3/4, key light top-left.// Two wooden posts (pointed tops, a sprig of wheat tucked into each rope knot, a little mound of earth and grass at// the foot) carry a sagging rope strung with four round paper lanterns — orange, red, violet and gold, each with a// dark cap, ribbed paper, a bottom ring and a tassel — and five small cloth bunting flags (green, cream, blue)// hung between them. Lantern glows are detached dithered halos; posts have contact shadows.// Variants://   idle    16f loop: the candles flicker (each lantern on its own phase), the halos breathe, the lanterns sway on//                     their cords with the tassels trailing, the rope bobs, the flags flutter//   windy   12f loop: a steady breeze from the left — the lanterns swing hard to the right and back, the tassels//                     whip, the rope bellies, the flags snap out sideways//   unlit   16f loop: daytime — the paper is unlit (lit from the top-left like any other surface), gentle sway,//                     flags flutter

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

festival_lanterns.js · 187–197
submit(Lanterns, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: 32, y: GROUND },  args: ["idle"],  variants: [    { name: "idle", args: ["idle"] },    { name: "windy", args: ["windy"], animate: { fps: 10, duration: N_WIND / 10, loop: true } },    { name: "unlit", args: ["unlit"] },  ],});
Show the whole program (197 lines)
festival_lanterns.js · 197 lines
// Festival lanterns — 64x48 floor prop, top-down 3/4, key light top-left.// Two wooden posts (pointed tops, a sprig of wheat tucked into each rope knot, a little mound of earth and grass at// the foot) carry a sagging rope strung with four round paper lanterns — orange, red, violet and gold, each with a// dark cap, ribbed paper, a bottom ring and a tassel — and five small cloth bunting flags (green, cream, blue)// hung between them. Lantern glows are detached dithered halos; posts have contact shadows.// Variants://   idle    16f loop: the candles flicker (each lantern on its own phase), the halos breathe, the lanterns sway on//                     their cords with the tassels trailing, the rope bobs, the flags flutter//   windy   12f loop: a steady breeze from the left — the lanterns swing hard to the right and back, the tassels//                     whip, the rope bellies, the flags snap out sideways//   unlit   16f loop: daytime — the paper is unlit (lit from the top-left like any other surface), gentle sway,//                     flags flutterconst W = 64, H = 48, GROUND = 45;const N_IDLE = 16, N_WIND = 12;const POSTS = [5, 58];const ROPE = { x0: 7, x1: 56, y: 9 };const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];const dith = (x, y, dens) => BAYER[(y & 3) * 4 + (x & 3)] / 16 < dens;// [x on the rope, cord length, colour ramps, sway phase, flicker phase]// lit: [edge, mid, bright, core]   unlit: [shadow, base, light, highlight]const LANTERNS = [  { x: 13, cord: 3, lit: ["r1", "r2", "r3", "g3"], day: ["r0", "r1", "r2", "r3"], ph: 0.0, fph: 0.13 },  { x: 25, cord: 4, lit: ["r0", "r1", "r2", "r3"], day: ["r0", "r0", "r1", "r2"], ph: 0.27, fph: 0.61 },  { x: 38, cord: 4, lit: ["v1", "v2", "v3", "n5"], day: ["v0", "v1", "v2", "v3"], ph: 0.55, fph: 0.37 },  { x: 49, cord: 3, lit: ["g1", "g2", "g3", "n5"], day: ["g0", "g1", "g2", "g3"], ph: 0.8, fph: 0.85 },];const FLAGS = [  { x: 9, k: ["e2", "e1"], ph: 0.1 },  { x: 19, k: ["n5", "n4"], ph: 0.42 },  { x: 31, k: ["b2", "b1"], ph: 0.74 },  { x: 44, k: ["n5", "n4"], ph: 0.2 },  { x: 54, k: ["e2", "e1"], ph: 0.57 },];// ---- rope ------------------------------------------------------------------------------------------------------function ropeY(x, sag) {  const u = (x - (ROPE.x0 + ROPE.x1) / 2) / ((ROPE.x1 - ROPE.x0) / 2);  return ROPE.y + sag * (1 - u * u);}function rope(g, sag) {  g.part("rope", () => {    for (let x = ROPE.x0; x <= ROPE.x1; x++) {      const y = Math.round(ropeY(x, sag));      g.pixel(x, y, x % 3 === 0 ? "w2" : "w3");                       // twisted strands      const y2 = Math.round(ropeY(x + 1, sag));      if (x < ROPE.x1 && Math.abs(y2 - y) > 1) g.line([x, y], [x, y2 + (y2 > y ? -1 : 1)], "w2");   // keep it unbroken    }  });}// ---- posts -----------------------------------------------------------------------------------------------------function post(g, cx, lit) {  g.outlined("ink", () => g.part(lit ? "postL" : "postR", () => {    for (let y = 6; y <= 44; y++) {      g.pixel(cx - 1, y, "w2"); g.pixel(cx, y, "w1"); g.pixel(cx + 1, y, "w0");    }    g.pixel(cx, 4, "w2"); g.pixel(cx - 1, 5, "w3"); g.pixel(cx, 5, "w2"); g.pixel(cx + 1, 5, "w1");   // pointed top    for (const y of [13, 14, 27, 28, 29, 38]) g.pixel(cx - 1, y, "w3");                              // lit streaks    g.pixel(cx, 21, "w0"); g.pixel(cx, 33, "w0"); g.pixel(cx + 1, 17, "ink");                          // knots, a split  }));}function knot(g, cx, side) {  g.part(side < 0 ? "knotL" : "knotR", () => {    g.line([cx - 1, 8], [cx + 1, 8], "w4"); g.line([cx - 1, 10], [cx + 1, 10], "w3");               // rope wraps    g.pixel(cx + 1, 9, "w3"); g.pixel(cx - 1, 9, "w2");    g.line([cx - side, 11], [cx - side, 13], "w2");                                                    // loose end  });  g.part(side < 0 ? "wheatL" : "wheatR", () => {                    // a sprig of wheat tucked into the knot    const d = -side;    g.line([cx, 9], [cx + d * 3, 4], "g0");    g.pixel(cx + d * 3, 4, "g2"); g.pixel(cx + d * 3, 3, "g3"); g.pixel(cx + d * 2, 5, "g1");    g.pixel(cx + d * 4, 5, "g2"); g.pixel(cx + d * 4, 4, "g1");  });}function footing(g, cx, lit) {  g.outlined("ink", () => g.part(lit ? "moundL" : "moundR", () => g.field({ x: [cx - 4, cx + 4], y: [42, 45] }, (x, y) => {    const nx = (x + 0.5 - (cx + 0.5)) / 4.2, ny = (y + 0.5 - 45) / 2.6;    if (nx * nx + ny * ny > 1 || y > 45) return null;    return -0.8 * nx - 0.6 * ny > 0.45 ? "w2" : nx < 0.3 ? "w1" : "w0";  })));  g.part(lit ? "grassL" : "grassR", () => {    for (const [dx, h, k] of [[-3, 3, "e2"], [-2, 4, "e1"], [2, 3, "e1"], [3, 2, "e2"]]) {      g.line([cx + dx, 44], [cx + dx + (dx < 0 ? -1 : 1), 44 - h], k);      g.pixel(cx + dx + (dx < 0 ? -1 : 1), 44 - h, k === "e2" ? "e3" : "e2");    }  });}// ---- flags -----------------------------------------------------------------------------------------------------// a cloth triangle hung from the rope: top edge follows the rope, the tip flutters (and snaps out in the wind)function flag(g, f, sag, t, wind) {  const a = Math.sin(TAU * (2 * t + f.ph)), b = Math.sin(TAU * (3 * t + f.ph * 1.7));  const tipDx = wind ? Math.round(2.2 + 1.3 * a) : Math.round(0.9 * a);  const tipH = wind ? 5 - Math.round(1 + 0.8 * (0.5 + 0.5 * b)) : 5;  const yl = ropeY(f.x - 2, sag), yr = ropeY(f.x + 2, sag), yc = ropeY(f.x, sag);  const P = [[f.x - 2.5, yl + 0.5], [f.x + 2.5, yr + 0.5], [f.x + tipDx, yc + tipH + 0.5]];  const side = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);  g.outlined("ink", () => g.part("flag", () => g.field({ x: [f.x - 4, f.x + 5], y: [Math.floor(Math.min(yl, yr)), Math.ceil(yc + tipH + 1)] }, (x, y) => {    const px = x + 0.5, py = y + 0.5;    const s1 = side(...P[0], ...P[1], px, py), s2 = side(...P[1], ...P[2], px, py), s3 = side(...P[2], ...P[0], px, py);    if (!((s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0))) return null;    const mid = f.x + (tipDx * (py - yc) / tipH) * 0.6;                 // the fold runs from the top centre to the tip    return px < mid + 0.2 ? f.k[0] : f.k[1];  })));}// ---- lanterns --------------------------------------------------------------------------------------------------const BODY_RX = 4.2, BODY_RY = 4.1;function lanternGeom(L, sag, swing) {  const ax = L.x, ay = Math.round(ropeY(L.x, sag));  const len = L.cord + 5;  const off = Math.round(len * Math.sin(swing));  const top = ay + L.cord;                                           // cap row  return { ax, ay, cx: L.x + off, top, cy: top + 6, off };}function halo(g, L, G, amt) {  g.detached("halo", () => g.field({ x: [G.cx - 9, G.cx + 9], y: [G.cy - 9, G.cy + 9] }, (x, y) => {    const r = Math.hypot(x + 0.5 - (G.cx + 0.5), (y + 0.5 - G.cy) * 1.1);    const R = 7.5 * amt;    if (r > R || r < 4.6) return null;    return dith(x, y, 0.34 * (1 - (r - 4.6) / (R - 4.6))) ? L.lit[1] : null;  }));}function lantern(g, i, L, G, lit, flick, tassel) {  const { cx, cy, top } = G;  g.part("cord" + i, () => g.line([G.ax, G.ay + 1], [cx, top - 1], "n1"));  g.outlined("ink", () => g.part("lantern" + i, () => {    g.line([cx - 1, top], [cx + 1, top], "n1"); g.pixel(cx - 1, top, "n3");          // cap    g.line([cx - 2, top + 1], [cx + 2, top + 1], "n0"); g.pixel(cx - 2, top + 1, "n2");    g.field({ x: [cx - 5, cx + 5], y: [top + 2, top + 9] }, (x, y) => {      const nx = (x + 0.5 - (cx + 0.5)) / BODY_RX, ny = (y + 0.5 - cy) / BODY_RY;      if (nx * nx + ny * ny > 1) return null;      const rib = y === cy - 2 || y === cy + 1;      if (lit) {        // light from the candle near the bottom: brightest low in the middle, falling off to the rim        const d = Math.hypot(nx, (ny - 0.25) * 1.1) / flick;        let k = d > 0.85 ? 0 : d > 0.6 ? 1 : d > 0.3 ? 2 : 3;        if (rib && k < 3) k = Math.max(0, k - 1);                    // ribs show against the paper, not the flame        return L.lit[k];      }      let k = litIndex(x, y, cx + 0.5, cy, BODY_RX, BODY_RY, 4, 0.1);      if (k === 3 && !(nx < -0.2 && ny < -0.15)) k = 2;              // keep the highlight a small top-left patch      if (rib) k = Math.max(0, k - 1);      return L.day[k];    });    g.line([cx - 2, top + 10], [cx + 2, top + 10], "n0"); g.pixel(cx - 2, top + 10, "n2");   // bottom ring  }));  g.part("tassel" + i, () => {    const tb = Math.round(tassel);    g.pixel(cx, top + 11, "n1");    g.line([cx, top + 12], [cx + tb, top + 14], lit ? L.lit[1] : L.day[1]);    g.pixel(cx + tb, top + 14, lit ? L.lit[2] : L.day[2]);  });}// ---- scene -----------------------------------------------------------------------------------------------------function scene(g, t, mode) {  const wind = mode === "windy", lit = mode !== "unlit";  const sag = wind ? 11.6 + 1.3 * Math.sin(TAU * t) : 11 + 0.6 * Math.sin(TAU * t);  for (const cx of POSTS) contactShadow(g, cx, GROUND, 5, 1);  const G = LANTERNS.map((L) => {    const swing = wind      ? (0.22 + 0.16 * Math.sin(TAU * (t + L.ph * 0.5))) + 0.06 * Math.sin(TAU * (2 * t + L.ph))      : 0.13 * Math.sin(TAU * (t + L.ph));    return lanternGeom(L, sag, swing);  });  const flicks = LANTERNS.map((L) => 1 + 0.07 * Math.sin(TAU * (2 * t + L.fph)) + 0.05 * Math.sin(TAU * (3 * t + 2 * L.fph)));  if (lit) LANTERNS.forEach((L, i) => halo(g, L, G[i], 0.95 + 0.6 * (flicks[i] - 1)));  rope(g, sag);  for (const f of FLAGS) flag(g, f, sag, t, wind);  LANTERNS.forEach((L, i) => {    const lag = wind ? Math.sin(TAU * (t + L.ph * 0.5 - 0.15)) : Math.sin(TAU * (t + L.ph - 0.12));    const tassel = wind ? 1.6 + 1.2 * lag : 0.9 * lag;    lantern(g, i, L, G[i], lit, flicks[i], tassel);  });  post(g, POSTS[0], true); post(g, POSTS[1], false);  knot(g, POSTS[0], -1); knot(g, POSTS[1], 1);  footing(g, POSTS[0], true); footing(g, POSTS[1], false);}const Lanterns = sprite(pal, (g, t, mode) => {  t = ((t % 1) + 1) % 1;  scene(g, t, mode);});submit(Lanterns, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: 32, y: GROUND },  args: ["idle"],  variants: [    { name: "idle", args: ["idle"] },    { name: "windy", args: ["windy"], animate: { fps: 10, duration: N_WIND / 10, loop: true } },    { name: "unlit", args: ["unlit"] },  ],});

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.