Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This book is mainly targetted towards game developers, ones that are familiar with basics of Rust, ones that are familiar with async/await but only used it for IO, ones that came from other languages or game engines and are used to using coroutines for game logic in there and are looking for how to achieve such concept in Rust.

And additionally, this book is targetted also towards beginners, as it attempts to explain in simpler, non-formal way, how async works, using more real life than abstract scenarios.

This book most likely is not for advanced future-wizards, as it might feel oversimplifying entire concepts that you might find important to be explained the proper way - don’t worry, there are other, better and more formal materials for advanced async/await topics. But if you’re still curious about game developer point of view, i’ll be happy to share it with you too!

What we are used to

While working on games, we tend to express game logic in form of an actions timeline deferred in time, awaiting for some game changes to react on and change to another state.

The simplest scenario is non-blocking loading screen, where before we jump into the game session, we request set of assets to load, wait for it’s progress to end, tracking it for loading bar visuals, and after all assets load, we actually change to game session state.

Usually we just trigger non-blocking asset loading request, on update we check if assets are loaded and only then we change state. We could express all of that as awaitable set of actions in a couple of lines of code. In gamedev we call this concept a coroutine - an asynchronous, suspendable code block or function, that awaits for some other work or change to complete, in order to continue it’s own work.

The typical ways game developers handle such states is with enum variants and flags read and write in various places all over the code, or a bit smarter with dedicated state machine objects (but still controlled from various external places).

I’m not saying it’s necessarily a bad way to handle suspendable logic - of course it works and therefore people use this pattern. But some patterns i’ve seen in games, could benefit from coroutines instead of manually driven state machines, IMHO.

What we could do instead

While researching the topic, i see a general lack of (or very limited support of) such concept in Rust’s Game Development area. I’ve had a pleasure working with coroutines in other languages in various game engines and i’ve found that lack of support dedicated towards games is quite astonishing to me, but i understand why it might be the case.

If you think Async in Rust, you immediatelly think IO, and i don’t blame you - it was somewhat made for it mainly if not exclusively, but during my experiments with coroutines in Rust, i’ve found that one can express suspendable game logic in surprisingly ergonomic way with coroutines, compared to state machine spaghetti all over the game code!

Less cognitive load means more pleasant and fun times making your games :)

What we will learn

In this book i’m gonna try to show the idea of coroutines in Rust for gamedevs that came to Rust from other languages and engines supporting coroutine game logic, as well as for those who think async equals IO - that it’s capable of more than IO and how to utilize this pattern.

We will cover:

  • Coroutines as action timelines.
  • User-side asynchronous suspension instead of per-frame polling.
  • Fearless coroutines as means for cleaner, more readable and easier to maintain and refactor game logic.
  • Local vs background job modes and means to move between those and change priorities.
  • Suspendable game logic that survives saves.

Quick start

I see you’re curious of how coroutines looks like in Rust - let’s feed you some minimal example!

Installation

Add Moirai to the project:

cargo add moirai

Minimal code

First we create jobs runtime to spawn coroutines into:

use moirai::{coroutine::yield_now, job::JobLocation, jobs::Jobs};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    utils::quit_if_jobs_completed,
};
use std::time::Duration;

fn main() {
    let jobs = Jobs::default();
    Game::new(Example { jobs }).run_blocking();
}

struct Example {
    jobs: Jobs,
}

impl GameState for Example {
    fn enter(&mut self) {
        self.jobs.spawn(JobLocation::Local, async {
            let mut counter = 0;
            for _ in 0..10 {
                counter += 1;
                // Yield execution to allow other jobs to run.
                yield_now().await;
            }
            println!("Counter: {:?}", counter);
        });
    }

    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        self.jobs.run_local();

        quit_if_jobs_completed(&self.jobs)
    }
}

Then we spawn some job somewhere in game:

use moirai::{coroutine::yield_now, job::JobLocation, jobs::Jobs};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    utils::quit_if_jobs_completed,
};
use std::time::Duration;

fn main() {
    let jobs = Jobs::default();
    Game::new(Example { jobs }).run_blocking();
}

struct Example {
    jobs: Jobs,
}

impl GameState for Example {
    fn enter(&mut self) {
        self.jobs.spawn(JobLocation::Local, async {
            let mut counter = 0;
            for _ in 0..10 {
                counter += 1;
                // Yield execution to allow other jobs to run.
                yield_now().await;
            }
            println!("Counter: {:?}", counter);
        });
    }

    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        self.jobs.run_local();

        quit_if_jobs_completed(&self.jobs)
    }
}

In game loop we run pending local jobs queue, usually at the end of game frame:

use moirai::{coroutine::yield_now, job::JobLocation, jobs::Jobs};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    utils::quit_if_jobs_completed,
};
use std::time::Duration;

fn main() {
    let jobs = Jobs::default();
    Game::new(Example { jobs }).run_blocking();
}

struct Example {
    jobs: Jobs,
}

impl GameState for Example {
    fn enter(&mut self) {
        self.jobs.spawn(JobLocation::Local, async {
            let mut counter = 0;
            for _ in 0..10 {
                counter += 1;
                // Yield execution to allow other jobs to run.
                yield_now().await;
            }
            println!("Counter: {:?}", counter);
        });
    }

    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        self.jobs.run_local();

        quit_if_jobs_completed(&self.jobs)
    }
}

As you see, this is bare minimum needed to process coroutines in your game. Just a runtime running local jobs at the end of a game frame, and a spawned jobs in it - that’s all!

Understanding the problem

In this section we will try to understand the problem we are trying to solve with coroutines, go through what traditional frame-based game logic does and what suspendable game logic fixes.

We are gonna try to gradually shift the mindset towards coroutines and help gain an understanding where they fit and where they don’t.

Traditional game logic

Let me walk you through some patterns i’ve seen in games, where we solve suspendable game logic with custom state machines or events/callbacks. With each pattern i’ll show and explain my observations about each of them.

Boss fight pattern

I’ve choosen to show simplified version, because the actual boss fight state machines typically look quite more complex as the state machines are rather much bigger in actual games, and we don’t want to overwhelm ourselves with such complexity - this version will still remind you of something you might know from your own or others experience.

The game mechanics of simplified version are:

  • Character (player or NPC) accumulates stamina.
  • Stamina can be spent on either attacking or blocking:
    • Attack takes accumulated stamina and deals its value as damage to the opponent.
    • Block takes accumulated stamina and makes opponent unable to damage us for the duration of accumulated stamina.
  • While in blocking state, character cannot perform any other action until cooldown drops to zero - no attacking, no blocking, no stamina regeneration.

Boss fight pattern with state machines

What we have is usually NPC state variants that tell what actions NPC will try to perform:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;

#[derive(Default)]
struct Example {
    terminal: Terminal,
    player: Character,
    enemy: Npc,
    log: Vec<String>,
}

impl GameState for Example {
    fn frame(&mut self, delta_time: Duration) -> GameStateChange {
        // Collect application input events that happened since last frame.
        let events = Terminal::events().collect::<Vec<_>>();

        // Run game systems
        if self.player.is_alive() && self.enemy.character.is_alive() {
            update_player_system(
                &events,
                &mut self.player,
                &mut self.enemy.character,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
            update_enemy_system(
                &mut self.enemy,
                &mut self.player,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
        }
        draw_system(
            &mut self.terminal,
            &self.player,
            &self.enemy.character,
            &self.log,
        );

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    health: f32,
    stamina: f32,
    blocking_cooldown: f32,
}

impl Default for Character {
    fn default() -> Self {
        Self {
            health: 100.0,
            stamina: 0.0,
            blocking_cooldown: 0.0,
        }
    }
}

impl Character {
    fn is_alive(&self) -> bool {
        self.health > 0.0
    }

    fn is_blocking(&self) -> bool {
        self.blocking_cooldown > f32::EPSILON
    }

    fn update(&mut self, delta_time: f32) {
        if self.is_blocking() {
            self.blocking_cooldown = (self.blocking_cooldown - delta_time * TIME_SCALE).max(0.0);
        } else {
            self.stamina = (self.stamina + delta_time * TIME_SCALE).min(25.0);
        }
    }

    fn deal_damage(&mut self, target: &mut Character) -> bool {
        if !target.is_blocking() && self.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
            target.health = (target.health - self.stamina).max(0.0);
            self.stamina = 0.0;
            true
        } else {
            false
        }
    }

    fn block(&mut self) {
        self.blocking_cooldown = self.stamina;
        self.stamina = 0.0;
    }
}

#[derive(Default)]
struct Npc {
    character: Character,
    fight_phase_index: usize,
}

enum NpcFightState {
    Charge { target_stamina: f32 },
    Attack,
    Block,
}

const NPC_FIGHT_PATTERN: &[NpcFightState] = &[
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Attack,
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Block,
    NpcFightState::Charge {
        target_stamina: 20.0,
    },
    NpcFightState::Attack,
];

fn update_player_system(
    events: &[Event],
    player: &mut Character,
    enemy: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update player character state, such as stamina regeneration and blocking cooldown.
    player.update(delta_time);

    // Perform actions based on inputs.
    if is_key_pressed(events, KeyCode::Enter) {
        if player.deal_damage(enemy) {
            log.push("Player attacking!".to_string());
        }
    } else if is_key_pressed(events, KeyCode::Char(' ')) {
        player.block();
        log.push("Player blocking!".to_string());
    }
}

fn update_enemy_system(
    enemy: &mut Npc,
    player: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update NPC character state, such as stamina regeneration and blocking cooldown.
    enemy.character.update(delta_time);

    // Get current NPC state or reset if out of bounds, to loop through pattern.
    let Some(state) = NPC_FIGHT_PATTERN.get(enemy.fight_phase_index) else {
        enemy.fight_phase_index = 0;
        return;
    };

    // Perform an action of currently active state.
    match state {
        // Stay idle while recharching stamina.
        NpcFightState::Charge { target_stamina } => {
            if enemy.character.stamina >= *target_stamina {
                enemy.fight_phase_index += 1;
            }
        }
        // Attack the player with accumulated stamina.
        NpcFightState::Attack => {
            if enemy.character.deal_damage(player) {
                log.push("Enemy attacking!".to_string());
            }
            enemy.fight_phase_index += 1;
        }
        // Block the player's incoming attack for time of accumulated stamina.
        NpcFightState::Block => {
            enemy.character.block();
            log.push("Enemy blocking!".to_string());
            enemy.fight_phase_index += 1;
        }
    }
}

fn draw_system(terminal: &mut Terminal, player: &Character, enemy: &Character, log: &[String]) {
    terminal.begin_draw(true);

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));
            terminal.display([2, 4], format!("Cooldown: {:.1}", player.blocking_cooldown));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));
            terminal.display([2, 9], format!("Cooldown: {:.1}", enemy.blocking_cooldown));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

And our boss fight pattern then composes a list of actions too build a pattern for its fighting style:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;

#[derive(Default)]
struct Example {
    terminal: Terminal,
    player: Character,
    enemy: Npc,
    log: Vec<String>,
}

