Home / Blog / Values, not side effects
CricCuts Blog · Architecture

Values,
not side effects.

A renderer that returns a picture can be interrogated by a machine. One that draws into a system cannot. That distinction decided how eleven of our twelve animated scenes are built — and it is the same rule behind ports and adapters, functional core / imperative shell, SQLite's storage interface, FoundationDB's simulator and React's virtual DOM. The principle is thirty years old. What changed is its price.

⏱ ~18 min read 🏛 Architecture 🎨 Software rendering 🤖 Agentic development

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.

🔑
Everything in this post is a consequence of that one line. Testability, portability, reviewability and the ability to hand work to a machine collaborator all turn out to be the same property asked in different accents: is the outcome a thing you can hold, or a thing that happened elsewhere?

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.

📦
Why one integer rather than four bytes? The CPU moves 32 bits as cheaply as 8, the array has one element per pixel instead of four, indexing is a single multiply-add, and access is sequential and cache-friendly. Practically: Android's 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.

🔒
The clip is what makes a boundary true, not the shape's own arithmetic. An ellipse whose centre sits on the ground line still straddles it by its own radius. A hillside drawn as a dome, a contact shadow under a boat — both spill past the line they stand on, and no care in the shape's own maths fixes it, because the shape is behaving correctly. Anything standing on the ground is drawn against a cap region; anything belonging to the masonry against the masonry's own profile. Never trusted to stay put on its own.

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.

💡
The currency is bytes that have to change — not bytes allocated, not draw calls. A sprite that only moves, fades or rotates is effectively free, because the pipeline deduplicates texture uploads by bitmap instance: one upload for the whole export, and a handful of floats varying per frame.

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.

ApproachHow it worksWhere it winsWhy 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%.

⚖️
The honest version. Robolectric's native graphics mode loads real Skia and renders 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

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.

Ports & AdaptersAlistair Cockburn, 2005

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.

Functional core,
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's VFSongoing

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.

FoundationDBFlow & deterministic simulation

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.

React's
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.

🤖
The design rule that follows: prefer the representation a test can inspect, even when it is the worse tool. A pixel array is a value — assert on it, diff it, count it, sweep it, write it to a file and look. A Canvas call is an instruction sent into an opaque system. You cannot ask it anything, and neither can your collaborator.

What that bought, concretely

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.

🔁
Testability and portability are the same property. Both ask: can something other than the Android runtime execute this? A JVM unit test was simply the first host to ask. Everything that made the scenes testable — no framework calls, no hidden state, a value in and a value out — is what would make them portable, and the test suite proved the property years before there was a reason to want it. React got the identical pair from the identical decision.

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.

🎯
One line, if you want one: ask of every component whether its output is a thing you can hold or a thing that happened somewhere else — and when you can afford to, choose the one you can hold, even if it is the worse tool. That choice is what a test can check, what a reviewer can read, what another platform can run, and what a machine can be trusted with.

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 agents

Related 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.

Comments

Thoughts, questions, corrections — all welcome. No account needed.