From Lua Coroutines and C++20 Coroutine Schedulers
Per-entity behaviour reads best as a sequence: walk two steps, turn, wait a second, open the door. Lua writes it with coroutine.create and coroutine.yield. A C++20 engine writes it as a coroutine type with a promise and a scheduler that resumes handles every frame. A JaiScript behaviour is a coroutine function. Calling it returns a handle and runs nothing; next() runs to the next yield, next(n) advances n yields, and done() reports completion. The host drives handles from script or from C++, and the scheduler is a loop. Three things differ from Lua: a yield sits in the coroutine's own body, not in a function it calls; no value goes in through next(); and a handle is a value you store in a field or an array like any other. This page maps both, converts a patrol behaviour, and covers hosting, budgets and hot reload. The language side is the guide's Coroutines; the host side is Coroutine Hosting.
The concept map
From Lua
| Lua | JaiScript |
|---|---|
local co = coroutine.create(function() ... end) | coroutine function patrol() { ... } and auto co = patrol();: the call makes the handle and runs nothing |
coroutine.yield(v), coroutine.yield() | yield v;, yield; |
coroutine.resume(co), returning true, v | co.next(), returning v; a failure inside raises at the next(), catchable around it |
a loop of coroutine.resume calls | co.next(n): n yields, the last one's value |
coroutine.status(co) == "dead" | co.done() |
coroutine.wrap(f), a function that resumes | a handle: next() is the call. A range-for drives one to the end: for (auto v : waves()) { ... } |
coroutine.resume(co, x) passing a value in, received as yield's result | no counterpart: next() takes only its count. Shared state goes through a field or a variable both sides see |
coroutine.yield() inside a helper function the coroutine calls | no counterpart: yield outside a coroutine body is a parse error, yield can only be used inside a coroutine function |
the last resume returns the function's results | the last next() returns the return value; a next() after that raises Cannot resume a completed coroutine |
self captured by a closure around a method | a coroutine method: the handle keeps its object alive while it is suspended |
From C++20 coroutines
| C++20 | JaiScript |
|---|---|
a return type with a promise_type (initial_suspend, final_suspend, yield_value, return_value, unhandled_exception) | the coroutine keyword; the declared type is what yield and return hand out (coroutine int waves()) |
co_await std::suspend_always{}, co_yield v | yield;, yield v; |
co_return v | return v; |
std::coroutine_handle<P>: resume(), done(), destroy() | a script handle with next(), advance(v), done() and result(); in C++, a std::shared_ptr<jai::coroutine_handle> with next(engine*) and done(). The last owner frees it |
unhandled_exception() storing std::current_exception() | the jai::checked_result that next(engine*) returns; a failed coroutine reports done() |
| an awaitable for "wait n frames" or "wait until" | a loop of yield; in the body: for (int i = 0; i < n; ++i) { yield; }, while (!door.open) { yield; } |
a stackless frame: co_await only in the coroutine's own body | the same rule for yield |
| a scheduler: a container of handles resumed each frame | an array of handles and a loop, in script or in C++ (below) |
A behaviour converted
Two guards walk two steps out, report, and walk back, one step per frame. The C++20 version needs a coroutine type and a tick:
#include <coroutine>
struct Behavior {
struct promise_type {
Behavior get_return_object() { return Behavior{std::coroutine_handle<promise_type>::from_promise(*this)}; }
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
explicit Behavior(std::coroutine_handle<promise_type> h) : handle(h) {}
Behavior(Behavior&& other) noexcept : handle(std::exchange(other.handle, {})) {}
~Behavior() { if (handle) { handle.destroy(); } }
bool tick() {
if (!handle.done()) { handle.resume(); }
return !handle.done();
}
std::coroutine_handle<promise_type> handle;
};
struct Guard {
std::string name;
int x = 0;
};
Behavior patrol(Guard& g) {
while (true) {
for (int i = 0; i < 2; ++i) { g.x += 1; co_await std::suspend_always{}; }
std::cout << g.name << " turns at " << g.x << "\n";
for (int i = 0; i < 2; ++i) { g.x -= 1; co_await std::suspend_always{}; }
}
}
std::vector<Guard> guards{{"north"}, {"south"}};
std::vector<Behavior> brains;
for (auto& g : guards) { brains.push_back(patrol(g)); }
for (int frame = 0; frame < 3; ++frame) {
for (auto& b : brains) { b.tick(); }
}
std::cout << guards[0].x << " " << guards[1].x << "\n";The Lua version is a closure per guard:
local Guard = {}
Guard.__index = Guard
function Guard.new(name) return setmetatable({ name = name, x = 0 }, Guard) end
function Guard:patrol()
while true do
for i = 1, 2 do self.x = self.x + 1; coroutine.yield() end
print(self.name .. " turns at " .. self.x)
for i = 1, 2 do self.x = self.x - 1; coroutine.yield() end
end
end
local guards = { Guard.new("north"), Guard.new("south") }
local brains = {}
for _, g in ipairs(guards) do
brains[#brains + 1] = coroutine.create(function() g:patrol() end)
end
for frame = 1, 3 do
for _, b in ipairs(brains) do coroutine.resume(b) end
end
print(guards[1].x .. " " .. guards[2].x)JaiScript makes the behaviour a method coroutine and the scheduler a loop over handles:
class Guard {
string name;
int x = 0;
Guard(string n) { name = n; }
coroutine function patrol() {
while (true) {
for (int i = 0; i < 2; ++i) { x += 1; yield; }
print(name, " turns at ", x);
for (int i = 0; i < 2; ++i) { x -= 1; yield; }
}
}
}
array<Guard> guards = [Guard("north"), Guard("south")];
var brains = [];
for (auto& g : guards) { brains.push(g.patrol()); }
for (int frame = 0; frame < 3; ++frame) {
for (auto& b : brains) { b.next(); }
}
print(guards[0].x, " ", guards[1].x);north turns at 2
south turns at 2
1 1The promise type, the move constructor, the destructor and the tick are gone: the handle is a value that knows how to advance, and it keeps its guard alive while it is suspended. A handle is shared when it is copied (a copy advances the same coroutine), which is what lets a game object hold its behaviour in a field.
Advancing a handle
next() and done()
next() runs to the next yield and returns its value; the next() that finishes the body returns the return value, and done() asks without advancing. A cutscene is a coroutine whose yields are frame boundaries:
coroutine function cutscene() {
print("door opens");
yield;
print("hero walks in");
yield;
print("door closes");
}
auto s = cutscene();
while (!s.done()) { s.next(); print("-- frame"); }door opens
-- frame
hero walks in
-- frame
door closes
-- framenext(n)
next(n) advances n yields in one call and returns the last one's value, the way a loop of coroutine.resume would. A count that runs past the end raises as next() at the end does:
coroutine int waves() {
yield 1;
yield 2;
yield 3;
return 0;
}
auto w = waves();
print(w.next(2));
print(w.next());
print(w.next(), " ", w.done());
try { w.next(); } catch (e) { print("caught: ", e); }2
3
0 true
caught: Cannot resume a completed coroutineHosting from C++
A C++ scheduler holds the handles itself. The script value of a handle is a jai::coroutine_handle; taken out once, it advances with no script round trip:
#include <jaiscript/core/coroutine.hpp>
eng->execute(R"(
var north = new Guard("north");
var south = new Guard("south");
var brains = [north.patrol(), south.patrol()];
)");
std::vector<std::shared_ptr<jai::coroutine_handle>> brains;
for (const char* name : {"brains[0]", "brains[1]"}) {
auto holder = eng->execute(name).get_object_holder();
brains.push_back(std::static_pointer_cast<jai::coroutine_handle>(holder->host_object()));
}
for (int frame = 0; frame < 3; ++frame) {
for (auto& brain : brains) {
if (brain->done()) { continue; }
jai::checked_result<jai::script_value> step = brain->next(eng.get());
if (!step) { std::cerr << eng->format_error(step) << "\n"; }
}
}next(engine*)returns ajai::checked_result<jai::script_value>; a failure inside the coroutine is reported there, the coroutine is finished (done()), and the engine is ready for the next call.next(engine*, n)is the script'snext(n).- The
shared_ptrkeeps the coroutine, and a method coroutine's object, alive, so the host owns the behaviour as long as it holds the pointer, the way a C++20 scheduler owns its handles. - Handles stored in script globals also persist across
execute()calls: a frame loop can runeng->execute("for (auto& b : brains) { b.next(); }")instead (pattern 1).
Budgets
A Lua host bounds a runaway coroutine with a debug hook; a C++20 coroutine runs until it suspends. The JaiScript execution budget (eng->execution_budget(seconds), 5 by default, 0 for none) applies to each execute() and each next() a host makes, so a behaviour that stops yielding fails its frame instead of hanging the game:
eng->execution_budget(0.05);
eng->execute(R"(coroutine function spin() { yield; while (true) { } } var s = spin(); s.next();)");
try {
eng->execute("s.next();");
} catch (const std::exception& e) {
std::cerr << e.what(); // Script execution budget exceeded - raise engine::execution_budget or break up the work
}An overrun is terminal: a catch inside the coroutine cannot swallow it (Safety Rails).
Hot reload
A Lua coroutine keeps running the closure it was created from, and a reload swaps the functions new code will call. JaiScript behaves the same way: a suspended coroutine keeps its old body until it finishes, and new calls get the new definition. A behaviour that should pick up an edit soon restarts at a natural boundary (a host that tests done() and calls the coroutine again), or keeps its long-lived loop short and delegates to functions it calls, which resolve at call time (Hot Reload).
Where to next
The guide's Coroutines covers every spelling, receiver pinning and the edges; Coroutine Hosting covers both host patterns and the semantics a host builds on.