Skip to main content

Command Palette

Search for a command to run...

Tiny Colony (part 4)

Scaling the simulation

Updated
4 min read
Tiny Colony (part 4)

Up to now, the simulation was politely lying to me.

It looked like a colony sim:

  • pawns moved

  • trees got chopped

  • numbers went up

  • UI reacted

But under the hood, it was cheating.

There was only one pawn really doing work.

This post is about what happened when I stopped cheating.


From one pawn to many

The first lie was obvious: if pawn.id == 0 { ... }

That single check kept everything simple:

  • one job at a time

  • no conflicts

  • no coordination problems

Removing it was the first real step toward a simulation instead of a scripted demo.

Once I removed the early break in tick_jobs, every pawn started ticking… and immediately exposed a new class of problems.

That was expected. And, honestly, kind of satisfying.


The “everyone go chop the same tree” problem

As soon as multiple pawns existed, they all did the same thing:

  • find the nearest tree

  • walk to it

  • start chopping

Which, of course, meant they all picked the same tree.

This is the moment where you realize: Pawns are not smart. They need to be coordinated

So I introduced a tiny but powerful concept: reservations.


Reservations: the smallest coordination system that works

I added a Reservations resource that tracks which world tiles are already claimed.

The rules are straightforward

  • idle pawns reserve a tree before walking to it

  • reserved trees are skipped by other pawns

  • reservations are released when:

    • chopping finishes

    • the tree disappears

    • the task aborts

Resource:

#[derive(Resource)]
pub struct Reservations {
    pub reserved_tiles: HashMap<IVec2, Entity>
}

Claim on idle:

if let Some(tree) = find_nearest_tree(map, from, reservations) {
    reservations.reserved_tiles.insert(tree, pawn_entity);
    Task::GoToTree(tree)
} else {
    Task::Idle
}

Release when done / invalid:

let next = progress + 1;
if next >= 10 {
    world::set_with_sprite(map, &tile_entities, q_tiles, at.x, at.y, Tile::Ground);
    inv.wood += 1;
    if reservations.reserved_tiles.get(&at) == Some(&pawn_entity) {
        reservations.reserved_tiles.remove(&at);
    }
    Task::GoToStockpile
} else {
    Task::Chop { at, progress: next }
}

No locks. No queues. No AI magic.
Just a set of intents.

And suddenly:

  • pawns spread out naturally

  • no more dogpiling

  • task lifecycles became clearer


Selection is not pawn 0

Another quiet lie in the earlier version:
the UI always showed pawn id == 0.

That’s fine, until you have more than one pawn.

So I added a SelectedPawn resource and some very basic click-to-select logic:

  • click a pawn → it becomes selected

  • selected pawn gets a visual highlight

  • the inspector UI shows that pawn’s state

Nothing fancy, but suddenly the simulation feels interactive instead of hardcoded.


“What if I spawn 1000 pawns?”

This was the fun (and dangerous) thought.

I cranked PAWN_COUNT up to 1000 and immediately learned why most games don’t do this casually.

Spawning pawns with fixed offsets doesn’t scale, so I switched to:

  • a spiral search around the stockpile

  • skipping trees and occupied tiles

  • validating positions against the world map

This required reordering world initialization so pawn spawning could query the map instead of guessing.

for p in spiral_positions(stockpile, max_radius) {
    if spawned >= PAWN_COUNT { break; }
    if p.x < 0 || p.x >= MAP_W || p.y < 0 || p.y >= MAP_H { continue; }
    if world::get(map, p.x, p.y) == Tile::Tree { continue; }
    if occupied.contains(&p) { continue; }
    occupied.insert(p);

    commands.spawn((Pawn { id: spawned as u32, x: p.x, y: p.y }, /* sprite */, /* task */));
    spawned += 1;
}

The result:

  • pawns spawn cleanly

  • no overlaps

  • no trees overwritten

  • no hardcoded magic numbers


The beautiful lag

Then came the best part.

With 1000 pawns and a large forest, everything ran fine…
until trees started getting sparse.

Frame time exploded.

Why?

Because every pawn was scanning the entire map to find the nearest tree.

That’s when the illusion fully shattered. I added a FPS indicator to illustrate the problem.


WorldTrees: stop scanning the universe

The fix was simple and very unglamorous:

  • maintain a WorldTrees index (HashSet<IVec2>)

  • iterate only existing trees

  • remove trees from the index when chopped

Index:

#[derive(Resource)]
pub struct WorldTrees(pub HashSet<IVec2>);

Iterate only trees instead of the full grid:

for &target in world_trees.0.iter() {
    let reserved = reservations.reserved_tiles.contains_key(&target);
    if !reserved && world::get(map, target.x, target.y) == Tile::Tree {
        let dist = (from.x - target.x).abs() + (from.y - target.y).abs();
        // keep best...
    }
}

Remove when chopped:

world_trees.0.remove(&at);

Suddenly:

  • late-game performance stabilized

  • search cost scaled with remaining trees, not map size

  • the simulation stopped punishing success


What’s next

At this point:

  • multiple pawns work concurrently

  • jobs are coordinated

  • selection and inspection are real

  • performance problems are visible and explainable

There’s still a lot that could be done here: collisions, better pathfinding, smarter work distribution.

But for now, I’m pretty happy watching 1000 tiny idiots politely avoid chopping the same tree.

The code at this stage is available on GitHub under the post-4 tag: https://github.com/GuzzoLM/tiny-colony/tree/post-4/tiny-colony

A Tiny Colony Simulation in Rust

Part 1 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 3)

From Moving Dots to Working Pawns