Our codebase contains the same job done two ways, and the fork is instructive enough that I want to lay it out before explaining either.
Twenty-seven files draw their graphics the normal Android way: Canvas, Paint,
Path, gradients, blend modes. Eleven thousand lines. It is the mature, hardware-accelerated,
C++-backed path, and by every measure a graphics engineer would care about it produces better output than
what we wrote instead.
Thirteen files — twenty-nine thousand lines, the backdrop scenes — do not call a graphics library at all. They compute which pixels a shape covers and write 32-bit integers into a flat array, in Kotlin, on the CPU, one pixel at a time.
The second group was written later, deliberately, knowing the first existed. This is why.
The distinction that decides it
Strip away the graphics and the difference is one line of type signature.
// A side effect. Returns nothing. Its result is somewhere else. fun draw(canvas: Canvas) // A value. Everything it did is in your hand. fun render(plan: Plan): IntArray
The first form hands instructions to a system and trusts the system. You can ask it nothing afterwards. To find out what happened, you need the system present, running, and willing to show you its framebuffer.
The second form gives you the outcome as data. You can assert on it, count it, diff it against last week's, feed it a thousand different inputs in a loop, or write it to a file and look at it. All of that works on a build machine with no phone attached, in milliseconds.
Now the concrete version, because the abstract statement is easy to agree with and hard to act on.
What an ARGB int array actually is
A digital image is a grid of coloured dots. Each dot needs four numbers: red, green, blue, and how opaque
it is. Each fits in one byte — 0 to 255. Four bytes is thirty-two bits, and a Kotlin Int is
thirty-two bits, so a pixel's entire colour fits in one integer:
// Packing four 0..255 channels into one Int val pixel = (alpha shl 24) or (red shl 16) or (green shl 8) or blue // Unpacking again val alpha = (pixel ushr 24) and 0xFF // ushr: unsigned, so alpha ignores the sign bit val red = (pixel shr 16) and 0xFF val green = (pixel shr 8) and 0xFF val blue = pixel and 0xFF
An image is then IntArray(width * height), stored row by row, with the pixel at column
x, row y at index y * width + x. Our scenes are 720 pixels on the
short side, so a portrait scene is an array of about 921,600 integers.
Bitmap.setPixels() takes exactly this layout, so handing the finished array to the system is
one call with no conversion.How you draw into one
Drawing is deciding which indices to write and what to write. Three ideas carry all of it.
Coverage — the difference between a shape and a staircase
Pixels are squares; shapes are not. Where a curve crosses a pixel, that pixel is partly inside and partly outside. The fraction inside is its coverage. Round it to 0 or 1 and you get jagged edges; use it as a blending weight and the edge pixel becomes a partial mix with whatever was behind it, which the eye reads as smooth. Anti-aliasing is not a switch you turn on — it is a number carried through every primitive. Ours compute coverage analytically per row rather than supersampling, because a 4× supersample is a 16× memory cost we cannot pay.
Compositing — putting one colour on another
This is the operation everything else is built from, and almost every graphics bug is a compositing bug.
Painting a source colour with coverage c over a destination that already has opacity
d is called source-over:
// alpha out: what the pixel's opacity becomes outA = c + d * (1 - c) // colour out: a weighted average, then un-multiplied by the new alpha outR = (srcR * c + dstR * d * (1 - c)) / outA // ...and the same for green and blue
The numerator says something simple: the source weighted by how much of the pixel it covers, plus the destination weighted by how much it covers and how opaque it already was. The division at the end converts from premultiplied back to straight alpha — a distinction that sounds pedantic and causes more dark halos around glowing objects than any other single mistake in computer graphics.
A second operator is needed too. Source-over makes a bright thing painted onto a dark thing less bright — right for a surface, wrong for a light. So there is an additive mode that sums and clamps:
outR = min(255, dstR + srcR * amount)
We learned that one the hard way. A scene of ours is a long dark corridor where depth is conveyed by progressively darkening surfaces as they recede. That dimming swallowed the lit ceiling ribs, the doorway strips and every glowing edge, because they were being composited as surfaces. The fix splits the paint at the depth falloff: everything behind it dims, everything in front of it adds. Atmosphere dims surfaces and must not dim emitters — now a comment in the source.
Clipping — regions as spans, not shapes
Real graphics libraries clip against arbitrary paths, with winding rules and intersection tests. We don't. A region here is two float arrays: for each row, the leftmost and rightmost x that drawing may touch.
That sounds crippling and is exactly the right limitation. Every clip our scenes need is a horizontal cap (nothing below the ground line), a vertical profile around a centre (inside the dome, outside the archway), or the intersection of the two — and per-row spans express all three exactly, with no winding machinery and no allocation. Intersecting two regions is a loop taking a max and a min.
How you animate one, inside a phone's budget
The naive approach is to redraw the array every frame. For a 3-second clip at 30fps that is 90 full renders of a 921,600-pixel scene, per clip, while the phone is also decoding video, encoding video and running a neural model. Not close to affordable.
So we don't animate the array. We animate what happens to it. The video exporter composites bitmap overlays onto each frame on the GPU, and each overlay specifies, per frame, a scale, a rotation, an alpha and where its anchor lands. That is the whole animation vocabulary, and it is enough for most things.
- Tier 1 — transform-only. Free. One bitmap, uploaded once, animated through scale, alpha,
rotation and position. The comic impact burst is exactly this: a single unchanging
POW!image whose entire "pop" is a scale overshoot, an alpha ramp and a rotation wobble. No sprite sheet exists. - Tier 2 — a small filmstrip. Cheap. When the shape itself must change, pre-render a few frames of a small sprite and cycle them. One scene's rocket is 63 × 94 pixels × 12 frames = 284 KB.
- Tier 3 — a full-frame animated layer. The wall. Those same 12 frames at scene size are 3.7 MB × 12 = 44 MB, eleven times the whole scene, for one layer. This tier is refused.
A cached scene is about 4 MB per camera setup — the 3.7 MB static raster plus its live sprites — and the cache key is the camera plan, so a twelve-clip reel shot from one position pays once, not twelve times.
The two limits nobody warns you about
Both are export-only, invisible in preview, and cost us a release each.
Fifteen overlays. The overlay shader throws if handed more than fifteen layers in one effect, and the check runs when the GPU pipeline is configured — so an oversized list compiles, passes every geometry test, renders perfectly in the in-app preview (which draws the layers itself, with no such limit), and then fails the export with a generic frame-processing error. Four scenes crossed it at once. The fix is one resolver that chunks a layer list into consecutive effects, which is safe because overlays composite in list order within an effect and effects in list order in the chain, so the seam is invisible.
Anchors clamped to ±1. The overlay API rejects a frame anchor outside [-1, 1], and
throws per frame, so it fires on whichever clip and framing reaches the value. One scene's freighter
is meant to fly off-frame at both ends of its run, spanning −0.17 to 1.17 — so that scene could never export
at all. Clamping is the obvious fix and it is wrong: it parks the ship half-visible against the edge for the
first and last 13% of every clip. The right fix splits the position — the background anchor takes
what fits, the overlay's own anchor carries the remainder, which buys exactly one half-sprite of extra
reach. Precisely "fully off-frame", which is all any entering or leaving sprite needs.
The alternatives, honestly
Four other approaches were available, and the integer array is genuinely not the best renderer of the five. It is the best fit, which is a different claim.
| Approach | How it works | Where it wins | Why not here |
|---|---|---|---|
| android.graphics Canvas + Path |
You describe shapes; Skia rasterises them in optimised C++, usually on the GPU. | Faster, far less code, excellent anti-aliasing, real path clipping, text. Genuinely better at drawing. | Stubbed on the JVM test classpath — every call returns a default and draws nothing, silently. See below. |
| OpenGL ES fragment shaders |
A small program runs on the GPU once per pixel, thousands in parallel. | Orders of magnitude faster. The only option that can move the video's own pixels rather than paint over them. | Needs an EGL context, so it is untestable off-device. Driver-dependent. GLSL is a second language nothing can cross-check. |
| Lottie (After Effects) |
A designer animates in After Effects; a JSON export is replayed by a runtime. | Beautiful results, no engineering per animation, and a real design workflow. | Nothing in a scene can respond to this batter. Our openings are solved per clip from a measured pose; a pre-baked animation cannot do that. |
| Sprite sheets / pre-rendered video |
Render every frame ahead of time, ship the pixels, play them back. | Trivially cheap at runtime. Arbitrary visual complexity for free. | Same fatal flaw, plus the 44 MB wall — a full-frame animated layer cannot be shipped or generated on demand. |
What "untestable" actually means
This deserves precision, because it is the load-bearing claim and "Canvas can't be tested" is too strong.
When unit tests run on the JVM there is no Android — no Skia, no native code, no framebuffer. The
android.jar on the test classpath is a shell: every method body has been stripped. By default a
call to one throws. But a project with any logging inside otherwise-pure functions has to switch that off:
testOptions { unitTests { isReturnDefaultValues = true } }
And that single line converts every stubbed method from loud to silent. Now
Bitmap.createBitmap() returns null, the canvas holds nothing, every draw call is a no-op, and —
the part that bit us hardest — a geometry type constructed with four real numbers comes back with all four
fields zero. We lost a day to that recently: a probe measuring one scene's clearance reported a feature as
removed entirely, because a zero-width rectangle took a null branch. It did not error. It returned confident
wrong numbers and overstated the defect by about 35%.
Canvas to a readable bitmap on the JVM, so
Canvas is testable with a second runtime — one that costs seconds per test rather than
milliseconds, and ties the tests to Android. The accurate claim is not "Canvas is untestable". It is that
Canvas needs a harness to become a value again, and the integer array already is one.And one place we use a shader anyway
An overlay paints on top of the video; it structurally cannot displace the video's own pixels. One of our effects — a chromatic split where the colour channels tear apart radially on impact and snap back — needs exactly that, so it is a real GLSL fragment shader. It is the honest counter-example, and its costs are written into its own file: it cannot be unit tested at all; it created a formula mirrored across two languages that no compiler can check, so the shared curve lives in Kotlin and is never inlined into the shader; and it must degrade to a pass-through on any driver that won't compile it, because a clip missing its effect beats a reel that refuses to export.
Where this is genuinely worse
- It is slower. Single-threaded CPU work over a million pixels, versus a GPU doing them in parallel. We hide it by caching aggressively and never redrawing per frame, but the cost is real — a scene's first render has a visible warm-up.
- It is much more code. Twenty-nine thousand lines to do what a library already does. Every primitive is ours to get right and ours to fix.
- Anti-aliasing is hand-rolled per primitive, so quality varies with how carefully each was written. Skia's is uniformly excellent.
- No text rendering. Font rasterisation is genuinely hard and we have not solved it; one scene uses an abstract 5×7 cell where letterforms belong, which is a visible compromise.
- It cannot touch the source video. Overlay-only, by construction.
None of this is new
Having written all that, I should be clear that we did not discover anything. The rule we arrived at by bruising ourselves against a graphics API is one the industry has landed on repeatedly, from completely different directions, for forty years. It is worth walking through the cases, because the pattern in them is sharper than the principle stated on its own.
The pattern usually called hexagonal architecture. Application logic sits in the middle; every outside thing — database, UI, network — reaches it through an interface it defines, and each real implementation is an adapter plugged into that port.
What is worth noticing is the stated motivation. Cockburn's one-sentence statement of intent is that an application should be equally drivable by users, by programs, and by automated tests, and be developable in isolation from its eventual runtime devices. Testability was not a benefit discovered afterwards. It was the reason the shape exists.
imperative shellGary Bernhardt, 2012
Push all decision-making into pure functions that take values and return values. Keep every side effect — files, sockets, screens — in a thin outer shell that does no thinking.
The testing consequence is the whole point: the core needs no mocks, no stubs, no test doubles and
no framework. You call a function and assert on what comes back. This is precisely the rule this post
arrives at, stated fourteen years earlier and more clearly. Our scene files are the functional core; the
one setPixels call at the end is the imperative shell.
SQLite reaches the filesystem through a swappable interface rather than calling the OS directly. That indirection buys portability — and it exists so the test suite can substitute a filesystem that simulates I/O errors, memory exhaustion and power loss mid-write, deterministically, which no real disk will do on demand.
SQLite ships with vastly more test code than library code and full branch coverage. Its maintainers have been explicit that this is what lets them refactor and optimise aggressively inside the most conservative deployed codebase on earth. The tests are not a tax on changing it; they are the permission to change it.
The most extreme case I know of, and the one that reframes the whole question. Building a distributed database that must never lose data, the team's first move was not to write the database. It was to write a deterministic simulator — including a C++ language extension, Flow, so the entire system could run single-threaded and reproducibly.
The database then runs inside that simulator with faults injected on purpose: disks fail, networks partition, machines die, clocks skew. Any failure replays exactly from its seed. They have said they spent longer building the testing infrastructure than the database it tests.
The architecture is downstream of the verification strategy, not the other way round. That approach has since spread — it is why TigerBeetle is built the way it is, and there is now a company selling deterministic simulation as a service.
virtual DOM2013 → React Native, 2015
React components do not mutate the page. They return a description of what the UI should be — plain objects — and a separate renderer commits that description to a host.
Honesty first: that indirection was designed for the programming model, not for tests. But look at what fell out of it. Because the output is a value, you can render a component to a JSON tree with no browser and assert on it. And because the renderer is separable from the description, the same component code could be pointed at native iOS and Android views — which is React Native, arrived at two years later.
Returning a value instead of performing a side effect bought a testing story and a portability story from one decision. That is not a coincidence, and it is the same pair we ended up with.
Five different problems — enterprise applications, Ruby design, an embedded database, a distributed database, a UI library — and the same move each time: put a value where a side effect was, and hold the outside world at arm's length behind something you can swap.
So what actually changed?
Not the principle. The price of ignoring it.
Design for testability has always been advice everyone nods at and a good half of us quietly skip, because it costs real effort up front against a benefit that arrives later and diffusely. When a human writes every line, that trade is genuinely arguable. Production is the bottleneck; the person who wrote the code has read it, and holds a model of it; review is reading.
Most of our scene code was written by an AI agent, and two things about that break the old arithmetic.
The first is speed. An agent writes a thousand-line rasteriser in four minutes. Production stops being the bottleneck, and verification becomes the entire job. Any architecture whose correctness is established by a careful human reading it has just had its only checking mechanism outpaced by an order of magnitude.
The second is more specific, and it is the one people underrate. An agent can reason about geometry impeccably and have no idea that the thing it drew is invisible, in the wrong place, or eleven times too large — and it will report success with complete confidence. It is not lying; it genuinely cannot see. So the defence cannot be judgement. It has to be an oracle the agent cannot argue with, and an oracle needs something to inspect.
Canvas call is an instruction
sent into an opaque system. You cannot ask it anything, and neither can your collaborator.What that bought, concretely
- Proofs, not spot-checks. Each scene's opening must never overlap the batter, at any framing. That is checked by sweeping the input space rather than sampling it: seven body heights × five stances × six positions across the frame, each verified at 201 points along the opening's curve — some forty thousand assertions, in under a second, with no device. Nine scenes carry a sweep like it. A human reviewer checks three screenshots.
- Rendering with no device. Ten test files render scenes at full resolution and write image files you can open. The image libraries are not on the unit-test classpath either, so the 54-byte file header is hand-written — exactly the tedious, well-specified job an agent is superb at.
- A census that catches nothing at all. This is the one that justifies the whole approach. Geometry tests ask "are marks inside the safe area?" and "are marks outside the forbidden area?" — and a layer that draws absolutely nothing satisfies both, with better clearance than one that draws. Every ceiling member of one scene was missing for two review rounds with the entire suite green. A pixel array lets you simply count what each layer contributed. Nothing else we could have built can see that.
That last point generalises well past graphics, and it is the thing I would carry to any project now: the failure mode of an agent is rarely a wrong answer. It is a confident answer to a question nobody asked. Absence is the hardest condition to test for, and it is exactly what a fast, blind, tireless collaborator produces most.
None of which removes the need to look. We still find defects no test can see — two individually correct changes in tension with each other, a shape that is safe but illegible, a layer in the right place at the wrong size. Renders and a human eye remain irreplaceable. But a suite that can render, sweep, count and diff makes those the rare case rather than the only case, and that ratio is the whole difference between an agent being an accelerator and an agent being a liability.
The dividend nobody planned for
There is a second consequence of the same property, and we noticed it rather than chose it.
A scene file imports kotlin.math and nothing else. It touches Android in exactly one place,
at the very end, when the finished array is handed over:
return Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888).also {
it.setPixels(px, 0, w, 0, 0, w, h) // ← the only Android line in 29,195
}
kotlin.math is in Kotlin's common standard library, so those files already compile
for iOS. Moving them into a shared source set is a build change, not a rewrite, and that one line becomes a
platform-specific function returning a Core Graphics bitmap instead. Twenty-nine thousand lines of scene art
would cross platforms essentially untouched.
The twenty-seven Canvas files would not. The concepts map onto Core Graphics closely enough
— blend modes, gradients, paths — but Paint is a bag of state you hand to each call while
CGContext is a state machine you push and pop, so every function changes shape; the blur filter
has no direct equivalent; and Skia and Core Graphics anti-alias and interpolate gradients differently, so the
output is never pixel-identical. You cannot diff the iOS render against the Android one to check your work.
Every effect needs re-approving by eye, on the platform where the drawing is no longer a value and you have
no harness.
Two caveats so this is not oversold. Straight versus premultiplied alpha: our blend produces un-premultiplied ARGB and Core Graphics wants premultiplied, so there is a conversion at the boundary — one function, but precisely the one whose failure mode is the dark-halo bug described earlier, applied to every glow at once. And ahead-of-time compilation is not a JIT: tight integer loops over nine hundred thousand array elements are the workload most sensitive to bounds-check elimination, which would need measuring rather than assuming.
And one alternative I owe the reader, because it may simply be the better answer: Skiko — the Kotlin binding to Skia that Compose Multiplatform renders through. Art written against its drawing API would run on iOS, Android and desktop through the same Skia, so renders are pixel-identical across platforms; it is testable on the JVM desktop target by rendering to a surface and reading the pixels back; and it keeps Skia's rasterisation quality and gives you text for free. That beats what we built on both axes at once. What our approach still wins is narrower: no dependency, no second runtime, millisecond tests, and total control when the thing being asserted is a geometric safety property rather than an appearance.
The uncomfortable part
There is a persistent instinct in software that sophistication is safety — that the mature library, the hardware path, the framework with a decade of production behind it, is by definition the responsible choice. Very often it is.
But every abstraction that saves you work also removes your ability to look inside it. That was a fine trade when the scarce resource was engineering hours. It is a much worse trade when the scarce resource is attention — when code is cheap and plentiful and arrives from a collaborator who is fast, capable, and completely unable to see what it has drawn.
Cockburn, Bernhardt, the SQLite maintainers and the FoundationDB team all worked this out before any of this was automated. They were solving for humans who forget, teams that grow, and systems that must survive being changed. The agent did not invent the problem. It removed the last excuse for not solving it.
See what all those integers add up to
Twelve animated scenes, dozens of impact effects, and a highlight engine that runs entirely on your phone. Free, private, offline.
Get the app → Built with agentsRelated reading: what it's actually like building with AI agents, the anchor is the interface, and the gust that fixed the engine. More on the CricCuts blog.