A zoetrope. William Horner’s daedalum of 1834, sold in America from 1867 under the trade name this borrows: a slotted drum, a paper strip pasted round the inside, a crank. Spin it, look through the slits, and the drawings move.
Drawn as a cutaway, the way a patent figure would: the near wall survives only at the two edges — where you can still see the slits go past — and the middle is sawn out so the strip is legible. That is a liberty, and a deliberate one. With the wall intact you would see the strip as a dozen one-pixel slivers, which is the honest picture and the useless one. The third loop pays it back.
The strip’s subject is a rolling wagon wheel, four spokes, one of them painted red so you can tell which way it is going. Not decoration: the wagon-wheel effect and the zoetrope are the same phenomenon, and this device is the first one a person could hold in their hands.
Three loops:
- spin — the shuttered view. Twelve slits, twelve drawings, the drum turning exactly one slit per frame. Because a slit-step is also a twelve-fold symmetry of the drum, every frame’s chassis is pixel-identical — the slits, the rim, the wood grain, all of it — and the only thing that changes is which pose is standing in each slot. That equality is not a rendering trick. It is the illusion, and the sprite is built so you can diff two frames and see it.
- crank — that same one slit, taken in six. What the shutter throws away: the strip sliding continuously round the drum between two flashes, drawings crowding together as they turn toward the drum’s edge.
- smear — the naked eye at running speed. No shutter, no animation, a mottled band of overlapping ghosts. This is what the slits buy.
The demo
Crank it and the drum turns from a continuous angle — the twenty-two exported frames are samples of exactly this, not a separate path. The panel on the right is the part worth reading: what you actually see through a slit, the strip unwrapped, a drawing and a half of it, centred on whatever is opposite your eye. With the slits in place it holds the frame the last flash exposed. With them off it is the strip’s continuous position, smeared over the ~55 ms an eye integrates.
Then the control that makes it a piece rather than a toy: the strip is swappable for one with eleven or thirteen drawings on it, on the same twelve-slit drum.
The drum has S slits and turns at f revolutions per second, so a slit
crosses your eye S·f times a second — that is the shutter. Between two
flashes the drum turns one slit, 1/S of a revolution, and the strip carries
n drawings round that revolution, so it advances n/S drawings per flash.
Everything falls out of that one ratio:
n = S— exactly one drawing per flash. Each flash lands on the next drawing, in its neighbour’s place. The wheel turns and stays put.n = S − 1— 0.917 drawings per flash. The pose still advances, but each flash lands a twelfth of a cell short of where the last one did, so the wheel creeps backwards across the window while its spokes keep turning forwards. One full cell per revolution.n = S + 1— 1.083, and it creeps the other way.
which is the identity drift = (n − S)·f drawing-widths per second, exported
as driftPerSecond and checked in validateGrids rather than asserted here.
Wind the crank down instead and a third thing happens: under about 10
flashes a second the eye stops filling in the dark between them, and the
demo stops filling it in too — you see the shutter rather than the motion.
Three knobs, three ways for a sampler to lie to you, and all of them are mechanical.
Reuse
export/zoetrope-sheet.png— 1408×44 strip, twenty-two 64×44 frames inFRAME_ORDER:spin-0…11,crank-0…5,smear-0…3.export/zoetrope-{spin,crank,smear}-*.png— the individual frames.export/zoetrope@6x.png— prescaled 6× hero shot for docs/previews.
Render at integer scales with nearest-neighbor filtering or the edges smear
(differently, and worse). spin plays at 12 fps, crank at 6, smear at 12
(ANIMATIONS carries all three).
Import the module rather than the PNGs if you want the drum to do anything.
paintScene(ctx, spec, opts) takes { phi, n, mode, crank } directly, so the
rotation is a parameter and the strip length is too. The maths is exported
separately and has no drawing in it:
flashRate(revsPerSec, slits),advancePerFlash(n, slits),driftPerSecond(n, revsPerSec, slits),FUSION_HZ.stripSampleAt(u, rot, n)→ which drawing, and which of its columns, is on the far wall at that screen column. This is the whole projection.wheelCell(pose)→ one drawing as a character grid, generated rather than drawn.
Gotchas
CELL_Wis derived, not chosen. 2π·27/12 = 14.1 px is the arc one drawing covers at the back of the drum, so a source column maps to almost exactly one screen column there.validateGridsthrows if the two drift more than 1.5 px apart, because past that the strip starts either doubling columns or leaving seams.- The strip is gathered, not scattered — the painter walks screen columns and asks each one what drum angle it is looking at, rather than walking the drawings and asking where each column lands. The scatter version is the obvious one and it was wrong: wherever the cosine rounded twice onto the same pixel it punched a stripe clean through a wheel. At 64 px there is no supersampling budget to hide that.
mode: 'smear'fakes blur with a dither, not alpha — five ghosts, each let through a different fifth of the pixels. SolidfillRecthas no opacity to work with and a palette this small can’t afford blended tones.- The slits are a twentieth of the pitch on each side of centre. Wider and the shutter stops freezing the strip; narrower and there is nothing left to see through. They were a fifth in the first draft, which turned the side walls into two black rectangles.
demo/wheel-of-life.jsis generated. Editsource/wheel-of-life.mjsand re-render.- The demo draws the drum from the character map and the geometry at runtime,
which is fine at this size. A game should blit
zoetrope-sheet.png.
Source
No .aseprite — the canonical source is source/wheel-of-life.mjs: a
framework-free ES module. The only character map in it is the stand, because a
turned wooden foot is hand-drawn work and geometry can’t help; everything round
or moving — rim ellipses, slits, the strip’s wrap, the wheels themselves — is
painted procedurally over it. Edit and regenerate everything with:
node source/render.mjs
(needs site/node_modules installed — the script resolves Playwright from
there.) That one command rewrites export/, media/ frames, thumb.png,
and the demo’s bundled copy of the module, so nothing drifts. The demo
screenshots in media/ come from node scripts/screenshot-demo.mjs, which
also fails on any page error — SHOT_W=900 SHOT_H=480 renders it at the site’s
embed size, which is the one that catches a caption running off the canvas.
Port notes
paintScene is grid-walking and trigonometry with no DOM dependency beyond a
2D context — same shape as the claw’s and inner-groove’s — so a DragonRuby port
translates directly: keep the stand as a string grid, map cells to
args.outputs.solids, or just consume zoetrope-sheet.png as a spritesheet
with source_x = frame * 64. The sampling functions are pure arithmetic and
port as-is.