Coroutines

Reviewed & published by Brayan K

By the end of this lesson you'll create and drive coroutines with create, resume, yield, status, and wrap, pass values in and out across yield/resume, build generator and producer-consumer patterns, understand why coroutines are cooperative rather than OS threads, and see why game loops love them.

Part of the free Lua course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

A coroutine is like reading a book with a bookmark. You read for a while, then slip the bookmark in and put the book down to do something else (that's yield). Later you pick it up and carry on from the exact page you left (that's resume) — no need to start over. Unlike juggling several books at once on different tables (true parallel threads), you only ever read one book at a time, and you choose precisely when to set it down. That cooperative, "pause exactly here and continue exactly there" behaviour is the whole idea.

1. Create, Resume, Yield & Status

A coroutine is a function that can pause itself with coroutine.yield and later continue from that exact point. You build one with coroutine.create(fn), which returns a coroutine that starts suspended. Each coroutine.resume(co) runs it until the next yield, the end of the function, or an error. coroutine.status(co) tells you where it stands — suspended, running, normal, or dead — and a finished (dead) coroutine can't be resumed again. Read and run this example.

-- A coroutine is a function that can PAUSE itself and later RESUME
-- from exactly where it paused. Only one runs at a time (cooperative).

-- create() makes a coroutine; it starts SUSPENDED, not running.
local co = coroutine.create(function()
  print("step 1")
  coroutine.yield()      -- pause here, hand control back
  print("step 2")
  coroutine.yield()      -- pause again
  print("step 3")        -- runs on the third resume, then finishes
end)

print(coroutine.status(co))   -- prints: suspended
coroutine.resume(co)          -- prints: step 1   (runs to first yield)
print(coroutine.status(co))   -- prints: suspended
coroutine.resume(co)          -- prints: step 2
coroutine.resume(co)          -- prints: step 3   (reaches the end)
print(coroutine.status(co))   -- prints: dead     (finished, can't resume)

-- Resuming a dead coroutine returns false, not an error:
print(coroutine.resume(co))   -- prints: false   cannot resume dead coroutine

2. Passing Values In and Out

Coroutines are a two-way channel. On the very first resume, the extra arguments become the body function's parameters. After that, whatever you pass to resume becomes the return value of the yield that paused it. Going the other way, the values you hand to yield come back as the extra results of resume (after the leading true success flag), and a final return is delivered just like one last yield. This flow is what powers generators and pipelines.

-- Values flow BOTH ways across yield/resume.
-- resume returns:  true (ok) , then whatever yield handed out.
-- yield returns:   whatever the NEXT resume passes back in.

local co = coroutine.create(function(a, b)
  print("got args", a, b)          -- a, b come from the FIRST resume
  local x = coroutine.yield(a + b) -- send out a+b, pause; x = next resume's value
  print("resumed with", x)
  return "done"                    -- a final return is delivered like a yield
end)

-- First resume: its extra args become the function's parameters.
local ok, out = coroutine.resume(co, 10, 5)
print(ok, out)        -- prints: true   15   (15 is the yielded a+b)

-- Second resume: 99 becomes the RESULT of the yield (the x above).
local ok2, out2 = coroutine.resume(co, 99)
print(ok2, out2)      -- prints: true   done   ("done" was returned)

Your turn. Drive a simple coroutine through its single pause and confirm it ends up dead. Fill in the blanks marked ___.

-- 🎯 YOUR TURN — drive a coroutine through its yields.

local co = coroutine.create(function()
  print("a")
  coroutine.___()        -- 👉 the function that PAUSES the coroutine
  print("b")
end)

-- 1) Run it up to the first pause
coroutine.resume(___)    -- 👉 the coroutine to run

-- 2) Run it again to finish
coroutine.resume(co)

-- 3) Check it is finished
print(coroutine.status(co))

-- ✅ Expected output:
-- a
-- b
-- dead

3. wrap, Generators & Producer-Consumer

