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 yield sits 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​

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), or C++23's std::generator<T> for a coroutine that only yields valuesthe 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. 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.)

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

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 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).