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​

jaiscript
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 only
output
3
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:

jaiscript
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()));
output
1 a 2.5 true

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

jaiscript
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));
output
10 phase=1

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

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

C++
#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)
}
output
C++ next -> 10
C++ next -> 20
C++ next -> -1

API notes (coroutine.hpp):

  • next(engine*) returns checked_result<script_value>. Test it before using the value; a failed coroutine reports status::failed. next(engine*, n) is the script's next(n): n steps, the last one's value.
  • done() == completed || failed; get_status() exposes created/running/suspended/completed/failed.
  • The shared_ptr you 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 the return value (the -1 / 99 above), and typed coroutine returns convert to the declared type. A next() after that raises Cannot resume a completed coroutine, so test done() first; done() only asks and never advances. next() takes only its count and done() 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 return completes 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: a catch around the yield cannot 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).