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

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.