coroutine.wrap turns a coroutine into a plain function: each call resumes it and returns the yielded values directly, with no true flag to unpack (errors are re-raised instead of returned). That makes wrap perfect for generators — functions that produce a stream of values on demand — and for producer-consumer setups where one coroutine yields items and a loop consumes them. A wrap result even works directly as a for-loop iterator.

-- coroutine.wrap turns a coroutine into a plain FUNCTION.
-- Each call resumes it and returns the yielded values directly.
-- (Errors are raised instead of returned as false.)

-- A GENERATOR that produces an endless stream of squares:
local function squares()
  return coroutine.wrap(function()
    local n = 1
    while true do
      coroutine.yield(n * n)   -- hand out the next square, then pause
      n = n + 1
    end
  end)
end

local nextSquare = squares()
print(nextSquare())   -- prints: 1
print(nextSquare())   -- prints: 4
print(nextSquare())   -- prints: 9

-- PRODUCER/CONSUMER: a producer yields items, a consumer pulls them.
local producer = coroutine.wrap(function()
  for _, fruit in ipairs({ "apple", "pear", "plum" }) do
    coroutine.yield(fruit)
  end
end)

for fruit in producer do        -- wrap result works as a for-iterator
  print("consumed", fruit)
end
-- prints: consumed apple / consumed pear / consumed plum

Now you try a generator. Finish the counter so each call hands out the next number starting from start. Fill in the blank:

-- 🎯 YOUR TURN — build a counter generator with wrap.

local function counter(start)
  return coroutine.wrap(function()
    local n = start
    while true do
      coroutine.yield(___)    -- 👉 hand out the current value n
      n = n + 1
    end
  end)
end

local nextNum = counter(10)
print(nextNum())   -- 👉 should print the start value
print(nextNum())
print(nextNum())

-- ✅ Expected output:
-- 10
-- 11
-- 12

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

TaskCodeResult / Note
Createcoroutine.create(fn)starts suspended
Resumecoroutine.resume(co, ...)true, yielded...
Yieldcoroutine.yield(...)pause, send out
Statuscoroutine.status(co)suspended/dead/...
Wrapcoroutine.wrap(fn)callable generator
Who am Icoroutine.running()current coroutine

Mini-Challenge: a frame-by-frame behaviour

No blanks this time — just a brief and an outline. You'll write a coroutine that does one step per "frame" and yields between them, exactly how scripted behaviours work in real game engines. Build it, run it, and check your output.

-- 🎯 MINI-CHALLENGE: a frame-by-frame game behaviour
-- 1. Write walkTo(steps) that returns coroutine.wrap of a function which,
--    for i = 1, steps, prints "step " .. i and then coroutine.yield()s
--    (simulating doing one step per frame).
-- 2. Drive it like a game loop: call the wrapped function once per "frame"
--    until coroutine.status would be dead. With wrap you can simply loop a
--    fixed number of times, or check a returned flag.
--
-- ✅ Example shape:
--   local move = walkTo(3)
--   move()   -- step 1   (frame 1)
--   move()   -- step 2   (frame 2)
--   move()   -- step 3   (frame 3)

-- your code here

🎉 Lesson Complete!

Practice quiz

Which function creates a new coroutine from a function?

  • coroutine.create
  • coroutine.new
  • coroutine.spawn
  • coroutine.make

Answer: coroutine.create. coroutine.create(fn) returns a coroutine object that is initially suspended.

How do you start or continue a suspended coroutine?

  • coroutine.start
  • coroutine.run
  • coroutine.resume
  • coroutine.go

Answer: coroutine.resume. coroutine.resume(co, ...) runs the coroutine until it yields, finishes, or errors.

What does coroutine.yield do?

  • Ends the coroutine permanently
  • Suspends the coroutine and returns control to the resumer
  • Creates a new thread
  • Pauses the whole program for a second

Answer: Suspends the coroutine and returns control to the resumer. yield suspends the coroutine, handing values and control back to whoever resumed it.

Are Lua coroutines preemptive OS threads?

  • Yes, they run in parallel on multiple cores
  • Only on Linux
  • Only in Lua 5.4
  • No, they are cooperative and run one at a time

