Skip to main content

Command Palette

Search for a command to run...

Tiny Colony (part 3)

From Moving Dots to Working Pawns

Updated
6 min read
Tiny Colony (part 3)

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

A Tiny Colony Simulation in Rust

Part 2 of 4

This series documents building a tiny colony simulation in Rust with Bevy. It’s a learning journal, not a tutorial: a small, understandable sim with a clear core loop, built step by step, focused on real trade-offs, constraints, and finishing.

Up next

Tiny Colony (part 2)

Create Something Moving