impl GameState for Example {
    fn frame(&mut self, delta_time: Duration) -> GameStateChange {
        // Collect application input events that happened since last frame.
        let events = Terminal::events().collect::<Vec<_>>();

        // Run game systems
        if self.player.is_alive() && self.enemy.character.is_alive() {
            update_player_system(
                &events,
                &mut self.player,
                &mut self.enemy.character,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
            update_enemy_system(
                &mut self.enemy,
                &mut self.player,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
        }
        draw_system(
            &mut self.terminal,
            &self.player,
            &self.enemy.character,
            &self.log,
        );

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    health: f32,
    stamina: f32,
    blocking_cooldown: f32,
}

impl Default for Character {
    fn default() -> Self {
        Self {
            health: 100.0,
            stamina: 0.0,
            blocking_cooldown: 0.0,
        }
    }
}

impl Character {
    fn is_alive(&self) -> bool {
        self.health > 0.0
    }

    fn is_blocking(&self) -> bool {
        self.blocking_cooldown > f32::EPSILON
    }

    fn update(&mut self, delta_time: f32) {
        if self.is_blocking() {
            self.blocking_cooldown = (self.blocking_cooldown - delta_time * TIME_SCALE).max(0.0);
        } else {
            self.stamina = (self.stamina + delta_time * TIME_SCALE).min(25.0);
        }
    }

    fn deal_damage(&mut self, target: &mut Character) -> bool {
        if !target.is_blocking() && self.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
            target.health = (target.health - self.stamina).max(0.0);
            self.stamina = 0.0;
            true
        } else {
            false
        }
    }

    fn block(&mut self) {
        self.blocking_cooldown = self.stamina;
        self.stamina = 0.0;
    }
}

#[derive(Default)]
struct Npc {
    character: Character,
    fight_phase_index: usize,
}

enum NpcFightState {
    Charge { target_stamina: f32 },
    Attack,
    Block,
}

const NPC_FIGHT_PATTERN: &[NpcFightState] = &[
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Attack,
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Block,
    NpcFightState::Charge {
        target_stamina: 20.0,
    },
    NpcFightState::Attack,
];

fn update_player_system(
    events: &[Event],
    player: &mut Character,
    enemy: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update player character state, such as stamina regeneration and blocking cooldown.
    player.update(delta_time);

    // Perform actions based on inputs.
    if is_key_pressed(events, KeyCode::Enter) {
        if player.deal_damage(enemy) {
            log.push("Player attacking!".to_string());
        }
    } else if is_key_pressed(events, KeyCode::Char(' ')) {
        player.block();
        log.push("Player blocking!".to_string());
    }
}

fn update_enemy_system(
    enemy: &mut Npc,
    player: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update NPC character state, such as stamina regeneration and blocking cooldown.
    enemy.character.update(delta_time);

    // Get current NPC state or reset if out of bounds, to loop through pattern.
    let Some(state) = NPC_FIGHT_PATTERN.get(enemy.fight_phase_index) else {
        enemy.fight_phase_index = 0;
        return;
    };

    // Perform an action of currently active state.
    match state {
        // Stay idle while recharching stamina.
        NpcFightState::Charge { target_stamina } => {
            if enemy.character.stamina >= *target_stamina {
                enemy.fight_phase_index += 1;
            }
        }
        // Attack the player with accumulated stamina.
        NpcFightState::Attack => {
            if enemy.character.deal_damage(player) {
                log.push("Enemy attacking!".to_string());
            }
            enemy.fight_phase_index += 1;
        }
        // Block the player's incoming attack for time of accumulated stamina.
        NpcFightState::Block => {
            enemy.character.block();
            log.push("Enemy blocking!".to_string());
            enemy.fight_phase_index += 1;
        }
    }
}

fn draw_system(terminal: &mut Terminal, player: &Character, enemy: &Character, log: &[String]) {
    terminal.begin_draw(true);

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));
            terminal.display([2, 4], format!("Cooldown: {:.1}", player.blocking_cooldown));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));
            terminal.display([2, 9], format!("Cooldown: {:.1}", enemy.blocking_cooldown));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

Let me remind you, that for the sake of simplicity, we assume this is entire AI state machine, but in real games this might be just a single branch of a behavior tree - you get the idea, i hope!

Then in enemy update system we execute logic of an active state:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;

#[derive(Default)]
struct Example {
    terminal: Terminal,
    player: Character,
    enemy: Npc,
    log: Vec<String>,
}

impl GameState for Example {
    fn frame(&mut self, delta_time: Duration) -> GameStateChange {
        // Collect application input events that happened since last frame.
        let events = Terminal::events().collect::<Vec<_>>();

        // Run game systems
        if self.player.is_alive() && self.enemy.character.is_alive() {
            update_player_system(
                &events,
                &mut self.player,
                &mut self.enemy.character,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
            update_enemy_system(
                &mut self.enemy,
                &mut self.player,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
        }
        draw_system(
            &mut self.terminal,
            &self.player,
            &self.enemy.character,
            &self.log,
        );

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    health: f32,
    stamina: f32,
    blocking_cooldown: f32,
}

impl Default for Character {
    fn default() -> Self {
        Self {
            health: 100.0,
            stamina: 0.0,
            blocking_cooldown: 0.0,
        }
    }
}

impl Character {
    fn is_alive(&self) -> bool {
        self.health > 0.0
    }

    fn is_blocking(&self) -> bool {
        self.blocking_cooldown > f32::EPSILON
    }

    fn update(&mut self, delta_time: f32) {
        if self.is_blocking() {
            self.blocking_cooldown = (self.blocking_cooldown - delta_time * TIME_SCALE).max(0.0);
        } else {
            self.stamina = (self.stamina + delta_time * TIME_SCALE).min(25.0);
        }
    }

    fn deal_damage(&mut self, target: &mut Character) -> bool {
        if !target.is_blocking() && self.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
            target.health = (target.health - self.stamina).max(0.0);
            self.stamina = 0.0;
            true
        } else {
            false
        }
    }

    fn block(&mut self) {
        self.blocking_cooldown = self.stamina;
        self.stamina = 0.0;
    }
}

#[derive(Default)]
struct Npc {
    character: Character,
    fight_phase_index: usize,
}

enum NpcFightState {
    Charge { target_stamina: f32 },
    Attack,
    Block,
}

const NPC_FIGHT_PATTERN: &[NpcFightState] = &[
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Attack,
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Block,
    NpcFightState::Charge {
        target_stamina: 20.0,
    },
    NpcFightState::Attack,
];

fn update_player_system(
    events: &[Event],
    player: &mut Character,
    enemy: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update player character state, such as stamina regeneration and blocking cooldown.
    player.update(delta_time);

    // Perform actions based on inputs.
    if is_key_pressed(events, KeyCode::Enter) {
        if player.deal_damage(enemy) {
            log.push("Player attacking!".to_string());
        }
    } else if is_key_pressed(events, KeyCode::Char(' ')) {
        player.block();
        log.push("Player blocking!".to_string());
    }
}

fn update_enemy_system(
    enemy: &mut Npc,
    player: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update NPC character state, such as stamina regeneration and blocking cooldown.
    enemy.character.update(delta_time);

    // Get current NPC state or reset if out of bounds, to loop through pattern.
    let Some(state) = NPC_FIGHT_PATTERN.get(enemy.fight_phase_index) else {
        enemy.fight_phase_index = 0;
        return;
    };

    // Perform an action of currently active state.
    match state {
        // Stay idle while recharching stamina.
        NpcFightState::Charge { target_stamina } => {
            if enemy.character.stamina >= *target_stamina {
                enemy.fight_phase_index += 1;
            }
        }
        // Attack the player with accumulated stamina.
        NpcFightState::Attack => {
            if enemy.character.deal_damage(player) {
                log.push("Enemy attacking!".to_string());
            }
            enemy.fight_phase_index += 1;
        }
        // Block the player's incoming attack for time of accumulated stamina.
        NpcFightState::Block => {
            enemy.character.block();
            log.push("Enemy blocking!".to_string());
            enemy.fight_phase_index += 1;
        }
    }
}

fn draw_system(terminal: &mut Terminal, player: &Character, enemy: &Character, log: &[String]) {
    terminal.begin_draw(true);

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));
            terminal.display([2, 4], format!("Cooldown: {:.1}", player.blocking_cooldown));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));
            terminal.display([2, 9], format!("Cooldown: {:.1}", enemy.blocking_cooldown));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}
Update Player System

We omit talking about player update system, as it doesn’t use any fight patterns and it’s mostly about reacting to user input, but as you can see, both player and NPC only execute actions of the character.

We don’t make the mistake of state machine doing what actions do - state machine is only orchestrating characters to execute said actions in proper time.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;

#[derive(Default)]
struct Example {
    terminal: Terminal,
    player: Character,
    enemy: Npc,
    log: Vec<String>,
}

impl GameState for Example {
    fn frame(&mut self, delta_time: Duration) -> GameStateChange {
        // Collect application input events that happened since last frame.
        let events = Terminal::events().collect::<Vec<_>>();

        // Run game systems
        if self.player.is_alive() && self.enemy.character.is_alive() {
            update_player_system(
                &events,
                &mut self.player,
                &mut self.enemy.character,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
            update_enemy_system(
                &mut self.enemy,
                &mut self.player,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
        }
        draw_system(
            &mut self.terminal,
            &self.player,
            &self.enemy.character,
            &self.log,
        );

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    health: f32,
    stamina: f32,
    blocking_cooldown: f32,
}

impl Default for Character {
    fn default() -> Self {
        Self {
            health: 100.0,
            stamina: 0.0,
            blocking_cooldown: 0.0,
        }
    }
}

impl Character {
    fn is_alive(&self) -> bool {
        self.health > 0.0
    }

    fn is_blocking(&self) -> bool {
        self.blocking_cooldown > f32::EPSILON
    }

    fn update(&mut self, delta_time: f32) {
        if self.is_blocking() {
            self.blocking_cooldown = (self.blocking_cooldown - delta_time * TIME_SCALE).max(0.0);
        } else {
            self.stamina = (self.stamina + delta_time * TIME_SCALE).min(25.0);
        }
    }

    fn deal_damage(&mut self, target: &mut Character) -> bool {
        if !target.is_blocking() && self.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
            target.health = (target.health - self.stamina).max(0.0);
            self.stamina = 0.0;
            true
        } else {
            false
        }
    }

    fn block(&mut self) {
        self.blocking_cooldown = self.stamina;
        self.stamina = 0.0;
    }
}

#[derive(Default)]
struct Npc {
    character: Character,
    fight_phase_index: usize,
}

enum NpcFightState {
    Charge { target_stamina: f32 },
    Attack,
    Block,
}

const NPC_FIGHT_PATTERN: &[NpcFightState] = &[
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Attack,
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Block,
    NpcFightState::Charge {
        target_stamina: 20.0,
    },
    NpcFightState::Attack,
];

fn update_player_system(
    events: &[Event],
    player: &mut Character,
    enemy: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update player character state, such as stamina regeneration and blocking cooldown.
    player.update(delta_time);

    // Perform actions based on inputs.
    if is_key_pressed(events, KeyCode::Enter) {
        if player.deal_damage(enemy) {
            log.push("Player attacking!".to_string());
        }
    } else if is_key_pressed(events, KeyCode::Char(' ')) {
        player.block();
        log.push("Player blocking!".to_string());
    }
}

fn update_enemy_system(
    enemy: &mut Npc,
    player: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update NPC character state, such as stamina regeneration and blocking cooldown.
    enemy.character.update(delta_time);

    // Get current NPC state or reset if out of bounds, to loop through pattern.
    let Some(state) = NPC_FIGHT_PATTERN.get(enemy.fight_phase_index) else {
        enemy.fight_phase_index = 0;
        return;
    };

    // Perform an action of currently active state.
    match state {
        // Stay idle while recharching stamina.
        NpcFightState::Charge { target_stamina } => {
            if enemy.character.stamina >= *target_stamina {
                enemy.fight_phase_index += 1;
            }
        }
        // Attack the player with accumulated stamina.
        NpcFightState::Attack => {
            if enemy.character.deal_damage(player) {
                log.push("Enemy attacking!".to_string());
            }
            enemy.fight_phase_index += 1;
        }
        // Block the player's incoming attack for time of accumulated stamina.
        NpcFightState::Block => {
            enemy.character.block();
            log.push("Enemy blocking!".to_string());
            enemy.fight_phase_index += 1;
        }
    }
}

fn draw_system(terminal: &mut Terminal, player: &Character, enemy: &Character, log: &[String]) {
    terminal.begin_draw(true);

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));
            terminal.display([2, 4], format!("Cooldown: {:.1}", player.blocking_cooldown));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));
            terminal.display([2, 9], format!("Cooldown: {:.1}", enemy.blocking_cooldown));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

