Hourglass
A 32×48 hourglass in a turned wood-and-brass frame, modelled as turned solids, so the same model gives the resting pose and every angle of the flip. The glass is see-through, with an ink silhouette, a 1px rim and a glint, showing the sand, the lower cap or the floor behind it. “idle” (16 frames) holds the heaps still like a timer mid-pour while the stream of grains and the grains sliding and rolling move; “flip” (10 frames, once) hops, tumbles 180° end over end toward the camera as the sand slides to the lower bulb, and lands with dust on the first frame of “idle”.
6×
Get the animated sprites: Arcane Curiosities Vol.1 on itch.io12 animated props · 52 animations
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Hourglass in a turned wood-and-brass frame — 32x48, top-down 3/4, key light top-left.// The frame is modelled as solids of revolution (two turned caps, a two-bulb glass, two turned brass posts) and// ray-cast per pixel, so the same model gives the resting pose and every angle of the flip. The camera elevation// matches the pack (ellipse ratio 0.35). Glass is see-through: ink silhouette, a 1px glass rim, a specular glint,// and whatever lies behind it (sand, the lower cap) — or the floor.// idle: 16 frames @ 10 fps, loop. The heaps hold still (a timer mid-pour); the stream of grains, the grains sliding// into the top funnel and the grains rolling down the bottom cone move, so the loop reads continuous.// flip: 10 frames, one-shot. A hop, a 180° end-over-end tumble toward the camera (the sand slides to whichever bulb// is lower), landing with dust; the last frame is idle frame 0 (the frame is symmetric, the sand amounts swap).The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(Hourglass, { size: [32, 48], origin: "top-left", kind: "icon", scale: 1, anchor: { x: 16, y: 45 }, variants: [ { name: "idle", args: ["idle"], animate: { fps: 10, duration: 1.6, loop: true } }, { name: "flip", args: ["flip"], animate: { fps: 10, duration: 1.0, loop: false } }, ],});Show the whole program (251 lines)
// Hourglass in a turned wood-and-brass frame — 32x48, top-down 3/4, key light top-left.// The frame is modelled as solids of revolution (two turned caps, a two-bulb glass, two turned brass posts) and// ray-cast per pixel, so the same model gives the resting pose and every angle of the flip. The camera elevation// matches the pack (ellipse ratio 0.35). Glass is see-through: ink silhouette, a 1px glass rim, a specular glint,// and whatever lies behind it (sand, the lower cap) — or the floor.// idle: 16 frames @ 10 fps, loop. The heaps hold still (a timer mid-pour); the stream of grains, the grains sliding// into the top funnel and the grains rolling down the bottom cone move, so the loop reads continuous.// flip: 10 frames, one-shot. A hop, a 180° end-over-end tumble toward the camera (the sand slides to whichever bulb// is lower), landing with dust; the last frame is idle frame 0 (the frame is symmetric, the sand amounts swap).const W = 32, H = 48, GROUND = 45;const ELEV = Math.asin(0.35); // camera elevationconst CXS = 16, CYS = 26; // screen position of the hourglass centre at restconst HC0 = 13.5, HC1 = 16.5, RC = 10.5; // caps: inner face, outer face, radiusconst PR = 9.2; // posts' distance from the axis (left and right)const LIGHT = [-0.5, 0.45, 0.74]; // view space: right, up, toward the cameraconst GLINT = (() => { const v = [-0.6, 0.55, 0.58], n = Math.hypot(...v); return v.map((c) => c / n); })(); // glass normal that mirrors the key lightconst LTOP = 4.4, LAPEX = -5.2; // idle sand: top level (h), bottom cone apex (h)const N_IDLE = 16, N_FLIP = 10;// glass radius along the axis (u = |h| / HC0): waist, two teardrop bulbs, necks into the capsconst PROFILE = [[0, 0.9], [0.08, 1.6], [0.2, 3.5], [0.35, 5.5], [0.5, 6.8], [0.65, 7.25], [0.78, 7.0], [0.88, 6.0], [0.95, 4.9], [1, 4.1]];function glassR(h) { const u = Math.min(1, Math.abs(h) / HC0); for (let i = 1; i < PROFILE.length; i++) { const [u0, r0] = PROFILE[i - 1], [u1, r1] = PROFILE[i]; if (u <= u1) return r0 + ((r1 - r0) * (u - u0)) / (u1 - u0); } return PROFILE[PROFILE.length - 1][1];}function postR(h) { // turned post: a knob at the waist and at each foot const a = Math.abs(h); return a < 1.4 ? 1.55 : a > 11.4 ? 1.4 : 0.95;}// ---- sand ----------------------------------------------------------------------------------------------------function idleSand(h, z, X) { const rho = Math.hypot(X, z); if (h > 0) return h <= LTOP - 2.4 * Math.max(0, 1 - rho / 3.4); // top: flat level with a funnel dip return h <= LAPEX - rho * 0.85; // bottom: a cone heap}// volume samples of one bulb (h > 0); the other bulb is its mirrorconst BULB = [];for (let h = 0.3; h < HC0; h += 0.6) for (let x = -7.5; x <= 7.5; x += 0.6) for (let z = -7.5; z <= 7.5; z += 0.6) { if (x * x + z * z <= glassR(h) ** 2) BULB.push([h, z, x]);}const F_TOP = BULB.filter(([h, z, x]) => idleSand(h, z, x)).length / BULB.length;const F_BOT = BULB.filter(([h, z, x]) => idleSand(-h, z, x)).length / BULB.length;// level (world height) under which a fraction f of a bulb lies, for a frame tilted by thetafunction levelFor(sign, theta, f) { const ct = Math.cos(theta), st = Math.sin(theta); const ys = BULB.map(([h, z]) => sign * h * ct - z * st).sort((a, b) => a - b); return ys[Math.min(ys.length - 1, Math.max(0, Math.floor(f * ys.length)))];}// ---- ray cast --------------------------------------------------------------------------------------------------// Screen (X right, Y down from the centre) + depth d (toward the camera) <-> object (x, h up the axis, z front):// h = -Y cos(phi) + d sin(phi), z = Y sin(phi) + d cos(phi), phi = theta (tilt) + ELEV.function castRay(X, Y, cp, sp, sand) { if (Math.abs(X) > RC + 0.6) return null; const hA = -Y * cp, zA = Y * sp; let glass = null; for (let d = 21; d >= -21; d -= 0.25) { const h = hA + d * sp, z = zA + d * cp, ah = Math.abs(h); if (ah > HC1) continue; const rr = X * X + z * z; if (ah >= HC0) { if (rr > RC * RC) continue; const dOut = HC1 - ah, dIn = ah - HC0, dSide = RC - Math.sqrt(rr); let n; if (dOut <= dSide && dOut <= dIn) n = [0, Math.sign(h), 0]; else if (dIn <= dSide) n = [0, -Math.sign(h), 0]; else { const r = Math.sqrt(rr); n = [X / r, 0, z / r]; } return { part: "cap", d, h, z, X, n, glass }; } for (const s of [-1, 1]) { const px = X - s * PR, pr = postR(h); if (px * px + z * z <= pr * pr) { const r = Math.hypot(px, z) || 1; return { part: "post", d, h, z, X, n: [px / r, 0, z / r], glass }; } } const gr = glassR(h); if (rr <= gr * gr) { if (!glass) glass = { d, h, z }; const top = sand(h, z, X); if (top) { const r = Math.sqrt(rr) || 1, wall = gr - r < 0.7; return { part: "sand", d, h, z, X, n: wall ? [X / r, 0, z / r] : top, glass }; } } } return glass ? { part: "glass", d: glass.d, glass } : null;}const toView = (n, cp, sp) => [n[0], n[1] * cp - n[2] * sp, n[1] * sp + n[2] * cp];const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];function capTone(hit, dif, sp) { const ah = Math.abs(hit.h), rho = Math.hypot(hit.X, hit.z); const face = hit.n[0] === 0 && hit.n[2] === 0; if (!face && ah > HC0 + 1.1 && ah < HC0 + 1.9) return dif > 0.75 ? "g3" : dif > 0.3 ? "g2" : dif > -0.1 ? "g1" : "g0"; // brass inlay let k = dif > 0.8 ? 4 : dif > 0.58 ? 3 : dif > 0.3 ? 2 : dif > -0.05 ? 1 : 0; if (face) { const outer = ah > HC1 - 0.5; if (rho > RC - 0.9) k = -hit.X - hit.z > 0 ? 4 : 2; // turned bevel catches the light else if (outer && rho > 1) { // one turned groove, 1px on screen const c = hit.X / rho, s2 = hit.z / rho, f = Math.sqrt(c * c + s2 * s2 * sp * sp); if (Math.abs(rho - 6.6) < 0.55 / Math.max(0.3, f)) k = Math.max(0, k - 1); } else if (!outer && rho > 4.1 && rho < 5.1) return "g2"; // brass seat round the glass } return ["w0", "w1", "w2", "w3", "w4"][k];}// Render one pose to a per-pixel {key, part} buffer.function renderPose(theta, hop, sand, squash) { const phi = theta + ELEV, cp = Math.cos(phi), sp = Math.sin(phi); const hits = new Array(W * H).fill(null); const bottomY = CYS + 19.1; // squash about the ground contact for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { let Y = y + 0.5 - CYS + hop; if (squash) Y = (y + 0.5 - bottomY) / squash + bottomY - CYS + hop; hits[y * W + x] = castRay(x + 0.5 - CXS, Y, cp, sp, sand); } const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? null : hits[y * W + x]); const opaque = (h) => h && h.part !== "glass"; const out = new Array(W * H).fill(null); for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const hit = at(x, y), nb = [at(x - 1, y), at(x + 1, y), at(x, y - 1), at(x, y + 1)]; let key = null, part = null; if (!hit) { if (nb.some((n) => n)) { key = "ink"; part = "outline"; } // silhouette } else { // an outline grows from every nearer solid (not sand) onto what lies behind it, glass included const front = nb.find((n) => opaque(n) && n.part !== "sand" && n.d > hit.d + 0.9 && (!opaque(hit) || n.part !== hit.part)); if (front) { key = "ink"; part = "outline"; } else if (hit.part === "cap" || hit.part === "post" || hit.part === "sand") { const dif = dot(toView(hit.n, cp, sp), LIGHT); part = hit.part; if (hit.part === "cap") key = capTone(hit, dif, Math.abs(sp)); else if (hit.part === "post") key = dif > 0.78 ? "g3" : dif > 0.3 ? "g2" : dif > -0.1 ? "g1" : "g0"; else if (hit.n[1] !== 0 && theta === 0 && hit.h > 0) { if (hit.h < LTOP - 0.5) key = hit.z < 0 ? "g2" : "g1"; // the funnel dip: far wall lit else key = hit.X < -1.5 ? "g3" : "g2"; // top level, lit on the left } else key = dif > 0.8 ? "g3" : dif > 0.42 ? "g2" : "g1"; } else part = "glass"; // glass in front of this pixel: 1px rim just inside the silhouette, then the specular glint if (key !== "ink" && hit.glass && (!opaque(hit) || hit.glass.d > hit.d)) { const rimIdx = [0, 1, 2, 3].find((i) => !nb[i]); if (rimIdx !== undefined) { key = rimIdx === 0 || rimIdx === 2 ? "n4" : "n2"; part = "glass"; } else { const { h, z } = hit.glass, X = x + 0.5 - CXS, gr = glassR(h); const slope = (glassR(h + 0.3) - glassR(h - 0.3)) / 0.6; const nv = toView([X / gr, -slope, z / gr], cp, sp), len = Math.hypot(...nv); const s = dot(nv, GLINT) / len; if (s > 0.975) { key = "n5"; part = "glass"; } else if (s > 0.94) { key = "b3"; part = "glass"; } } } } out[y * W + x] = key ? { key, part } : null; } return out;}// ---- poses ------------------------------------------------------------------------------------------------------const CACHE = {};const IDLE_POSE = () => CACHE.idle || (CACHE.idle = renderPose(0, 0, (h, z, X) => (idleSand(h, z, X) ? (h > 0 ? [0, 1, 0] : sandConeN(h, z, X)) : null)));function sandConeN(h, z, X) { // normal of the bottom cone (tilted out from the axis) const rho = Math.hypot(X, z) || 1; const n = [(X / rho) * 0.85, 1, (z / rho) * 0.85], l = Math.hypot(...n); return n.map((c) => c / l);}// flip keyframes: tilt (deg), hop (px), squashconst FLIP = [[0, 0, 0], [12, 2, 0], [48, 6, 0], [92, 8, 0], [136, 6, 0], [170, 3, 0], [180, 0, 0.93], [180, 1, 0], [180, 0, 0], [180, 0, 0]];function flipPose(f) { if (CACHE["f" + f]) return CACHE["f" + f]; const [deg, hop, squash] = FLIP[f]; let buf; if (deg === 0 || deg === 180) { buf = squash ? renderPose(0, 0, (h, z, X) => (idleSand(h, z, X) ? (h > 0 ? [0, 1, 0] : sandConeN(h, z, X)) : null), squash) : IDLE_POSE(); } else { const th = (deg * Math.PI) / 180, k = deg / 180; const la = levelFor(1, th, F_TOP + (F_BOT - F_TOP) * k), lb = levelFor(-1, th, F_BOT + (F_TOP - F_BOT) * k); const ct = Math.cos(th), st = Math.sin(th), up = [0, ct, -st]; // world up in object space buf = renderPose(th, hop, (h, z) => ((h * ct - z * st) <= (h > 0 ? la : lb) ? up : null)); } return (CACHE["f" + f] = buf);}function drawPose(g, buf) { for (const part of ["cap", "post", "glass", "sand", "outline"]) { g.part(part === "outline" ? "outline" : part, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => { const c = buf[y * W + x]; return c && c.part === part ? c.key : null; })); }}// ---- idle motion: the stream, grains sliding into the funnel, grains rolling down the cone -----------------------// Pixel positions are read off the resting pose (waist x15-16 rows 25-26, cone apex (15,31), top level row 21).// Every cycle is 4 frames long, so the 16-frame loop closes exactly.function grains(g, t) { const f = Math.round(t * N_IDLE) % N_IDLE, k = f % 4; g.part("funnel", () => { g.pixel(14, 21, "g1"); g.pixel(15, 21, "g1"); g.pixel(15, 20, "g3"); }); g.part("stream", () => { for (let y = 25; y <= 30; y++) g.pixel(15, y, (y - f + 16) % 4 === 0 ? "g1" : "g3"); // dark grains fall 1px/frame }); g.part("rolling", () => { const L = [[14, 32], [13, 33], [12, 33], [11, 34]], R = [[17, 32], [18, 33], [19, 33], [20, 34]]; const a = L[k], b = R[(k + 2) % 4]; g.pixel(a[0], a[1], "g2"); g.pixel(b[0], b[1], "g3"); // grains rolling down both flanks const T = [[12, 21], [13, 21], [13, 21], [14, 21]], U = [[18, 21], [17, 21], [16, 21], [16, 21]]; g.pixel(T[k][0], T[k][1], "g2"); g.pixel(U[(k + 1) % 4][0], U[(k + 1) % 4][1], "g3"); // sliding into the funnel }); g.detached("bounce", () => { if (k === 0) g.pixel(14, 30, "g3"); else if (k === 2) g.pixel(16, 30, "g2"); // grains kicked off the apex });}function dust(g, f) { g.detached("dust", () => { const k = f - 6; if (k < 0 || k > 2) return; for (const s of [-1, 1]) { const x = CXS + s * (12 + k * 2) - (s > 0 ? 1 : 0), y = GROUND - 1 - k; g.pixel(x, y, ["n4", "n3", "n2"][k]); if (k < 2) g.pixel(x + s, y + 1, "n3"); } });}const Hourglass = sprite(pal, (g, t, mode) => { if (mode === "flip") { const f = Math.min(N_FLIP - 1, Math.round(t * N_FLIP)); const [deg, hop] = FLIP[f]; contactShadow(g, CXS, GROUND, 12 - Math.min(4, hop), 2); drawPose(g, flipPose(f)); if (deg === 0 || deg === 180) grains(g, 0); // starts on and hands back to idle frame 0 dust(g, f); return; } contactShadow(g, CXS, GROUND, 12, 2); drawPose(g, IDLE_POSE()); grains(g, t);});submit(Hourglass, { size: [32, 48], origin: "top-left", kind: "icon", scale: 1, anchor: { x: 16, y: 45 }, variants: [ { name: "idle", args: ["idle"], animate: { fps: 10, duration: 1.6, loop: true } }, { name: "flip", args: ["flip"], animate: { fps: 10, duration: 1.0, 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.