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​

LuaJaiScript
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, vco.next(), returning v; a failure inside raises at the next(), catchable around it
a loop of coroutine.resume callsco.next(n): n yields, the last one's value
coroutine.status(co) == "dead"co.done()
coroutine.wrap(f), a function that resumesa 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 resultno 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 callsno 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 resultsthe last next() returns the return value; a next() after that raises Cannot resume a completed coroutine
self captured by a closure around a methoda coroutine method: the handle keeps its object alive while it is suspended

From C++20 coroutines​

C++20JaiScript
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 vyield;, yield v;
co_return vreturn 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 bodythe same rule for yield
a scheduler: a container of handles resumed each framean 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:

C++20
#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:

lua
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:

jaiscript
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);
output (all three)
north turns at 2
south turns at 2
1 1

The 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:

jaiscript
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"); }
output
door opens
-- frame
hero walks in
-- frame
door closes
-- frame

next(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:

jaiscript
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); }
output
2
3
0 true
caught: Cannot resume a completed coroutine

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:

C++
#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 a jai::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's next(n).
  • The shared_ptr keeps 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 run eng->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:

C++
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.