In game development we tend to try not to repeat ourselves more than we need to, so player and NPC are only different in how we control the characters, therefore they share actions execution logic, not needing to pollute each of their update systems with direct manipulation of common character state.

Observations

First and foremost: this logic pattern isn’t bad or wrong, it does its job and it does it well. That’s why game developers use it. Nothing wrong with that!

This pattern encodes a timeline of actions using data with control flow

It’s not actually a state machine in spirit, it’s a script of actions that step in reaction to state data changes.

charge -> attack -> charge -> block -> charge -> attack

The expressed intent is sequential, but the execution model is reactive. The mismatch is the problem, as you are expected to follow this script by jumping back and forth between actions list and the code they evaluate into.

Timing is implicit and scattered

Where does time live?

  • stamina regeneration - Character::update().
  • blocking duration - hidden inside Character::block_opponent() action.
  • charge duration - inferred via stamina threshold.
  • pattern looping - index switching.

Time is not represented directly, it emerges from side effects across systems. Tuning gets hard, debugging harder and extending behavior risky.

Progression is manual and fragile

This line: enemy.fight_phase_index += 1; is effectively a program counter. This logic handler behaves like a mini-virtual-machine. In order to switch state, one has to increment program counter and if it doesn’t happen in right place at the right time, character gets stuck in state because we are expressing sequential progression with manual state changes.

Although AI systems behind decision making primitives tend to do manual state changes hidden from user eyes, so let’s agree it’s not the pattern problem per se, as user don’t need to do it manually if uses decision making primitives. That’s fine.

The logic is frame-driven, not action-driven

The NPC doesn’t charge until stamina is ready - it checks every frame whether charging is done. Intent expressed in reactivity. It gives feeling of logic spread in time, while execution is fragmented.

Take a look at the NPC_FIGHT_PATTERN and update_player_system - notice how the npc fight pattern is readable, but the execution is not. The behavior is sequential, but code executing it isn’t.

Complexity scales non-linearly

The more this AI code evolve, the more states in it, the less code becomes readable without switching between sequential and reactive mindset more frequently, while trying to decypher the behavior. This solution doesn’t scale well, because it requires more cognitive load the bigger it gets.

Every new requirement multiplies state interactions, not lines of code. It gets really tricky at that point, trying to follow the behavior. The code is correct, yet hostile to changes i would say (i would urge you, dear reader, to imagine 20 states instead of 3 - you might start to scratch your head).

Refactors might feel scary at that point.

UI dialogue

In this chapter we will show event-driven logic based on UI dialogue system in simple text-based game, where dialogue widget shows a message and awaits for user to decide on the option to choose from.

Pattern used in this example applies not only to text-based games, but also in any modern game that has dialogues for conversations - similar approach even applies cut scenes system. The point here is to showcase suspension and resumes with events.

UI dialogue system with events

Typical dialogue system uses asset-driven container type for storing conversation points that have message with available options, that each points to another conversation point when confirmed, effectively building a graph of conversation flow.

Asset-driven systems like that are rightfully state machines and it’s nothing wrong with that per se, but i want to make sure you, dear reader, understand that we are not talking about dialogue system specifically, rather we talk about event-driven logic in general, so please don’t dismiss this example, as it’s goal is to demonstrate events orchestrating suspension, not focusing on a game feature.

Let’s start with a widget object:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai_book_samples::{
    events::Events,
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};

fn main() {
    Game::new(Example::default()).run_blocking();
}

struct Example {
    terminal: Terminal,
    events: Events<DialogueEvent>,
    dialogue: DialogueWidget,
    conversation: Conversation,
}

impl Default for Example {
    fn default() -> Self {
        let conversation = Conversation::default()
            .point(
                "start",
                ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
                    .option(ConversationOption::new("Tavern", "tavern"))
                    .option(ConversationOption::new("Forest", "forest"))
                    .option(ConversationOption::new("Bed", "bed")),
            )
            .point(
                "tavern",
                ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
                    .option(ConversationOption::new("Order a beer", "beer"))
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "beer",
                ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
                    .option(ConversationOption::new("Pay and leave", "pay"))
                    .option(ConversationOption::new("Don't pay and leave", "no-pay")),
            )
            .point(
                "pay",
                ConversationPoint::new("You paid the bartender and left the tavern.")
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "no-pay",
                ConversationPoint::new(
                    "The bartender caught you! You had to run away to the forest!",
                )
                .option(ConversationOption::new("Run", "forest")),
            )
            .point(
                "forest",
                ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
                    .option(ConversationOption::new("Fight wolf with sword", "fight"))
                    .option(ConversationOption::new("Run back to town", "start")),
            )
            .point(
                "fight",
                ConversationPoint::new("You bravely fought the wolf and won!")
                    .option(ConversationOption::new("Return to town", "start")),
            )
            .point(
                "bed",
                ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
                    .option(ConversationOption::new("Wake up", "start")),
            );

        let events = Events::default();
        let on_confirm = events.sender();
        Self {
            terminal: Terminal::default(),
            events,
            dialogue: DialogueWidget::new("start", on_confirm),
            conversation,
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let events = Terminal::events().collect::<Vec<_>>();

        self.dialogue.handle_input(&events, &self.conversation);

        // Receive all pending events sent from UI.
        for event in self.events.receive() {
            match event {
                DialogueEvent::ShowDialogue { id } => {
                    // We got an event from dialogue widget, so we update said
                    // widget to show new conversation point.
                    self.dialogue = DialogueWidget::new(id, self.events.sender());
                }
            }
        }

        self.terminal.begin_draw(true);
        self.dialogue.draw(&mut self.terminal, &self.conversation);
        self.terminal.end_draw();

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

enum DialogueEvent {
    ShowDialogue { id: String },
}

struct DialogueWidget {
    // Current conversation point ID.
    id: String,
    // Currently selected option index.
    index: usize,
    // Sender to emit events when an option is confirmed.
    on_confirm: Sender<DialogueEvent>,
}

impl DialogueWidget {
    fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
        Self {
            id: id.to_string(),
            index: 0,
            on_confirm,
        }
    }

    fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };

        // Move to previous option.
        if is_key_pressed(events, KeyCode::Up) {
            self.index = (self.index + conversation_point.options.len() - 1)
                % conversation_point.options.len();
        } else
        // Move to next option.
        if is_key_pressed(events, KeyCode::Down) {
            self.index = (self.index + 1) % conversation_point.options.len();
        } else
        // Confirm selection.
        if is_key_pressed(events, KeyCode::Enter) {
            let id = conversation_point.options[self.index].jump_to.clone();
            self.on_confirm
                .send(DialogueEvent::ShowDialogue { id })
                .unwrap();
            self.index = 0;
        }
    }

    fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };
        let (w, _) = size().unwrap();

        let message = text_wrap(&conversation_point.message, w as usize);
        terminal.display([0, 0], &message);
        let mut y = text_size(&message).y;
        terminal.display([0, y as u16], "-".repeat(w as usize));
        y += 1;

        for (i, option) in conversation_point.options.iter().enumerate() {
            let prefix = if i == self.index { "> " } else { "  " };
            let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
            let height = text_size(&content).y;
            let content = if i == self.index {
                content.on_white().black().bold().italic()
            } else {
                content.on_black().white()
            };
            terminal.display([0, y as u16], content);
            y += height;
        }
    }
}

#[derive(Clone)]
struct ConversationPoint {
    message: String,
    options: Vec<ConversationOption>,
}

impl ConversationPoint {
    fn new(message: impl ToString) -> Self {
        Self {
            message: message.to_string(),
            options: Default::default(),
        }
    }

    fn option(mut self, option: ConversationOption) -> Self {
        self.options.push(option);
        self
    }
}

#[derive(Clone)]
struct ConversationOption {
    text: String,
    jump_to: String,
}

impl ConversationOption {
    fn new(text: impl ToString, jump_to: impl ToString) -> Self {
        Self {
            text: text.to_string(),
            jump_to: jump_to.to_string(),
        }
    }
}

#[derive(Default)]
struct Conversation {
    points: HashMap<String, ConversationPoint>,
}

impl Conversation {
    fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
        self.points.insert(id.to_string(), point);
        self
    }

    fn get(&self, id: &str) -> Option<&ConversationPoint> {
        self.points.get(id)
    }
}

Usually dialogue widget has some information about what conversation point we are showing with what options, as well as some means to notify game about user taking decision - here we use events to signal confirming user-selected option.

Within this widget we also react on user changing selected option, confirming which sends an event to game:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai_book_samples::{
    events::Events,
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};

fn main() {
    Game::new(Example::default()).run_blocking();
}

struct Example {
    terminal: Terminal,
    events: Events<DialogueEvent>,
    dialogue: DialogueWidget,
    conversation: Conversation,
}

impl Default for Example {
    fn default() -> Self {
        let conversation = Conversation::default()
            .point(
                "start",
                ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
                    .option(ConversationOption::new("Tavern", "tavern"))
                    .option(ConversationOption::new("Forest", "forest"))
                    .option(ConversationOption::new("Bed", "bed")),
            )
            .point(
                "tavern",
                ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
                    .option(ConversationOption::new("Order a beer", "beer"))
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "beer",
                ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
                    .option(ConversationOption::new("Pay and leave", "pay"))
                    .option(ConversationOption::new("Don't pay and leave", "no-pay")),
            )
            .point(
                "pay",
                ConversationPoint::new("You paid the bartender and left the tavern.")
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "no-pay",
                ConversationPoint::new(
                    "The bartender caught you! You had to run away to the forest!",
                )
                .option(ConversationOption::new("Run", "forest")),
            )
            .point(
                "forest",
                ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
                    .option(ConversationOption::new("Fight wolf with sword", "fight"))
                    .option(ConversationOption::new("Run back to town", "start")),
            )
            .point(
                "fight",
                ConversationPoint::new("You bravely fought the wolf and won!")
                    .option(ConversationOption::new("Return to town", "start")),
            )
            .point(
                "bed",
                ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
                    .option(ConversationOption::new("Wake up", "start")),
            );

        let events = Events::default();
        let on_confirm = events.sender();
        Self {
            terminal: Terminal::default(),
            events,
            dialogue: DialogueWidget::new("start", on_confirm),
            conversation,
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let events = Terminal::events().collect::<Vec<_>>();

        self.dialogue.handle_input(&events, &self.conversation);

        // Receive all pending events sent from UI.
        for event in self.events.receive() {
            match event {
                DialogueEvent::ShowDialogue { id } => {
                    // We got an event from dialogue widget, so we update said
                    // widget to show new conversation point.
                    self.dialogue = DialogueWidget::new(id, self.events.sender());
                }
            }
        }

        self.terminal.begin_draw(true);
        self.dialogue.draw(&mut self.terminal, &self.conversation);
        self.terminal.end_draw();

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

enum DialogueEvent {
    ShowDialogue { id: String },
}

struct DialogueWidget {
    // Current conversation point ID.
    id: String,
    // Currently selected option index.
    index: usize,
    // Sender to emit events when an option is confirmed.
    on_confirm: Sender<DialogueEvent>,
}

impl DialogueWidget {
    fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
        Self {
            id: id.to_string(),
            index: 0,
            on_confirm,
        }
    }

    fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };

        // Move to previous option.
        if is_key_pressed(events, KeyCode::Up) {
            self.index = (self.index + conversation_point.options.len() - 1)
                % conversation_point.options.len();
        } else
        // Move to next option.
        if is_key_pressed(events, KeyCode::Down) {
            self.index = (self.index + 1) % conversation_point.options.len();
        } else
        // Confirm selection.
        if is_key_pressed(events, KeyCode::Enter) {
            let id = conversation_point.options[self.index].jump_to.clone();
            self.on_confirm
                .send(DialogueEvent::ShowDialogue { id })
                .unwrap();
            self.index = 0;
        }
    }

    fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };
        let (w, _) = size().unwrap();

        let message = text_wrap(&conversation_point.message, w as usize);
        terminal.display([0, 0], &message);
        let mut y = text_size(&message).y;
        terminal.display([0, y as u16], "-".repeat(w as usize));
        y += 1;

        for (i, option) in conversation_point.options.iter().enumerate() {
            let prefix = if i == self.index { "> " } else { "  " };
            let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
            let height = text_size(&content).y;
            let content = if i == self.index {
                content.on_white().black().bold().italic()
            } else {
                content.on_black().white()
            };
            terminal.display([0, y as u16], content);
            y += height;
        }
    }
}

#[derive(Clone)]
struct ConversationPoint {
    message: String,
    options: Vec<ConversationOption>,
}

impl ConversationPoint {
    fn new(message: impl ToString) -> Self {
        Self {
            message: message.to_string(),
            options: Default::default(),
        }
    }

    fn option(mut self, option: ConversationOption) -> Self {
        self.options.push(option);
        self
    }
}

#[derive(Clone)]
struct ConversationOption {
    text: String,
    jump_to: String,
}

impl ConversationOption {
    fn new(text: impl ToString, jump_to: impl ToString) -> Self {
        Self {
            text: text.to_string(),
            jump_to: jump_to.to_string(),
        }
    }
}

#[derive(Default)]
struct Conversation {
    points: HashMap<String, ConversationPoint>,
}

impl Conversation {
    fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
        self.points.insert(id.to_string(), point);
        self
    }

    fn get(&self, id: &str) -> Option<&ConversationPoint> {
        self.points.get(id)
    }
}

Then somewhere there is a game system, that listens for such events and handle updating widget to point to new conversation point:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai_book_samples::{
    events::Events,
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};

fn main() {
    Game::new(Example::default()).run_blocking();
}

struct Example {
    terminal: Terminal,
    events: Events<DialogueEvent>,
    dialogue: DialogueWidget,
    conversation: Conversation,
}

impl Default for Example {
    fn default() -> Self {
        let conversation = Conversation::default()
            .point(
                "start",
                ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
                    .option(ConversationOption::new("Tavern", "tavern"))
                    .option(ConversationOption::new("Forest", "forest"))
                    .option(ConversationOption::new("Bed", "bed")),
            )
            .point(
                "tavern",
                ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
                    .option(ConversationOption::new("Order a beer", "beer"))
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "beer",
                ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
                    .option(ConversationOption::new("Pay and leave", "pay"))
                    .option(ConversationOption::new("Don't pay and leave", "no-pay")),
            )
            .point(
                "pay",
                ConversationPoint::new("You paid the bartender and left the tavern.")
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "no-pay",
                ConversationPoint::new(
                    "The bartender caught you! You had to run away to the forest!",
                )
                .option(ConversationOption::new("Run", "forest")),
            )
            .point(
                "forest",
                ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
                    .option(ConversationOption::new("Fight wolf with sword", "fight"))
                    .option(ConversationOption::new("Run back to town", "start")),
            )
            .point(
                "fight",
                ConversationPoint::new("You bravely fought the wolf and won!")
                    .option(ConversationOption::new("Return to town", "start")),
            )
            .point(
                "bed",
                ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
                    .option(ConversationOption::new("Wake up", "start")),
            );

        let events = Events::default();
        let on_confirm = events.sender();
        Self {
            terminal: Terminal::default(),
            events,
            dialogue: DialogueWidget::new("start", on_confirm),
            conversation,
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let events = Terminal::events().collect::<Vec<_>>();

        self.dialogue.handle_input(&events, &self.conversation);

        // Receive all pending events sent from UI.
        for event in self.events.receive() {
            match event {
                DialogueEvent::ShowDialogue { id } => {
                    // We got an event from dialogue widget, so we update said
                    // widget to show new conversation point.
                    self.dialogue = DialogueWidget::new(id, self.events.sender());
                }
            }
        }

        self.terminal.begin_draw(true);
        self.dialogue.draw(&mut self.terminal, &self.conversation);
        self.terminal.end_draw();

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

enum DialogueEvent {
    ShowDialogue { id: String },
}

struct DialogueWidget {
    // Current conversation point ID.
    id: String,
    // Currently selected option index.
    index: usize,
    // Sender to emit events when an option is confirmed.
    on_confirm: Sender<DialogueEvent>,
}

impl DialogueWidget {
    fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
        Self {
            id: id.to_string(),
            index: 0,
            on_confirm,
        }
    }

    fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };

        // Move to previous option.
        if is_key_pressed(events, KeyCode::Up) {
            self.index = (self.index + conversation_point.options.len() - 1)
                % conversation_point.options.len();
        } else
        // Move to next option.
        if is_key_pressed(events, KeyCode::Down) {
            self.index = (self.index + 1) % conversation_point.options.len();
        } else
        // Confirm selection.
        if is_key_pressed(events, KeyCode::Enter) {
            let id = conversation_point.options[self.index].jump_to.clone();
            self.on_confirm
                .send(DialogueEvent::ShowDialogue { id })
                .unwrap();
            self.index = 0;
        }
    }

    fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };
        let (w, _) = size().unwrap();

        let message = text_wrap(&conversation_point.message, w as usize);
        terminal.display([0, 0], &message);
        let mut y = text_size(&message).y;
        terminal.display([0, y as u16], "-".repeat(w as usize));
        y += 1;

        for (i, option) in conversation_point.options.iter().enumerate() {
            let prefix = if i == self.index { "> " } else { "  " };
            let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
            let height = text_size(&content).y;
            let content = if i == self.index {
                content.on_white().black().bold().italic()
            } else {
                content.on_black().white()
            };
            terminal.display([0, y as u16], content);
            y += height;
        }
    }
}

#[derive(Clone)]
struct ConversationPoint {
    message: String,
    options: Vec<ConversationOption>,
}

impl ConversationPoint {
    fn new(message: impl ToString) -> Self {
        Self {
            message: message.to_string(),
            options: Default::default(),
        }
    }

    fn option(mut self, option: ConversationOption) -> Self {
        self.options.push(option);
        self
    }
}

#[derive(Clone)]
struct ConversationOption {
    text: String,
    jump_to: String,
}

impl ConversationOption {
    fn new(text: impl ToString, jump_to: impl ToString) -> Self {
        Self {
            text: text.to_string(),
            jump_to: jump_to.to_string(),
        }
    }
}

#[derive(Default)]
struct Conversation {
    points: HashMap<String, ConversationPoint>,
}

impl Conversation {
    fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
        self.points.insert(id.to_string(), point);
        self
    }

    fn get(&self, id: &str) -> Option<&ConversationPoint> {
        self.points.get(id)
    }
}
Finally, let's show the conversation graph we've made, just to show you some scale:
use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai_book_samples::{
    events::Events,
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};

fn main() {
    Game::new(Example::default()).run_blocking();
}

struct Example {
    terminal: Terminal,
    events: Events<DialogueEvent>,
    dialogue: DialogueWidget,
    conversation: Conversation,
}

impl Default for Example {
    fn default() -> Self {
        let conversation = Conversation::default()
            .point(
                "start",
                ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
                    .option(ConversationOption::new("Tavern", "tavern"))
                    .option(ConversationOption::new("Forest", "forest"))
                    .option(ConversationOption::new("Bed", "bed")),
            )
            .point(
                "tavern",
                ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
                    .option(ConversationOption::new("Order a beer", "beer"))
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "beer",
                ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
                    .option(ConversationOption::new("Pay and leave", "pay"))
                    .option(ConversationOption::new("Don't pay and leave", "no-pay")),
            )
            .point(
                "pay",
                ConversationPoint::new("You paid the bartender and left the tavern.")
                    .option(ConversationOption::new("Exit", "start")),
            )
            .point(
                "no-pay",
                ConversationPoint::new(
                    "The bartender caught you! You had to run away to the forest!",
                )
                .option(ConversationOption::new("Run", "forest")),
            )
            .point(
                "forest",
                ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
                    .option(ConversationOption::new("Fight wolf with sword", "fight"))
                    .option(ConversationOption::new("Run back to town", "start")),
            )
            .point(
                "fight",
                ConversationPoint::new("You bravely fought the wolf and won!")
                    .option(ConversationOption::new("Return to town", "start")),
            )
            .point(
                "bed",
                ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
                    .option(ConversationOption::new("Wake up", "start")),
            );

        let events = Events::default();
        let on_confirm = events.sender();
        Self {
            terminal: Terminal::default(),
            events,
            dialogue: DialogueWidget::new("start", on_confirm),
            conversation,
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let events = Terminal::events().collect::<Vec<_>>();

        self.dialogue.handle_input(&events, &self.conversation);

        // Receive all pending events sent from UI.
        for event in self.events.receive() {
            match event {
                DialogueEvent::ShowDialogue { id } => {
                    // We got an event from dialogue widget, so we update said
                    // widget to show new conversation point.
                    self.dialogue = DialogueWidget::new(id, self.events.sender());
                }
            }
        }

        self.terminal.begin_draw(true);
        self.dialogue.draw(&mut self.terminal, &self.conversation);
        self.terminal.end_draw();

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

enum DialogueEvent {
    ShowDialogue { id: String },
}

struct DialogueWidget {
    // Current conversation point ID.
    id: String,
    // Currently selected option index.
    index: usize,
    // Sender to emit events when an option is confirmed.
    on_confirm: Sender<DialogueEvent>,
}

impl DialogueWidget {
    fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
        Self {
            id: id.to_string(),
            index: 0,
            on_confirm,
        }
    }

    fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };

        // Move to previous option.
        if is_key_pressed(events, KeyCode::Up) {
            self.index = (self.index + conversation_point.options.len() - 1)
                % conversation_point.options.len();
        } else
        // Move to next option.
        if is_key_pressed(events, KeyCode::Down) {
            self.index = (self.index + 1) % conversation_point.options.len();
        } else
        // Confirm selection.
        if is_key_pressed(events, KeyCode::Enter) {
            let id = conversation_point.options[self.index].jump_to.clone();
            self.on_confirm
                .send(DialogueEvent::ShowDialogue { id })
                .unwrap();
            self.index = 0;
        }
    }

    fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
        let Some(conversation_point) = conversation.get(&self.id) else {
            return;
        };
        let (w, _) = size().unwrap();

        let message = text_wrap(&conversation_point.message, w as usize);
        terminal.display([0, 0], &message);
        let mut y = text_size(&message).y;
        terminal.display([0, y as u16], "-".repeat(w as usize));
        y += 1;

        for (i, option) in conversation_point.options.iter().enumerate() {
            let prefix = if i == self.index { "> " } else { "  " };
            let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
            let height = text_size(&content).y;
            let content = if i == self.index {
                content.on_white().black().bold().italic()
            } else {
                content.on_black().white()
            };
            terminal.display([0, y as u16], content);
            y += height;
        }
    }
}

#[derive(Clone)]
struct ConversationPoint {
    message: String,
    options: Vec<ConversationOption>,
}

impl ConversationPoint {
    fn new(message: impl ToString) -> Self {
        Self {
            message: message.to_string(),
            options: Default::default(),
        }
    }

    fn option(mut self, option: ConversationOption) -> Self {
        self.options.push(option);
        self
    }
}

#[derive(Clone)]
struct ConversationOption {
    text: String,
    jump_to: String,
}

