We Accidentally Built a Game Engine.
Split Your Rust Game Into Modules and a Library

Our game works, but its rules and its screen are tangled together in one file of more than four hundred lines. Today we pull them apart, so the same game can run in a terminal now and in a browser later.
Nothing about the game changes. We will not touch a rule.
Nobody planned that file. Each article needed one more thing, and each thing went in the next free spot. But look at what is in there: everything above main() knows nothing about a terminal or a screen. Only main() does.
So we put a wall between them. When we are done, main.rs is fifty-six lines: it creates a game, clicks, and prints. The game itself lives in a library that has never heard of a terminal.
A library like that, made of rules and state that do not know how they are being displayed, is the heart of what people call a game engine.
A small one.
Ours will have a front door and nothing else.
The program still prints the same thing, a few stages cleared and then an hour of waiting:
One hour away.
Stage 4 -> 131 Level 2 -> 48 Gold 11 -> 17410
Same output.
Same twenty-one tests, plus one new one.
Different house.
Our starting point
This series builds a small idle game: a player clicks to fight slimes, earns gold and XP, and keeps progressing while you are away.
If you are only reading along, skip past this code to "The groups are already visible."
If you are building along, create a project with cargo new rustbound-idle if you do not already have one. The name matters later.
At this point, src/main.rs holds three structs, an enum, three impl blocks, twelve small functions, and twenty-one tests:
struct Player {
gold: u32,
xp: u32,
level: u32,
click_upgrade_level: u32,
}
struct Enemy {
name: String,
health: u32,
gold_reward: u32,
xp_reward: u32,
}
struct Game {
stage: u32,
player: Player,
enemy: Enemy,
}
#[derive(Debug, PartialEq)]
enum PurchaseOutcome {
Purchased,
NotEnoughGold,
}
impl Player {
fn new() -> Player {
Player {
gold: 0,
xp: 0,
level: 1,
click_upgrade_level: 0,
}
}
fn add_gold(&mut self, amount: u32) {
self.gold = self.gold.saturating_add(amount);
}
fn add_xp(&mut self, amount: u32) {
self.xp = self.xp.saturating_add(amount);
while self.xp >= xp_to_next_level(self.level) {
self.xp -= xp_to_next_level(self.level);
self.level = self.level.saturating_add(1);
}
}
fn base_damage(&self) -> u32 {
base_damage_for_level(self.level)
}
fn bonus_damage(&self) -> u32 {
click_upgrade_bonus(self.click_upgrade_level)
}
fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
let cost = click_upgrade_cost(self.click_upgrade_level);
if self.gold < cost {
return PurchaseOutcome::NotEnoughGold;
}
self.gold -= cost;
self.click_upgrade_level = self.click_upgrade_level.saturating_add(1);
PurchaseOutcome::Purchased
}
}
impl Enemy {
fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
gold_reward: gold_reward_for_stage(stage),
xp_reward: xp_reward_for_stage(stage),
}
}
}
impl Game {
fn new() -> Game {
Game {
stage: 1,
player: Player::new(),
enemy: Enemy::for_stage(1),
}
}
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
self.resolve_hit();
}
fn tick(&mut self, seconds: u32) {
for _ in 0..seconds {
let damage = idle_damage_for_level(self.player.level);
self.enemy.health = health_after_hit(self.enemy.health, damage);
self.resolve_hit();
}
}
fn resolve_hit(&mut self) {
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
fn defeat_enemy(&mut self) {
self.player.add_gold(self.enemy.gold_reward);
self.player.add_xp(self.enemy.xp_reward);
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
self.player.buy_click_upgrade()
}
}
fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
fn health_after_hit(health: u32, damage: u32) -> u32 {
health.saturating_sub(damage)
}
fn is_defeated(health: u32) -> bool {
health == 0
}
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
fn gold_reward_for_stage(stage: u32) -> u32 {
stage.saturating_mul(2).saturating_add(3)
}
fn xp_reward_for_stage(stage: u32) -> u32 {
stage.saturating_mul(2).saturating_add(4)
}
fn xp_to_next_level(level: u32) -> u32 {
level
.saturating_sub(1)
.saturating_mul(15)
.saturating_add(20)
}
fn base_damage_for_level(level: u32) -> u32 {
level.saturating_add(4)
}
fn idle_damage_for_level(level: u32) -> u32 {
level
}
fn click_upgrade_bonus(upgrades: u32) -> u32 {
upgrades.saturating_mul(2)
}
fn click_upgrade_cost(upgrades: u32) -> u32 {
upgrades.saturating_add(1).saturating_mul(10)
}
fn attack(player: &Player, enemy: &mut Enemy) {
let damage = click_damage(player.base_damage(), player.bonus_damage());
enemy.health = health_after_hit(enemy.health, damage);
}
fn main() {
let mut game = Game::new();
while game.stage <= 3 {
let stage = game.stage;
let level = game.player.level;
game.click();
if game.stage > stage {
println!(
"Stage {stage} cleared! Gold: {} XP: {}/{}",
game.player.gold,
game.player.xp,
xp_to_next_level(game.player.level)
);
if game.player.level > level {
println!(
"Level up! Now level {}. Click damage: {}",
game.player.level,
click_damage(game.player.base_damage(), game.player.bonus_damage())
);
}
println!(
" Next up: {} with {} health.",
game.enemy.name, game.enemy.health
);
}
match game.buy_click_upgrade() {
PurchaseOutcome::Purchased => println!(
"Bought click upgrade {}! Click damage: {} Gold left: {}",
game.player.click_upgrade_level,
click_damage(game.player.base_damage(), game.player.bonus_damage()),
game.player.gold
),
PurchaseOutcome::NotEnoughGold => {}
}
}
let stage = game.stage;
let level = game.player.level;
let gold = game.player.gold;
game.tick(3600);
println!("One hour away.");
println!(
"Stage {} -> {} Level {} -> {} Gold {} -> {}",
stage, game.stage, level, game.player.level, gold, game.player.gold
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn click_damage_adds_base_and_bonus() {
assert_eq!(click_damage(3, 4), 7);
}
#[test]
fn health_never_drops_below_zero() {
assert_eq!(health_after_hit(3, 10), 0);
}
#[test]
fn zero_health_is_defeated() {
assert!(is_defeated(0));
assert!(!is_defeated(1));
}
#[test]
fn attack_reduces_enemy_health() {
let player = Player::new();
let mut enemy = Enemy::for_stage(1);
enemy.health = 10;
attack(&player, &mut enemy);
assert_eq!(enemy.health, 5);
}
#[test]
fn later_stages_have_tougher_enemies() {
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
}
#[test]
fn later_stages_pay_better() {
assert_eq!(gold_reward_for_stage(1), 5);
assert_eq!(gold_reward_for_stage(2), 7);
assert_eq!(xp_reward_for_stage(1), 6);
assert_eq!(xp_reward_for_stage(2), 8);
}
#[test]
fn levels_need_more_xp_as_they_go() {
assert_eq!(xp_to_next_level(1), 20);
assert_eq!(xp_to_next_level(2), 35);
}
#[test]
fn leveling_up_keeps_leftover_xp() {
let mut player = Player::new();
player.add_xp(25);
assert_eq!(player.level, 2);
assert_eq!(player.xp, 5);
}
#[test]
fn a_big_reward_can_skip_levels() {
let mut player = Player::new();
player.add_xp(60);
assert_eq!(player.level, 3);
assert_eq!(player.xp, 5);
}
#[test]
fn levels_make_the_player_hit_harder() {
assert_eq!(base_damage_for_level(1), 5);
assert_eq!(base_damage_for_level(2), 6);
}
#[test]
fn upgrades_cost_more_each_time() {
assert_eq!(click_upgrade_cost(0), 10);
assert_eq!(click_upgrade_cost(1), 20);
}
#[test]
fn buying_an_upgrade_needs_gold() {
let mut player = Player::new();
assert_eq!(
player.buy_click_upgrade(),
PurchaseOutcome::NotEnoughGold
);
assert_eq!(player.click_upgrade_level, 0);
assert_eq!(player.bonus_damage(), 0);
}
#[test]
fn buying_an_upgrade_spends_gold() {
let mut player = Player::new();
player.gold = 12;
assert_eq!(
player.buy_click_upgrade(),
PurchaseOutcome::Purchased
);
assert_eq!(player.gold, 2);
assert_eq!(player.click_upgrade_level, 1);
assert_eq!(player.bonus_damage(), 2);
}
#[test]
fn a_new_game_starts_at_stage_one() {
let game = Game::new();
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
assert_eq!(game.player.gold, 0);
}
#[test]
fn clicking_damages_the_enemy() {
let mut game = Game::new();
game.click();
assert_eq!(game.enemy.health, 15);
}
#[test]
fn defeating_an_enemy_pays_the_player() {
let mut game = Game::new();
game.enemy.health = 1;
game.click();
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
assert_eq!(game.player.gold, 5);
assert_eq!(game.player.xp, 6);
}
#[test]
fn idle_damage_grows_with_level() {
assert_eq!(idle_damage_for_level(1), 1);
assert_eq!(idle_damage_for_level(2), 2);
}
#[test]
fn enough_time_defeats_the_enemy() {
let mut game = Game::new();
game.tick(20);
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
assert_eq!(game.player.gold, 5);
assert_eq!(game.player.xp, 6);
}
#[test]
fn one_second_is_one_idle_hit() {
let mut game = Game::new();
game.tick(1);
assert_eq!(game.enemy.health, 19);
}
#[test]
fn zero_seconds_changes_nothing() {
let mut game = Game::new();
game.tick(0);
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
}
#[test]
fn one_long_tick_matches_many_short_ones() {
let mut long = Game::new();
let mut short = Game::new();
long.tick(45);
for _ in 0..45 {
short.tick(1);
}
assert_eq!(long.stage, short.stage);
assert_eq!(long.enemy.health, short.enemy.health);
assert_eq!(long.player.gold, short.player.gold);
assert_eq!(long.player.xp, short.player.xp);
assert_eq!(long.player.level, short.player.level);
}
}
Run it, and the player clears three stages, buys an upgrade, and spends an hour away.
Run cargo test, and all twenty-one tests pass.
You need a recent Rust version. This project uses edition 2024, which requires Rust 1.85 or newer.
Hold on to that test count.
Everything we do today is a reorganization, and the tests are how we prove we did not break anything.
The groups are already visible
Nobody has to invent categories for this code.
It has been sorting itself since the first article:
rules click_damage, health_after_hit, is_defeated,
enemy_health_for_stage, gold_reward_for_stage,
xp_reward_for_stage, xp_to_next_level,
base_damage_for_level, idle_damage_for_level,
click_upgrade_bonus, click_upgrade_cost
enemy Enemy
player Player, PurchaseOutcome
game Game, attack
main everything that prints
Look at the first group.
Those eleven functions take plain values and return plain values.
They never touch a Player, an Enemy, or a Game.
They are the rules of the game, and every other group leans on them.
Rust has a name for a named group of code: a module.
For this project, we are going to give each top-level module its own file, one at a time, and run the tests after every move.
Give the rules their own module
Create a new file next to main.rs:
src/
├── main.rs
└── rules.rs
Cut the eleven rule functions out of main.rs and paste them into rules.rs.
Leave attack() where it is.
It takes a Player and an Enemy, so it is not one of our plain-value rules. It belongs with the game.
The tests for those rules should move too.
Nine of them check nothing but rules:
click_damage_adds_base_and_bonus
health_never_drops_below_zero
zero_health_is_defeated
later_stages_have_tougher_enemies
later_stages_pay_better
levels_need_more_xp_as_they_go
levels_make_the_player_hit_harder
idle_damage_grows_with_level
upgrades_cost_more_each_time
Cut each one out of the tests module in main.rs, including the #[test] line above it, and give rules.rs a tests module of its own at the bottom:
#[cfg(test)]
mod tests {
use super::*;
// the nine rule tests go here
}
The use super::*; line means:
Bring in everything from the module above me.
Now main.rs needs to find its rules again.
Add this to the very top of main.rs, above everything else:
mod rules;
use rules::{
base_damage_for_level, click_damage,
click_upgrade_bonus, click_upgrade_cost,
enemy_health_for_stage, gold_reward_for_stage,
health_after_hit, idle_damage_for_level,
is_defeated, xp_reward_for_stage,
xp_to_next_level,
};
mod rules; says:
There is a module called
rules, and its code lives insrc/rules.rs.
The use line brings its functions into main.rs by name, so the rest of the file can keep calling click_damage() exactly as before.
Build it:
cargo build
Rust refuses:
error[E0603]: function `base_damage_for_level` is private
--> src/main.rs:4:5
|
4 | base_damage_for_level,
| ^^^^^^^^^^^^^^^^^^^^^ private function
|
note: the function `base_damage_for_level` is defined here
--> src/rules.rs:32:1
|
32 | fn base_damage_for_level(level: u32) -> u32 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You will get one of these for every function.
That is not a bug in what we did.
It is our first encounter with one of Rust's module boundaries:
By default, an item is visible inside its module and the modules beneath it. Sibling modules and outside code cannot use it unless we open that boundary.
main.rs sits outside the rules module.
It cannot reach those functions yet.
The simplest way to open that boundary is pub, short for public.
Put it in front of each of the eleven functions:
pub fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
Run both:
cargo test
cargo run
Twenty-one tests pass, and the output is the same as before.
The count should stay at twenty-one after every move in this article.
We moved eleven functions and nine tests into another file, and nothing changed.
That is what a good move looks like.
The enemy
Enemy is next because it is the smallest.
Create src/enemy.rs, and move the Enemy struct and its impl block into it.
The new file needs to find the rules it uses, so it starts like this:
use crate::rules::{
enemy_health_for_stage,
gold_reward_for_stage,
xp_reward_for_stage,
};
crate means the top of the crate this file belongs to.
A path beginning with crate:: starts there no matter which module we are currently inside.
For now, all of these modules still belong to the executable crate.
In a moment we are going to move them into a library crate, and that distinction will matter.
Back in main.rs, add:
mod enemy;
under mod rules;, and:
use enemy::Enemy;
under the rules import.
Build again.
Rust has an opinion:
error[E0603]: struct `Enemy` is private
--> src/main.rs:4:12
|
4 | use enemy::Enemy;
| ^^^^^ private struct
Same boundary, same first fix:
pub struct Enemy
Build again, and Rust has a few more opinions.
Making the struct public does not automatically make all of its fields public.
Each field has its own visibility.
So does Enemy::for_stage().
For now, here is the finished file:
use crate::rules::{
enemy_health_for_stage,
gold_reward_for_stage,
xp_reward_for_stage,
};
pub struct Enemy {
pub name: String,
pub health: u32,
pub gold_reward: u32,
pub xp_reward: u32,
}
impl Enemy {
pub fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
gold_reward: gold_reward_for_stage(stage),
xp_reward: xp_reward_for_stage(stage),
}
}
}
Everything is pub for now.
That is too generous.
We are going to fix it on purpose once the boundaries are in place.
Run:
cargo test
Twenty-one pass.
You may also see warnings that some imports in main.rs are unused.
That is true.
Enemy was the only caller of enemy_health_for_stage(), gold_reward_for_stage(), and xp_reward_for_stage(), and it just moved.
Delete those names from the use rules::{...} list in main.rs.
The player
Same move, bigger file.
Create src/player.rs.
Move in:
PlayerPurchaseOutcome- the
impl Playerblock
PurchaseOutcome goes with Player because it is the player's vocabulary.
It is how a purchase answers.
At the top of the file, import the rules Player uses:
use crate::rules::{
base_damage_for_level,
click_upgrade_bonus,
click_upgrade_cost,
xp_to_next_level,
};
Then add pub to the struct, its four fields, the enum, and every function in impl Player.
An enum's variants follow the enum's visibility.
Make PurchaseOutcome public, and its Purchased and NotEnoughGold variants are available with it.
Four tests are about the player and nothing else:
leveling_up_keeps_leftover_xp
a_big_reward_can_skip_levels
buying_an_upgrade_needs_gold
buying_an_upgrade_spends_gold
Move those into a tests module at the bottom of player.rs, the same way we did with the rules.
In main.rs, add:
mod player;
and:
use player::{Player, PurchaseOutcome};
Game and attack() still live in main.rs for now, so main.rs still needs Player.
That import drops out again in the next section.
Run:
cargo test
Twenty-one pass.
Clean up the new unused-import warnings the same way: remove base_damage_for_level, click_upgrade_bonus, and click_upgrade_cost from the use rules::{...} list in main.rs.
The game
Last one.
Create src/game.rs and move in:
Game- its
implblock attack()
It needs three things from the other modules:
use crate::enemy::Enemy;
use crate::player::{Player, PurchaseOutcome};
use crate::rules::{
click_damage,
health_after_hit,
idle_damage_for_level,
is_defeated,
};
Add pub to the struct and its three fields.
Then add it to the four functions the outside world is going to call:
pub fn new() -> Game
pub fn click(&mut self)
pub fn tick(&mut self, seconds: u32)
pub fn buy_click_upgrade(&mut self) -> PurchaseOutcome
Now look at what is not on that list.
resolve_hit() stays private.
defeat_enemy() stays private.
So does attack().
Nothing outside game.rs has a reason to call them.
When a slime dies, the game decides what that means.
A caller should not get to say:
Pretend that enemy just died.
We will close the field loophole shortly.
That is what privacy is for.
It is not about hiding code for the sake of hiding it.
It is about deciding what the rest of the program is allowed to depend on.
Eight tests are about the game, including the one for attack():
attack_reduces_enemy_health
a_new_game_starts_at_stage_one
clicking_damages_the_enemy
defeating_an_enemy_pays_the_player
one_second_is_one_idle_hit
zero_seconds_changes_nothing
enough_time_defeats_the_enemy
one_long_tick_matches_many_short_ones
Move them to the bottom of game.rs.
In main.rs, add:
mod game;
and:
use game::Game;
At this point, the tests module in main.rs is empty.
Delete it.
You can also remove Player from:
use player::{Player, PurchaseOutcome};
because only Game needs it now.
Clear out any remaining unused imports.
The top of main.rs should look like this:
mod enemy;
mod game;
mod player;
mod rules;
use game::Game;
use player::PurchaseOutcome;
use rules::{click_damage, xp_to_next_level};
Under that, only main() is left.
Run both:
cargo test
cargo run
Twenty-one tests.
Same output.
Four files hold the game.
One holds the printing.
But we have not built the wall yet.
main.rs still declares all four modules.
They are part of the executable crate.
Nothing outside that executable could use them as a library.
Build the wall
A Cargo package can contain both a library target and a binary target:
src/lib.rs library
src/main.rs executable
Rust compiles them as separate crates.
The binary can depend on the library.
That gives us the wall we have been looking for.
A crate is one compiled unit of Rust code.
Until now, our project has had one crate: the executable.
Now our package will contain two:
library crate
↑
binary crate
They live in the same project, but Rust treats them as separate pieces of code.
Create src/lib.rs, and move the four mod lines out of main.rs and into it:
mod enemy;
mod game;
mod player;
mod rules;
The project is named:
rustbound-idle
Rust turns the hyphen into an underscore when we refer to the library in code:
rustbound_idle
At the top of main.rs, replace all the old mod and use lines with:
use rustbound_idle::{Game, PurchaseOutcome};
Build it:
cargo build
Rust complains.
The first problem looks like this:
error[E0432]: unresolved imports
`rustbound_idle::Game`,
`rustbound_idle::PurchaseOutcome`
--> src/main.rs:1:22
|
| no `Game` in the root
| no `PurchaseOutcome` in the root
The library has a game module.
That module has a Game.
But mod game; is private.
From outside the library, there is no public path to Game.
As far as the binary can tell, the library's front door is empty.
We choose what appears there.
That is what pub use is for:
mod enemy;
mod game;
mod player;
mod rules;
pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};
pub use means:
Take this item from inside the library and make it available through this public path.
Now code using the library can write:
rustbound_idle::Game
Where Game actually lives inside the library is no longer the caller's problem.
Build again.
Now the remaining errors are about rules:
error[E0425]: cannot find function `xp_to_next_level` in this scope
error[E0425]: cannot find function `click_damage` in this scope
error[E0425]: cannot find function `click_damage` in this scope
Those calls used to work because main.rs imported the rules directly.
Now rules is private inside the library.
There are two ways we could fix that.
We could put rules on the front door too:
pub mod rules;
Then main.rs could import those formulas and keep doing this:
click_damage(
game.player.base_damage(),
game.player.bonus_damage()
)
That would compile.
But think about what it means.
Every formula in the game becomes part of the library's public API.
And the screen keeps doing game arithmetic.
Today the screen is a terminal.
Later it will be a browser.
If both of them know how to calculate damage, we now have multiple callers depending on an internal game rule.
The rules should live in one place.
The screen should ask for answers.
So we take the second path.
Leave rules private.
Let Player provide the information the outside world needs.
Add two methods inside impl Player, after add_xp():
pub fn xp_needed(&self) -> u32 {
xp_to_next_level(self.level)
}
pub fn click_damage(&self) -> u32 {
click_damage(
self.base_damage(),
self.bonus_damage()
)
}
The method and the rule share a name.
That is fine.
Inside the method:
click_damage(...)
with no self. in front of it means the imported rule.
The method is the player asking that rule a question about itself.
First, add click_damage to the use crate::rules::{...} list at the top of player.rs.
It was not there before because Player used to leave that arithmetic to attack().
While we are in there, add_xp() has been calling:
xp_to_next_level(self.level)
twice.
Now that the player has a method for that question, replace its loop with:
while self.xp >= self.xp_needed() {
self.xp -= self.xp_needed();
self.level = self.level.saturating_add(1);
}
The loop is still the same loop.
xp_to_next_level stays imported because xp_needed() uses it.
attack() in game.rs can lean on the other new method too:
fn attack(player: &Player, enemy: &mut Enemy) {
enemy.health =
health_after_hit(
enemy.health,
player.click_damage()
);
}
It no longer needs the click_damage rule, so remove that name from the use statement at the top of game.rs.
Last, main() gets simpler.
Where it said:
xp_to_next_level(game.player.level)
it now says:
game.player.xp_needed()
And where it said:
click_damage(
game.player.base_damage(),
game.player.bonus_damage()
)
it now says:
game.player.click_damage()
That second replacement happens twice in main().
Build, test, and run:
cargo test
cargo run
Same twenty-one tests.
Same output.
Cargo also prints test blocks for main.rs and doc-tests that say:
running 0 tests
That is normal.
And main.rs no longer knows a single game formula.
Make the front door smaller
We said everything was too pub.
Time to fix that.
Right now main() can see far more than it needs.
Try it.
Add this line just after:
let mut game = Game::new();
game.player.add_gold(1_000_000);
It compiles.
A screen can hand the player a million gold, and the game never gets a say.
Let us close that off.
Rust has a visibility setting between fully private and fully public:
pub(crate)
It means:
Visible anywhere inside this crate, but not outside it.
Our library can use the item freely.
main.rs belongs to a different crate now, so it cannot.
Go through the files and tighten everything main() does not need.
The rule for now is:
pubfor what outside code needs.pub(crate)or private for everything else.
In rules.rs, change all eleven:
pub fn
to:
pub(crate) fn
In enemy.rs:
- change
for_stage()topub(crate) - make
gold_rewardandxp_rewardpub(crate) - leave
nameandhealthpublic becausemain()displays them
In player.rs, change:
newadd_goldadd_xpbuy_click_upgrade
to pub(crate).
Take pub off base_damage() and bonus_damage() entirely.
Only Player needs those now.
What remains fully public is what the current screen needs:
Game
new
click
tick
buy_click_upgrade
stage
player
enemy
Player
gold
xp
level
click_upgrade_level
xp_needed
click_damage
Enemy
name
health
PurchaseOutcome
Now try this again:
game.player.add_gold(1_000_000);
It fails.
add_gold() is pub(crate), and main.rs is outside the library crate.
Delete that line.
Run:
cargo test
cargo run
Nothing changes.
That is the point.
If tightening the boundary had broken main(), it would mean main() depended on something we did not actually want to promise publicly.
Now try to cheat another way.
In main(), add:
let secret = game.enemy.gold_reward;
Rust says no:
error[E0616]: field `gold_reward` of struct `Enemy` is private
Delete it.
Now try reaching for an internal damage detail.
Replace:
game.player.click_damage()
with:
game.player.base_damage()
Rust refuses again:
error[E0624]: method `base_damage` is private
Put it back.
Those errors are the wall doing its job.
A test from the outside
Our twenty-one tests all live inside the library, next to the code they check.
That is useful.
Those tests can work closely with the modules they belong to.
But it leaves another question:
Does the library work for code that only gets to use the public front door?
Cargo has a place for that.
Create a folder called tests next to src, and put a file in it:
rustbound-idle/
├── src/
└── tests/
└── idle_hour.rs
Each Rust file directly inside tests/ is compiled as a separate integration test crate.
It uses the library the same way outside code does: through the public API.
This is the first code in the project that sees the game the way a stranger would.
Put this in tests/idle_hour.rs:
use rustbound_idle::Game;
#[test]
fn an_hour_away_moves_the_game_forward() {
let mut game = Game::new();
game.tick(3600);
assert_eq!(game.stage, 128);
assert_eq!(game.player.level, 47);
}
The terminal program clicked through three stages before it waited an hour.
This test waits an hour from a brand-new game, so the numbers are different:
stage 128
level 47
Run everything:
cargo test
Cargo prints a block for each piece it tests.
You should see:
- the twenty-one tests inside the library
- the new integration test in
tests/idle_hour.rs - blocks for
main.rsand doc-tests that sayrunning 0 tests
That is fine.
Twenty-two tests in all.
One last complaint
There is one more thing, and it has been waiting for us.
Clippy is Rust's linter.
It looks for patterns that compile but could be clearer, more idiomatic, or less error-prone.
If:
cargo clippy
is not available, install the component first:
rustup component add clippy
Then run:
cargo clippy
Clippy has a suggestion:
warning: you should consider adding a `Default` implementation for `Game`
It did not complain before because Game::new() was not part of a public API.
Now Game is public, and so is its zero-argument new().
Clippy notices that callers may reasonably expect the standard Rust Default trait too.
Default describes a type's standard starting value.
We already have one:
Game::new()
So we can say that explicitly:
impl Default for Game {
fn default() -> Game {
Game::new()
}
}
Put it in game.rs, between the impl Game block and attack().
Now:
Game::new()
and:
Game::default()
produce the same starting game.
Run Clippy again.
It should be quiet.
The complete program
If you are only reading along, skip to the last two sections.
If you have been following along in your editor, here is everything in one place.
Your project should now look like this:
rustbound-idle/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── rules.rs
│ ├── enemy.rs
│ ├── player.rs
│ └── game.rs
└── tests/
└── idle_hour.rs
src/lib.rs
mod enemy;
mod game;
mod player;
mod rules;
pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};
src/main.rs
use rustbound_idle::{Game, PurchaseOutcome};
fn main() {
let mut game = Game::new();
while game.stage <= 3 {
let stage = game.stage;
let level = game.player.level;
game.click();
if game.stage > stage {
println!(
"Stage {stage} cleared! Gold: {} XP: {}/{}",
game.player.gold,
game.player.xp,
game.player.xp_needed()
);
if game.player.level > level {
println!(
"Level up! Now level {}. Click damage: {}",
game.player.level,
game.player.click_damage()
);
}
println!(
" Next up: {} with {} health.",
game.enemy.name,
game.enemy.health
);
}
match game.buy_click_upgrade() {
PurchaseOutcome::Purchased => println!(
"Bought click upgrade {}! Click damage: {} Gold left: {}",
game.player.click_upgrade_level,
game.player.click_damage(),
game.player.gold
),
PurchaseOutcome::NotEnoughGold => {}
}
}
let stage = game.stage;
let level = game.player.level;
let gold = game.player.gold;
game.tick(3600);
println!("One hour away.");
println!(
"Stage {} -> {} Level {} -> {} Gold {} -> {}",
stage,
game.stage,
level,
game.player.level,
gold,
game.player.gold
);
}
src/rules.rs
pub(crate) fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
pub(crate) fn health_after_hit(health: u32, damage: u32) -> u32 {
health.saturating_sub(damage)
}
pub(crate) fn is_defeated(health: u32) -> bool {
health == 0
}
pub(crate) fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
pub(crate) fn gold_reward_for_stage(stage: u32) -> u32 {
stage.saturating_mul(2).saturating_add(3)
}
pub(crate) fn xp_reward_for_stage(stage: u32) -> u32 {
stage.saturating_mul(2).saturating_add(4)
}
pub(crate) fn xp_to_next_level(level: u32) -> u32 {
level
.saturating_sub(1)
.saturating_mul(15)
.saturating_add(20)
}
pub(crate) fn base_damage_for_level(level: u32) -> u32 {
level.saturating_add(4)
}
pub(crate) fn idle_damage_for_level(level: u32) -> u32 {
level
}
pub(crate) fn click_upgrade_bonus(upgrades: u32) -> u32 {
upgrades.saturating_mul(2)
}
pub(crate) fn click_upgrade_cost(upgrades: u32) -> u32 {
upgrades
.saturating_add(1)
.saturating_mul(10)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn click_damage_adds_base_and_bonus() {
assert_eq!(click_damage(3, 4), 7);
}
#[test]
fn health_never_drops_below_zero() {
assert_eq!(health_after_hit(3, 10), 0);
}
#[test]
fn zero_health_is_defeated() {
assert!(is_defeated(0));
assert!(!is_defeated(1));
}
#[test]
fn later_stages_have_tougher_enemies() {
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
}
#[test]
fn later_stages_pay_better() {
assert_eq!(gold_reward_for_stage(1), 5);
assert_eq!(gold_reward_for_stage(2), 7);
assert_eq!(xp_reward_for_stage(1), 6);
assert_eq!(xp_reward_for_stage(2), 8);
}
#[test]
fn levels_need_more_xp_as_they_go() {
assert_eq!(xp_to_next_level(1), 20);
assert_eq!(xp_to_next_level(2), 35);
}
#[test]
fn levels_make_the_player_hit_harder() {
assert_eq!(base_damage_for_level(1), 5);
assert_eq!(base_damage_for_level(2), 6);
}
#[test]
fn idle_damage_grows_with_level() {
assert_eq!(idle_damage_for_level(1), 1);
assert_eq!(idle_damage_for_level(2), 2);
}
#[test]
fn upgrades_cost_more_each_time() {
assert_eq!(click_upgrade_cost(0), 10);
assert_eq!(click_upgrade_cost(1), 20);
}
}
src/enemy.rs
use crate::rules::{
enemy_health_for_stage,
gold_reward_for_stage,
xp_reward_for_stage,
};
pub struct Enemy {
pub name: String,
pub health: u32,
pub(crate) gold_reward: u32,
pub(crate) xp_reward: u32,
}
impl Enemy {
pub(crate) fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
gold_reward: gold_reward_for_stage(stage),
xp_reward: xp_reward_for_stage(stage),
}
}
}
src/player.rs
use crate::rules::{
base_damage_for_level,
click_damage,
click_upgrade_bonus,
click_upgrade_cost,
xp_to_next_level,
};
pub struct Player {
pub gold: u32,
pub xp: u32,
pub level: u32,
pub click_upgrade_level: u32,
}
#[derive(Debug, PartialEq)]
pub enum PurchaseOutcome {
Purchased,
NotEnoughGold,
}
impl Player {
pub(crate) fn new() -> Player {
Player {
gold: 0,
xp: 0,
level: 1,
click_upgrade_level: 0,
}
}
pub(crate) fn add_gold(&mut self, amount: u32) {
self.gold = self.gold.saturating_add(amount);
}
pub(crate) fn add_xp(&mut self, amount: u32) {
self.xp = self.xp.saturating_add(amount);
while self.xp >= self.xp_needed() {
self.xp -= self.xp_needed();
self.level = self.level.saturating_add(1);
}
}
pub fn xp_needed(&self) -> u32 {
xp_to_next_level(self.level)
}
pub fn click_damage(&self) -> u32 {
click_damage(
self.base_damage(),
self.bonus_damage()
)
}
fn base_damage(&self) -> u32 {
base_damage_for_level(self.level)
}
fn bonus_damage(&self) -> u32 {
click_upgrade_bonus(self.click_upgrade_level)
}
pub(crate) fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
let cost =
click_upgrade_cost(self.click_upgrade_level);
if self.gold < cost {
return PurchaseOutcome::NotEnoughGold;
}
self.gold -= cost;
self.click_upgrade_level =
self.click_upgrade_level.saturating_add(1);
PurchaseOutcome::Purchased
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leveling_up_keeps_leftover_xp() {
let mut player = Player::new();
player.add_xp(25);
assert_eq!(player.level, 2);
assert_eq!(player.xp, 5);
}
#[test]
fn a_big_reward_can_skip_levels() {
let mut player = Player::new();
player.add_xp(60);
assert_eq!(player.level, 3);
assert_eq!(player.xp, 5);
}
#[test]
fn buying_an_upgrade_needs_gold() {
let mut player = Player::new();
assert_eq!(
player.buy_click_upgrade(),
PurchaseOutcome::NotEnoughGold
);
assert_eq!(player.click_upgrade_level, 0);
assert_eq!(player.bonus_damage(), 0);
}
#[test]
fn buying_an_upgrade_spends_gold() {
let mut player = Player::new();
player.gold = 12;
assert_eq!(
player.buy_click_upgrade(),
PurchaseOutcome::Purchased
);
assert_eq!(player.gold, 2);
assert_eq!(player.click_upgrade_level, 1);
assert_eq!(player.bonus_damage(), 2);
}
}
src/game.rs
use crate::enemy::Enemy;
use crate::player::{Player, PurchaseOutcome};
use crate::rules::{
health_after_hit,
idle_damage_for_level,
is_defeated,
};
pub struct Game {
pub stage: u32,
pub player: Player,
pub enemy: Enemy,
}
impl Game {
pub fn new() -> Game {
Game {
stage: 1,
player: Player::new(),
enemy: Enemy::for_stage(1),
}
}
pub fn click(&mut self) {
attack(&self.player, &mut self.enemy);
self.resolve_hit();
}
pub fn tick(&mut self, seconds: u32) {
for _ in 0..seconds {
let damage =
idle_damage_for_level(self.player.level);
self.enemy.health =
health_after_hit(
self.enemy.health,
damage
);
self.resolve_hit();
}
}
fn resolve_hit(&mut self) {
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
fn defeat_enemy(&mut self) {
self.player.add_gold(self.enemy.gold_reward);
self.player.add_xp(self.enemy.xp_reward);
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
pub fn buy_click_upgrade(
&mut self
) -> PurchaseOutcome {
self.player.buy_click_upgrade()
}
}
impl Default for Game {
fn default() -> Game {
Game::new()
}
}
fn attack(player: &Player, enemy: &mut Enemy) {
enemy.health =
health_after_hit(
enemy.health,
player.click_damage()
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attack_reduces_enemy_health() {
let player = Player::new();
let mut enemy = Enemy::for_stage(1);
enemy.health = 10;
attack(&player, &mut enemy);
assert_eq!(enemy.health, 5);
}
#[test]
fn a_new_game_starts_at_stage_one() {
let game = Game::new();
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
assert_eq!(game.player.gold, 0);
}
#[test]
fn clicking_damages_the_enemy() {
let mut game = Game::new();
game.click();
assert_eq!(game.enemy.health, 15);
}
#[test]
fn defeating_an_enemy_pays_the_player() {
let mut game = Game::new();
game.enemy.health = 1;
game.click();
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
assert_eq!(game.player.gold, 5);
assert_eq!(game.player.xp, 6);
}
#[test]
fn one_second_is_one_idle_hit() {
let mut game = Game::new();
game.tick(1);
assert_eq!(game.enemy.health, 19);
}
#[test]
fn zero_seconds_changes_nothing() {
let mut game = Game::new();
game.tick(0);
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
}
#[test]
fn enough_time_defeats_the_enemy() {
let mut game = Game::new();
game.tick(20);
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
assert_eq!(game.player.gold, 5);
assert_eq!(game.player.xp, 6);
}
#[test]
fn one_long_tick_matches_many_short_ones() {
let mut long = Game::new();
let mut short = Game::new();
long.tick(45);
for _ in 0..45 {
short.tick(1);
}
assert_eq!(long.stage, short.stage);
assert_eq!(
long.enemy.health,
short.enemy.health
);
assert_eq!(
long.player.gold,
short.player.gold
);
assert_eq!(
long.player.xp,
short.player.xp
);
assert_eq!(
long.player.level,
short.player.level
);
}
}
tests/idle_hour.rs
use rustbound_idle::Game;
#[test]
fn an_hour_away_moves_the_game_forward() {
let mut game = Game::new();
game.tick(3600);
assert_eq!(game.stage, 128);
assert_eq!(game.player.level, 47);
}
Run everything:
cargo run
cargo test
cargo clippy
The game should clear three stages, buy one upgrade, and spend an hour away.
All twenty-two tests should pass.
Clippy should be quiet.
What we learned
Along the way, we encountered:
- modules, and
modto declare one - organizing top-level modules into separate files
use, andcrate::paths- Rust's default privacy boundaries
pubpub(crate)pub use, for choosing what appears on a library's front door- a library crate and a binary crate in the same Cargo package
- tests that live beside their code
- integration tests in
tests/ Default, which Clippy asked us to provide
Two ideas are worth keeping.
The first is that privacy is not about hiding.
It is about deciding what everyone else is allowed to depend on.
A private function gives us freedom.
We can rename it, reorganize it, or replace its implementation without outside code depending on that detail, as long as the public behavior stays the same.
resolve_hit() can move.
base_damage() can change.
Code outside their visibility boundary does not get to build itself around those details.
The second idea is where the line went.
We did not decide to build an engine.
We sorted the code, and the sorting kept asking one question:
Does this need to know about a screen?
For everything except main(), the answer was no.
That is the heart of what our little game engine is becoming.
It is not a graphics library.
It does not open a window.
It does not print to a terminal.
It does not know whether a browser even exists.
It owns rules and state.
The game's four main commands are:
new
click
tick
buy_click_upgrade
We still expose some state so main() can display it.
That part of the front door is wider than I want.
Game, Player, and Enemy still have public fields, which means main() could write:
game.enemy.health = 0;
and the compiler would allow it.
That is a loose end.
We are going to lean on it soon.
It cannot remember
Our game can simulate an hour instantly.
It cannot survive the program ending.
Close the terminal and everything is gone:
stage
gold
XP
level
upgrades
An idle game that forgets you the moment you leave is not much of an idle game.
Next time, the game learns to save itself.
Not to a file.
Not to a browser.
Not to a server.
The library will not know where saves go, and it should not.
It will only know how to say, in a form that can be written down:
This is everything about me.
And later, given that same form:
I remember.
Small function. Bigger game.
About the author
- Web developer building full-stack applications with JavaScript, React, Node.js, and modern web APIs.
Comments
Join the discussionNo comments yet. Be the first to comment.
Rustbound - Build Your First Rust App, One Function at a Time
Learn Rust by building a real idle game from small functions to a complete WebAssembly app with testing, persistence, Web Components, PWA support, and deployment.





