Coroutine Hosting
Script coroutines are the built-in answer to per-entity behaviors, cutscenes, and multi-frame AI.
Calling a coroutine function returns a handle and runs nothing yet: next() runs to
the next yield, next(n) advances n yields, done() reports completion.
Each backend owns its continuation representation behind that one interface. The interpreter saves
an environment and replays an AST continuation, the VM parks a fiber
(include/jaiscript/core/coroutine.hpp). Nothing you write has to know which.
Script surface
coroutine int countdown(int from) {
for (int i = from; i > 0; --i) { yield i; }
return 0;
}
auto c = countdown(3);
while (!c.done()) { print(to_string(c.next())); } // the last next() delivers the return value
var seen = [];
for (auto v : countdown(4)) { seen.push(v); } // range-for drives coroutines
print(to_json(seen)); // yielded values only3
2
1
0
[4,3,2,1]Two things to read off that: the final next() hands you the return value
(the 0), and a range-for consumes only what the coroutine yields: the
return value never lands in seen.
How to spell one
coroutine is a prefix on the function spellings you already have, so a script you ship can
declare one however the rest of the file reads:
coroutine int ticks() { yield 1; return 0; }
coroutine function names() -> string { yield "a"; return ""; }
coroutine function guess() -> auto { yield 2.5; return 0.0; }
coroutine function plain() { yield true; }
print(to_string(ticks().next()) + " " + names().next() + " " +
to_string(guess().next()) + " " + to_string(plain().next()));1 a 2.5 trueThe bare form drops function as well (coroutine g() { … } and
coroutine g() -> int { … }), and the same prefix works on methods, static ones
included, and inside a namespace body. Coroutines cannot return references (a parse error). Every spelling is listed in the guide's
Spellings; the language-side tour, covering independent instances per call, receiver pinning (a method coroutine keeps its object alive while suspended) and the edges, is
the guide's Coroutines chapter. Hosts moving from Lua coroutines or a C++20 scheduler have a mapping in
From Lua Coroutines and C++20 Schedulers.
Coroutine methods work too. The handle pins the receiver alive across suspension, this stays bound, and mutations through the receiver are visible to the outside world while the coroutine is parked (examples/crawler's boss is built this way):
class Boss {
int phase = 0;
coroutine int fight() { phase = 1; yield 10; phase = 2; yield 20; return 99; }
}
auto b = Boss();
auto c = b.fight();
print(to_string(c.next()) + " phase=" + to_string(b.phase));10 phase=1All of it (yield/next sequencing, done() transitions, multiple simultaneous handles, range-for driving) behaves identically on both backends; the test suite holds them to it. The checker types a coroutine call as a handle (a member other than next or done is a load error) whose next() returns an unknown type, and does not check yield or return types (see the Static Checking precision map). A return the declared type rejects is a run-time error at the final next() (Cannot return string from 'k' (declared -> int)), never a load error; a yield takes the declared type the same way at the next() that runs it (coroutine int hands out 2 for yield 2.9; Cannot yield string from 'k' (declared -> int), catchable around the yield), and a bare yield; hands out nothing.
Driving from C++, pattern 1: through executes (the simple way)
A handle stored in an engine global persists across separate execute() calls. Each execute continues exactly where the last left off. This is the frame-loop pattern: create behaviors once, tick them per frame with tiny next() scripts. (The strict checker is happy with this: c is declared by the first execute, and later executes on the same engine see it as a declared global.)
eng->execute(R"( coroutine int spawner(int n) {
for (int i = 1; i <= n; ++i) { yield i * 10; }
return -1;
}
var c = spawner(3); )");
for (int i = 0; i < 4; ++i) {
auto v = eng->execute("c.next();"); // 10, 20, 30, -1
bool finished = eng->execute("c.done()").as<bool>(); // false ×3, then true
}This survives re-executing the declaring line in between (which redeclares c), and running the whole pattern through a jaibite instead of raw strings.
Driving from C++, pattern 2: the native handle
The script value is a jai::coroutine_handle in an object holder. You can pull it out once and drive it with zero script round-trips:
#include <jaiscript/core/coroutine.hpp>
eng->execute("var c2 = spawner(2);");
auto holder = eng->execute("c2").get_object_holder();
auto handle = std::static_pointer_cast<jai::coroutine_handle>(holder->host_object());
while (!handle->done()) {
jai::checked_result<jai::script_value> step = handle->next(eng.get());
if (!step) break; // error channel (checked_result)
std::cout << "C++ next -> " << step.value().as_int() << "\n"; // 10, 20, then -1 (the return value)
}C++ next -> 10
C++ next -> 20
C++ next -> -1API notes (coroutine.hpp):
next(engine*)returnschecked_result<script_value>. Test it before using the value; a failed coroutine reportsstatus::failed.next(engine*, n)is the script'snext(n):nsteps, the last one's value.done()==completed || failed;get_status()exposescreated/running/suspended/completed/failed.- The
shared_ptryou extracted keeps the coroutine (and, for method coroutines, its receiver) alive. This is the supported way to own a behavior from C++.
Semantics worth knowing before you build on them
- The
next()that finishes the body delivers thereturnvalue (the-1/99above), and typed coroutine returns convert to the declared type. Anext()after that raisesCannot resume a completed coroutine, so testdone()first;done()only asks and never advances.next()takes only its count anddone()takes no arguments: a value cannot be sent in. Range-for consumes only the yielded values. - Running off the end of a coroutine with no
returncompletes with null, exactly like a function falling off the end, identical on both backends. - Budget interaction: the execution budget applies per
execute()/next()call, and a budget overrun inside a coroutine is terminal like everywhere else: acatcharound theyieldcannot swallow it. - Coroutine parameters bind by value on both backends, even reference-shaped arguments; captures and closure state behave like lambdas.
- Hot reload: a suspended coroutine keeps its old body until it finishes; new invocations get the new definition (see Hot Reload and the crawler's boss brain).