impl ConversationOption {
    fn new(text: impl ToString, jump_to: impl ToString) -> Self {
        Self {
            text: text.to_string(),
            jump_to: jump_to.to_string(),
        }
    }
}

#[derive(Default)]
struct Conversation {
    points: HashMap<String, ConversationPoint>,
}

impl Conversation {
    fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
        self.points.insert(id.to_string(), point);
        self
    }

    fn get(&self, id: &str) -> Option<&ConversationPoint> {
        self.points.get(id)
    }
}

Observations

This pattern of event-driven asynchronous logic flow is correct and widely used in games, because it allows to properly await for game state changes, and it’s easy to implement.

Clean declaration, messy execution

When we compare declararing conversation graph to how it’s executed, we can see the difference - IMHO the declarative part is the only part that keeps it sane to reason about, as it tells the user in single place, how high level flow in the graph looks like.

But now imagine scenarios where you can’t or don’t express your asynchronous logic that way, instead you just have game systems and objects coupled with events sent between them - suddenly the cognitive load required to follow through the logic becomes challenging, the bigger the scale of game systems and objects interactions, more events sent from unrelated places to objects and systems that will handle all events important to them in a somewhat single place - it gets really messy real quick! It’s really hard to maintain the bigger it gets.

I’m sure you have experienced such event-driven code and maybe even hate the complexity at some point.

Hard to enforce correct and stable flow

At the moment we have very simple flow loop:

… Show dialogue -> Wait for user confirmation …

Let’s say we want to add other dialogue feature such as automatic progression after specified time, blocking input until voice-over is done. In that case we grow in number of states, we need to rework entive dialogue system to incorporate that.

We no longer rely only on conversation points but also on various other states dialogue can be in, effectively turning it into real state machine, making it harder to ensure some received events will only be able to get executed if some properties are in very specific state, like not reacting to show dialogue event if widget is blocked for some time, or to invalidate auto-progressing timer, when user receives user confirmation before.

Similarily, thinking about general event-driven logic, i bet you’ve had experienced or seen cases, where some game systems reacted on some events when they weren’t expecting it to happen, invalidating current state.

For example: your character is in the middle of a cutscene, while game enviroment system spawned a fire next to you, spreading during cutscene, killing the player before cutscene ends - not fun experience, and usually hacked with turning player invincible for duration of cutscene, or generally requiring to carefuly disabling some game systems just to not make it happen.

The coroutine mental model

In this chapter i want to show you what we know from traditional patterns, how it might look like when done using async logic as coroutines.

Do you remember boss fight state machines and the work we needed to do to run them manually to achieve sequential state flow?

Here is how all of that could be expressed as async action timeline:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}

Action timelines (Coroutines)

Action timelines, or coroutines as game developers tends to call them, are used to describe async logic flow in more sequential (to be more precise: procedural) way, reducing suspension and continuation boundary to single place (.await), instead of traditional reactive, scattered and manual state machine management.

It’s worth noting that neihter of those is better than the other - they both express same end goal, but in different ways, and we shouldn’t feel guilty for using one over another.

Coroutines allow constructing such state machines in more procedural way in code, automatically, as suspension and continuation is a first class concept in async, which covers perfectly the declaration and execution part of work that’s spread across time, but not across codebase - a state machine that we don’t need to handle manually, built for us by the compiler from single code block.

Automatic state machines

Imagine a scenario where we start with those 3 states we have made for NPC fight pattern, then we want to add a state where NPC can parry incoming player attack while it’s in a blocking phase.

Async can provide us with primitives that allow us to easily express “while doing this, you can also try do that other thing” without much of an architectural refactoring, as we would might have need to do with manual state machines.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}

Instead of just blocking, the enemy will try to parry the player’s upcoming attack while it’s in blocking phase of a timeline.

To help us with ergonomic expression of this, we use here an async primitive to run both states at the same time, where blocking state is required to complete, but parry is optional and can be terminated when blocking state completes.

So, as you can see, we didn’t had to spend much time on plugging in yet another state, as we did it just in one place, still keeping mental picture of how it fits within general states flow.

Awaiting game signals

Do you also remember traditional event-based logic flow?

Wanna see how easy would it get to express such construct with coroutines?

Here, let’s consider a scenario of quest timeline, where we expect player to go to a couple of places and pick up some stuff:

use crossterm::event::KeyCode;
use moirai::job::{JobHandle, JobLocation};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    events::oneshot::{Receiver, Sender, channel},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

struct Example {
    _terminal: Terminal,
    coroutines: Coroutines,
    _quest: JobHandle<()>,
    go_to_events: Sender<String>,
    pick_up_object: Sender<String>,
    quest_done: Receiver<()>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();
        let (go_to_events_sender, go_to_events_receiver) = channel();
        let (pick_up_object_sender, pick_up_object_receiver) = channel();
        let (quest_done_sender, quest_done_receiver) = channel();

        let quest = coroutines
            .queue()
            .spawn(JobLocation::Local, async move {
                println!("Starting quest...");

                wait_for_event("tavern", &go_to_events_receiver).await;
                println!("Arrived at the tavern.");

                wait_for_event("food", &pick_up_object_receiver).await;
                println!("Picked up food.");

                wait_for_event("castle", &go_to_events_receiver).await;
                println!("Arrived at the castle.");

                wait_for_event("drink", &pick_up_object_receiver).await;
                println!("Picked up drink.");

                quest_done_sender.send(());
                println!("Quest completed!");
            })
            .cancel_on_drop();

        Self {
            _terminal: Terminal::default(),
            coroutines,
            _quest: quest,
            go_to_events: go_to_events_sender,
            pick_up_object: pick_up_object_sender,
            quest_done: quest_done_receiver,
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let events = Terminal::events().collect::<Vec<_>>();

        if is_key_pressed(&events, KeyCode::Char('q')) {
            self.go_to_events.send("tavern".to_string());
            println!("> go to tavern.");
        }

        if is_key_pressed(&events, KeyCode::Char('a')) {
            self.go_to_events.send("castle".to_string());
            println!("> go to castle.");
        }

        if is_key_pressed(&events, KeyCode::Char('w')) {
            self.pick_up_object.send("food".to_string());
            println!("> pick up food.");
        }

        if is_key_pressed(&events, KeyCode::Char('s')) {
            self.pick_up_object.send("drink".to_string());
            println!("> pick up drink.");
        }

        self.coroutines.run_frame(Default::default());

        if self.quest_done.try_recv().is_some() {
            println!("All done! Press ESC to quit.");
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

async fn wait_for_event(id: &str, events: &Receiver<String>) {
    loop {
        if let Some(event) = events.try_recv()
            && event == id
        {
            return;
        }
        next_frame().await;
    }
}

All we had to do was to declare waiting for certain things to happen in game, and so quest suspends until each step is completed. Being able to declare awaiting for certain game system signal as part of a bigger sequential timeline is quite a benefit!

Of course in Big Games ™, we prefer quests systems to be rather asset-driven, but quests are best at showing in general how to make action timelines awaiting on various game systems triggering events.

When coroutines are the wrong tool

While coroutines might seem like a nice way to organize your suspendable state machines in procedural way, they aren’t meant to fit all problems. In fact, they can even worsen your architecture if applied to wrong problems, and then here comes spaghetti in just another flavor!

Let’s make sure we do understand first, that coroutines describe when things happen, while general game systems describe what is always happening.

Now, let’s talk about where they fit and where they don’t, to gain some intuition on deciding when and where to use them.

Where coroutines doesn’t belong

Per-frame simulation logic

Generally things that must happen every frame with immediately applied side effects, such as for example:

  • Movement integration.
  • Physics.
  • Animation blending.
  • Game and camera controls.
  • Rendering and audio.

And so on. Those things belong to proper per-frame systems, where there is no suspension and continuation needed or not even encouraged to model as an async actions timeline.

Don’t even try expressing your entire game loop frame with silly:

#![allow(unused)]
fn main() {
async {
    loop {
        player_movement_system();
        physics_system();
        ai_system();
        render_system();

        next_frame().await;
    }
}
}

As this would be just a confusing and overdone way to do what regular proper frame iteration method on a game state would do.

If your coroutine logic suspends only on frame end, you just added hidden state machine with useless suspension for no good reason.

Immediate computation

Most of the game logically is usually computation and data transformation with optional side effects. In there, there is usually zero need for suspension outside of special cases, and so another silly thing would be to just do things like:

#![allow(unused)]
fn main() {
async fn increment_until_10(mut value: i32) {
    while value < 10 {
        value += 1;
        next_frame().await;
    }
}
}

As we don’t wait for some specific game state being available, we are here incrementing some value and no side effect happen that needs to be suspendable - we could just compute the expected state with no artificial suspension baked in and be fine.

If your function starts and ends at the same time, don’t bother making it a coroutine.

Async doesn’t mean simpler by default

Coroutines hide state, they don’t eliminate it. Poorly designed await chains are just delayed spaghetti, that you wanted to avoid.

Coroutines code at the async function building blocks level may also end up ugly, depending on what you’re doing in them. Take a look at this state function:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}

What makes it ugly is that this.write().unwrap() accessor, which is needed for accessing data that’s essentially smart pointers, in order to make async compilation happy about lifetime of objects and their mutability (more about it in later chapters).

Honestly, this is my personal grudge towards async in Rust, that you will need to use smart pointers, accessed especially like that, because if you operate on shared state instead of moving data in and out of future, you would want to access said data also outside of coroutine in per-frame game systems. So no prospect for win here.

This is not a problem of async itself, but comes from requirements of multithreaded async runtimes, so in the end when you need shared state (instead of sending it around), you need smart pointers, and therefore your code ends up usually uglier.

This doesn’t happen when you use singlethreded runtimes, but that has less coverage on average, so..

The only redeeming value here is that this seeming uglyness is hidden from the user most of the times, as user mostly just calls this function in a timeline, not necessarily needing to dive into its code to figure out the state flow, as states flow is directed at the caller location, not in the function.

Debugging is HARD

I can’t stress this enough, but debugging async functions is much harder than debugging regular functions, as your callstack will get fragmented locations not showing the origin of an async function, but most of the times showing the polling executor site, with bit of an async function where current poll happen.

This makes entire experience much harder than it should be. Even if stepping with a debugger nowadays is somewhat reasonable and works in debuggers, the stack, that should tell you how you got into that place in code, tells you nothing useful at all.

So, before you jump into making some part of game logic a coroutine, take that into consideration, and evaluate if expressing suspendable logic in a tidy manner is more important to you than being able to debug why something went wrong in there, with unsane lack of useful information.

Serializing is Super HARD

Till this day there is not much tools for serializing running coroutines (or any async tasks in that matter).

Some runtimes try to provide some means to add durability of async tasks, at least partially, but usually you are better off with not relying on these mechanisms, and instead making coroutine rely on serializable state it operates on, so game can serialize that state outside of coroutine, and not any state of a coroutine.

For example take enemy fight pattern timeline:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}

There is no state exclusive to this coroutine that needs to be serialized, as it operates purely on game characters that will be serialized by game.

The only thing we would like to serialize here would be a point between which states enemy is in its fight pattern.

Although games usually save game at safe checkpoints, outside of battles, because that’s another category of problems in general, whether we do sync or async game logic.

Where coroutines do belong

Coroutines have small, but rather useful set of scenarios to be used in, replacing manual state machines with automatic ones there.

They are great when:

  • Logic spans across multiple frames or time.
  • You wait for resources being available or external events to happen.
  • The flow is naturally linear.

Which makes sense for long lived tasks, such as:

  • Cutscenes

    Triggering some related game state changes, awaiting for when that state change completes to move to next step.

  • Tutorials

    Orchestrating in order tooltips to show on screen, awaiting user inputs.

  • Sometimes AI action patterns

    Where player should be able to learn them in order to anticipate NPC’s next move for advantage. It gets less useful the more AI is asset-driven, then coroutines might end up actually worsening the readability, but YMMV.

