Ettaala: walking one lamp through an isometric house, room by room
The premise is Kevin Ngo's again. What I brought is a Ntinda house in cutaway, a camera baked into the projection instead of the canvas, and four separate discoveries that a knockout is not a lighting effect — it is occlusion, and it will erase your entire family.
The premise is not mine. A second time: the idea of an isometric riso world
of cutaway rooms, and of a film that walks a single light through them one room
at a time, is Kevin Ngo's (@kevin_t_ngo) — his
piece "a small light, room by room", also made with Claude Code. The workflow
I followed (plan the rooms, block them out, screenshot, dress, only then
animate) is the one published as the
riso-rooms Claude Code skill,
which credits the same original.
No code came from either. The engine underneath is the one written for Nsenene Season last week — the nine-ink riso compositor, the registration-aware knockout, the halftone screens, the amadinda. This post is about the layer on top of it, which is new: an isometric renderer, a camera rig, and a lighting model in which a lit room is the night ink removed.
Ettaala is Luganda for the lamp. The house is a three-storey Ntinda plot: a duka and its store on the ground, a kitchen and a sitting room above it, a bedroom and a balcony above that, and a mabati roof with a water tank, a washing line and room to sit. Dusk falls, the shop light comes on, and the light climbs the building ahead of the family until the last window on the hill is one of thirty.


The camera goes in the projection, not on the canvas
The obvious way to move a camera over a canvas scene is ctx.translate and
ctx.scale. It is wrong here, and the reason is the print.
Every ink in this engine is filled with a halftone pattern set in canvas space. That is what makes the frame look printed: the dot screen belongs to the sheet, not to the drawing. A canvas-space camera transform drags the pattern along with the buildings — the dots slide across the picture as the crane moves and the whole image swims like a badly-keyed video texture.
So the camera lives inside the projection function instead:
World in, screen out, every frame, with no canvas transform anywhere in the file. The dot screen stays pinned to the frame — which is exactly right, because every frame of this film is its own print.
The rig is fourteen keys that read as nine shots. Position eases on a cubic; zoom interpolates in log space, because the film goes from 0.5 to 2.7 and back, and a linear zoom across a change that size races at the wide end and crawls at the tight end. No crane does that.
Knockout is occlusion, and it will erase your family
Here is the law that cost the most. In this engine a bright ink does not print over a dark one; it punches a hole in every darker layer first, and fills the hole. That is what a riso separation is, and it is why the film looks printed rather than muddy.
The consequence nobody warns you about: a knockout is an occlusion test with no depth buffer. Whatever knocks last wins, whatever it covers is gone. Four separate times, the same symptom — a person printed as a white paper slab — with four different causes.
-
The figure knocked its own ink. A person is five boxes; a box is three faces. Each face erased the one before it, so the whole figure came out as bare paper. One line in
paint(), and it fixed both films: -
The lamp's cone was drawn after the people. Split into
lampGlow()(with the shell) and bulb()(the fitting, late). -
The next room's floor slab was drawn after them. A floor is a horizontal plane, so in isometric it projects up and over the room behind it. The shop's customer was being erased by the floor of the store next door.
-
The television's beam knocked indigo, so the light deleted the sofa and the family it was supposed to be falling on.


There was a fifth version of the same mistake, upstream of all of them. The sitting room was first lit the way every other room is lit — a lamp, tinted blue — and it came out beige. A room lit by a television is a dark room with one bright thing in it, and you cannot get there by dimming a lamp, because the night pass is printed over the room's own television.


Causes 1–3 produced the same three render passes the file has now:
- SHELL — every floor, wall and lamp cone in the house, laid first.
- STUFF — everything that stands on a floor, back to front.
- GLOW — anything that is a light. This pass exists because the night ink is printed over a whole room after its contents: without it, the darker you make a room the darker its own television gets, and the set can never be the brightest thing in its own shot.
When something is invisible, render it in another ink
The stirring cook in the kitchen was drawn, positioned correctly, and completely invisible — indigo at 0.68 over a lamp-lit floor is a smudge. I spent longer than I should have deciding whether the figure was missing or merely unreadable. The answer took one line and one screenshot:

The fix was the silhouette rule restated at prop scale: a figure reads at 0.9 of an ink, not 0.7 — and it reads because of its shadow as much as its tone. Every person now drops a violet pool at their feet before any of their boxes go down. Without one, a figure in isometric floats a few inches above the ground and the eye knows it even when it cannot say why.
Light is not a hole
A pool of lamplight that knocks the night out and then prints one pale ink is bare paper. Bare paper does not read as light; it reads as a hole in the picture — which is precisely what the first kitchen looked like, and what the television beam looked like from the next room over.
Light is a bright ink over a trace of the thing it is lighting. Every pool in the film now puts a little violet back inside itself, which is also how a two-colour riso prints a highlight.
The same idea one scale up: a lit wall is not a flat slab of colour. One bulb throws more light at the wall beside it than at the far corner, and the eye reads that gradient as the size of the room. Six bands along the wall's own depth is enough — below four it stripes, above eight you are paying for a gradient nobody can see through a halftone screen.


