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.
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)
}
}Playable version on itch.io:
Check main.odin to check how it looks.
Copy coroutines.odin file into your project.
The library provides three core types of execution nodes: Composites (control flow), Decorators (behavior wrappers), and Leaves (actions & timers).
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.
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 returnsfalse.named(child, name): Assigns a user-friendly debug name to the node for visual diagnostics.
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 returnstrue. 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.
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.
Rc(T)Struct: Every managed payload is wrapped in anRc(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)torun(),wait_until(), orweak()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.
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
)Implementing a "limit of 2 concurrent charges" for enemies seems simple but hides several traps in a coroutine environment:
- 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 seecount=1in the same frame before either increments it.- Solution: Use
wait_untilwith a callback that performs the acquisition:if count < 2 { count += 1; return true }.
- Solution: Use
- 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.
- State Protection: Use an
acquiredflag in yourRcpayload so the destructor only decrements if the slot was actually taken.
Check code in main.odin
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 enqueuingenqueue_node()to the executor.Optional_Seq/Loop_Seq: These were "syntactic sugar" that complicated the core loop; they can be easily mimicked with standardseqandloop.Not/Catch/Capture_Return: Logic flow is better handled withinruncallbacks orselectcompositions.Nop/Fail: Useruncallbacks returningtrueorfalsefor clearer intent.
- 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