  • Sometimes Quests

    Where player is expected to do concrete actions, and/or specific game events should happen. But just like with AI action patterns, this also makes coroutines less useful the more asset-driven quests system is.

  • Scripted behaviors

    This is something between cutscenes and quests, where we might have some NPC AI paused for short duration, doing a very custom behavior, which wouldn’t be easily produced with pure AI state machine.

    From my experience i can say, that the more complex AI could get, the more problematic it gets to bake scripted behavior into the AI system and so we might wanna prefer to disable NPC’s AI entirely and Do The Thing ™, then get back to AI handling its usual self.

  • Spreading compute-heavy logic across frames

    This applies generally to game initialization or non-blocking loading, as we would rather wanna show loading screen not jittering every second, or bluntly freezing game window until completes.

    Let’s say we do some procedural scene generation for your roguelike, which usually involves quite intense computation, the bigger the world it generates is, for example using Wave Function Collapse algorithm - we don’t want to stall any game frame for more than couple of milliseconds top, so we might wanna identify useful points in PCG logic, where suspending until next game frame will come beneficial to smoothen user experience. Let’s make it clear: this is not a Skill Issue problem, it’s generally a hard problem to make ergonomic scripted actions subsystem of AI system, where there is as many solutions as there is number of systems.

And scenarios similar to above, that i didn’t mentioned, because list could go on and show very little difference between next positions.


If i want you to take anything from this chapter, it should definitely be:

Coroutines don’t replace systems - they only tidy gluecode between their parts.

Core skills

In this section we will build up your core skill set for effective use of coroutines.

While we started with showing the difference between:

  1. Traditional asynchronous game flow driven by various game systems intertwined by game state changes or event communication all over the codebase
  2. More procedural take on suspension and continuation in a single async scope.

In next chapters we will:

  • Explain awaitables and action timelines.
  • Walk through making a simple JRPG game, where we will build coroutines for various tasks.
  • Showcase useful patterns for different scenarios to build better coroutines.

Awaitables and timeline thinking

What is awaitable

Awaitable is a point, where logic intentionally stops until X happen. It’s a contract with the engine that tells: “wake me up when requirement for progression gets fulfilled”.

Take a look at this coroutine:

#![allow(unused)]
fn main() {
async {
    wait_for_input_action("interact").await;
    wait_for_seconds(0.1).await;
    wait_for_animation_to_finish("punch").await;
}
}

Every .await in there is that point, which suspends coroutine until future we await is complete.

It’s important to remember: awaiting doesn’t mean sleeping - it means yielding control.

When we await at wait_for_seconds(), we are yielding control to other coroutines running in the background, until timer hits the elapsed time we requested, and only then coroutine moves forward to the next awaiting point.

We are suspending coroutine, no code is executed at all until coroutine gets told that whatever we wait for is done and available, and only then execution does continue further - nothing happen until then, other coroutines can do their work at the same time until they hit await point.

This distinction is important, as async might at first feel like a magic, and it might bring up wrong intuition for gamedevs that are used to just spawn task in a thread and sleep() in it to wait for some time, or in case of something like wait_for_input_action(), that it just blocking-loop constantly asking if input action did triggered - that’s wrong way to look at it, it just pauses and lets game do other work while it waits.

What are action timelines

Action timeline is a concept, where you lay out a sequence of state changes spread in time.

In traditional way, you can see action timeline defined within our NPC fight pattern as:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;

#[derive(Default)]
struct Example {
    terminal: Terminal,
    player: Character,
    enemy: Npc,
    log: Vec<String>,
}

impl GameState for Example {
    fn frame(&mut self, delta_time: Duration) -> GameStateChange {
        // Collect application input events that happened since last frame.
        let events = Terminal::events().collect::<Vec<_>>();

        // Run game systems
        if self.player.is_alive() && self.enemy.character.is_alive() {
            update_player_system(
                &events,
                &mut self.player,
                &mut self.enemy.character,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
            update_enemy_system(
                &mut self.enemy,
                &mut self.player,
                &mut self.log,
                delta_time.as_secs_f32(),
            );
        }
        draw_system(
            &mut self.terminal,
            &self.player,
            &self.enemy.character,
            &self.log,
        );

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    health: f32,
    stamina: f32,
    blocking_cooldown: f32,
}

impl Default for Character {
    fn default() -> Self {
        Self {
            health: 100.0,
            stamina: 0.0,
            blocking_cooldown: 0.0,
        }
    }
}

impl Character {
    fn is_alive(&self) -> bool {
        self.health > 0.0
    }

    fn is_blocking(&self) -> bool {
        self.blocking_cooldown > f32::EPSILON
    }

    fn update(&mut self, delta_time: f32) {
        if self.is_blocking() {
            self.blocking_cooldown = (self.blocking_cooldown - delta_time * TIME_SCALE).max(0.0);
        } else {
            self.stamina = (self.stamina + delta_time * TIME_SCALE).min(25.0);
        }
    }

    fn deal_damage(&mut self, target: &mut Character) -> bool {
        if !target.is_blocking() && self.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
            target.health = (target.health - self.stamina).max(0.0);
            self.stamina = 0.0;
            true
        } else {
            false
        }
    }

    fn block(&mut self) {
        self.blocking_cooldown = self.stamina;
        self.stamina = 0.0;
    }
}

#[derive(Default)]
struct Npc {
    character: Character,
    fight_phase_index: usize,
}

enum NpcFightState {
    Charge { target_stamina: f32 },
    Attack,
    Block,
}

const NPC_FIGHT_PATTERN: &[NpcFightState] = &[
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Attack,
    NpcFightState::Charge {
        target_stamina: 10.0,
    },
    NpcFightState::Block,
    NpcFightState::Charge {
        target_stamina: 20.0,
    },
    NpcFightState::Attack,
];

fn update_player_system(
    events: &[Event],
    player: &mut Character,
    enemy: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update player character state, such as stamina regeneration and blocking cooldown.
    player.update(delta_time);

    // Perform actions based on inputs.
    if is_key_pressed(events, KeyCode::Enter) {
        if player.deal_damage(enemy) {
            log.push("Player attacking!".to_string());
        }
    } else if is_key_pressed(events, KeyCode::Char(' ')) {
        player.block();
        log.push("Player blocking!".to_string());
    }
}

fn update_enemy_system(
    enemy: &mut Npc,
    player: &mut Character,
    log: &mut Vec<String>,
    delta_time: f32,
) {
    // Update NPC character state, such as stamina regeneration and blocking cooldown.
    enemy.character.update(delta_time);

    // Get current NPC state or reset if out of bounds, to loop through pattern.
    let Some(state) = NPC_FIGHT_PATTERN.get(enemy.fight_phase_index) else {
        enemy.fight_phase_index = 0;
        return;
    };

    // Perform an action of currently active state.
    match state {
        // Stay idle while recharching stamina.
        NpcFightState::Charge { target_stamina } => {
            if enemy.character.stamina >= *target_stamina {
                enemy.fight_phase_index += 1;
            }
        }
        // Attack the player with accumulated stamina.
        NpcFightState::Attack => {
            if enemy.character.deal_damage(player) {
                log.push("Enemy attacking!".to_string());
            }
            enemy.fight_phase_index += 1;
        }
        // Block the player's incoming attack for time of accumulated stamina.
        NpcFightState::Block => {
            enemy.character.block();
            log.push("Enemy blocking!".to_string());
            enemy.fight_phase_index += 1;
        }
    }
}

fn draw_system(terminal: &mut Terminal, player: &Character, enemy: &Character, log: &[String]) {
    terminal.begin_draw(true);

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));
            terminal.display([2, 4], format!("Cooldown: {:.1}", player.blocking_cooldown));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));
            terminal.display([2, 9], format!("Cooldown: {:.1}", enemy.blocking_cooldown));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

In traditional way this is how we tell the manually driven state machine what is expected chain of actions, and state machine takes care of switching to the next action, when current action completes. In there we declare it in single place, so it could help us keep mental picture of the logic flow.

You can also think of action timelines as scripting a sequence of steps, where all steps doesn’t happen now, but each does sometime in the future, if that helps you.

And similarily to traditional way, we can declare such timeline of actions as a coroutine, in form of a procedurally looking code, denoting with await points where the transition happen, explicitly telling we are awaiting for each step completion.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}

With difference that we don’t need to care about any state machine running the states and transitions, as it’s built for us by the compiler - we get a coroutine with action timeline, send it for execution and do something useful with its result when it’s done.

Key points on more complex example

To summarize, looking at this code, slightly more complex than what we have shown so far:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}
  1. At every .await point, this coroutine suspends - it does not block current thread, does not sleep. It just suspends execution entirely, until requested resource is available (like new batch of events, that might be available immediatelly or soon).
  2. Each branch of if statement represents a selection of action sub-timeline, meaning whether we choose to do attack(), block() or update() state, we will suspend entire coroutine, loop itself, until selected state completes, then after resuming we might then wait for next game frame to occur and repeat.
  3. Every loop iteration here represents a game frame or more (if we are currently executing any of above states; explained why later).

I get that this specific coroutine might look weird, as we see a loop waiting for next frame at the end. It might give us thinking that single iteration is single game frame, but then we suddenly await inside that iteration? This is confusing!

It might not be intuitive at first, i really get you. But bare with me for a second.

What if that next_frame() await in loop is actually the single point of confusion? Here, look at this:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
};
use moirai::{
    coroutine::{CompletionImportance, meta, with_importance},
    job::{JobHandle, JobLocation},
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    coroutines::{Coroutines, next_frame},
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::is_key_pressed,
};
use std::time::Duration;

fn main() {
    Game::new(Example::default()).run_blocking();
}

const TIME_SCALE: f32 = 5.0;
const DAMAGE_STAMINA_MIN_REQUIREMENT: f32 = 5.0;
const LOG_CAPACITY: usize = 5;
const EVENTS_META: &str = "~events~";
const DELTA_TIME_META: &str = "~delta-time~";
const LOG_META: &str = "~log~";

struct Example {
    terminal: Terminal,
    coroutines: Coroutines,
    player: AsyncShared<Character>,
    enemy: AsyncShared<Character>,
    log: Vec<String>,
}

impl Default for Example {
    fn default() -> Self {
        let coroutines = Coroutines::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        player.write().unwrap().timeline(&coroutines, async move {
            loop {
                let events = events().await;

                if is_key_pressed(&events, KeyCode::Enter) {
                    attack(&player_shared, &enemy_shared).await;
                } else if is_key_pressed(&events, KeyCode::Char(' ')) {
                    block(player_shared.clone()).await;
                } else {
                    update(&player_shared).await;
                }

                next_frame().await;
            }
        });

        let player_shared = player.clone();
        let enemy_shared = enemy.clone();
        enemy.write().unwrap().timeline(&coroutines, async move {
            loop {
                charge(&enemy_shared, 10.0).await;
                attack(&enemy_shared, &player_shared).await;
                charge(&enemy_shared, 10.0).await;
                block(enemy_shared.clone()).await;
                charge(&enemy_shared, 20.0).await;
                attack(&enemy_shared, &player_shared).await;
            }
        });

        Self {
            terminal: Terminal::default(),
            coroutines,
            player,
            enemy,
            log: Vec::new(),
        }
    }
}

impl GameState for Example {
    fn frame(&mut self, mut delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        draw_system(&mut self.terminal, &self.player, &self.enemy, &self.log);

        {
            let (events_lazy, _events_lifemtime) = DynamicManagedLazy::make(&mut events);
            let (dt_lazy, _dt_lifemtime) = DynamicManagedLazy::make(&mut delta_time);
            let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);
            self.coroutines.run_frame(
                [
                    (EVENTS_META.into(), events_lazy.into()),
                    (DELTA_TIME_META.into(), dt_lazy.into()),
                    (LOG_META.into(), log_lazy.into()),
                ]
                .into_iter()
                .collect(),
            );
        }

