← all posts
September 17, 2026·Ronald

Nsenene season: a viral risograph animation, rebuilt in code and re-set in Kampala

The idea is Kevin Ngo's and the brief is a stranger's. What I brought was Uganda and a printer's model of ink: a riso separation does not overprint light on dark, it punches a hole. One index.html, 22 Ugandan scenes, an amadinda tuned to five equal steps, and a screenshot harness that caught every scene that did not read.

22Ugandan scenes
105 KBone index.html
533koutput tokens
240 centsamadinda step
AnimationCanvasRisographClaude CodeUgandaWebAudio

The ⁠idea ​is ​not ​mine. ​The ‌risograph ​animation ‌that ​started ‌this ​— ​a ​whole film ​drawn ‌in ​code ​on ​a ​canvas, ‌ink ‌by ​ink ‌— ​is ‌by ‌Kevin ​Ngo (@kevin_t_ngo), ​and ‌it ​went ‌round ‌r/ClaudeAI ​last week. ‌The ​top ​comment ‌under ​it ​was ‌"what's ‌the ⁠prompt?", ⁠and ​nobody ​had ​it. ​So somebody ‌else ​wrote ‌one ​from ‌scratch ​and ​published ​it: ​a ‌~10,000-character brief, ​in ​that r/ClaudeAI thread, under ​the ​honest ‌heading ‌"It's ​a ‌recreation ​of ‌the ‌idea, ​not ​the ‌original."

That ​brief ‌is ‌what ​this ‌was ​built ​against, ‌and ​it ​deserves ‌the ‌credit ⁠for ⁠the shape ​of ​the ​thing: ​six ‌acts, ​a ‌centre ​dot ‌that ​calls ​out ​in ​ripples ‌and ​is answered, ​disc ​reveals ​and ‌a ‌dive, ​an ‌archive ​of ‌medallions ‌that ​drains ​to ‌one ink, ​a ‌duotone ‌reprise, ​a ‌hand-lettered ​signature, ​the ‌riso ​ink ​list ‌with ‌its screen ⁠angles, ⁠and ​— ​the ​part ​that ‌mattered ​most ‌— ​an ‌instruction ​not ​to one-shot ​it.

What ​is ‌mine ​is ​everything ​inside ​the ‌frame. ‌The ​film ‌is ​Ugandan: ‌nsenene ‌in ​a lit ​drum, ‌a ​shoebill ‌in ‌Mabamba's ​papyrus, ‌matatus, ​boda ​stage, ‌the ​rolex ​being rolled, ‌mabati ‌roofs ⁠in ⁠the ​rain, ​marabou ​storks ​on ‌a ​streetlight, ‌Kibuli's dome, ​an ‌anthill ​at ​dusk. ​The ​soundtrack ‌is ​an ​amadinda, ​not ​a ‌pad. ‌And ​the ‌one genuinely ​technical ‌idea ‌below ​— ​how ‌a ​riso ‌separation ‌actually ​works ‌in ​a canvas ​compositor ‌— ​is ​the ‌thing ‌I ⁠got ⁠wrong ​first, ​then ​got ​right, ‌and ​it ‌is the ​reason ‌the ​film ​looks ​printed ​instead ‌of ​muddy.

The live page. Space to play, arrow keys to step a frame, ?t=12.5 to open paused anywhere. Sound is off until you press it — WebAudio needs a gesture. Everything you see is drawn by code at 720x720; there is not one image file in it.

A riso separation is a hole, not a layer

Here ​is ​the ​whole ‌problem ‌in ​one ‌picture. ​On ‌the ‌left, ​a ​nsenene ‌(a ​grasshopper — ‌the ‌ones ​that ‌come ​with ​the ‌November ​rains ​and ‌get ‌fried ⁠with ⁠onions) ​drawn the ​obvious ​way: ​flood ‌the ​ground ‌in ​violet, ‌then ​draw ​the ​body ​in ‌yellow ​over it. ​On ​the ​right, ‌the ‌same ​scene ‌after ​the ‌fix.