Answer: No, they are cooperative and run one at a time. Coroutines are cooperative: exactly one runs at a time and it must yield to give up control.

What does the first return value of coroutine.resume indicate?

  • A boolean success flag
  • The coroutine's status string
  • The number of yields so far
  • The coroutine's id

Answer: A boolean success flag. resume returns true plus yielded values on success, or false plus an error message on failure.

Which function returns a coroutine's status such as 'suspended' or 'dead'?

  • coroutine.info
  • coroutine.status
  • coroutine.check
  • coroutine.state

Answer: coroutine.status. coroutine.status(co) reports suspended, running, normal, or dead.

What does coroutine.wrap return?

  • A status string
  • A coroutine object
  • A table of yielded values
  • A function you call to resume the coroutine

Answer: A function you call to resume the coroutine. wrap returns a callable; each call resumes the coroutine and returns its yielded values directly, raising errors instead of returning false.

How are values passed INTO a coroutine on the very first resume?

  • They become the return of yield
  • You cannot pass values in
  • As the arguments to the coroutine's body function
  • Through a global variable

Answer: As the arguments to the coroutine's body function. On the first resume, the extra arguments become the parameters of the function the coroutine wraps.

After the first resume, where do values passed to resume appear?

  • As new function arguments
  • As the return values of the yield that paused it
  • In coroutine.running
  • They are discarded

Answer: As the return values of the yield that paused it. On later resumes, the values you pass become the results returned by the yield that suspended the coroutine.

A common use of coroutines in game loops is to...

  • Write step-by-step behaviours that pause and resume each frame
  • Run code on multiple CPU cores at once
  • Replace the table data structure
  • Speed up math operations

Answer: Write step-by-step behaviours that pause and resume each frame. Coroutines let you write scripted, frame-by-frame behaviours that yield each frame and resume on the next.

Continue this course

Frequently asked questions

How are coroutines different from OS threads?

OS threads are preemptive and can run in parallel on multiple CPU cores; the operating system switches between them at any moment, so you need locks to avoid data races. Lua coroutines are cooperative and single-threaded: exactly one coroutine runs at a time, and it only gives up control when it explicitly calls coroutine.yield. That makes them far easier to reason about — there are no surprise context switches and no locking — but it also means a coroutine that never yields will block everything, and coroutines alone do not give you true multicore parallelism.

What is the difference between coroutine.create and coroutine.wrap?

create returns a coroutine object that you drive with coroutine.resume, and resume returns a success boolean first so you can detect errors yourself. wrap returns a plain function: each call resumes the coroutine and returns the yielded values directly, with no success boolean. The trade-off is error handling — wrap re-raises any error inside the coroutine to the caller, whereas resume catches it and hands it back as false plus a message. Use wrap for clean generators and iterators; use create when you need to inspect status or handle errors gracefully.

How do values pass into and out of a coroutine?

Values flow in both directions across the yield/resume boundary. On the very first resume, the extra arguments you pass become the parameters of the coroutine's body function. After that, any values you pass to resume become the return values of the yield that paused the coroutine. Going the other way, the values you pass to yield become the extra return values of resume, and a final return from the body is delivered just like a last yield. This two-way channel is what makes producer/consumer patterns natural.

What does coroutine.status report and when is it 'normal'?

status returns one of four strings. 'suspended' means the coroutine is paused and can be resumed. 'running' is what it sees about itself while executing. 'dead' means it has finished or errored and can no longer be resumed. 'normal' is the less common one: it describes a coroutine that has resumed another coroutine and is waiting for that inner one to finish — so it is active but not the one currently running. coroutine.running() tells you which coroutine is executing right now.

Why are coroutines so useful in game loops?

A game runs a loop that updates everything once per frame, and many behaviours are naturally step-by-step over time: walk to a point, wait two seconds, then open a door. Without coroutines you would track that progress with state machines and counters. With a coroutine you write the behaviour as ordinary sequential code that calls yield each frame; the engine resumes it next frame and it continues exactly where it left off. This turns awkward state tracking into readable scripts, which is why engines like LOVE and Roblox lean on them.