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.