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 a coroutine type 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. Three things differ from Lua:
- A
yieldsits in the coroutine's own body, not in a function it calls (the C++20 rule). - No value goes in through
next(). - A handle is a value you store in a field or an array like any other.
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), or C++23's std::generator<T> for a coroutine that only yields values | 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. In C++20 the behaviour needs a coroutine type, written here by hand. (C++23's std::generator can stand in for it, with each co_yield of a dummy value as the frame boundary and the scheduler holding an iterator per generator.)
#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 handle is a value that knows how to advance, and it keeps its guard alive while it is suspended. A copy of a handle advances the same coroutine, which is what lets a game object hold its behaviour in a field.
Advancing a handle
done() asks without advancing, so while (!s.done()) { s.next(); } plays a cutscene whose yields are frame boundaries. With coroutine int waves() { yield 1; yield 2; yield 3; return 0; }, w.next(2) is 2, the next w.next() is 3, the one after is the return value 0 with w.done() true, and one more raises Cannot resume a completed coroutine, as a next(n) whose count runs past the end does.
Hosting 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 with Script execution budget exceeded - raise engine::execution_budget or break up the work instead of hanging the game. 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).