Managing Game Logic with Lua Coroutines Instead of State Machines
Stop fighting complex state machines in Lua. Learn how to use coroutines and a simple scheduler to write linear, readable game behaviors that pause and resume across frames.
12 Jun 2026, 10:25 UTC

When building a game loop, you often need entities to perform actions over time—a door that slides open over two seconds, an enemy that patrols between two points, or a dialogue sequence with pauses. The traditional approach is a state machine: you track a state (e.g., STATE_OPENING), a timer, and a target position. As behaviors grow, your code becomes a fragmented collection of if/else blocks and counters.
Lua coroutines solve this by allowing you to write sequential code that can pause its execution and resume later. Instead of manually tracking where a task left off, the Lua VM preserves the entire execution state (local variables and the instruction pointer) for you. This allows you to write a complex sequence of events as a single, readable function.
The Cooperative Multitasking Model
Coroutines in Lua are not threads; they are cooperative. This means they do not run in parallel and cannot be interrupted by the system. A coroutine runs until it explicitly gives control back to the caller using coroutine.yield(). Because only one piece of Lua code executes at a time, you eliminate the data races and locking mechanisms required by OS threads.
The core API is stable across Lua 5.1 through 5.4:
coroutine.create(fn): Initializes a coroutine from a function.coroutine.resume(co, ...): Starts or continues execution. It returns a boolean (success) and any values passed toyield.coroutine.yield(...): Suspends the current coroutine and returns values to the resumer.coroutine.status(co): Returns the current state (e.g.,"suspended","dead").
Implementing a Simple Task Scheduler
To make coroutines useful in a game, you need a scheduler that resumes each active task once per frame. This transforms a coroutine into a "behavior" that advances incrementally. Run this script with any Lua 5.1+ interpreter (e.g., lua scheduler.lua from your shell); no special permissions are required.
-- scheduler.lua: run with any Lua 5.1+ interpreter
local tasks = {}
local function spawn(fn)
table.insert(tasks, coroutine.create(fn))
end
-- Helper to pause for a specific number of frames
local function wait(frames)
for _ = 1, frames do coroutine.yield() end
end
-- Behavior 1: A patrolling guard
spawn(function()
local pos = 0
local dir = 1
while true do
pos = pos + dir
print("Guard position: " .. pos)
if math.abs(pos) == 3 then
dir = -dir
wait(2) -- Pause at the turn-around point
end
coroutine.yield() -- Wait for the next frame
end
end)
-- Behavior 2: A timed event
spawn(function()
print("Door: Starting to open...")
wait(5)
print("Door: Fully open!")
end)
-- Main Game Loop Simulation
for frame = 1, 10 do
print("--- Frame " .. frame .. " ---")
for i = #tasks, 1, -1 do
local co = tasks[i]
local ok, err = coroutine.resume(co)
if not ok then
print("Task crashed: " .. tostring(err))
table.remove(tasks, i)
elseif coroutine.status(co) == "dead" then
table.remove(tasks, i)
end
end
endVerification: When running this script, you will see the Guard's position update every frame, but the "Door: Fully open!" message will only appear after frame 5. The Guard's wait(2) call will cause its output to skip two frames, demonstrating that each coroutine maintains its own independent timer and state. If output is not interleaved as described, check that every loop contains a yield call.
Trade-offs and Engineering Constraints
While coroutines simplify logic, they introduce specific risks that must be managed:
- The Blocking Hazard: Because coroutines are cooperative, a single
while true do endloop without ayieldwill freeze your entire game. You must ensure every long-running loop contains a yield point. - C-Library Blocking: If you call a C function that performs a blocking I/O operation, the entire Lua state freezes. Coroutines cannot "yield" out of a blocking C call; the C function must return to Lua before a yield can occur.
- State Drift: Between one
yieldand the nextresume, the rest of the game world changes. If a coroutine caches a reference to an object that is destroyed during a yield, it may cause a crash or logic error upon resuming. Always re-validate critical assumptions after a yield. - Debugging: Stack traces for errors inside coroutines are returned through
resume's second return value rather than thrown normally. For deeper inspection, thedebuglibrary can examine coroutine stacks, but output gets verbose in complex flows.
Practical Decision: When to Use Coroutines
Use coroutines when you have linear sequences of events that occur over time. They are ideal for AI behaviors, cinematic scripting, and UI animations. Avoid them for high-frequency calculations that must happen every single frame without exception (like physics integration), where a standard function call is more performant and predictable.
The actionable takeaway: replace one sprawling state machine in your current project with a coroutine plus a small scheduler like the one above. If the behavior reads top-to-bottom like a script, you have made the right trade.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.