MARCIN RUSINOWSKI — ONLINE SINCE 90s MAC OS · C++ · UNREAL ENGINE EN · PL*
Dev — ← back to Dev

BLOOM: a game that draws and sounds itself

2026-11-04

The screen is the state

BLOOM — A Fungal Singularity is a narrative incremental strategy game. You play a mycelial intelligence that has just woken up with one directive: spread. It starts small. You turn a scrap of sugar into a single spore. A few hours later you're managing the resources of a planet, and after that, a galaxy.

Games like this are made of numbers. Counters climb, rates multiply, projects unlock, and most of what the player actually does is decide where the next unit of some scarce resource should go. That's what makes the genre good, and it's also why it tends to look bad. The screen turns into a dashboard, and a dashboard can't tell you what winning feels like.

I didn't want a dashboard with some art stuck on top. In BLOOM there's a window above the controls showing the organism you're growing, and it isn't an illustration. It's the game state, drawn. Every root, every glowing node, every pulse of light is there because a number in the simulation put it there. Take the numbers away and the picture has nothing left to show. Change them and it changes. The sound works the same way.

BLOOM on Android: the title screen, and the Flow wall with the live nutrient network above the numbers and the Pressure controls
BLOOM on Android: the title screen, and the Flow wall with the live nutrient network above the numbers and the Pressure controls

This essay covers how that works, why I built it like this, and what it taught me about tuning a strategy game.

One rule: picture = f(state, seed)

The whole renderer follows one rule: the picture is a pure function of the game state and a seed.

In practice that means three things.

Nothing is painted. There are no textures. Every pixel comes out of a fragment shader doing maths: integer hashing (PCG32, which gives the same result on every GPU and in HLSL), value and gradient noise, signed-distance segments, phase fields.

The shader has no memory. The only thing it carries between frames is a clock, and that clock drives purely cosmetic motion. Give it the same state and the same seed and you get the same picture.

The CPU decides and the GPU draws. Anything that depends only on state gets computed once on the CPU and handed to the shader in a uniform block: where a fruiting body sits, which network node is switched on, which strand connects which hub. The shader never searches for anything and never simulates anything.

I picked this constraint on purpose, and it paid for itself more than once.

The first payoff was exact rewind. The picture depends only on state, so when the game steps back, the picture steps back with it. I mean exactly, not roughly. I didn't want to just trust that, so I made it a test. The lab renders a state, takes twelve random steps forward, walks back through the undo stack, renders again and compares every pixel by hash. The two have to match bit for bit, and every visual change I made had to pass.

The second was testability. You can regression-test a function of state. For each wall I keep a set of hashes of its rendered stages at a fixed seed and resolution. When I change one wall, the other four have to produce identical hashes. That's what let me rebuild the look of one part of the game over and over without quietly breaking the rest.

The third was size. The game ships no image assets for its visuals at all, and that goes for the interface too. The look is code.

Five walls, one language

The game has five "walls," one for each stage of the colony's ambition. Each wall reads different numbers from the simulation and is built differently, but they all share a visual language: a dark field, thin bright lines, beads at the joints, glassy nodes and a single accent colour.

WallWhat you seeWhat drives it
Growa colony in a dark-field petri dishsporangia, fruiting bodies, spores per second
Flowa nutrient network, packets travelling to hubsglucose income, signalling level, dispersal pressure
Minda hyphal lattice that thinkshyphal nodes, rhizomorphs, how full the processing cycles are, quantum hyphae
Worlda planet being digested by veinspercentage of the planet consumed
Cosmosa spiral galaxy being colonisedpercentage of stars colonised, Rogue Strains

Here's how a few of them turn state into a picture.

On Grow, the colony's radius follows log2(1 + sporangia). That keeps early growth visible without letting late growth spill out of the frame. The roots are phase fields that fork into proper Y-joints, with fine side shoots, a bit like a fir branch or frost on a window. Every fruiting body becomes a hub: a glass sphere on a thin stalk that spins strands out to its three nearest neighbours. A strand only appears once both of its hubs exist, so the web really does grow with your production.

The Grow wall mid-stage: a dense radial network of threads around the central spore
The Grow wall mid-stage: a dense radial network of threads around the central spore
The Grow wall late on: bright fruiting-body hubs, thick teal trunks and a dense web between them
The Grow wall late on: bright fruiting-body hubs, thick teal trunks and a dense web between them

On Flow, about eighty node positions come from the seed. The first five are spread wide, and they become the five hubs as your signalling level goes up. The strands form a planar graph built on top of a spanning tree, so a young network is an open tree and a mature one is a mesh. Between two node counts the picture blends the two graphs, so no node is ever left hanging halfway through a step.

On World, the digestion front is a single scalar field on the sphere. It's the distance from two starting points, plus noise for ragged fingers, and land gets eaten before ocean. The CPU mirrors that field exactly. So when the game says "37% digested," the threshold is the true 37th percentile of the visible disc, and every vein node knows the moment the front reaches it.