        {
            let player = self.player.write().unwrap();
            if !player.is_alive() {
                player.timeline.cancel();
            }
            let enemy = self.enemy.write().unwrap();
            if !enemy.is_alive() {
                enemy.timeline.cancel();
            }
        }

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

struct Character {
    name: String,
    health: f32,
    stamina: f32,
    timeline: JobHandle<()>,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: 100.0,
            stamina: 0.0,
            timeline: Default::default(),
        }
    }

    fn timeline(
        &mut self,
        coroutines: &Coroutines,
        timeline: impl Future<Output = ()> + Send + Sync + 'static,
    ) {
        self.timeline = coroutines
            .queue()
            .spawn(JobLocation::Local, timeline)
            .cancel_on_drop();
    }

    fn is_alive(&self) -> bool {
        self.health > 0.0
    }
}

async fn update(this: &AsyncShared<Character>) {
    let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
    let mut this = this.write().unwrap();
    this.stamina = (this.stamina + dt).min(100.0);
}

async fn attack(this: &AsyncShared<Character>, target: &AsyncShared<Character>) {
    log(format!(
        "{} attacks {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    if this.stamina >= DAMAGE_STAMINA_MIN_REQUIREMENT {
        target.health = (target.health - this.stamina).max(0.0);
        this.stamina = 0.0;
    }
}

async fn charge(this: &AsyncShared<Character>, target_stamina: f32) {
    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.stamina = (this.stamina + dt).min(100.0);
            if this.stamina >= target_stamina {
                break;
            }
        }

        next_frame().await;
    }
}

async fn block(this: AsyncShared<Character>) {
    log(format!(
        "{} blocks with {:.02} cooldown",
        this.read().unwrap().name,
        this.read().unwrap().stamina
    ))
    .await;

    let health = this.read().unwrap().health;

    loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        {
            let mut this = this.write().unwrap();
            this.health = health;
            this.stamina = (this.stamina - dt).min(100.0);
            if this.stamina <= 0.0 {
                break;
            }
        }

        next_frame().await;
    }

    log(format!("{} stops blocking", this.read().unwrap().name)).await;
}

async fn parry(this: AsyncShared<Character>, target: AsyncShared<Character>, mut cooldown: f32) {
    let origin_health = this.read().unwrap().health;
    let damage = loop {
        let dt = delta_time().await.as_secs_f32() * TIME_SCALE;
        cooldown -= dt;
        if cooldown <= 0.0 {
            return;
        }

        let current_health = this.read().unwrap().health;
        if current_health < origin_health {
            break origin_health - current_health;
        }

        next_frame().await;
    };

    log(format!(
        "{} parries {} with {:.02} hitpoints",
        this.read().unwrap().name,
        target.read().unwrap().name,
        damage
    ))
    .await;

    let mut this = this.write().unwrap();
    let mut target = target.write().unwrap();

    target.health = (target.health - damage).max(0.0);
    this.stamina = 0.0;
    this.health = (this.health + damage).min(100.0);
}

fn draw_system(
    terminal: &mut Terminal,
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
    log: &[String],
) {
    terminal.begin_draw(true);

    let player = player.read().unwrap();
    let enemy = enemy.read().unwrap();

    match (player.is_alive(), enemy.is_alive()) {
        (true, true) => {
            terminal.display([1, 1], "Player:".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
            terminal.display([2, 3], format!("Stamina: {:.1}", player.stamina));

            terminal.display([1, 6], "Enemy:".red().bold());
            terminal.display([2, 7], format!("Health: {:.1}", enemy.health));
            terminal.display([2, 8], format!("Stamina: {:.1}", enemy.stamina));

            terminal.display([20, 1], "Log:".underlined());
            for (i, log) in log.iter().rev().take(LOG_CAPACITY).enumerate() {
                terminal.display([21, 2 + i as u16], log);
            }
        }
        (false, false) => {
            terminal.display([1, 1], "It's a draw!".yellow().bold());
        }
        (true, false) => {
            terminal.display([1, 1], "You are victorious!".green().bold());
            terminal.display([2, 2], format!("Health: {:.1}", player.health));
        }
        (false, true) => {
            terminal.display([1, 1], "You have been defeated!".red().bold());
            terminal.display([2, 2], format!("Health: {:.1}", enemy.health));
        }
    }

    terminal.end_draw();
}

async fn delta_time() -> Duration {
    *meta::<Duration>(DELTA_TIME_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());
}

// ===

#[allow(dead_code)]
async fn example_with_parry(
    enemy_shared: AsyncShared<Character>,
    player_shared: AsyncShared<Character>,
) {
    loop {
        charge(&enemy_shared, 10.0).await;
        attack(&enemy_shared, &player_shared).await;
        charge(&enemy_shared, 10.0).await;

        let parry = parry(enemy_shared.clone(), player_shared.clone(), 10.0);
        let block = block(enemy_shared.clone());

        with_importance(vec![
            CompletionImportance::ignored(parry),
            CompletionImportance::required(block),
        ])
        .await;

        charge(&enemy_shared, 20.0).await;
        attack(&enemy_shared, &player_shared).await;
    }
}

#[allow(dead_code)]
async fn example_with_waiting_for_input(
    player: &AsyncShared<Character>,
    enemy: &AsyncShared<Character>,
) {
    loop {
        let events = events().await;

        if is_key_pressed(&events, KeyCode::Enter) {
            attack(player, enemy).await;
        } else if is_key_pressed(&events, KeyCode::Char(' ')) {
            block(player.clone()).await;
        } else {
            update(player).await;
        }
    }
}

If we remove it, then this loop reads:

Acquire events -> Decide on which state user wants to be next -> Do selected state -> Repeat

So why we have put that next_frame() in the first place?

Well, because what if all async functions called in there would complete immediatelly? It can happen, if given function depends only on resource availability, and it happen that resource is available upfront.

In that case we don’t want to loop for ever, blocking entire game until some of async functions won’t complete immediatelly, so we just expect given iteration to wait for next frame for good measure.

Turn-based JRPG walkthrough

In this section we will build a small turn-based JRPG with player and enemies, slashing eachother with swords, casting spells, giving multi-turn status effects.

This is not gonna be a full game with lots of game systems - it’s gonna be specifically simplified to the point of showing only how to marry traditional sync game systems with async coroutines where it makes sense, without much focus on perfect game mechanics and superb game design - using Moirai library.

None of this is hard to do alone in traditional state machine pattern, but they become hard when they overlap and communicate - we will try to show how coroutines makes them easier to deal with and especially easier to reason about!

Basic battle flow

In this chapter we are gonna show an overview of a text-based turn-based battle flow, as coroutine.

The flow is rather a simple loop conceptually:

  • New battle turn
    • Check win conditions of either player or enemy and end battle if either wins.
    • Present information abut player and enemy state to the user.
    • Do player turn.
    • Do enemy turn.
use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

Quick note: AsyncShared<T> is equivalent with Arc<RwLock<T>> in there to remove a bit of boilerplate. When using async runtimes to run async functions, those that are multithreaded, like Moirai or Tokio, they require data used in there to be Send and sometimes also Sync to make multithreading work. Of course runtimes can have primitives to run thread-local futures, in that case you can use references to data, but general use is rather multithreaded runtimes.

As you can see in the example, we have easily been able to express entire game loop as simple long-living coroutine.

Now, let’s also take a look at one of the other coroutines we are calling in there, a player turn for example (as it will come handy for next chapter):

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

You might be thinking right now, how that compares to traditional game state machine where each step of this is a separate state that handles its suspension and progression to other steps - you might remind yourself how boilerplate-y expressing stuff like that is. And yet with async we have got same behavior, without constructing such state machine ourselves. And we have got it automated, just by having each step as an awaiting future.

I would call it a quite big win, to be honest!

Status effects

In previous chapter we have shown a battle loop and player turn - in this chapter we will take a look at one future we call in there: casting spells.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

If we have enough mana for given spell cost, we spawn new job that will run in the background.

Spawning jobs enables concurrency - an ability to run multiple jobs at the same time.

If you come from Unity (coroutines) or JavaScript (timers/async-await), or you just used threads before to achieve background work, you might have been using similar pattern already!

Spawned fire spell itself does nothing at the time of casting it (except deducing mana), but the spawned fire effect job will do its work each turn.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

Quite simpler to understand than explicit state machines signalled with events, don’t you think? Maintanance and expanding of such logic is also quite easy to perform, as there isn’t much of moving parts scattered around that need careful focus to connect the dots.

Prompts

Prompts are kinds of coroutines where we have to await on specific user input.

Let’s remind ourselves the player turn:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

There are two things interesting to us:

  • wait_for_key()
  • selected_action()

Waiting for key requires us to check for events if there is a specific key pressed event.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

While awaiting for user selected action is bit more complex, as it interacts with the user input and UI widget to fetch data from the UI options list.

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

And this is how our synchronous side manages user-selected action provided to running coroutines:

use crossterm::{
    event::{Event, KeyCode},
    style::Stylize,
    terminal::size,
};
use moirai::{
    coroutine::{meta, spawn, wait_time, yield_now},
    job::JobLocation,
    jobs::Jobs,
    third_party::intuicio_data::{managed::DynamicManagedLazy, shared::AsyncShared},
};
use moirai_book_samples::{
    game::{Game, GameState, GameStateChange},
    terminal::Terminal,
    utils::{is_key_pressed, text_size, text_wrap},
};
use std::time::Duration;

fn main() {
    Game::new(Jrpg::default()).run_blocking();
}

const EVENTS_META: &str = "~events~";
const TURN_INDEX_META: &str = "~turn-index~";
const SELECTED_ACTION_META: &str = "~selected-action~";
const UI_SCREEN: &str = "~ui-screen~";
const LOG_META: &str = "~log~";
const STEP_DELAY: Duration = Duration::from_secs(1);
const HEALTH_CAPACITY: usize = 100;
const MANA_CAPACITY: usize = 100;

struct Jrpg {
    terminal: Terminal,
    jobs: Jobs,
    turn_index: usize,
    ui_screen: Box<dyn UiScreen>,
    selected_action: Option<Action>,
    log: Vec<String>,
}

impl Default for Jrpg {
    fn default() -> Self {
        let jobs = Jobs::default();

        let player = AsyncShared::new(Character::new("Player"));
        let enemy = AsyncShared::new(Character::new("Enemy"));

        jobs.queue()
            .spawn(JobLocation::Local, battle(player, enemy));

        Self {
            terminal: Terminal::default(),
            jobs,
            turn_index: 0,
            ui_screen: Box::new(LogsScreen),
            selected_action: None,
            log: Default::default(),
        }
    }
}

impl GameState for Jrpg {
    fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
        let mut events = Terminal::events().collect::<Vec<_>>();

        self.ui_screen.update(&events, &mut self.selected_action);

        self.render_system();

        self.coroutines_system(&mut events);

        if is_key_pressed(&events, KeyCode::Esc) {
            GameStateChange::Quit
        } else {
            GameStateChange::None
        }
    }
}

impl Jrpg {
    fn render_system(&mut self) {
        self.terminal.begin_draw(true);

        self.ui_screen.render(&mut self.terminal, &self.log);

        self.terminal.end_draw();
    }

