Apple tub
A 48×48 floor prop showing a wooden half-barrel bound by iron hoops and filled with water where red and green apples float. In the 16-frame idle loop, the apples bob and drift as rings spread and glints shimmer. The splash variant is an 8-frame one-shot where the centre apple ducks under with a crown of droplets, while empty loops across 16 frames with a single circling apple.
6×
Get 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.
// Apple tub — 48x48 floor prop, top-down 3/4, key light top-left.// Bobbing for apples: a wide wooden half-barrel of tapered staves bound by two riveted iron hoops, its end-grain rim// jointed per stave, filled to just under the rim with water (dark under the far wall, lighter toward the near edge).// Red and green apples float in it, each with a stem, a lit shoulder and a bright meniscus ring at the waterline.// Variants:// idle 16f loop the apples bob and drift slowly and turn their stems, rings spread from each, a glint shimmers// splash 8f once the centre apple is ducked under, a crown of droplets rises and falls, rings spread wide,// the apple bobs back up and the other apples rock; ends on idle frame 0// empty 16f loop one apple left circling slowly, rings spreading over the open water, the glint shimmersThe last lines. submit declares the canvas and lists the variants the renderer exports.
submit(AppleTub, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "splash", args: ["splash"], animate: { fps: 10, duration: N_SPLASH / 10, loop: false } }, { name: "empty", args: ["empty"] }, ],});Show the whole program (270 lines)
// Apple tub — 48x48 floor prop, top-down 3/4, key light top-left.// Bobbing for apples: a wide wooden half-barrel of tapered staves bound by two riveted iron hoops, its end-grain rim// jointed per stave, filled to just under the rim with water (dark under the far wall, lighter toward the near edge).// Red and green apples float in it, each with a stem, a lit shoulder and a bright meniscus ring at the waterline.// Variants:// idle 16f loop the apples bob and drift slowly and turn their stems, rings spread from each, a glint shimmers// splash 8f once the centre apple is ducked under, a crown of droplets rises and falls, rings spread wide,// the apple bobs back up and the other apples rock; ends on idle frame 0// empty 16f loop one apple left circling slowly, rings spreading over the open water, the glint shimmersconst W = 48, H = 48, CX = 24, GROUND = 45;const RCY = 24, RRX = 20, RRY = 7; // rim (outer ellipse)const MRX = 17, MRY = 5; // mouth (inside of the rim)const WY = RCY + 2; // water surface centre (2px below the mouth)const BCY = 36, BRX = 18, BRY = 6; // bottom curve of the tapered wallconst N_LOOP = 16, N_SPLASH = 8;const e2 = (x, y, cx, cy, rx, ry) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2;const inRim = (x, y) => e2(x, y, CX, RCY, RRX, RRY) <= 0.99;const inMouth = (x, y) => e2(x, y, CX, RCY, MRX, MRY) <= 0.99;const inWater = (x, y) => inMouth(x, y) && e2(x, y, CX, WY, MRX, MRY) <= 0.99;const isRing = (x, y) => inRim(x, y) && !inMouth(x, y);const hwAt = (y) => RRX + (BRX - RRX) * Math.max(0, Math.min(1, (y - RCY) / (BCY - RCY)));const isWall = (x, y) => { if (y <= RCY || inRim(x, y)) return false; if (y <= BCY) return Math.abs(x + 0.5 - CX) <= hwAt(y); return e2(x + 0.5, y, CX, BCY, BRX, BRY) <= 1;};const LAST = []; // last rim row per columnfor (let x = 0; x < W; x++) { LAST[x] = -1; for (let y = 0; y < H; y++) if (inRim(x, y)) LAST[x] = y; }// ---- tub ------------------------------------------------------------------------------------------------------const WOOD = ["w0", "w1", "w2", "w3", "w4"], IRON = ["n0", "n1", "n2", "n3", "n4"];const cl = (i, a = 0, b = 4) => Math.max(a, Math.min(b, i));const staveOf = (x, y) => Math.floor(Math.asin(Math.max(-0.99, Math.min(0.99, (x + 0.5 - CX) / hwAt(y)))) / (Math.PI / 10));const HOOPS = [[3, 4], [9, 10]]; // rows below the rim's front edgefunction wallTone(x, y) { const dy = y - LAST[x], nx = (x + 0.5 - CX) / hwAt(y); const lv = nx < -0.62 ? 3 : nx < 0.05 ? 2 : nx < 0.7 ? 1 : 0; // cylinder lit from the left const hoop = HOOPS.findIndex(([a, b]) => dy >= a && dy <= b); if (hoop >= 0) { const top = dy === HOOPS[hoop][0]; const riv = top && [-0.75, -0.3, 0.2, 0.65].some((u) => Math.round(CX + u * hwAt(y) - 0.5) === x); if (riv) return lv >= 2 ? "n4" : "n3"; return IRON[cl(lv + (top ? 1 : 0) - (lv === 0 ? 0 : 0), 0, 4)]; } if (HOOPS.some(([a, b]) => dy === b + 1)) return WOOD[cl(lv - 1)]; // the hoop's shadow on the staves let s = lv; if (dy === 1) s -= 1; // under the rim's lip if (staveOf(x, y) !== staveOf(x + 1, y) && x + 1 < CX + hwAt(y)) s -= 1; // stave seam if (!isWall(x, y + 1) || !isWall(x, y + 2)) s -= 1; // the floor-facing bottom edge return WOOD[cl(s)];}function rimTone(x, y) { const nx = (x + 0.5 - CX) / RRX, ny = (y + 0.5 - RCY) / RRY; const ang = (xx, yy) => Math.floor(((Math.atan2((yy + 0.5 - RCY) / RRY, (xx + 0.5 - CX) / RRX) / TAU) * 20 + 20.5)) % 20; if (ang(x + 1, y) !== ang(x, y) && isRing(x + 1, y) && ny < 0.3) return "w1"; // stave joints (far half) const outer = !inRim(x - 1, y) || !inRim(x + 1, y) || !inRim(x, y - 1) || !inRim(x, y + 1); const inner = inMouth(x - 1, y) || inMouth(x + 1, y) || inMouth(x, y - 1) || inMouth(x, y + 1); const a = -0.75 * nx - 0.6 * ny; if (outer && a > 0.2) return "w4"; // lit outer arris, upper left if (inner && ny > 0.25 && nx < 0.35) return "w4"; // near inner arris catches the light if (inner && ny < 0) return "w2"; // far inner bevel drops into the tub if (outer && ny > 0.3 && nx > 0.35) return "w1"; return a > -0.35 ? "w3" : "w2";}function insideTone(x, y) { // far inner wall above the water: staves seen from inside; their right side faces the light if (inWater(x, y + 1)) return "w0"; // dark line where wall meets water const nx = (x + 0.5 - CX) / MRX; return nx > 0.25 ? "w2" : "w1";}function waterTone(x, y) { if (!inWater(x, y - 1) || !inWater(x, y - 2)) return "b0"; // the far wall's reflection const ny = (y + 0.5 - WY) / MRY; if (ny > 0.55 && inWater(x, y + 1)) return "b1"; return "b1";}function tub(g) { g.part("tub", () => g.field({ x: [CX - RRX - 1, CX + RRX + 1], y: [RCY - RRY - 1, H - 1] }, (x, y) => { if (isRing(x, y)) return rimTone(x, y); if (inMouth(x, y)) return inWater(x, y) ? waterTone(x, y) : insideTone(x, y); if (isWall(x, y)) return wallTone(x, y); return null; })); g.part("wear", () => { g.pixel(9, 37, "w1"); g.pixel(10, 38, "w1"); // a split in a stave g.pixel(37, 33, "w0"); // a knot g.pixel(30, 39, "w1"); g.pixel(6, 25, "e1"); g.pixel(7, 26, "e2"); // a fleck of moss on the rim });}// ---- water light -----------------------------------------------------------------------------------------------function glint(g, t) { g.part("glint", () => { const q = Math.floor(t * 8) % 4; const len = [3, 4, 3, 2][q], x0 = 10 + [0, 0, 1, 1][q]; g.line([x0, 26], [x0 + len - 1, 26], "b3"); g.line([x0 + 1, 27], [x0 + 1 + [1, 2, 1, 0][q], 27], "b2"); if (Math.sin(TAU * (t + 0.2)) > 0.3) g.pixel(38, 25, "b2"); if (Math.sin(TAU * (t * 2 + 0.6)) > 0.5) g.pixel(36, 27, "b3"); g.line([26, 29], [28, 29], "b2"); // near-edge sheen });}function rings(g, list) { // list: [[cx, cy, r, key]] — elliptical ripple rings, only on open water if (!list.length) return; g.part("ripples", () => g.field({ x: [CX - MRX, CX + MRX], y: [RCY - MRY, RCY + MRY] }, (x, y) => { if (!inWater(x, y) || !inWater(x, y - 1)) return null; for (const [cx, cy, r, key] of list) { const q = Math.hypot(x + 0.5 - cx, (y + 0.5 - cy) * 2.6); if (Math.abs(q - r) < 0.75) return key === "faint" ? (chk(x, y) ? "b2" : null) : key; } return null; }));}// ---- apples ----------------------------------------------------------------------------------------------------// 6 rows; a floating apple shows rows 0..4 above its waterline (row 5 is under water)const SHAPE = [[1, 4], [0, 5], [0, 5], [0, 5], [0, 5], [1, 4]];const SKIN = { red: ["r0", "r1", "r1", "r2"], green: ["e0", "e1", "e2", "e2"] };const SHINE = { red: "r3", green: "e3" };function apple(g, ax, wl, kind, sink, stem, name) { // ax: left column, wl: waterline row (the last visible row), sink: rows pushed under (negative = lifted) const top = wl - 4 + sink, m = new Map(); const P = (x, y, k) => m.set(y * W + x, k); const ramp = SKIN[kind]; for (let r = 0; r < 6; r++) { const y = top + r; if (y > wl) break; for (let c = SHAPE[r][0]; c <= SHAPE[r][1]; c++) { let i = litIndex(c, r, 3, 3, 3.2, 3.2, 4, -0.02); if (i < 0) i = 0; P(ax + c, y, ramp[i]); } } if (top <= wl) { P(ax + 1, top + 1, "n5"); // specular on the lit shoulder if (top + 2 <= wl) P(ax + 1, top + 2, SHINE[kind]); if (top + 1 <= wl) P(ax + 2, top + 1, SHINE[kind]); if (top >= 0) P(ax + 2 + stem, top, ramp[0]); // the dimple } const out = new Map(m); if (top - 1 <= wl && top >= 1) out.set((top - 1) * W + ax + 2 + stem, "w1"); // stem if (kind === "green" && top >= 1 && top <= wl) out.set((top - 1) * W + ax + 3 + stem, "e2"); // a leaf // ink around it (not below the waterline), meniscus ring under it const ink = new Set(); for (const i of out.keys()) { const x = i % W, y = (i - x) / W; for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y - 1], [x, y + 1]]) { const k = ny * W + nx; if (!out.has(k) && ny <= wl) ink.add(k); } } const wr = SHAPE[Math.min(5, wl - top)] || [1, 4]; g.part(name, () => g.field({ x: [ax - 2, ax + 8], y: [wl - 7, wl + 1] }, (x, y) => { const i = y * W + x; if (out.has(i)) return out.get(i); if (ink.has(i)) return "ink"; if (y === wl + 1 && top <= wl && inWater(x, y)) { const c = x - ax; if (c >= wr[0] && c <= wr[1]) return "b3"; if (c === wr[0] - 1 || c === wr[1] + 1) return "b2"; } return null; }));}// apples: [left x, waterline, kind, phase]const APPLES = [ [9, 24, "red", 0.0], [30, 23, "green", 0.62], [19, 24, "red", 0.37], [24, 28, "green", 0.81],];const DUCK = 2; // the centre apple is the one ducked in the splashfunction applePose(ph, t) { return { dx: Math.round(Math.sin(TAU * (t + ph)) * 1.2), sink: Math.sin(TAU * (2 * t + ph * 1.7)) > 0.35 ? 1 : 0, stem: Math.round(Math.sin(TAU * (t + ph + 0.25)) * 1.1), };}function appleRing(ax, wl, ph, t) { const p = (t * 2 + ph) % 1; return [ax + 3, wl + 1, 3.5 + p * 4.5, p < 0.45 ? "b2" : "faint"];}// ---- splash ----------------------------------------------------------------------------------------------------// per splash frame: sink of the ducked apple, rock of the others, ring radii, droplet setconst DUCK_SINK = [0, 3, 6, 5, -1, 1, 0, 0];const ROCK = [0, 0, 0, 1, 1, 0, 0, 0];function droplets(g, f, cx, cy) { // the crown: a central column and side drops, each a 2px streak (bright head, darker tail along its motion) const up = f <= 4; const D = { 2: [[0, -1], [-2, 0], [2, 0]], 3: [[0, -4], [-2, -3], [2, -3], [-4, -1], [4, -1], [-1, -6]], 4: [[0, -8], [-3, -6], [3, -6], [-5, -3], [5, -4], [1, -10], [-1, -5]], 5: [[0, -6], [-4, -5], [4, -5], [-7, -1], [7, -2], [1, -8]], 6: [[-6, 1], [7, 1], [0, -2], [-9, 2], [9, 2]], }[f] || []; g.detached("droplets", () => { for (const [dx, dy] of D) { g.pixel(cx + dx, cy + dy, "b3"); if (f !== 6) g.pixel(cx + dx, cy + dy + (up ? 1 : -1), "b2"); } if (f === 3) g.rect({ x: [cx - 1, cx + 1], y: [cy - 1, cy + 1] }, "b2", { fill: "b2" }); // the water heaves up if (f === 3) g.pixel(cx, cy - 2, "b3"); });}function stateAt(state, t) { t = ((t % 1) + 1) % 1; if (state !== "splash") return { mode: state, t, f: -1 }; const f = Math.min(N_SPLASH - 1, Math.round(t * N_SPLASH)); if (f === 0 || f === N_SPLASH - 1) return { mode: "idle", t: 0, f: -1 }; return { mode: "splash", t: 0, f };}const AppleTub = sprite(pal, (g, t, state) => { const S = stateAt(state, t); contactShadow(g, CX, GROUND, 20, 2); g.outlined("ink", () => tub(g)); if (S.mode === "empty") { const [ax0, wl, kind] = [21, 25, "red"]; const ax = ax0 + Math.round(Math.cos(TAU * S.t) * 4), wlx = wl + Math.round(Math.sin(TAU * S.t) * 1.4); glint(g, S.t); rings(g, [0, 0.5].map((o) => { const p = (S.t + o) % 1; return [ax + 3, wlx + 1, 3.5 + p * 9, p < 0.4 ? "b2" : "faint"]; })); const sink = Math.sin(TAU * (2 * S.t)) > 0.35 ? 1 : 0; apple(g, ax, wlx, kind, sink, Math.round(Math.sin(TAU * (S.t + 0.25)) * 1.1), "apple0"); return; } glint(g, S.t); const L = []; const f = S.f; APPLES.forEach(([ax, wl, , ph], i) => { const P = applePose(ph, S.t); if (f < 0 || i !== DUCK) L.push(appleRing(ax + P.dx, wl, ph, S.t)); }); if (f > 0) { const [ax, wl] = APPLES[DUCK], cx = ax + 3, cy = wl + 1; const R = { 1: [[3, "b3"]], 2: [[2, "b3"], [5, "b2"]], 3: [[4, "b3"], [8, "b2"]], 4: [[6, "b2"], [11, "faint"]], 5: [[9, "b2"], [3, "b3"]], 6: [[12, "faint"], [6, "b2"]] }[f] || []; for (const [r, k] of R) L.push([cx, cy, r, k]); } rings(g, L); const order = APPLES.map((a, i) => i).sort((a, b) => APPLES[a][1] - APPLES[b][1]); for (const i of order) { const [ax, wl, kind, ph] = APPLES[i]; const P = applePose(ph, S.t); let sink = P.sink; if (f > 0) sink = i === DUCK ? DUCK_SINK[f] : Math.min(1, sink + ROCK[f]); if (sink >= 5) continue; // fully under apple(g, ax + P.dx, wl, kind, sink, P.stem, "apple" + i); } if (f > 0) { const [ax, wl] = APPLES[DUCK]; if (f === 2) g.detached("bubble", () => { g.pixel(ax + 3, wl + 1, "b3"); g.pixel(ax + 1, wl, "b2"); }); droplets(g, f, ax + 3, wl - 1); }});submit(AppleTub, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "splash", args: ["splash"], animate: { fps: 10, duration: N_SPLASH / 10, loop: false } }, { name: "empty", args: ["empty"] }, ],});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.