Hello! Let's dive into the next part of our project.
In our last two lessons, we built the core of our character simulation: a system for needs that decay over time and a mood score that reflects a character's overall well-being. Both of these rely on a delta_time value that we pass into our update_simulation function to move the game world forward.
However, we currently have no control over this flow of time. A life simulation game isn't very playable without the ability to pause, check on your characters, and fast-forward through mundane moments. This lesson is all about giving the player that control.
Our learning outcome is to create a game clock in Rust with pause, normal, and fast-forward speeds that drives need decay. We will build a dedicated GameClock that acts as the master timekeeper for our simulation engine.
1. The Challenge of Time in Game Development
First, let's understand why managing time is a fundamental problem in game development. In a simple game loop, you might be tempted to update your game's state once per frame. But what happens if the game runs on a fast computer versus a slow one?
To see this problem in action, let's watch the beginning of a great video on game loops.
This clip from Vittorio Romeo's "Getting The Game Loop Right" clearly demonstrates why tying game logic directly to the frame rate results in an inconsistent and unpredictable player experience.
Watch from the beginning to 01:09.
As the video shows, if your character moves 10 pixels per frame, their speed in pixels per second will be dramatically different on a machine running at 30 frames per second (FPS) versus one running at 120 FPS. This is unacceptable for game logic. Our characters' needs shouldn't decay four times faster just because the player has a better graphics card.
The solution is to make our updates dependent on real-world time, not frame rate. This is done using delta time: the amount of real time that has passed since the last frame.
The same video then explains how 'delta time' solves this problem by scaling updates based on elapsed time, ensuring the simulation speed is consistent everywhere.
Continue watching from 01:09 to 02:36.
By multiplying any time-dependent value (like speed or, in our case, decay rate) by delta_time, we ensure the total change over one second is the same, whether it happens in 30 small steps or 120 tiny steps.
Our goal now is to create a system that can manipulate this delta_time before it affects our simulation, allowing us to implement pause and fast-forward.

