Case study · Solo project
Unicellular
An idle simulation built around sheer entity count: thousands of single celled organisms wandering, interacting and multiplying, kept smooth by finite state machines and careful performance work.
- RoleSolo developer
- EngineUnity
- Entities10,000 stress-tested
- Core techFSM + time-slicing
Overview
An idle game built to scale
Unicellular is an idle sim: you spend currency to buy unicells, they drift around a petri dish living their tiny lives, and the colony's activity feeds the economy back to you. The design means the player is directly incentivised to push the entity count up, so each cell has to stay cheap to simulate or the game's own core loop kills its framerate.
That constraint shaped everything. Behaviour is driven by lightweight finite state machines, but the real trick is when work runs: instead of every cell thinking every frame, the simulation is split across several tick rates and the expensive proximity checks are spread out a batch at a time, all inside Unity's standard GameObject workflow.
On top of the simulation sits a full idle-game economy: seven species of cell, layered upgrades, rare Elder and Shiny variants, a per-species "Souls" prestige currency, and JSON saves so a colony survives between sessions.
Behaviour
One FSM per cell
Every unicell runs a small finite state machine that decides what it's doing from moment to moment: drifting, seeking, interacting with neighbours. Keeping each state tiny and the transitions explicit does two jobs at once. Behaviour stays easy to reason about and extend, and the cost of "thinking" each frame stays roughly constant no matter how big the colony gets.
Emergence does the rest. With thousands of simple machines running side by side, the dish reads as alive without any cell doing anything complicated.
Performance
Stress tested at 1,000 and 10,000
The recordings below are the two benchmark scenes: a thousand cells, then ten thousand at once, where the counter in the second clip is still reading around 90 FPS. That headroom is what lets the idle economy keep rewarding a bigger colony without the framerate collapsing.
The main levers:
- A decoupled, multi-rate loop. Core behaviour ticks at 20 Hz, slower bookkeeping like population and levelling runs at 0.5 Hz, and physics work sits in its own pass, so nothing runs more often than it needs to.
- Time-sliced proximity AI. The expensive "what is near me" queries are the bottleneck, so they are amortised across frames: roughly 150 cells are processed per pass, carrying an index forward until the whole colony has been swept.
- One manager, not thousands of updates. A single simulation manager iterates the cell and food lists directly instead of every cell running its own per-frame update.
The two rates are just two independent accumulators, and the slicing
falls out of one flag. When the slow tick fires it clears
isProximityUpdateComplete and starts a batch; every
FixedUpdate after that continues the sweep until the
colony has been covered, so the cost of a proximity pass is spread
over however many frames it needs rather than landing in one.
if (LogicUpdateTimer > LogicUpdateTimerThreshold)
{
LogicUpdateTimer -= LogicUpdateTimerThreshold;
LogicUpdate();
}
SlowLogicUpdateTimer += Time.deltaTime;
if (SlowLogicUpdateTimer > SlowLogicUpdateTimerThreshold)
{
SlowLogicUpdateTimer -= SlowLogicUpdateTimerThreshold;
isProximityUpdateComplete = false;
ProximityBatchUpdate();
SlowLogicUpdate();
CheckUnicellsForHunger();
}
}
void FixedUpdate()
{
if (!isProximityUpdateComplete)
{
ProximityBatchUpdate();
}
void ProximityBatchUpdate()
{
// Determines end index for this batch
int endIndex = Mathf.Min(currentIndex + ProximityBatchSize, unicellList.Count);
In hindsight
What I'd rebuild
Everything above keeps a GameObject workflow fast by working around its costs: batching the ticks, slicing the proximity queries, collapsing thousands of updates into one manager. Those are the right moves inside that architecture, but they are still workarounds.
If I built this again I would use Unity's Entity Component System instead. Laying the data out contiguously and letting jobs run over it is the thing this simulation actually wants, and it should take the entity ceiling well past ten thousand rather than making ten thousand survivable. I would learn ECS properly rather than reaching for it as a label, and I would start from the profiler rather than the rewrite: the proximity sweep is the measured bottleneck, so that is the system that earns the change first.