Tiny Colony (part 3)
From Moving Dots to Working Pawns

In the previous post, I set up the absolute basics of a tiny colony simulation in Rust using Bevy:
a grid, some tiles, and a handful of pawns rendered as little circles.
Everything compiled. Things moved.
And… that was about it.
At that point, pawns were basically well-behaved pixels. They could move, but they didn’t really do anything. There was no intent, no persistence, no reason for a pawn to go somewhere other than because I told it to.
This post is about fixing that.
The real problem: movement is not behavior
Initially, I had a system that moved pawn 0 toward a hardcoded target. It worked fine, but it immediately felt wrong:
It didn’t scale beyond one pawn
The logic was brittle
Nothing was inspectable or debuggable
There was no concept of why a pawn was moving
In other words:
I was telling pawns how to move, not what they were trying to achieve.
So I stopped doing that.
Tasks: making pawn intent explicit
The first big shift was introducing a Task component.
Instead of directly moving pawns, each pawn now carries an explicit job state. In simplified form, it looks like this:
#[derive(Component, Debug)]
pub enum Task {
Idle,
GoToTree(IVec2),
Chop { at: IVec2, progress: f32 },
GoToStockpile,
}
This enum does a lot of heavy lifting.
Instead of “a system that moves pawns”, the simulation now has pawns with intent. A pawn isn’t being pushed around, it’s in a state, and systems simply react to that state.
This has a few immediate benefits:
Behavior becomes inspectable
Refactors stop breaking everything
UI becomes trivial later
At this point, pawns finally stopped being dots and started being… workers.
A quick aside: why tasks feel so nice in Rust
One thing I really liked here is how naturally tasks with different data fit into the model.
In Rust, enums aren’t just labels. Each variant can carry its own data:
pub enum Task {
Idle,
GoToTree(IVec2),
Chop { at: IVec2, progress: f32 },
GoToStockpile,
}
Each task only carries exactly what it needs. No optional fields, no “only valid when state == X” comments.
This isn’t completely unique to Rust, but Rust makes it unusually pleasant and safe. In C# or Java this often turns into class hierarchies or nullable fields; in Python it’s easy to represent, but easy to misuse.
Here, the type system does most of the work: adding a new task forces you to handle it everywhere, and impossible states simply can’t exist.
For a simulation where behavior is state, that turned out to be a really good fit.
The world grows up: introducing WorldMap
Once pawns had tasks, they needed to reason about the world:
Is there still a tree at this position?
Did another pawn already chop it?
What happens to the tile when the tree is gone?
To support this, I introduced a WorldMap resource. It owns:
The tile grid
Tile types (
Ground,Tree,Stockpile)Small helpers to query and mutate tiles
The important design choice here is ownership:
Pawns don’t own the world.
They query it, reason about it, and sometimes modify it — but the world is shared state.
This cleared up a lot of accidental coupling early on and made the simulation logic much easier to reason about.
Colony state: stockpiling without pawns knowing
Next question: where does the chopped wood go?
Instead of letting pawns “own” resources, I added a Colony resource:
#[derive(Resource, Default)]
pub struct Colony {
pub wood: u32,
}
Pawns don’t know how much wood the colony has.
They just deliver items into it.
This separation turned out to be surprisingly nice:
Pawns stay simple
Colony-wide logic stays centralized
UI and future systems have a clean place to read from
It also mirrors how I want the game to feel later: pawns do work, the colony accumulates results.
The job loop
At this point, the simulation loop stopped being about movement and became about progression.
Very roughly, a pawn now follows this loop:
Idle
→ GoToTree
→ Chop (with progress)
→ GoToStockpile
→ DropOff
→ Idle
No single system “knows” this whole flow.
Each task handler only knows:
What it needs to do right now
When it’s done
What the next task should be
For example, chopping a tree looks roughly like this:
pub fn handle_chop(
pawn: &mut Task,
colony: &mut Colony,
world: &mut WorldMap,
) {
if let Task::Chop { at, progress } = pawn {
*progress += 0.01;
if *progress >= 1.0 {
world.set_with_sprite(*at, Tile::Ground);
colony.wood += 1;
*pawn = Task::GoToStockpile;
}
}
}
No scheduler.
No behavior tree.
Just “are we done yet?” — and if so, move on.
And yes, watching the first pawn successfully chop a tree felt way better than it should have.

Observability: adding the first UI
At this point, the simulation worked. But I couldn’t see what was going on inside it.
So I added UI early. Not for gameplay, but for observability.
Two simple UI sections:
Colony UI
- Current wood count
Pawn inspector
Pawn ID
Grid position
Current task
Task progress (when applicable)
This paid off immediately.
Suddenly I could:
See task transitions
Verify progress increments
Spot logic bugs instantly
The UI isn’t gameplay yet.
It’s a debugging tool that happens to be on screen.

A small detour: change detection
While wiring up the UI, I ran into something new (for me): change detection.
My first version updated the UI every frame. That works, but it’s noisy, both mentally and architecturally.
Bevy lets you filter queries so systems only run when something actually changed:
Query<(&Pawn, &Task), Changed<Task>>
This query only returns pawns whose Task component changed since the last frame. If a pawn is still chopping the same tree, the UI system doesn’t even run for it.
A few clarifications:
This is not an optimization I needed yet
It’s mostly about intent: “update UI when state changes”
Unexpected UI updates now usually mean unexpected state changes
I like this pattern because it nudges the code toward being event-driven, without having to introduce explicit events this early.
Keeping visuals honest: updating tile sprites
One annoying issue showed up quickly:
A tree would be chopped in the simulation…
…but visually, it stayed a tree forever
To fix this, I added a simple mapping from logical tiles to sprite entities. Tile updates now go through a centralized helper that updates both:
The world state
The corresponding sprite color
Once this was in place, visuals stopped lying.

Small refactors that paid off
As the systems grew, I made a few unglamorous but valuable cleanups:
Centralized tile + sprite updates
Used change detection for UI instead of per-frame updates
Reduced UI marker components into a single enum
Cleaned up system signatures and warnings
None of these are flashy, but together they made the codebase much calmer to work in.
Where things stand now
At this point, the project has crossed an important threshold.
The simulation now:
Has intentional pawn behavior
Modifies shared world state
Keeps visuals in sync automatically
Exposes internal state via UI
It’s still tiny.
It’s still very much a toy.
But it’s no longer just rendering things, it’s simulating a system.
In the next steps, I want to explore:
Multiple pawns competing for work
Job selection instead of hardcoded flows
Basic player interaction and selection
For now though, this feels like a good stopping point.
The dots have learned to work. 🙂
The code at this stage is available on GitHub under the post-3 tag: https://github.com/GuzzoLM/tiny-colony/tree/post-3/tiny-colony



