architecturerustbevyddd

Why We Split the Game in Two — Domain-Driven Design in a Bevy Project

Adding new buildings was becoming a chore. New file here, new branch there, register that thing, don't forget the other thing. We needed a better structure. Here's what we landed on.

NeToNuH Team1 min read

The problem we were about to have

Imagine you want to add a sawmill. Not a complicated sawmill — just something that eats wood and spits out planks.

Without any real architecture, here’s what happens: you open components.rs and add a new state variant. Then state.rs to handle that state. Then movement.rs because the workers need to know what to do near the sawmill. Then lib.rs twice — once to register the resource, once to register the plugin. Five files, four context switches, and you’ve barely described what the sawmill is.

We don’t have hundreds of buildings yet. But we want to. And if every new building costs this much friction, we’ll stop adding them. Not consciously — just gradually, because it’s tedious, and one day we’ll realize we’ve been polishing the same three buildings for six months.

So we restructured before it became a problem. That’s the rare thing here: the refactor happened early, while the codebase was still small enough to move cleanly.


The idea: split “what the game is” from “how Bevy runs it”

The core insight behind Domain-Driven Design — in any context, not just games — is that your business logic shouldn’t be entangled with your infrastructure. In web dev, that means separating database queries from business rules. In a Bevy game, it means separating “what a sawmill does” from “how Bevy’s ECS renders and ticks it.”

We now have two distinct layers:

packages/domain/ — pure Rust. No Bevy. No ECS. No rendering. Just data structures, rules, and algorithms. A BuildingDef describes a building. A JobDef describes a job. Inventory::transfer() enforces that you can’t take what isn’t there. This layer doesn’t know what a Transform is and never will.

packages/core/ — Bevy infrastructure. This is where systems live, where ECS queries happen, where things get drawn. Its job is to read domain state, call domain logic, and write the results back. It’s the adapter between the game world and the simulation rules.

The isolation is enforced by the Rust compiler itself: packages/domain/Cargo.toml has no Bevy dependency. If someone accidentally writes use bevy::* inside a domain file, it’s a compile error. Not a code review comment. Not a convention. A hard failure.


What “data-driven” actually means here

Here’s what adding a sawmill looks like now:

registry.register(BuildingDef {
    id: 42,
    name: "Sawmill",
    footprint: vec![GridPos::new(0, 0), GridPos::new(1, 0)],
    worker_slots: 2,
    recipe: Some(Recipe {
        inputs: vec![(ResourceType::Wood, 2)],
        output: (ResourceType::Plank, 1),
        duration_secs: 5.0,
    }),
    storage_capacity: 20,
});

That’s it. One registry call. A single generic production system handles all buildings that have a recipe. You’re not writing a new system. You’re not adding a new component. You’re describing a thing, and the existing machinery runs it.

Same idea for jobs. The woodcutter used to be hardcoded logic scattered across multiple systems. Now it’s expressed as a sequence of phases:

JobDef {
    id: JOB_WOODCUTTER,
    name: "Woodcutter",
    phases: vec![
        JobPhase::GoTo(JobTarget::NearestResource(ResourceType::Wood)),
        JobPhase::Work { duration_secs: 1.2 },
        JobPhase::PickUp { resource: ResourceType::Wood, amount: 1 },
        JobPhase::GoTo(JobTarget::Hub),
        JobPhase::Deliver(JobTarget::Hub),
    ],
}

And adding a miner? Zero new systems. Zero new components. Just a new JobDef with different phases. The job executor doesn’t care which job it’s running — it reads the phases and executes them.


The citizen scheduler, briefly

Citizens aren’t just passive workers. Each one has a priority list, RimWorld-style. Given [(Builder, High), (Woodcutter, Normal), (Hauler, Low)], a citizen will build if there’s construction to do, cut wood if there isn’t, and haul if neither is available.

The scheduler is a pure function in the domain layer. It takes a citizen’s priorities and a trait object (WorkAvailability) that answers questions like “are there trees left?” — that trait is implemented by the ECS layer using actual queries. The domain doesn’t know how the answer is computed. It just asks.


What this costs

Honestly? Not that much, once the structure exists. The initial refactor took effort — pulling things apart that were tangled together, establishing the boundaries. But the result is a codebase where:

  • The domain can be tested without spinning up a Bevy app. cargo test -p game-domain runs in milliseconds.
  • New content is additive, not editorial. You’re registering things, not editing existing systems.
  • The plugin structure scales: GamePluginWorldPlugin, CitizenPlugin, BuildingPlugin… each domain gets its own plugin, lib.rs stays thin, and adding a new entity type means creating two files and wiring them in.

The tradeoff is that there’s more ceremony up front. A BuildingDef needs to be registered. A system needs to be an adapter. You can’t just jam logic anywhere — there’s a right place for things now.

That’s a tradeoff we’re happy to make.


Where this is going

Right now the domain has woodcutters, stone miners, buildings with recipes, and inventories. The scheduler exists but isn’t fully wired to all job types yet. Production chains are defined but not visually represented in the UI.

The architecture is ahead of the content — which is the right order to do things. Now that adding a building is just data, we can actually focus on designing interesting buildings rather than plumbing.

Whether we’ll get there is another question. But at least the path is clear.


The NeToNuH Team