State Management
If you’re coming from Unity, Unreal, or Godot, the way Tiramisu handles state might feel unusual at first. There’s no GameObject with mutable properties. No MonoBehaviour scripts modifying fields. Instead, all your game state lives in one place, changes happen through one mechanism, and the flow of data is always predictable.
This guide explains why that matters and how to structure your state as your game grows.
The problem with mutable state
In traditional game engines, objects own their state and modify it directly:
// Pseudocode - typical mutable approach
class Player {
var health = 100
var position = Vector3(0, 0, 0)
fun takeDamage(amount) {
health -= amount
if (health <= 0) die()
}
}
class Enemy {
fun attack(player) {
player.takeDamage(10) // Directly mutates player
}
}
This works fine for small games. But as complexity grows, problems emerge:
- Hidden dependencies: Who modified the player’s health? Could be anyone with a reference.
- Order-dependent bugs: The behavior changes depending on which update runs first.
- Hard to test: You need to set up entire object graphs to test one interaction.
- Impossible to replay: You can’t “go back” to a previous state without saving everything.
The Elm Architecture solves these problems by making data flow explicit and unidirectional.
Unidirectional data flow
Here’s how Tiramisu manages state:
+------------------------------------------------------------------+
| |
| +---------+ +-------------+ +---------+ |
| | Model | ------> | view | ------> | Scene | |
| +---------+ +-------------+ +---------+ |
| ^ | |
| | | |
| | v |
| +---------+ +-------------+ +---------+ |
| | update | <------ | Message | <------ | Input | |
| +---------+ +-------------+ +---------+ |
| |
+------------------------------------------------------------------+
Data flows in one direction:
- The Model contains all game state
- The view function reads the Model and produces a Scene
- User Input (or effects) generates Messages
- The update function receives Messages and produces a new Model
- Repeat
There’s exactly one way for state to change: through update. This single constraint eliminates entire categories of bugs.
Designing your Model
The Model is a Gleam type that holds everything about your game. Start simple:
pub type Model {
Model(
player_position: Vec3(Float),
player_health: Float,
score: Int,
)
}
As your game grows, you’ll add more fields. But resist the urge to nest too deeply at first. Flat structures are easier to work with:
// Good: flat and clear
pub type Model {
Model(
player_x: Float,
player_y: Float,
player_health: Float,
player_facing: Direction,
enemies: List(Enemy),
bullets: List(Bullet),
score: Int,
game_state: GameState,
)
}
When to use nested types
Nest when a group of fields always change together:
// The player's position, velocity, and facing are tightly coupled
pub type Player {
Player(
position: Vec3(Float),
velocity: Vec3(Float),
facing: Direction,
health: Float,
)
}
pub type Model {
Model(
player: Player,
enemies: List(Enemy),
// ...
)
}
Making impossible states impossible
One of Gleam’s superpowers is preventing invalid states at compile time. Instead of:
// Bad: Many ways to be in an invalid state
pub type Model {
Model(
is_game_over: Bool,
is_paused: Bool,
is_in_menu: Bool,
score: Int,
// What if is_game_over AND is_paused are both true?
)
}
Use a custom type that encodes valid states:
// Good: Only valid states are representable
pub type GameState {
Playing(score: Int, lives: Int)
Paused(score: Int, lives: Int)
GameOver(final_score: Int)
InMenu
}
pub type Model {
Model(
game_state: GameState,
// ...
)
}
Now it’s impossible to be “game over” and “paused” simultaneously. The type system enforces your invariants.
Designing your Messages
Messages describe what happened, not what should change. This distinction matters.
Name messages as events
Bad message names describe actions:
// Bad: Describes what to do
pub type Msg {
SetPlayerHealth(Float)
DecrementLives
ResetGame
}
Good message names describe events:
// Good: Describes what happened
pub type Msg {
PlayerHitByEnemy(enemy_id: String, damage: Float)
PlayerCollectedCoin(value: Int)
PlayerRequestedRestart
TimerExpired
}
The naming convention is Subject-Verb-Object: who did what to whom. This makes your code self-documenting.
Why event names matter
Consider debugging. Which log is more helpful?
SetPlayerHealth(80)
SetPlayerHealth(70)
SetPlayerHealth(0)
vs
PlayerHitByEnemy("goblin-3", 20)
PlayerHitByEnemy("boss", 10)
PlayerFellInPit
Event names tell you why the state changed, not just that it changed.
Group related messages
As your game grows, you’ll have many messages. Group them logically:
pub type Msg {
// Game loop
Tick
// Player actions
PlayerMoved(direction: Direction)
PlayerJumped
PlayerAttacked
// Collisions
PlayerHitEnemy(enemy_id: String)
PlayerHitByEnemy(enemy_id: String, damage: Float)
BulletHitEnemy(bullet_id: String, enemy_id: String)
// Game flow
GameStarted
GamePaused
GameResumed
LevelCompleted(level: Int)
}
Writing update functions
The update function is where state changes happen. It should be:
- Pure: Same inputs always produce same outputs
- Total: Handles every possible message
- Focused: Does one thing well
Basic structure
fn update(
model: Model,
msg: Msg,
ctx: tiramisu.Context,
) -> #(Model, effect.Effect(Msg), Option(PhysicsWorld)) {
case msg {
Tick -> handle_tick(model, ctx)
PlayerMoved(direction) -> handle_movement(model, direction, ctx)
PlayerHitByEnemy(id, damage) -> handle_player_damage(model, id, damage)
// ... handle all messages
}
}
Extracting handlers
Keep individual handlers small and focused:
fn handle_player_damage(
model: Model,
enemy_id: String,
damage: Float,
) -> #(Model, effect.Effect(Msg), Option(PhysicsWorld)) {
let new_health = float.max(0.0, model.player.health -. damage)
let new_player = Player(..model.player, health: new_health)
let new_model = Model(..model, player: new_player)
case new_health <=. 0.0 {
True -> #(new_model, effect.dispatch(PlayerDied), None)
False -> #(new_model, effect.none(), None)
}
}
Notice how we:
- Compute the new value
- Create a new Player with the updated field (using
..model.playerspread syntax) - Create a new Model with the updated player
- Dispatch a follow-up message if needed
The tick pattern
Most games need a per-frame update. The standard pattern:
pub type Msg {
Tick
// ... other messages
}
fn init(ctx: tiramisu.Context) {
// Start the tick loop
#(initial_model, effect.dispatch(Tick), None)
}
fn update(model: Model, msg: Msg, ctx: tiramisu.Context) {
case msg {
Tick -> {
let new_model =
model
|> update_physics(ctx.delta_time)
|> update_animations(ctx.delta_time)
|> check_collisions()
// Continue the loop
#(new_model, effect.dispatch(Tick), ctx.physics_world)
}
// ... other messages
}
}
The effect.dispatch(Tick) at the end schedules another Tick for the next frame. This creates a continuous loop without any mutable state.
Delta time
Always use ctx.delta_time for movement and animations:
let delta_seconds = duration.to_seconds(ctx.delta_time)
// Movement at 5 units per second, regardless of frame rate
let movement = speed *. delta_seconds
This ensures your game runs at the same speed on 30fps and 144fps displays.
Scaling to larger games
As your game grows, you’ll want to split state management across modules. The pattern is straightforward: each module has its own Model, Msg, and update function.
Module structure
src/
+-- my_game.gleam # Main module, combines everything
+-- my_game/
+-- player.gleam # Player state and logic
+-- enemy.gleam # Enemy state and logic
+-- physics.gleam # Physics integration
+-- ui.gleam # UI state (if using Lustre)
Each module exports its types and update function:
// player.gleam
pub type Model {
Model(position: Vec3(Float), health: Float, ...)
}
pub type Msg {
Moved(Direction)
TookDamage(Float)
// ...
}
pub fn update(model: Model, msg: Msg, ctx: Context) -> #(Model, Effect(Msg)) {
// ...
}
Composing in the main module
The main module wraps child messages:
// my_game.gleam
pub type Model {
Model(
player: player.Model,
enemies: enemies.Model,
)
}
pub type Msg {
PlayerMsg(player.Msg)
EnemyMsg(enemies.Msg)
Tick
}
fn update(model: Model, msg: Msg, ctx: Context) {
case msg {
PlayerMsg(player_msg) -> {
let #(new_player, player_effect) = player.update(model.player, player_msg, ctx)
let new_model = Model(..model, player: new_player)
#(new_model, effect.map(player_effect, PlayerMsg), ctx.physics_world)
}
EnemyMsg(enemy_msg) -> {
// Similar pattern
}
Tick -> {
// Update all systems
}
}
}
This pattern scales to any size. The Architecture guide covers advanced composition patterns including cross-module communication.
Best practices
Keep the Model serializable
Avoid putting functions or opaque runtime objects in your Model. This enables:
- Saving/loading game state
- Networking (send state over the wire)
- Time-travel debugging
// Bad: Contains opaque types
pub type Model {
Model(
physics_world: physics.PhysicsWorld, // Opaque, can't serialize
render_texture: texture.Texture, // Runtime object
)
}
// Good: Only data
pub type Model {
Model(
player_position: Vec3(Float),
enemy_positions: List(Vec3(Float)),
)
}
Physics worlds are an exception—they’re returned separately from update, not stored in Model.
Don’t over-engineer
Start with a flat Model. Extract modules when you feel pain, not before. A 200-line update function is fine. It’s better to have clear, simple code than a complex architecture you don’t need yet.
Test your update function
Pure functions are trivial to test:
pub fn player_takes_damage_test() {
let model = Model(player: Player(health: 100.0, ..))
let msg = PlayerHitByEnemy("goblin", 30.0)
let #(new_model, _effect, _physics) = update(model, msg, mock_context())
let assert 70.0 = new_model.player.health
}
No mocking frameworks needed. No setup/teardown. Just functions.
Next steps
You now understand how Tiramisu manages state. The principles are simple:
- All state lives in the Model
- All changes happen through update
- Messages describe events, not mutations
- Data flows in one direction
Next, learn about Side Effects to understand how Tiramisu handles timers, audio, and other interactions with the outside world.