2. Designing and Implementing the GameClock
We'll create a GameClock struct in Rust. Its job is simple: to keep track of the desired game speed and provide a multiplier that we can apply to delta_time.
Let's get to the code.
Step 1: Define the Game Speeds
First, we need to represent the possible speed settings. An enum is perfect for this. In your Rust project (e.g., in lib.rs or a new clock.rs file), define the GameSpeed enum.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum GameSpeed {
Paused,
Normal,
Fast,
}
We derive Copy because the enum is small and easy to pass by value. PartialEq lets us compare speeds (e.g., if speed == GameSpeed::Paused). We also add Serialize and Deserialize so the game speed can be saved and loaded as part of the game state later on.
Step 2: Create the GameClock Struct
Next, let's define the GameClock struct itself. It will hold the current speed and define how fast the "Fast" speed is.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GameClock {
pub speed: GameSpeed,
fast_speed_multiplier: f32,
}
Step 3: Implement the Clock's Logic
Now, let's add the behavior. We need a way to create a new clock, change its speed, and get the current time multiplier.
impl GameClock {
pub fn new() -> Self {
GameClock {
speed: GameSpeed::Normal,
fast_speed_multiplier: 8.0, // Game runs 8x faster on "Fast"
}
}
/// Sets a new speed for the game clock.
pub fn set_speed(&mut self, new_speed: GameSpeed) {
self.speed = new_speed;
}
/// Returns the multiplier for the current game speed.
/// Paused = 0.0, Normal = 1.0, Fast = fast_speed_multiplier.
pub fn get_time_multiplier(&self) -> f32 {
match self.speed {
GameSpeed::Paused => 0.0,
GameSpeed::Normal => 1.0,
GameSpeed::Fast => self.fast_speed_multiplier,
}
}
}
This implementation is straightforward:
new()creates a clock that starts at normal speed with a fast-forward multiplier of 8x.set_speed()allows external code (like our UI) to change the speed.get_time_multiplier()is the core of our system. It returns0.0for paused (stopping time),1.0for normal (real-time), and8.0for fast-forward.
3. Integrating the Clock into the Game State
With our GameClock built, we need to wire it into our main GameState.
Step 1: Add the Clock to GameState
Modify your GameState struct to include an instance of the GameClock.
// In your GameState struct definition
pub struct GameState {
pub characters: Vec<Character>,
pub clock: GameClock, // Add this line
}
// In your GameState::new() function
impl GameState {
pub fn new() -> Self {
GameState {
characters: Vec::new(),
clock: GameClock::new(), // And initialize it here
}
}
// ... other methods
}
Step 2: Use the Clock to Drive the Simulation
Now, we'll refactor our simulation update logic. Previously, update_simulation took a delta_time. We'll change this. The main tick function of our engine will now take the unscaled delta time from Unity, use the clock to scale it, and then pass the result to the functions that need it.
// In your GameState implementation
impl GameState {
// ... new() and other functions ...
/// The main entry point for updating the entire game state.
/// This will be called from the FFI boundary on every frame from Unity.
pub fn tick(&mut self, unscaled_delta_time: f32) {
// 1. Get the time multiplier from the clock
let time_multiplier = self.clock.get_time_multiplier();
let scaled_delta_time = unscaled_delta_time * time_multiplier;
// 2. Only update the simulation if time is actually passing
if scaled_delta_time > 0.0 {
self.update_character_simulation(scaled_delta_time);
}
}
/// Updates all time-dependent character logic (needs, mood, etc.).
fn update_character_simulation(&mut self, delta_time: f32) {
for character in &mut self.characters {
character.needs.decay(delta_time);
character.update_mood();
}
}
}
Notice the separation of concerns:
tick()is the high-level orchestrator. It handles time scaling.update_character_simulation()contains the specific logic for what happens during a time step. This replaces our oldupdate_simulationfunction.
Your previous update_simulation function should be renamed or replaced with update_character_simulation.
4. Testing the Game Clock
How do we know it works? We write tests! This is crucial for verifying our logic without needing to run the full game in Unity.
Add these tests to your #[cfg(test)] mod tests block:
#[cfg(test)]
mod tests {
use super::*;
// ... your previous tests ...
#[test]
fn clock_provides_correct_multipliers() {
let mut clock = GameClock::new();
assert_eq!(clock.get_time_multiplier(), 1.0);
clock.set_speed(GameSpeed::Fast);
assert_eq!(clock.get_time_multiplier(), 8.0);
clock.set_speed(GameSpeed::Paused);
assert_eq!(clock.get_time_multiplier(), 0.0);
}
#[test]
fn need_decay_respects_game_clock_speed() {
// 1. Setup
let mut state = GameState::new();
state.characters.push(Character::new("Testy".to_string()));
let unscaled_delta_time = 0.1; // 100ms per frame
// 2. Action & Assertion: Normal Speed
state.clock.set_speed(GameSpeed::Normal);
state.tick(unscaled_delta_time);
let hunger_after_normal = state.characters[0].needs.hunger;
assert!(hunger_after_normal < 1.0, "Hunger should decay at normal speed.");
// 3. Action & Assertion: Paused
state.tick(unscaled_delta_time); // Tick again to check if it's the same
assert_eq!(
state.characters[0].needs.hunger, hunger_after_normal,
"Hunger should NOT change while paused."
);
// 4. Action & Assertion: Fast Speed
state.clock.set_speed(GameSpeed::Fast);
state.tick(unscaled_delta_time);
let hunger_after_fast = state.characters[0].needs.hunger;
let normal_decay = 1.0 - hunger_after_normal;
let fast_decay = hunger_after_normal - hunger_after_fast;
// Check if fast decay is roughly 8x the normal decay
assert!(
(fast_decay / normal_decay - 8.0).abs() < 1e-6,
"Hunger should decay much faster on Fast speed."
);
}
}
Run cargo test. These tests confirm two things:
- The
GameClockcorrectly returns the multiplier for each speed setting. - The
GameState'stickfunction correctly uses this multiplier to scale the need decay. The character's hunger doesn't change when paused and decays significantly faster when on fast-forward.
Conclusion
Fantastic! You have now implemented one of the most essential features for a simulation game: time control. Our Rust backend now has a robust GameClock that can pause, run, and fast-forward the simulation at will.
Key Takeaways:
- Game logic should be driven by real-world time (
delta_time), not frame rate, to ensure a consistent experience across different hardware. - We can control the flow of time by applying a multiplier to
delta_timebefore using it in our simulation updates. - A dedicated
GameClockstruct is a clean way to manage game speed settings (Paused,Normal,Fast). - The main game
tickfunction should be responsible for orchestrating the update, including fetching the time multiplier and scalingdelta_time. - Unit tests are invaluable for verifying that time-dependent logic behaves correctly at different speeds without needing to run the full game.
In our next lesson, we will begin connecting our simulation to the visual world. We'll start by defining interactions as data and creating a system for characters to perform actions like "Eat" or "Sleep" that can replenish their needs, setting the stage for autonomous AI.