Asteroid Dodger: Project Structure
Your src/ directory has 12 files in a flat list. Every module is a sibling of every other — sound.rs sits next to collision.rs sits next to entity.rs. That was fine at 4 files but it’s getting hard to scan. This tutorial reorganizes the project into a directory structure that groups related modules, without changing any game behavior.
What you’ll learn
- Grouping modules into subdirectories with
mod.rs - Re-exporting types with
pub usefor clean imports pub(crate)visibility for internal-only items- When a Cargo workspace makes sense (and when it doesn’t)
Milestones overview
Prerequisites
- Completed the traits and generics tutorial
- Project has ~12 source files in a flat
src/directory
Milestone 1 — Directory modules
Milestone 1 of 2Step 1 — Group files into directories
The current layout:
src/
├── main.rs
├── game.rs
├── entity.rs
├── player.rs
├── asteroid.rs
├── bullet.rs
├── bomb.rs
├── collision.rs
├── grid.rs
├── particle.rs
├── sound.rs
└── weapon.rs
The natural groups are entities (things in the game world) and systems (services that operate on them):
src/
├── main.rs
├── game.rs
├── entities/
│ ├── mod.rs
│ ├── player.rs
│ ├── asteroid.rs
│ ├── bullet.rs
│ └── bomb.rs
├── systems/
│ ├── mod.rs
│ ├── collision.rs
│ ├── grid.rs
│ ├── particle.rs
│ └── sound.rs
├── entity.rs
└── weapon.rs
entity.rs (the trait) and weapon.rs (the enum) stay at the root — they’re shared types, not part of either group.
Create the directories and move the files:
mkdir -p src/entities src/systems
mv src/player.rs src/entities/
mv src/asteroid.rs src/entities/
mv src/bullet.rs src/entities/
mv src/bomb.rs src/entities/
mv src/collision.rs src/systems/
mv src/grid.rs src/systems/
mv src/particle.rs src/systems/
mv src/sound.rs src/systems/
Rust has two conventions for directory modules. The older way uses
entities/mod.rs — a file named mod.rs inside the directory.
The newer way uses entities.rs alongside an entities/ directory.
Both work identically. This tutorial uses mod.rs because it keeps the
module declaration and its children in the same directory, which is easier to navigate
in a file tree.
Create src/entities/mod.rs:
// src/entities/mod.rs
pub mod player;
pub mod asteroid;
pub mod bullet;
pub mod bomb;
Create src/systems/mod.rs:
// src/systems/mod.rs
pub mod collision;
pub mod grid;
pub mod particle;
pub mod sound;
Update src/main.rs — replace the individual mod declarations with the two directory modules:
use macroquad::prelude::*;
mod entities;
mod systems;
mod entity;
mod game;
mod weapon;
use game::Game;
use systems::sound::Sounds;
#[macroquad::main("Asteroid Dodger")]
async fn main() {
let sounds = Sounds::load().await;
let mut game = Game::new(sounds);
loop {
game.update(get_frame_time());
game.draw();
next_frame().await;
}
}
Now update every use crate:: import across the project. The pattern is mechanical:
| Old import | New import |
|---|---|
use crate::player::Player | use crate::entities::player::Player |
use crate::asteroid::Asteroid | use crate::entities::asteroid::Asteroid |
use crate::bullet::Bullet | use crate::entities::bullet::Bullet |
use crate::bomb::Bomb | use crate::entities::bomb::Bomb |
use crate::collision | use crate::systems::collision |
use crate::collision::Aabb | use crate::systems::collision::Aabb |
use crate::grid::Grid | use crate::systems::grid::Grid |
use crate::particle::Particles | use crate::systems::particle::Particles |
use crate::sound::Sounds | use crate::systems::sound::Sounds |
Apply these across game.rs, entity.rs, entities/player.rs, entities/asteroid.rs, and any other file with cross-module imports.
cargo build — fix any import errors the compiler reports. The error
messages are precise: “unresolved import crate::player” tells you exactly
which line to update. Once it compiles, cargo run behaves identically.
Step 2 — Re-export for cleaner imports
The imports are now verbose: use crate::entities::player::Player. In game.rs you end up with a wall of long paths. Re-exporting from mod.rs fixes this.
Update src/entities/mod.rs:
// src/entities/mod.rs
mod player;
mod asteroid;
mod bullet;
mod bomb;
pub use player::{Player, PLAYER_RADIUS, PLAYER_MASS};
pub use asteroid::{self as asteroid_mod, Asteroid};
pub use bullet::Bullet;
pub use bomb::{Bomb, EXPLOSION_RADIUS};
Now game.rs can import directly from entities:
use crate::entities::{Player, PLAYER_RADIUS, PLAYER_MASS, Asteroid, Bullet, Bomb, EXPLOSION_RADIUS};
use crate::entities::asteroid_mod; // for asteroid_mod::resolve_collisions
Do the same for src/systems/mod.rs:
// src/systems/mod.rs
mod collision;
mod grid;
mod particle;
mod sound;
pub use collision::Aabb;
pub use grid::Grid;
pub use particle::Particles;
pub use sound::Sounds;
// Re-export the collision module itself for function access
pub use collision as collision_mod;
Now game.rs uses:
use crate::systems::{Aabb, Grid, Particles, Sounds, collision_mod as collision};
pub use re-exports an item from a child module as if it were declared
in the parent. The child modules become mod (private) — outside code
can’t reach crate::entities::player directly, only through the
re-exported types. This is the same pattern the Rust standard library uses:
std::collections::HashMap is actually defined deep inside
std::collections::hash::map but re-exported at the shorter path.
Notice that the child mod declarations changed from pub mod to mod. The re-exports are the public API — the internal file structure is hidden. You can rename, split, or merge files inside entities/ without changing any import in game.rs.
cargo build — the project compiles with the shorter imports. cargo run
is identical. The directory structure is now organized but the game hasn’t changed.
Milestone 2 — Visibility and workspaces
Milestone 2 of 2Step 3 — pub(crate) visibility
So far everything is either pub (visible to all) or private (visible only within the module). There’s a middle ground: pub(crate) makes something visible anywhere in the project but not to external consumers.
This matters when your code is a library. For a game binary it’s less critical — nothing external imports your crate. But it’s a good habit that communicates intent: “this is shared internally, not part of the public API.”
Apply pub(crate) to items that are used across modules but aren’t meant to be exported:
// In entities/player.rs
pub(crate) const PLAYER_RADIUS: f32 = 16.0;
pub(crate) const PLAYER_MASS: f32 = 500.0;
// In entities/bomb.rs
pub(crate) const EXPLOSION_RADIUS: f32 = 150.0;
// In game.rs — the Keybindings struct is used by player.rs
pub(crate) struct Keybindings { ... }
pub(crate) enum Action { ... }
The rule of thumb: use pub for types and methods that define the module’s interface (struct definitions, constructors, trait methods). Use pub(crate) for constants, helpers, and internal types that other modules need but that aren’t conceptually part of the API.
Rust also has pub(super) (visible to the parent module only) and
pub(in path) (visible to a specific ancestor). These are rarely
needed in practice — pub and pub(crate) cover nearly
every case. If you find yourself reaching for pub(super), consider
whether the modules should be reorganized instead.
Step 4 — When to use a workspace
A Cargo workspace splits a project into multiple crates that share a single Cargo.lock and target/ directory. Each crate compiles independently.
For this game, a workspace might look like:
asteroid-dodger/
├── Cargo.toml (workspace root)
├── crates/
│ ├── core/ (game logic — no rendering dependency)
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── collision.rs
│ │ ├── grid.rs
│ │ └── physics.rs
│ └── game/ (rendering + input — depends on macroquad + core)
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs
│ ├── player.rs
│ └── ...
The core crate has no macroquad dependency — just math and data structures. It can be tested with cargo test -p core without opening a window. The game crate depends on both core and macroquad.
When a workspace makes sense:
- Two binaries share logic (a game + a level editor)
- You want to test core logic without pulling in a rendering library
- Compile times matter — changing a file in
gamedoesn’t recompilecore - You’re publishing a library alongside the application
When it doesn’t:
- Single binary, all code is tightly coupled
- The overhead of managing multiple
Cargo.tomlfiles outweighs the benefit - You’re still learning — single-crate is simpler
This game is at the boundary. The integration testing tutorial already showed the pain of testing Game without a window. A workspace would solve that cleanly. But for a tutorial project with ~12 files, the single-crate approach with directory modules (what we just built) is the right level of organization.
Start with a single crate and directory modules. Split into a workspace when you feel the pain — usually when tests need a subset of dependencies, or when a second binary shares logic with the first. Premature workspace splitting adds configuration overhead without benefit.
cargo run — the game runs identically. The refactoring is purely
structural. Run cargo test to verify the unit tests still pass too.
Final directory layout
src/
├── main.rs — entry point, top-level module declarations
├── game.rs — Game struct, state machine, update/draw
├── entity.rs — Entity trait (shared interface)
├── weapon.rs — WeaponType enum (shared data)
├── entities/
│ ├── mod.rs — pub use re-exports
│ ├── player.rs
│ ├── asteroid.rs
│ ├── bullet.rs
│ └── bomb.rs
└── systems/
├── mod.rs — pub use re-exports
├── collision.rs
├── grid.rs
├── particle.rs
└── sound.rs
No files changed behavior. Imports are shorter via re-exports. Internal constants use pub(crate). The structure scales to many more files without becoming a wall of siblings.
Next steps
- Menu, Power-ups, and Shop — title screen, keybinding editor, collectible power-ups, and shop