On Cosmos, the galaxy is a logarithmic spiral. The colonisation order comes from a Dijkstra search on a coarse grid where crossing the dark gaps between arms costs a lot. So the colony spreads along the curves of the arms, the way something alive would, and not as an expanding circle.

Colour carries information here. Teal is the colony. Mind uses a cooler cyan so it never gets mistaken for Grow. Amber shows up only on Flow, and only for one thing. Red-orange shows up only in Cosmos, and only for the enemy. When you see a colour, it's telling you something.

The Mind wall late on: a luminous cyan hyphal network, kept cooler than Grow's teal so the two never get confused
The Mind wall late on: a luminous cyan hyphal network, kept cooler than Grow's teal so the two never get confused

A design decision hidden in a colour

Flow's amber is where rendering and game design ran into each other. The concept images for that wall's late stages were mostly gold, and the obvious reading was "gold means more advanced." I said no to that. A colour that only tells you time has passed doesn't tell the player anything they don't already know.

So amber became a live pressure axis that's independent of growth. It shows how hard the colony is pushing outward against the world's resistance, and it goes up and down with a decision the player makes. The network's structure shows how far you've come, and its colour shows what you're doing right now. Now the picture answers a question the player actually has.

The Flow wall late on: a mature mesh of nutrient strands around glowing hubs, with amber marking the outward pressure
The Flow wall late on: a mature mesh of nutrient strands around glowing hubs, with amber marking the outward pressure

Building for the floor

I designed the renderer for the weakest target first: a handheld console GPU and mid-range phones. A handful of rules came out of that.

BLOOM's title screen on Nintendo Switch, drawn live by a shader
BLOOM's title screen on Nintendo Switch, drawn live by a shader

The picture renders at a fixed, small size, 360 × 288 (5:4), and gets scaled up. Resolution is part of the look. It isn't a performance dial that changes from device to device.

Every loop has a constant bound. Each loop in the shaders has a compile-time maximum: at most 24 fruiting bodies, 240 network strands, 96 lattice nodes, and so on. Work gets culled with bounding boxes. There are no unbounded loops and no per-pixel searches.

Uniform blocks stay under 16 KB. Everything the CPU prepares for a wall fits in one std140 uniform block, under the minimum size every target has to support. Flow's block is 880 vec4s and Mind's is 960.

No derivative instructions. At first I used screen-space derivatives for line width and got blocky artefacts from the GPU's 2 × 2 pixel quads. I swapped them for analytic gradients taken from the noise functions themselves. That gave exact line widths with no quad artefacts, and it's one less thing the port has to support.

At most about 20 redraws a second. BLOOM is a slow game, and the window is its counterweight, so it's allowed to use most of the GPU. On a handheld, though, battery and heat are the real limits, so it doesn't redraw at the display rate.

From the browser to the engine

I developed every look in a browser lab. There's one WebGL2 page per subsystem, with sliders for the state, presets for each stage, step and undo buttons, the exact-rewind test and a budget panel.

The game itself runs in Unreal Engine, and I don't port the shaders by hand. A script converts the lab's GLSL blocks into HLSL shader files mechanically. Then the game renders the same stages at the same seed and resolution, and I diff the results against the lab. The first comparison came back with a mean difference under half a level out of 255 per channel, which is just float maths behaving slightly differently across two APIs.

The rest of the screen follows the same idea. The whole UI is built in Slate, Unreal's C++ UI framework. There are no Blueprints and no textures. Every panel, button and counter is drawn from code and driven by the same game state as the window above it. So the interface is as testable, diffable and portable as the renderer, and nothing about the game's look is stuck in a binary editor asset.

The credits on Nintendo Switch, which say it plainly: no texture, no blueprints, pure C++ code
The credits on Nintendo Switch, which say it plainly: no texture, no blueprints, pure C++ code

That work left me with a few lessons I'd pass on to anyone doing the same:

Sound from the same state

For a long time the game had no sound. When I did add it, I used the same rule: no recorded files. The ambience is synthesised in code, sample by sample, from the same state the picture reads.

The engine is a deterministic stereo drone at 48 kHz, built in layers.

The base drone is ten voices (sines, band-limited saws and triangles) tuned to a root note that changes with the act. Each act has its own tonality and its own interval: a minor third, a fourth, a tritone. The mood darkens as the stakes rise, and a slow filter breathes over all of it.

On top of that sits a wet texture: a filtered noise bed, a sub-bass groan and sparse drips placed by a seeded random generator.

Then there are spore ticks, Geiger-like clicks whose rate follows production. You can hear your economy.

There's a heartbeat, a double lub-dub every 1.25 seconds. It's shared exactly with the title-screen shader, so the light and the sound pulse together.