The problem a cutaway house has that a room does not
At the zoom that makes one room legible, three other rooms are still in frame, at full ink, competing for the eye. And you cannot fix it by turning their lamps down: the light in the shop is real, and dimming it to help the composition is a lie the next shot exposes.
The first attempt was a gobo: one more pass of violet over every room except the subject, with the subject's silhouette punched out of it by winding — the outer rectangle wound one way, the room's hexagon the other, one nonzero fill. It changed almost nothing. Then, when I strengthened it, it printed the entire house black.


Three things came out of that, and together they are the shot.
A dark room is two inks. One ink at 60% is a dot screen at 60%: everything under it reads through the gaps. A rich black on a riso is an overprint, so the night pass lays violet for the tone and indigo under it to close the screen.
Use a clip, not a winding rule. A clip is how you say "this ink, but not
there". paint() gained a clipFn, applied with evenodd around the fill.
Two holes, not one. A single hole leaves a hard hexagon on screen, because the step at its edge is the whole depth of the night ink and the eye finds a straight line of that length instantly. Half the veil stops at a near hull and half at a far one; the edge arrives in two steps and stops being an edge.
And then the thing none of it could do. In isometric, the shop's shelves genuinely stand in front of the kitchen a floor above — they overlap on screen, and no amount of ink over a volume changes which pixels they own. A 2D hole cannot separate two rooms that occlude each other.
So the last move is not a lighting trick at all, it is detail falloff: the rooms that are not the subject of a shot keep their shell and lose their contents. The house stays whole, the competing colour goes away, and the frame gets cheaper at the same time.
The frame everybody screenshots is the one in the middle of the crane
With detail falloff in, a new defect appeared exactly where nobody looks: the 1.5 seconds the camera takes to crane from the shop to the kitchen. The shop still owned the focus, so the kitchen arrived lit and completely empty.


The shot it buys is my favourite frame in the film: the dolly from the kitchen to the sitting room, where the two rooms sit side by side at two colour temperatures — filament on the left, television on the right.

Two more that only a screenshot finds
A lamp on a roof is not a bulb in a room. lampGlow() drops a wedge 120
units straight down the screen, which is right for a bulb hanging over a
floor and absurd for a tadooba on a parapet: with no floor under it, the wedge
hangs off the side of the building like a tractor beam.


And an empty room is not a dark room, it is an empty room. The bedroom was a bed and a cupboard in a cream field until it got what the room actually holds: a mattress on the floor for the younger ones, a suitcase, a louvred window, and school uniforms on a line.



The harness earns its keep twice
scripts/riso-shots.mjs is the same screenshot harness as the first film,
extended rather than copied — one harness per animation is how the checks drift
apart. Two additions:
--cost sweeps the timeline and reads the page's own render(t) timing,
because a film that is a pure function of time can still miss 60fps, and the
wide shots draw a hillside of neighbouring houses while the room shots draw
none of them.
Hook parity. The rooms film originally exposed __risoShot / __risoShots
where the first film exposed __risoScene / __risoScenes. The harness's
--all-scenes flag therefore did nothing at all on the new film, silently. A
hook that is only almost the same is a harness that quietly skips your
artifact. Both films now answer to the same two names.

What it cost
| measure | value | how it was counted |
|---|---|---|
| wall clock | 3 h 02 min | first line in the session log mentioning riso-rooms (15:43Z) to the last (18:45Z) |
| output tokens | 391,767 | sum of output_tokens over 350 assistant turns in that window |
| cache reads | 83.45 M | cache_read_input_tokens over the same window |
| cache writes | 668,981 | cache_creation_input_tokens over the same window |
| the artifact | 91 KB / 1,844 lines | one index.html, no dependencies of any kind |
| frame cost | 4.5 ms mean / 11.6 ms worst | the page's own render(t) timing, swept at 0.5s intervals across 42s |
Under half the time of the first film, on a harder renderer, because the engine already existed and the method was already known. That is what a ladder is for.
Credits, plainly
- The premise — an isometric riso world of cutaway rooms, and a film that walks one light through them — is Kevin Ngo's (@kevin_t_ngo), from "a small light, room by room", also made with Claude Code. None of his code was published and none of it is here; what he provided is the proof that the idea works.
- The workflow — plan the rooms, block out, screenshot, dress, then animate
— is the one published as the
riso-roomsClaude Code skill, which credits the same original. - The engine is ours, from Nsenene Season, which was itself built against an r/ClaudeAI brief credited in full in that post and in that file's header.
- Mine, for the avoidance of doubt: the isometric layer (projection, camera rig, the three passes, occlusion by knockout), the two-hull gobo and the detail falloff, the light model in which a lit room is the night ink removed, the Ntinda plot and everything standing in it, and every bug listed above.
The page carries the same credit under its own controls and in a header comment in the source, so it travels with the artifact rather than only with this post.
