o
odinpkg.dev
packages / app / coroutines-games-odin

coroutines-games-odin

8598e22app

Coroutines for Odin

MIT · updated 4 weeks ago

Coroutines for Games in Odin

A lightweight, hierarchical, stackless, cooperative task scheduler and coroutine library for the Odin programming language.

Inspired by the structured concurrency paradigm of SkookumScript. It allows you to write asynchronous code in a linear way to manage time-dependent gameplay logic, scripted sequences, AI behavior, and animations. It is highly portable and ticks entirely on the main game thread, without any OS context switching.

This is an idiomatic Odin port of ACE Team Coroutines for UE

WIP, but its already usable.

Basic Usage

To run a coroutine, initialize an Executor and enqueue a composed task node. Here is a simple sequence that waits, executes a callback, and then interpolates a value:

package main

import "core:fmt"
import k2 "shared:karl2d"

Game_State :: struct {
    exec:         Executor,
    player_y:     f32,
    player_score: int,
}

main :: proc() {
    state: Game_State
    executor_init(&state.exec)
    defer executor_destroy(&state.exec)

    // Construct a coroutine which executes tasks sequentially
    // wait 1.5s -> increase score -> apply tween 2s, to move player up
    context.user_ptr = &state.exec
    my_task := seq(
        wait(1.5),
        run(proc(s: ^Game_State) -> bool {
            s.player_score += 100
            fmt.println("Score increased!")
            return true
        }, &state),
        tween(0.0, 400.0, 2.0, &state.player_y, ease_in_out_cubic),
    )

    // Add coroutine to the scheduler
    enqueue_node(&state.exec, my_task)
  
    // Step the executor in your main loop
    dt: f32 = 1.0 / 60.0 // or k2.get_frame_time()
    for step in 0..120 {
      executor_step(&state.exec, dt)
    }
}

Complex Example

Playable version on itch.io:

Check main.odin to check how it looks.

Installation

Copy coroutines.odin file into your project.

Node Catalog

The library provides three core types of execution nodes: Composites (control flow), Decorators (behavior wrappers), and Leaves (actions & timers).

1. Composites (Control Flow)

  • seq(nodes...): Runs children sequentially. Fails if any child fails.
  • select(nodes...): Runs children sequentially until one succeeds. Only fails if all children fail.
  • race(nodes...): Runs children in parallel. The first child to end (success or failure) aborts all other branches.
  • sync(nodes...): Runs children in parallel. Waits for all children to finish. Fails if any child fails.

2. Decorators (Wrappers)

  • loop(child): Ticks its child indefinitely. Fails only if the child fails.
  • weak(child, is_valid, payload): Monitors a condition every frame. Instantly aborts the child and fails if the condition returns false.
  • named(child, name): Assigns a user-friendly debug name to the node for visual diagnostics.

3. Leaf Nodes (Actions & Timers)

  • wait(duration): Pauses execution for a float duration (supports values or pointers).
  • wait_frames(frames): Pauses execution for a specified number of frames (supports values or pointers).
  • run(callback, payload): Executes a procedure. Overloads support managed payloads (Rc(T)), direct pointers, or no payload.
  • wait_until(condition, payload): Suspends execution until the condition procedure returns true. Overloads support managed payloads (Rc(T)), pointers, or no payload.
  • tween(start, target, duration, output_ptr, ease_func): Smoothly interpolates an output float over time.

Memory Management

The engine features an explicit Reference-Counted Payload System (Rc(T)). This allows coroutines to share and manage complex state safely without manual free() calls, even when nodes are aborted.

How It Works:

  • Rc(T) Struct: Every managed payload is wrapped in an Rc(T) struct which contains the user data and a header with the reference count, allocator, and an optional Destructor.
  • Lifecycle Management:
    • rc_new(value, destructor): Allocates a new managed payload on the heap. Returns ^Rc(T) with an initial reference count of 1.
    • rc_inc(rc) / rc_dec(rc): Manually increment or decrement the reference count.
    • Automatic Node Attachment: Passing an ^Rc(T) to run(), wait_until(), or weak() automatically increments its reference count.
    • Automatic Cleanup: When a node is freed (on completion or abort), it decrements the reference count of its payload. If it hits zero, the destructor is called and the memory is reclaimed.

Pattern:

Payload :: struct { game: ^Game, id: int }

// Create managed state
p := rc_new(Payload{game, id})
defer rc_dec(p) // Drop the local reference; the coroutine will keep it alive

context.user_ptr = &game.exec
res := seq(
    run(proc(p: ^Payload) -> bool { ... }, p), // Increments RC
    wait(1.0),
    run(proc(p: ^Payload) -> bool { ... }, p), // Increments RC again
)

Technical Caveats & Concurrency

The Elite Charging Problem (Race Conditions)

Implementing a "limit of 2 concurrent charges" for enemies seems simple but hides several traps in a coroutine environment:

  1. Atomic Acquisition: Checking a counter and incrementing it must be done in the same step. Using seq(wait_until(can_charge), run(inc_counter)) is unsafe because two coroutines could both see count=1 in the same frame before either increments it.
    • Solution: Use wait_until with a callback that performs the acquisition: if count < 2 { count += 1; return true }.
  2. Counter Leaks: If a coroutine is aborted (e.g., the enemy dies) after incrementing the counter but before decrementing it, the counter will leak, eventually preventing all future charges.
    • Solution: Use an Rc Destructor. It is guaranteed to run whenever the last reference to the payload is dropped, regardless of why the coroutine ended.
  3. State Protection: Use an acquired flag in your Rc payload so the destructor only decrements if the slot was actually taken.

Check code in main.odin


Removed / Legacy Nodes (Historical Reference)

These nodes were removed during refactoring to have only minimal architecture, as their functionality is now covered by robuster patterns or compositions:

  • Semaphore / Semaphore_Scope: Replaced by logic-based counters + Payload Destructors (see above).
  • Scope: Replaced by native Destructor support in all payload-capturing nodes.
  • Managed: Now integrated into the core API; reference counting is automatic.
  • Fork: Spawning new root nodes is now handled explicitly by the user enqueuing enqueue_node() to the executor.
  • Optional_Seq / Loop_Seq: These were "syntactic sugar" that complicated the core loop; they can be easily mimicked with standard seq and loop.
  • Not / Catch / Capture_Return: Logic flow is better handled within run callbacks or select compositions.
  • Nop / Fail: Use run callbacks returning true or false for clearer intent.

Visual Debugging

  • F1: Toggle Debugger / Diagnostics DB.
  • F2: Toggle between Full Tree and Compact (Leaf-only) view.
  • F3: Pause/Unpause the game simulation.
  • F4: Clear substring filters.
  • Mouse Wheel: Scroll through long coroutine lists.
  • Status Colors:
    • SkyBlue - Running
    • Yellow - Suspended (waiting for child)
    • Green - Completed (fades out)
    • Red - Failed
    • Orange - Aborted