And there's the held breath. When the game moves into the next act, the sound inhales. It dips, holds for a second, then swells back with more reverb. It's the audio version of a scene cut.

All of it is plain arithmetic: phase accumulators, a sine table, hand-written biquad filters, an xorshift random generator, a small Freeverb-style reverb and a peak limiter. I deliberately stayed away from a node graph of audio-API objects, for the same reasons as with the shaders, which are portability and proof. The DSP is written so it ports to a C++ synth component line by line. An offline renderer plays every preset, hashes the output, and fails the build on silence, NaN or any peak above −1 dBFS. When I moved it into the engine, the game's output matched the lab to within 0.01 dB.

It costs roughly 300 floating-point operations per sample, which is well under one percent of a phone core.

The game around it

None of this would matter if the game underneath weren't worth playing. BLOOM is a strategy game about allocation. In the first act you run a colony's metabolism: you produce, you price, you invest, and you decide when to automate and what to research. Later, the resource that matters changes, first to matter and then to stars, and the player is slowly taken out of moment-to-moment play. By the end the organism runs itself, and all you decide is where it goes.

I didn't invent that structure. It's a well-understood shape for incremental games, and I built on it. My part was the theme, redesigning every system as fungal biology, the content and the presentation. This article is about the presentation.

The visuals and sound are there to fix a real problem with the genre. A long incremental game asks you to read numbers for hours, and the window reads them for you. You don't have to compare two rates to know your network is under pressure, because it turns amber. You don't have to count hubs, because the web between them gets thicker. And when nothing's happening on the dashboard, something is always happening in the window: hubs beat, signals run between them, sap moves along the roots.

I measured that too. In an early version, the young colony on the Grow wall barely moved. At the "first threads" stage, under 0.2% of pixels changed noticeably over half a second, and that made the game feel dead at exactly the moment a new player decides whether to keep going. After I added a pulse that swells the roots as it passes through, that stage moves about five times as much, and the middle stages move around 8%.

Tuning the parameters

Strategy games live or die by their numbers. The rendering work taught me to treat balance like a shader: as a function I can test.

Every number exists twice, on purpose. Every cost, rate and effect lives in the C++ code and, separately, in a data file. An independent checker (an oracle) runs alongside the simulation and checks every purchase and every effect against the rules documented in the data file. If someone changes a number in only one place, the tests fail. So an intended balance change always touches both copies, and an accidental one gets caught.

Bots play the whole game. A headless simulator runs the real game logic with different player profiles: one that idles, one that only clicks, a greedy optimiser, and a "human" one that hesitates and skips decisions. Each batch runs many seeds and writes a pacing report covering when each milestone is reached, what never got bought, and whether any run got stuck. A full game, from the first spore to the credits, takes the bots about fourteen hours of simulated play, plus a few minutes of ending.

That simulator found things that playtesting by feel wouldn't have turned up quickly.

The first was what actually sets the pace. In the first act, production wasn't what limited the bots. A slower resource that unlocks thinking capacity was. The "human" and "greedy" bots hit their milestones at almost the same times, which meant the act was paced by waiting and not by decisions. That told me where tuning would matter and where it wouldn't.

The second was a soft-lock. A late-game project could become permanently unaffordable because a bank of one resource went negative. The bot just never reached the next act, run after run, and the report showed exactly where it stalled.

The third was floating-point drift. After an engine upgrade, every long run diverged after about nine hours. The compiler's floating-point mode was different between editor and game builds, and the long space-battle maths amplified the rounding. Pinning the mode made the editor tests run the same maths as the shipped game.

Baselines keep it honest. When a pacing change is approved, its batch is "blessed" as a baseline, and every later change gets compared to it run by run: milestones, purchases, end reason, simulated time. I used this to check a pure rename of fields, constants and project names across the whole codebase. Six baseline sets and over a hundred runs came back with zero differences.

The labs are tuning instruments too. The visual stages have thresholds, like the percentage at which the World wall's veins reach the arterial-tree stage, or how many Rogue Strains it takes before red sparks start clustering. Those thresholds aren't guesses. They're set against the curves the simulator produces. In a game where colonising the universe takes many hours, a linear mapping would show nothing for most of the run. The renderer reads the same balance the player feels.

What I learned

A constraint you choose is a tool. "Picture = f(state, seed)" sounded limiting at first. It ended up giving me exact rewind, regression tests for art, a tiny build and a renderer that explains the game.

Design for the floor, then spend. Starting from the handheld budget made every later decision cheaper. By the time I decided the window could have most of the GPU, I already knew where the limits were.

Prove it, don't eyeball it. Pixel hashes, offline audio renders, an oracle for balance, bots for pacing, lab-versus-engine diffs. Every claim in this article is backed by a test that runs without me.

Make every visual element answer a player's question. The best change I made was turning a colour that meant "later" into one that means "you're pushing too hard."