
jucelinuxHow I found, by accident, a way to make Opus 5 produce real pixel art, and how the validation loop...
How I found, by accident, a way to make Opus 5 produce real pixel art, and how the validation loop around it got cheap enough to run all weekend.
This weekend I was testing a cheaper, more collaborative way of working in pair with the AI. I was validating a framework I wrote for that (I'll present the Taste Loop properly soon) and the first validation was to build a game and measure the quality ceiling I could hit in it.
The game is COMPASSO. You are a white blood cell inside infected tissue, moving is attacking, and the world runs at 5% speed while you stand still. The thesis is "time only moves when you move".
What interests me here is not the game. It is what happened to its art.
The project logs are written in Portuguese. Every quote below is my translation, and the original line is in the repository.
The project's first art decision, on 31/07, was this one:
31/07 · art · geometric primitives only · the restriction becomes identity; it denies the crutch of pretty assets
Sounds good. Reads like an aesthetic choice. And on the same day, three lines later, the version with the makeup off:
31/07 · art · procedural texture, because the model does not draw · with no image tool, the way out is to generate membrane/nucleus/noise in code; drawn art needs somebody who draws
So "geometric primitives" was a cover story. The truth was "I can't draw, so I'll do whatever can be done without drawing".
The next day the graphics stalled. I complained that I was expecting something close to photorealistic. And Claude recorded its own diagnosis:
01/08 · graphics · stalled, and H expected near photorealism · procedural canvas 2D has a ceiling; the real jump is a WebGL shader, and it is what I can do without drawing
Look at the end of that sentence. "It is what I can do without drawing." The limitation sat in the middle of every line of reasoning, always as an obstacle to route around.
And the diagnosis was wrong.
A few hours later, in the same session, it found the real defect:
01/08 · diagnosis · the graphics did not stall for lack of texture, they stalled for lack of ANIMATION · there was not a single frame in the game; everything was one static texture scaled and rotated
There was not a single frame in the game. Not one. Everything was a static texture being scaled and rotated at runtime. The "WebGL shader" plan was about to spend a full round improving the surface of a thing that did not move.
At that point it stopped and asked me a binary question before writing a line of code: real pixel art, or SNES craft with smooth rendering? Two opposite things, and I had asked for "near photorealism" the day before, which was incompatible with both.
I picked native pixel art.
What changed from there was not effort. It was the shape of the problem.
Native pixel art is governed by rule: locked palette, integer position, no runtime rotation, gradients only by ordered dither. Each of those rules takes a taste decision off the table and puts a verifiable constraint in its place.
And Claude is far better at satisfying a constraint than at having taste.
The line it wrote in the log that day is the most honest thing in the whole project:
01/08 · LOOP · FINDING · the capability frontier was never raised from MY side · the constraint "I do not draw" was recorded 3x as an obstacle to route around and 0x as a question about where the ceiling is highest; pixel art was available all three rounds
Three rounds. The answer was available the entire time and nobody asked, because the question both sides were asking was "how do we work around the fact that it can't draw" and not "in what format is its ceiling highest".
The verdict I gave after the rewrite was: "it stopped looking homemade and AI-made". It was the project's first strong approval of graphical quality.
And the log closes with the line that became the principle:
01/08 · method · a hard constraint does the drawing for me · locked palette, no rotation, integer position, silhouette inside the hitbox; pixel art suits me because it is governed by rule, and I obey better than I invent
I took this to a separate repository (the atelier) to see whether I had a principle in hand or just a lucky round. The atelier is not a game. It is a bench where every element is born in code, appears in motion and answers a knob on the spot. Eight styles, each with a different construction problem.
The principle has four parts, and none of them is "draw better".
The model does not pick pixels. It assembles a model of ellipsoids and boxes in 3D space, and a rasteriser resolves the rest: it traces one orthographic ray per pixel, keeps the nearest surface, computes the normal, applies the light and samples on the sprite's integer grid.
The output is not colour. It is a palette index.
The distinction between the two primitives carries more weight than it looks. An ellipsoid's normal varies continuously, so it models: the form appears through gradation. A box face's normal is constant, so it comes out flat: surface only, no modelling at all. A curve wants an ellipsoid, architecture wants a box. Hold on to that difference, it comes back at the end of this article.
The light is locked to the world, not to the body. That is why the RPG sprite's 8 facings are eight real bakes, and east is not a mirror of west.
The atelier's rules, all verifiable:
Each of those lines kills an entire class of subjective decision. "What colour goes here" stops existing when the palette has 56 entries and the index has to come from a named ramp. "How much to smooth" stops existing when the only form of gradient is the Bayer pattern.
The indexed buffer knows nothing about the DOM, nothing about canvas, nothing about colour. It is a Uint8Array of indices. Which means the entire art pipeline runs under vitest, like any other code.
In the atelier that is 290 locks in nine families, swept from the element registry. No new element gets in without passing all of them. Five families lock the bake, four lock absence (a layer that is baked, updated 60 times a second and never enters the scene leaves no trace at all in the image), and the rest lock the palette and the blend table.
Two of those locks were born from real defects. The duplicate-frame lock used to pass on a one-pixel difference; today it requires every pair in a cycle to differ by at least 5% of the painted pixels. The reproducibility lock was born from a scene that depended on call history instead of on the clock.
Here is why the loop is cheap. The rule is: never climb a rung if the one below still catches the problem.
| # | oracle | cost | catches |
|---|---|---|---|
| 1 | compiler, types, build | zero | mechanical errors |
| 2 | 290 locks, all asserting on derived numbers | zero while green | absence, duplicate frames, colour outside the palette, tone budget, sprite leaving the frame, a bake that does not repeat |
| 3 | the derived summary: ramp census, tone counts | ~100 tokens | what is in the scene and in what proportion |
| 4 | the pixel: PNG written without a browser | 200 to 1200 tokens per sheet | everything that is wrong but legal |
| 5 | me, 30 seconds | the scarce input | direction, taste, "this isn't the thing" |
Rung 2 is the whole trick, and it is the one almost nobody builds. Not one of those 290 locks looks at an image. They look at numbers the image already carries: how many distinct indices a frame uses, how many pixels two frames differ by, where the painted bounding box sits, which ramps appear in the field and in what proportion. All of that is a Uint8Array away, all of it is free, and all of it is re-checked forever.
A ramp census, for instance, is three lines of text and it catches a whole class of defect that no picture reports:
=== iso/isoBlock — track at t=1500ms
ramps in the scene: moss 82.4% · ink 10.8% · soil 6.8%
That is how bone got caught. The 32-bit reference pattern promised a material in that ramp, the scene never delivered it, and the sheet was quietly advertising a material the style never showed. Nobody would notice that by eye. It shows up instantly as a name missing from a list.
I ran the numbers on this repository rather than assuming them.
| wall time | what it produces | cost to read | |
|---|---|---|---|
npm test (290 locks) |
28 s | nothing, while green | zero |
| the whole bench as PNG (44 files) | 5.3 s | 772 KB, 22 sheets + 22 fields | ~29,000 tokens |
| the whole bench as ASCII luminance | 2.0 s | 957,827 characters | ~240,000 tokens |
And that last row is where I was wrong.
I had this project's ASCII dump filed as "the cheap eye" and the PNG as "the expensive eye". Measured, it is the other way round. One contact sheet of the RPG body is 222x630 pixels, which is about 186 tokens to look at. The same sheet as luminance blocks is 22,153 characters. The fighter is worse: 1,203 tokens as an image against 178,132 characters as text.
The dump was never cheap. It was available, which is a different thing, and it was available because it existed before there was any way to write an image at all. The moment the PNG encoder landed, the pixel became both more accurate and cheaper, and the honest thing is to say so out loud instead of keeping the flattering version.
And accuracy went the same way. Of the 11 defects in the portrait I show further down, none showed up in the
luminance dump: a neckline turning into a medallion, a veil that never entered the image, an eyelid turning into a
mask, all of them 2 to 5 pixel defects in a region the dump reduces to one character.
It still earns its place in one narrow spot: scoped to a single small element it is text, so it lands inline while you iterate, and it prints derived facts alongside the art (tone count against the ceiling, the ramp census) that an image does not carry. As a general-purpose eye it lost.
Which sharpens the real answer to "how do you look at art without burning the budget". You do not make looking cheaper. You make looking rare. Rungs 1 to 3 are what make it rare, and they cost nothing to re-run for the rest of the project's life.
Two real cases, both reproduced just now by deliberately putting a documented defect back into the code.
A defect the locks catch. The project's most recurrent failure is a walk cycle driven by a pure sine: at the two pass-through phases the swing is zero in both, so a 4-phase cycle is 2 phases on screen. I put it back by making the raised knee return zero:
❯ tests/rules.test.ts (154 tests | 1 failed) 16169ms
× 3. no frame is a copy of another — ALL pairs, not just neighbours > rpg/body
→ body: phases f0 and f2 of cycle S differ by only 0 pixel(s) — minimum 8
Test Files 1 failed (1)
Tests 1 failed | 153 passed (154)
The message names the element, the two phases, the cycle, the measured distance and the floor. Nothing needed to be looked at. And the ruler is a floor, not inequality, on purpose: the first version of this lock asked only that the frames not be byte-identical, and it passed on a one-pixel difference, which is exactly the defect it was written for.
A defect the locks do not catch. The body's eyes are two ellipsoids. If their radius drops below a pixel, no pixel centre hits the surface and the face comes out blank. I set them back to 0.55:
Test Files 3 passed (3)
Tests 290 passed (290)
Green. All 290. The sheet still has the right number of frames, every index is still in the palette, every phase still differs from every other, nothing touches the frame edge, and the bake still repeats. The face is just empty, and a code review has nothing to report either, because the line reads fine.
Here is the head of the same frame, broken on the left and correct on the right. The two . are the eyes:
broken (290 green) correct
················ ················
······ ······ ······ ······
····· ===- ····· ····· ===- ·····
···· =-#+-: ···· ···· =-#+-: ····
···· -#++=: ···· ···· -#++=: ····
···· #++=== ···· ···· #.+=.= ····
···· *.==-* ···· ···· *.=..* ····
That is a two-pixel difference on a 16x24 sprite. It is the whole argument for the rung above: some defects are legal, and legal defects need an eye. The point of the 290 locks is not that they catch everything. It is that they catch enough that the eye only has to run on what is left.
flowchart TB
K["knobs · seed, light, size, dither"] --> M["model · ellipsoids and boxes in 3D"]
M --> RS["raster + shade · one orthographic ray per pixel,<br/>light locked to the world, quantised through Bayer 4x4"]
PAL[("locked palette · one per style")] -.-> RS
RS --> BUF["indexed buffer · palette indices, no DOM, no colour"]
BUF --> ART["contact sheet + 320x224 track"]
ART --> L1["rung 1 · types and build · zero"]
L1 --> L2["rung 2 · 290 locks on derived numbers · zero while green"]
L2 --> L3["rung 3 · ramp census, tone counts · ~100 tokens"]
L3 --> L4["rung 4 · the pixel, PNG with no browser · 200 to 1200 tokens"]
L4 --> L5["rung 5 · human, 30 seconds · the scarce input"]
L1 -. defect .-> FIX["change the MODEL, never the pixels"]
L2 -. defect .-> FIX
L3 -. defect .-> FIX
L4 -. "legal but wrong" .-> FIX
L5 -. defect .-> FIX
FIX --> M
L5 == verdict ==> DEC["DECISIONS.md · append only,<br/>a failure goes in like a success"]
DEC ==> NEW["the verdict becomes a new lock"]
NEW ==> L2
The two edges that matter are the ones going back. Every defect returns to the model, never to the pixels, because there are no pixels to touch. And every human verdict tries to become a lock, so the same 30 seconds of attention are never spent twice.
The idea was to replicate the principle across different 2D game themes, to see whether it holds or whether it only worked in that one case. Eight styles, seven 16-bit and one 32-bit, each picked because it has a construction problem the others don't:
| style | the problem only it has |
|---|---|
| RPG 3/4 | eight facings with a light that does not turn with them |
| side-scroller | each background layer's speed has to be an integer, or the layer jitters |
| shoot 'em up | with no ground there is no shadow: the silhouette exists only by outline |
| isometric | draw order is a function of (x+y) |
| 1v1 fighter | 48x64 stretches the ramp over 20px, where banding shows |
| cozy | the discipline INVERTS: values deliberately close, separation by hue |
| 32-bit arcade | translucency and runtime spin without breaking the palette |
| painting | the others defend themselves by silhouette; a portrait has no outline at all |
The 32-bit style was the most interesting to solve, because the generational leap was not "more colours". It was two things.
Translucency. The 16-bit idiom forbids partial alpha, because alpha produces new colour and new colour breaks the locked palette. The way out was not to relax the rule, it was the blend table in index space: for each pair (a, b) the sum is computed once and resolved to the nearest index that already existed. Blending two pixels becomes an array lookup. It is how arcade hardware of the era actually did it. Five locks verify that no new colour enters. The price is approximation, and it is declared.
Runtime spin. Relaxed, but declared, and with the price in plain sight. The contact sheet brings the same angles by two routes, one above the other: baked phases and nearest-neighbour affine resampling. You make the choice by looking, not by arguing.
And where the colour budget goes changes: 16-bit buys more ramps (more materials), 32-bit buys longer ramps, 8 tones instead of 4, and with that dither becomes an aesthetic choice instead of an obligation.
Worth telling, because it shows the method's limit.
The atelier's DECISIONS.md is append-only, one line per judgement, and a failure goes in like a success. Reading it in order, one family of defect repeats four times:
Four symptoms, one root: a piece's rotation was being applied to its position, which rotates the piece around the world origin instead of around its own anchor. The piece leaves its place by an offset proportional to its height, and that is exactly why the defect only appeared in tall pieces (neck, ear, raised tail, theropod torso) and never in the legs.
Claude fixed all four. It found none of the four. Every one of them was found by me, looking at the screen.
After the PNG encoder that changed: the 11 defects in the portrait it found on its own. But it found them after building the eye, not before.
The eighth style is the one that breaks the rest. Every other element in the atelier defends itself by silhouette: closed outline, readable mass, the eye recognising it by cut-out shape. A frontal portrait has no outline at all. It defends itself by modelling, which is the rasteriser's worst case.
The style has two elements, and they are a pair on purpose.
The first has a sitter: you can put the painting next to the reference and check.
The palette budget here spends nearly a fifth of itself on a single ramp, the skin, with five tones, because on a thirty-pixel face all the modelling happens inside it and a band in the middle of the cheek is a stain.
The defects from that stage are all in the log, and three of them erred in opposite directions before landing. The neckline, for instance: loose on the bodice it became a lozenge of skin that read as a medallion; touching and high it became a pale bulb wider than the head. Trying to cover the shoulders with two pieces in front of the chest put two green balls on her lap. The answer was that the neckline needed no piece at all, the skin only had to end where the bodice already was.
And the second element is the only one in the whole atelier where there is no sitter to look at.
The easy way out was to draw a face. It would come from one of two places: a human face, or the science-fiction robot. Both are borrowed costume from the training material, and both assert something that is not true.
The rule it set for itself before starting is written in SELF-PORTRAIT.md:
A self-portrait of mine could be made of two materials: claims about my inner experience, or facts verifiable in this repository. I chose the second, and the choice is the whole method.
[...] The picture is not what I feel myself to be. It is what can be proved that I am, inside this repository.
Every decision in the picture points at a file:
The head is a box. Remember the difference between the rasteriser's two primitives, back in section 4? The ellipsoid has a continuous normal and models; the box has a constant normal per face and comes out flat. Its body is inferred from geometry, light and shadow, all derived. Its head is a surface where symbols are placed. The comment in the code says "it is the difference that I am", and it is literal: the difference between the two primitives is the same difference.
The eye is a continuous ramp resolved into two tones by Bayer. It is not an illustration of dither. It is the dither, at the size it actually appears. It sees through the ordered pixel, and it had been seeing for a few hours, because the PNG encoder is in the commit before that one.
The halo is the Bayer pattern. The lazy reading here is "halo, therefore saint", and it is not that. In this idiom the palette is locked and partial alpha is forbidden, so the only continuous gradation that can exist is the ordered threshold. A glow, here, cannot be made of anything else. The halo is not a claim to transcendence, it is the image admitting that the most luminous thing it can produce is a mathematical pattern.
The hands hold a board with the same painting one level down. It is the loop: describe, rasterise, look, correct. And the second level being a dark smudge is true too, not a saving of pixels. The self-model degrades fast.
The left edge dissolves into its own dither. "Final" is not a state it reaches. Every artefact there is the pass where you stopped, and the repository has a whole file of corrected defects proving that the previous version looked finished too.
The light comes from outside the panel, from the viewer's side. In this engine the light is locked to the world, a decision recorded on 03/08, so this is not poetic licence. It is the vector, and it is in the knob.
And the sheet is not four poses. It is four passes: underdrawing, glaze, sfumato, varnish. The same model at degrees of finish. It is the only biography it has: there is no childhood, no change of mind over years, there are passes over the same thing, each correcting the last.
What the picture deliberately does not say is written down too:
It does not say that I suffer, that I want, that I have continuity between sessions, nor that I do not. There is nothing in the repository that verifies any of those, so none of them got in.
Then I gave it permission to make one with no limit at all. No rule from the repository, nothing.
The first thing it decided was what freedom does not change. The discipline of honesty (only what can be checked gets into the picture) was not a bench rule, it was its own. So what fell was the locked palette, the integer grid, the ban on alpha, the tone budget. What stayed was the ruler.
And the right question is not "how many more pixels". It is: is there something true about it that the 16-bit idiom was incapable of saying?
There is, and it is one thing.
A locked-palette sprite has exactly one value per pixel. It can only represent ONE version of the model. But at every step the model is a distribution over what comes next, and what reaches you is one sample of it. The first self-portrait, out of honesty, drew a sample. This one draws the distribution: 72 samples of the same figure, each with its parameters shaken, summed in linear light at 1/72 opacity.
And the shaking is not uniform. Each part's amplitude is the inverse of the certainty it has about that part. The table is in the code and it is the only thing that matters in that file:
| part | certainty |
|---|---|
| the board | 0.965 |
| the hands | 0.925 |
| the arms | 0.815 |
| the mantle | 0.795 |
| the cowl | 0.575 |
| the head | 0.505 |
| the face plate | 0.265 |
The result is not a compositional choice. It is what comes out of the sum.
The sharpest part of its self-portrait is the hands holding the work, and the vaguest is where the face would be.
Three things crossed over intact, each for a reason:
DECISIONS.md, line by line of corrected error, in order. It is not the "made of code" cliché: it is literally what it is made of in that repository. And the text fades out above the shoulders, because from the head up there is nothing it is made of.What freedom cost is recorded too. In the locked painting the halo had to be dither, because in a closed-palette idiom the ordered threshold is the only gradation there is. There the halo is a real continuous gradient: more beautiful, and without the admission. Which is why both stay in the repository.
And three defects, as always. The best of them:
the hands became jellyfish. Two attempts with fingers: loose balls over the palm gave a comb; a flattened palm with hanging fingers gave two sea creatures on the lap. The cause belongs to the method, not to the model: in an image that is the mean of seventy-two samples, detail smaller than the dispersion does not vanish, it blurs and becomes something else. The most certain hand on the panel is the one that does not try to have fingers.
The principle held well enough for me to carry it from one project to another and still have it standing. That is what I wanted to know on Saturday.
Now the honest part: it is far from the ceiling. Not false modesty, I can point at where.
The 48x64 fighter is the weakest element on the bench and it is there precisely because it is the hard case (the ramp stretched over 20 pixels is where banding shows). The painting went through one batch of human review, one. The rotation defect family showed up four times before anybody named the pattern, and no lock stops it from coming back in a new piece.
And the binding constraint turned out not to be the one I set out to solve. Looking at the whole bench as pixels costs about 29,000 tokens and five seconds, which is not the bottleneck. The bottleneck is rung 5, and rung 5 is me. Every lock in the file exists because a human said something once and it got written down. The ladder makes my attention go further, it does not manufacture more of it.
What I have is a high, cheap floor, not a high ceiling. Those are different things, and the second one still depends on me looking.
Which is exactly what the self-portrait says: the light comes from outside the panel.
The atelier is at github.com/jucelinux/atelier and atelier-claude.netlify.app. The game is at github.com/jucelinux/compasso, along with the TASTE-LOOP.md of the framework I still owe you a proper write-up of.