    fn coroutines_system(&mut self, events: &mut Vec<Event>) {
        let (events_lazy, _events_lifetime) = DynamicManagedLazy::make(events);
        let (turn_lazy, _turn_lifetime) = DynamicManagedLazy::make(&mut self.turn_index);
        let (action_lazy, _action_lifetime) = DynamicManagedLazy::make(&mut self.selected_action);
        let (ui_screen_lazy, _ui_screen_lifetime) = DynamicManagedLazy::make(&mut self.ui_screen);
        let (log_lazy, _log_lifetime) = DynamicManagedLazy::make(&mut self.log);

        self.jobs.run_local_with_meta(
            [
                (EVENTS_META.into(), events_lazy.into()),
                (TURN_INDEX_META.into(), turn_lazy.into()),
                (SELECTED_ACTION_META.into(), action_lazy.into()),
                (UI_SCREEN.into(), ui_screen_lazy.into()),
                (LOG_META.into(), log_lazy.into()),
            ]
            .into_iter()
            .collect(),
        );
    }
}

async fn battle(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("Battle starts!").await;

    // Battle turns loop.
    loop {
        if win_conditions(player.clone(), enemy.clone()).await {
            break;
        }

        present_information(player.clone(), enemy.clone()).await;

        player_turn(player.clone(), enemy.clone()).await;

        enemy_turn(enemy.clone(), player.clone()).await;

        go_to_next_turn().await;
    }

    log("Hit ESC to exit the game!").await;
}

async fn win_conditions(player: AsyncShared<Character>, enemy: AsyncShared<Character>) -> bool {
    if enemy.read().unwrap().health == 0 {
        log("Enemy has been defeated! You win!").await;
        true
    } else if player.read().unwrap().health == 0 {
        log("Player has been defeated! Game over.").await;
        true
    } else {
        false
    }
}

async fn present_information(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log(format!(
        "* New turn #{}\n\
        - Player health: {}\n\
        - Player mana: {}\n\
        - Enemy health: {}",
        turn_index().await + 1,
        player.read().unwrap().health,
        player.read().unwrap().mana,
        enemy.read().unwrap().health,
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn player_turn(player: AsyncShared<Character>, enemy: AsyncShared<Character>) {
    log("* Player turn.\nHit ENTER to take an action!").await;

    wait_for_key(KeyCode::Enter).await;

    loop {
        match selected_action().await {
            Action::SkipTurn => {
                wait_time(STEP_DELAY).await;
                return;
            }
            Action::Forfeit => {
                player.write().unwrap().health = 0;
                yield_now().await;
                return;
            }
            Action::SwordSwing => {
                sword_swing(player.clone(), enemy.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(player.clone(), enemy.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(player.clone()).await {
                    return;
                }
            }
        }

        yield_now().await;
    }
}

async fn enemy_turn(enemy: AsyncShared<Character>, player: AsyncShared<Character>) {
    log("* Enemy turn").await;

    wait_time(STEP_DELAY).await;

    let action_probabilities = [
        (4, Action::SwordSwing),
        (2, Action::PoisonSpell),
        (1, Action::FireSpell),
        (
            if enemy.read().unwrap().health < 50 {
                4
            } else {
                1
            },
            Action::CureSpell,
        ),
    ];

    loop {
        match select_random_action(&action_probabilities) {
            Action::SwordSwing => {
                sword_swing(enemy.clone(), player.clone()).await;
                return;
            }
            Action::PoisonSpell => {
                if poison_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::FireSpell => {
                if fire_spell(enemy.clone(), player.clone()).await {
                    return;
                }
            }
            Action::CureSpell => {
                if cure_spell(enemy.clone()).await {
                    return;
                }
            }
            _ => {
                wait_time(STEP_DELAY).await;
                return;
            }
        }

        yield_now().await;
    }
}

fn select_random_action(action_probabilities: &[(usize, Action)]) -> Action {
    let total_weight = action_probabilities
        .iter()
        .map(|(weight, _)| *weight)
        .sum::<usize>();
    let mut choice = rand::random_range(0..total_weight) % total_weight;

    for (weight, action) in action_probabilities.iter().copied() {
        if choice < weight {
            return action;
        } else {
            choice -= weight;
        }
    }

    Action::SkipTurn
}

async fn sword_swing(this: AsyncShared<Character>, target: AsyncShared<Character>) {
    {
        let mut target = target.write().unwrap();
        target.health = target.health.saturating_sub(10).max(0);
    }

    log(format!(
        "{} swings sword at {}",
        this.read().unwrap().name,
        target.read().unwrap().name
    ))
    .await;

    wait_time(STEP_DELAY).await;
}

async fn poison_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(30) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        spawn(JobLocation::Local, poison_spell_effect(target.clone())).await;

        log(format!(
            "{} casts poison spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn poison_spell_effect(target: AsyncShared<Character>) {
    for index in 0..6 {
        {
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(5).max(0);
        }

        log(format!(
            "Poison status damage dealt to {} ({}/6)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        next_turn().await;
    }
}

async fn fire_spell(this: AsyncShared<Character>, target: AsyncShared<Character>) -> bool {
    // Attempt to cast the spell by checking and deducting mana.
    let cast = {
        let mut this = this.write().unwrap();
        if let Some(mana) = this.mana.checked_sub(40) {
            this.mana = mana;
            true
        } else {
            false
        }
    };

    if cast {
        // Spawn the fire spell effect as a concurrent job to be ran in the
        // background, alongside of battle loop job, and await until it completes.
        spawn(JobLocation::Local, fire_spell_effect(target.clone())).await;

        log(format!(
            "{} casts fire spell at {}",
            this.read().unwrap().name,
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

async fn fire_spell_effect(target: AsyncShared<Character>) {
    // Run this effect for 2 battle turns.
    for index in 0..2 {
        {
            // Deal high damage to target.
            let mut target = target.write().unwrap();
            target.health = target.health.saturating_sub(20).max(0);
        }

        log(format!(
            "Fire status damage dealt to {} ({}/2)",
            target.read().unwrap().name,
            index + 1,
        ))
        .await;

        // Wait until next turn to apply next tick of damage.
        next_turn().await;
    }
}

async fn cure_spell(target: AsyncShared<Character>) -> bool {
    let cast = {
        let mut target = target.write().unwrap();
        if let Some(mana) = target.mana.checked_sub(25) {
            target.mana = mana;
            target.health = (target.health + 25).min(HEALTH_CAPACITY);
            true
        } else {
            false
        }
    };

    if cast {
        log(format!(
            "{} casts cure spell on itself",
            target.read().unwrap().name
        ))
        .await;

        wait_time(STEP_DELAY).await;
    }
    cast
}

const ACTIONS: [Action; 6] = [
    Action::SkipTurn,
    Action::Forfeit,
    Action::SwordSwing,
    Action::PoisonSpell,
    Action::FireSpell,
    Action::CureSpell,
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action {
    SkipTurn,
    Forfeit,
    SwordSwing,
    PoisonSpell,
    FireSpell,
    CureSpell,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let text = match self {
            Action::SkipTurn => "Skip Turn",
            Action::Forfeit => "Forfeit",
            Action::SwordSwing => "Sword Swing",
            Action::PoisonSpell => "Poison Spell",
            Action::FireSpell => "Fire Spell",
            Action::CureSpell => "Cure Spell",
        };
        write!(f, "{}", text)
    }
}

trait UiScreen {
    #[allow(unused_variables)]
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {}

    #[allow(unused_variables)]
    fn render(&self, terminal: &mut Terminal, log: &[String]) {}
}

struct LogsScreen;

impl UiScreen for LogsScreen {
    fn render(&self, terminal: &mut Terminal, log: &[String]) {
        let (w, h) = size().unwrap();
        let mut y = 0;

        for (index, text) in log.iter().rev().enumerate() {
            let text = text_wrap(text, w as usize);
            let size = text_size(&text);
            if y + size.y > h as usize {
                break;
            }

            let text = if index == 0 {
                text.white().bold()
            } else {
                text.white().italic()
            };
            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

#[derive(Default)]
struct TakeActionScreen {
    selected_index: usize,
}

impl UiScreen for TakeActionScreen {
    fn update(&mut self, events: &[Event], selected_action: &mut Option<Action>) {
        if is_key_pressed(events, KeyCode::Up) {
            self.selected_index = (self.selected_index + ACTIONS.len() - 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Down) {
            self.selected_index = (self.selected_index + 1) % ACTIONS.len();
        } else if is_key_pressed(events, KeyCode::Enter) {
            *selected_action = Some(ACTIONS[self.selected_index]);
        }
    }

    fn render(&self, terminal: &mut Terminal, _log: &[String]) {
        let (w, _) = size().unwrap();
        let mut y = 0;

        let text = text_wrap("Your turn, take an action:", w as usize);
        let size = text_size(&text);

        terminal.display([0, y as _], text.green());

        y += size.y + 1;

        for (index, action) in ACTIONS.iter().enumerate() {
            let text = if index == self.selected_index {
                format!("> {}", action)
            } else {
                format!("  {}", action)
            };

            let text = text_wrap(&text, w as usize);
            let size = text_size(&text);

            let text = if index == self.selected_index {
                text.black().on_white().bold()
            } else {
                text.white().on_dark_grey().italic()
            };

            terminal.display([0, y as _], text);
            y += size.y + 1;
        }
    }
}

struct Character {
    name: String,
    health: usize,
    mana: usize,
}

impl Character {
    fn new(name: impl ToString) -> Self {
        Self {
            name: name.to_string(),
            health: HEALTH_CAPACITY,
            mana: MANA_CAPACITY,
        }
    }
}

async fn go_to_next_turn() {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .write()
        .unwrap() += 1;

    yield_now().await;
}

async fn turn_index() -> usize {
    *meta::<usize>(TURN_INDEX_META)
        .await
        .unwrap()
        .read()
        .unwrap()
}

async fn next_turn() {
    let index = turn_index().await;
    while turn_index().await <= index {
        yield_now().await;
    }
}

async fn events() -> Vec<Event> {
    meta::<Vec<Event>>(EVENTS_META)
        .await
        .unwrap()
        .read()
        .unwrap()
        .clone()
}

async fn wait_for_key(key: KeyCode) {
    loop {
        let events = events().await;
        if is_key_pressed(&events, key) {
            return;
        }

        // Yield to allow other coroutines to run (we suspend this coroutine).
        // Without suspending, this coroutine will block entirely and prevent
        // other coroutines from running in local thread (and all our coroutines
        // are running in local thread, so deadlock guaranteed).
        yield_now().await;
    }
}

async fn selected_action() -> Action {
    // Change UI to take action screen.
    // We hold current UI screen reference available in meta storage to be able
    // to access it at any time during coroutine work.
    *meta::<Box<dyn UiScreen>>(UI_SCREEN)
        .await
        .unwrap()
        .write()
        .unwrap() = Box::new(TakeActionScreen::default());

    loop {
        // Check if an action has been selected from the list.
        // Currently selected action by the user is stored in meta storage.
        let action = meta::<Option<Action>>(SELECTED_ACTION_META)
            .await
            .unwrap()
            .write()
            .unwrap()
            .take();

        if let Some(action) = action {
            // Change UI back to logs screen.
            *meta::<Box<dyn UiScreen>>(UI_SCREEN)
                .await
                .unwrap()
                .write()
                .unwrap() = Box::new(LogsScreen);

            // Return the selected action.
            return action;
        }

        yield_now().await;
    }
}

async fn log(content: impl ToString) {
    meta::<Vec<String>>(LOG_META)
        .await
        .unwrap()
        .write()
        .unwrap()
        .push(content.to_string());

    yield_now().await;
}

This method of a TakeActionScreen is then called every game frame.

Effectively, we have showed shared state communication between traditional game logic and running coroutines.

Helpful coroutine primitives

TODO

Coroutine orchestration patterns

TODO

Real-world usage

TODO

Engine integration

TODO

General game loop model

TODO

Advanced topics

TODO

Async under-the-hood from gamedev POV

TODO

Debugging and timeline tracing

TODO

Common bugs and anti-patterns

TODO

Architecture without hell

TODO

Custom runtimes

TODO