A grasshopper scene where the bright ink over the dark flood has gone brown and muddyThe same scene with the bright ink knocked out of the layers beneath, printing clean yellow
Left: yellow drawn over violet. Right: yellow drawn INTO violet. Same colours, same shapes, same compositor. The only difference is that the right-hand one knocks a hole in every darker ink before it prints.

Canvas ‌layers ​composite ​with ‌multiply, ​because ‌that ‌is ​what ‌ink ​does: ​each layer ‌subtracts ​light. ​Violet ‌times ‌yellow ⁠is ⁠brown. ​That ​is ​not ​a ‌bug ​in ‌the compositor, ​it ‌is ​the ​compositor ​being ​correct ‌— ​and ​it ​is ​exactly ‌why ‌a ​real riso ‌separation ​never ‌overprints ‌a ​light ​ink ‌on ​a ‌dark ‌one. ​The ‌bright ​shape is ​not ‌a ​layer ​on ‌top. ‌It ⁠is ⁠a ​hole ​in ​every ​layer ‌underneath, ​which ‌the bright ​ink ‌then ​fills. ​On ​a ​real ‌machine ​the ​paper ​takes ​the ‌yellow ‌drum's ​ink where ‌no ​other ‌drum ‌laid ​any ​down.

So ‌paint() ​takes ‌a ‌knock ​list, ‌and ​before ​it ‌draws ​anything ​it ‌punches ‌that path ⁠out ⁠of ​the ​named ​inks ​with ‌destination-out:

js
function paint(stack, ink, level, pathFn, { knock = [], alpha = 1, stroke = 0, trap = 1.6 } = {}) {
  for (const k of knock) {
    const c = stack.ctx(k);
    c.save();
    // Each ink composites at its OWN registration offset, so a hole punched at
    // the ink's own coordinates lands up to 3px away from the ink that is meant
    // to fill it -- which printed as a white halo around every knocked shape.
    const dx = (REG[ink] || [0, 0])[0] - (REG[k] || [0, 0])[0];
    const dy = (REG[ink] || [0, 0])[1] - (REG[k] || [0, 0])[1];
    c.translate(dx, dy);
    c.globalCompositeOperation = "destination-out";
    ...

That ​comment ‌is ​the ‌second ​half ​of ​the ​lesson, ‌and ​it ​cost ​an ​hour. ‌Riso ‌looks like ​riso ‌partly ​because ‌the ‌drums ​are ​not ‌perfectly ​aligned, ‌so ‌every ​ink ‌in this ​film ​composites ‌at ​its ​own ‌1–3px ‌offset. ⁠Punch ⁠the ​hole ​at ​the ​ink's ‌own coordinates ​and ‌it ​lands ‌up ​to ​three ​pixels ​away ‌from ​the ​ink ​meant ​to ‌fill ‌it — ​and ‌three ​pixels ‌of ‌bare ​paper ​around ‌every ​shape ‌reads ‌as ​a ‌white ​halo, ​not as ‌print.

Close crop showing a white halo of bare paper around a knocked-out shapeThe same crop with the halo gone, the ink meeting the hole cleanly
4x crops of the same corner. Left: the hole punched at the ink's own coordinates. Right: the hole translated by REG[ink] - REG[knocked], and the ink trapped 1.6px wider than the hole. Misregistration now lives only between whole layers, which is what a real machine does.

The ​fix ​is ‌the ‌printer's ⁠own: ⁠translate ​the ​knockout ​into ​the ‌knocked ​layer's coordinate ‌space, ​and ‌trap ​— ​make ​the ​ink ‌a ​hair ​wider ​than ​the ‌hole ‌it fills ​(1.6px ‌here), ​so ‌a ‌small ​misalignment ​shows ‌as ​overlap ‌rather ‌than ​as ‌a gap. ​Overlap ​prints ‌as ​a ​slightly ‌darker ‌seam. ⁠A ⁠gap ​prints ​as ​white, ​and ‌white is ​the ‌one ​thing ‌a ​riso ​cannot ​print.

The method is: build it, screenshot it, and then actually look at it

The ​brief's ‌best ​instruction ​was ​to ​not ‌one-shot ‌it, ​and ‌the ​harness ‌that enforces ‌that ​is ​more ‌of ​the ‌work ‌than ​it ‌sounds:

bash
node scripts/riso-shots.mjs --all-scenes --contact /tmp/library.png
node scripts/riso-shots.mjs --range 13.4:14.4:0.25     # around a transition
node scripts/riso-shots.mjs --audio                    # the part no screenshot can see

It ​drives ​the ‌Chrome ​already ​on ‌this ‌machine ⁠(npx playwright install ⁠is ​a ~150 ​MB ​download ​and ‌I ​am ‌on ​a ‌mobile ​plan), ​seeks ​render(t), ​and ‌writes ​a PNG ​per ​frame. ​It ‌exits ‌non-zero ​on ‌any ​page ‌error ‌and ​on ​any ‌frame ​whose contrast ‌spread ‌is ​under ‌6, ​so ​a ‌silently ​blank ​canvas ‌cannot ‌pass ⁠as ⁠a screenshot. ​--contact ​tiles ​the ​whole ‌scene ​library ‌into ​one ‌sheet, ​which ​is the ​only ​way ‌to ​judge ​a ​batch: ​one ‌muddy ‌panel ​is ‌obvious ​beside ‌seven ‌that read.

Contact sheet of 22 Ugandan risograph scenes
The contact sheet. Every scene at montage size, because that is how long anyone will actually see it — about 250 milliseconds. A scene that needs a second look has already failed.

Looking ​is ​what ‌caught ​all ‌of ‌these, ​and ‌none ​of ​them ‌threw ​an ​error:

Every one of these rendered without an error and produced a perfectly valid frame. They were simply wrong, and the only instrument that detects that is an eye.
scenewhat the screenshot showedthe actual cause
gorillaa circle with two orange ringsthe head filled the disc, so crest, ears and shoulders were outside the clip — and the muzzle knocked indigo out of itself, printing a beard-shaped hole
jackallightning in four scraps above the discthe moon was struck after the storm, so its knockout erased every bolt that crossed it
acaciaa mushroom, three timesa thick column under a domed mass IS a mushroom; an acacia forks low and its limbs reach the canopy's outer thirds
batsno batsdark bats over a violet flood over an orange flood — and a cave composed full-frame, entirely outside the disc clip
frogsgreen smudgesthree small frogs at thumbnail size; two large ones with knocked-out eyes read
archive8 of 22 medallionsa module-scope snapshot of a registry that two thirds of the scenes had not been added to yet
A gorilla scene that reads as a circle with two orange ringsThe gorilla scene rebuilt so the whole head reads inside the disc
The gorilla, before and after. Two failures in one frame: the head was composed full-frame so crest, ears and shoulders fell outside the disc clip, and the muzzle knocked indigo out of the ink it was drawn in — a hole the size of the lower face, which printed as a beard.
A night scene where the lightning is broken into disconnected scrapsThe same scene with continuous lightning crossing the moon
The jackal's storm, before and after. The bolts were drawn first and the moon second, so the moon's own knockout erased every part of every bolt that crossed it — leaving four scraps of lightning floating above the disc. Order of operations, not geometry.

The ‌archive ‌one ⁠is ⁠worth ​pausing ​on, ​because ​it ‌is ​the ‌most ​dangerous ‌kind ​of bug:

js
// const ARCHIVE_IDS = [...SCENES.keys()];   // ran before two thirds of the
//                                           // library had been declared
let archiveIds = null;
const ARCHIVE_IDS = () => (archiveIds ||= [...SCENES.keys()]);

No ​error. ​No ​blank ‌frame. ​The ​archive ​drew ​8 ‌medallions ‌beautifully, ​on ‌the correct ​orbits, ‌with ‌the ​right ​spin ‌— ​it ‌was ‌just ​missing ‌most ​of ​the ‌film. ​A module-scope ​snapshot ‌of ‌a ⁠lazily-populated ⁠registry ​fails ​silently ​and plausibly, ​which ‌is ​the ‌failure ​mode ‌that ​survives ​review.

Concentric orbits of circular medallions, each holding one scene
Act III, the archive: 24 medallions on three counter-rotating orbits, drawn guide rings, then a drain to a single blue ink before they scatter. With the snapshot bug it looked fine and held a third of the film.

Africanizing it, and knowing when to cut

A fast-cut montage frame of a Ugandan scene arriving over the previous one
Act II, mid-montage: eighteen hard cuts on the 120 BPM grid, accelerating from 0.5 s to 0.25 s a scene, each arriving over the last through an iris, a wipe, a coin flip or a split.

The ​brief ​ships ‌a ​scene ​library ​— ​wolves ‌under ‌aurora, ​a ‌rotary ​telephone, ‌a Ferris ‌wheel, ​an ​umbrella ‌pine. ​I ‌started ‌by ​adding ‌eight ​Kampala ​scenes ‌to ​the middle ​of ‌it, ‌which ⁠produced ⁠exactly ​the ​thing ​I ​did ‌not ​want: ‌a ​Japanese-ish film ‌with ​a ​Uganda ​section. ​The ‌instruction ​was ​Africanize ​it, ​so ‌the ‌whole vocabulary ​went: ‌22 ​scenes, ‌all ‌Ugandan, ​and ​the ‌library ​above ‌is ‌the ​result.

Two ‌of ​them ​are ‌worth ​the ​paragraphs.

Matoke ‌failed ‌three ⁠times ⁠and ​got ​cut. ​Fingers ​drawn ‌as ​ellipses ‌read ​as leaves. ‌Drawn ​as ​thin ​arcs ​they ‌merged ​into ​two ​vertical ​columns ‌and ‌the ​bunch read ‌as ​a ‌fir ‌tree. ​Leaves ​radiating ‌from ​one ‌point ‌read ​as ‌a ​palm ​fan. ‌Three attempts, ​three ​different ‌wrong ‌plants.

A failed matoke bunch that reads as a fir treeA matatu minibus scene that reads immediately
Matoke, attempt two: a fir tree. At 250 ms nobody sees the thing you meant, they see the silhouette you drew. After two consecutive failures the rule is pivot, not persist — so this became a matatu.

The ⁠matatu ⁠read ​first ​try, ​and ​it ‌carries ​a ‌riso ​joke ‌I ​only ​get ​to ​make because ‌the ​compositor ​is ​honest: ​its ‌white ‌body ​is ‌bare ​paper. ‌White ‌is ​the one ​colour ‌a ​riso ‌cannot ‌print, ​so ‌the ​van ​is ‌a ​hole ​in ‌every ‌ink ⁠— ⁠the ​same primitive ​as ​the ​grasshopper, ‌used ​as ‌a ​subject ‌rather ​than ​as ​a ​fix.

Acacia ‌is ​the ​one ​that ​taught ‌me ‌the ​difference ‌between ​a ‌decorative ‌fix ​and a ​structural ‌one. ​Three ‌attempts ‌of ​fiddling ‌with ​the ​canopy ‌edge ​— ​scallops, gaps, ‌a ‌thinner ⁠trunk ⁠— ​and ​it ​stayed ​a ‌mushroom, ​because ‌a ​single ‌thick ​column meeting ​a ​domed ​mass ‌in ​the ​middle ​is ​a ‌mushroom ‌whatever ​you ‌do ​to ‌the outline. ‌The ​fix ​was ‌the ​tree's ‌actual ‌structure: ​fork ‌low, ​push ​the ‌limbs ​out to ​the ‌canopy's ‌outer ⁠thirds, ⁠flatten ​the ​top, ​scallop ​the ‌underside, ​and ‌put the ​sun ‌behind ​it.

An acacia that reads as a mushroomAn acacia with a low fork and a flat canopy, reading as an acacia
Acacia, before and after. Nothing changed about the rendering, the inks or the halftone. The branching changed.

Two more places the obvious thing is wrong

A ​duotone ​is ​a ‌luminance ​ramp, ​not ​two ​overlays. ‌Act ‌V ​reprints ‌twelve scenes ​in ‌pink ‌and ​indigo ​only. ‌The ​obvious ‌order ‌— ​multiply ‌pink ​over ​the scene, ‌screen ​indigo ​under ‌it ‌— ⁠printed ⁠a ​flat ​magenta ​wash ​with ‌the ​picture gone. ‌What ​works ‌is ​to ​grayscale ​the ​plate, ‌destination-over ​the ​dark ​ink beneath ​it, ‌and ‌only ​then ‌multiply ​the ‌light ‌one:

js
duoCtx.filter = "grayscale(1) contrast(1.35) brightness(1.08)";
paintScene(duoCtx, REPRISE[i], {});
duoCtx.globalCompositeOperation = "destination-over";
duoCtx.fillStyle = INK.indigo.hex; duoCtx.fillRect(0, 0, W, H);
duoCtx.globalCompositeOperation = "multiply";
duoCtx.fillStyle = INK.pink.hex; duoCtx.fillRect(0, 0, W, H);
A scene reprinted as a pink and indigo duotone
Act V, the duotone reprise: twelve scenes reprinted in pink and indigo only, with both dots present in every frame. Pink-multiply then indigo-screen — the obvious order — prints a flat magenta wash with the picture gone.

Paper ​must ​be ‌built ​once. ‌Grain, ‌mottling ​and ‌ink-starved ​voids ​regenerated per ‌frame ​make ​the ‌paper ‌boil ⁠— ⁠it ​is ​the ​single ​most ‌obvious ​tell ‌that something ​is ‌fake, ​and ​it ​is ​invisible ‌in ​a ​still. ​Build ​them ‌into ‌bitmaps ​at boot ‌and ​never ‌touch ‌them ​again. ​Same ‌rule ​as ‌the ‌seeded ​PRNG: ‌render(t) ​is ​a pure ‌function ​of ​time, ‌and ‌Math.random() ⁠may ⁠never ​appear ​inside ​it.

Two ink dots with interlocking ripple rings and a blooming halftone flower between them
Act IV: the second dot answers, their rings meet, and a lotus blooms at the contact point. Three stacked rings per call, because a single ring at full reach is a hairline that is gone before its answer arrives.

The soundtrack is an amadinda, and it is not in any key

The ​brief ‌asks ​for ‌a ​warm ‌analog ​pad ​and ​an ​arpeggio ‌at ​120 ​BPM. ​A ​pad ‌is ‌a twelve-tone ​object, ‌and ​I ‌had ‌just ​spent ​five ‌hours ​making ‌the ‌pictures Ugandan. ​So ‌the ​soundtrack ​is ‌a ​log ​xylophone ‌instead.

Buganda's ‌amadinda ⁠and ⁠akadinda ​are ​tuned ​to ​an ‌approximately ​equal pentatonic: ‌five ​equal ‌steps ​to ​the ​octave, ​240 ‌cents ​each. ​That ​is ​not ‌a mode ‌of ​anything ‌in ​twelve-tone ‌equal ‌temperament ​— ​the ‌notes ​land ‌between ‌the piano's ​keys ‌and ​stay ​there. ‌Twelve ​lines ​of ‌code:

js
const OKU  = 110;                                    // the low log
const step = (i) => OKU * Math.pow(2, i / 5);        // 240 cents per step
const LADDER = Array.from({ length: 16 }, (_, i) => step(i));

Two ‌parts ⁠play ⁠it ​the ​way ​two ​players ‌do: ​okutema ‌(the ​starter) ‌and okwawula ​(the ​divider), ​a ​rung ‌apart ​and ​offset ​by ​half ‌a ‌step, ​so ‌each falls ​into ‌the ‌other's ​gaps ​and ‌the ​pattern ‌that ‌emerges ​belongs ‌to ​neither hand. ​Every ‌ripple ​in ​the ‌film ‌puts ⁠a ⁠ping ​on ​the ​ladder; ​the ‌scheduling ​is anchored ‌to ​the ‌animation ​clock, ​never ​wall ​time, ‌and ​re-anchors ​whenever ​it drifts ​more ‌than ‌0.15 ​s.

A ‌soundtrack ​is ‌the ‌one ​thing ​a ‌screenshot ​cannot ‌check, ‌which ​is ‌why ​the harness ​grew ‌an ​--audio ​mode: ‌it ‌clicks ⁠the ⁠real ​Sound ​button ​(WebAudio ​needs a ‌genuine ​gesture), ‌then ​asserts ‌that ​no ​AudioContext ​existed ​before ‌the ​click, that ​it ​is ​running ‌after, ‌and ​that ‌at ​least ‌one ‌ping ​and ​one ‌arpeggio ​note actually ‌got ‌queued. ​Scheduling ‌a ​note ​into ‌the ​past ​is ‌silent, ‌and ⁠silence looks ⁠exactly ​like ​success.

Hand-lettered signature in indigo ink with a pink misregistration fringe
Act VI. 'opus 5' and 'claude' are stroke paths revealed along their length, with per-point wobble and a pink misregistration fringe. No font file — the only glyphs that exist are the ten letters needed.

What it cost

Counted from the Claude Code session log over the animation work only; writing this post is not in it. The frame count is irrelevant to cost — the browser draws the frames. What costs is the loop of writing, looking and fixing.
measurevaluehow it was counted
wall clock~7 h, one sessionfirst commit 15:41, last 18:27, plus engine work before the first commit
output tokens532,807sum of output_tokens over the session log from the brief's timestamp
cache reads80.96 Mcache_read_input_tokens over the same window
cache writes1.16 Mcache_creation_input_tokens over the same window
the artifact105 KB / 2,007 linesone index.html, no dependencies of any kind

The ​token ​number ‌is ​large ‌and ​slightly ‌misleading ​in ​an ​interesting ​way: ‌the cache ​reads ​dominate ​by ​two ‌orders ‌of ​magnitude, ‌because ​the ‌loop ‌is "re-read ​a ​2,000-line ‌file, ​change ‌forty ‌lines, ​screenshot, ‌look" ​a ​few ‌hundred times ​over. ​Cheap ‌tokens, ‌many ⁠times.

Credits, plainly

  • The ⁠original ​animation ​is ​by ​Kevin ‌Ngo (@kevin_t_ngo), ​also ‌made ​with ‌Claude ​Code. Nothing ​here ​is ​derived ‌from ​his ​code, ​which ​was ‌never ‌published ​— ‌only ​from the ‌fact ‌that ​he ​proved ‌the ​idea ‌was ‌possible.
  • The ​brief ‌is ​from ​the ‌r/ClaudeAI ​writeup "I recreated the viral riso animation with Claude Code + Opus 5", written ​from ‌scratch ‌by ⁠its ⁠author ​after ​nobody ​could ​produce ‌the ​original prompt. ‌Its ​six-act ‌structure, ​call-and-response ​conceit, ​centre ​dot, transition ‌vocabulary, ​riso ​ink ​list ​and ‌screen ‌angles, ​and ‌its ​staged ‌"do not ‌one-shot ​this" ​process ‌are ​all ‌theirs ‌and ​are ‌used ​as ​given.
  • Mine, ‌for ​the ​avoidance ‌of ‌doubt: ⁠the ⁠registration-aware ​knockout ​and trapping ​model, ​the ‌22-scene ​Ugandan ‌library, ​the ‌amadinda ​tuning ​and ​the okutema/okwawula ​interlock ‌in ​place ​of ​the ​brief's ‌pad, ‌the ​screenshot-and- look ‌harness, ​and ‌every ‌bug ​listed ​above.
  • The ‌riso ​ink ‌hexes ‌and ​screen ‌angles ​are ​the ‌standard ​Riso ​drum ‌colours, which ‌are ⁠a ⁠printer's ​fact ​rather ​than ​anybody's ‌invention.

The ​page ‌carries ​the ‌same ​credit ​under ​its ​own ‌controls, ​and ​the ​source ​file carries ‌it ‌in ​a ‌header ​comment, ‌so ‌it ​travels ​with ‌the ​artifact ‌and ‌not ​only with ‌this ​post.

boracode.ai · Ronald Adonyo · nsenene-season
content hash 0a84359693a0
A wide horizon at dusk: layered hills in muted plum and ochre above a lake that holds the last light, a family of crested cranes at the water's edge, an acacia in silhouette and a single bright star above the ridge.
Nsenene season: a viral risograph animation, rebuilt in code and re-set in Kampala — boracode