TL;DR: The sequence sounds simple when a designer describes it: "wait two seconds, then spawn the enemy wave, then trigger the cutscene. " The code that implements it is where your sanity goes to die.
📖 Reading time: ~41 min
What's in this article
- The Problem: Your Game Loop Is a Callback Nightmare
- Prerequisites and Setup
- C++20 Coroutine Primitives You Actually Need (Skip the Rest)
- Building the Task Type (~60 Lines)
- The Scheduler (~80 Lines)
- Writing the Awaitables (~40 Lines)
- Plugging It Into a Real Game Loop
- Three Things That Surprised Me
The Problem: Your Game Loop Is a Callback Nightmare
The sequence sounds simple when a designer describes it: "wait two seconds, then spawn the enemy wave, then trigger the cutscene." The code that implements it is where your sanity goes to die. Here's what that looks like in a typical game loop without coroutines:
// What you write when you're optimistic
scheduleAfter(2.0f, [this]() {
spawnEnemyWave(wave_config, [this]() {
waitForWaveClear([this]() {
triggerCutscene("boss_intro", [this]() {
// you are now four lambdas deep
// 'this' might be dangling
// nobody knows what captures what
restorePlayerControl();
});
});
});
});
This is callback hell, and it's worse in games than in web backends because your callbacks fire inside the main loop, capture this pointers to objects that get destroyed mid-sequence, and interact with physics/render state that's only valid at specific points in the frame. I've seen codebases where a single quest sequence became 300 lines of nested lambdas with a six-level pyramid of doom. Debugging it meant counting closing braces. The thing that makes it worse: the lifetime bugs don't crash immediately — they corrupt state three frames later and the symptoms look like a physics glitch.
The thread-per-behavior answer sounds appealing until you actually run it. A dedicated thread for every enemy AI behavior, every timed sequence, every triggered event — you're looking at context switch overhead on every frame, mutex contention whenever two behaviors touch shared game state, and the reentrancy bugs that appear only on the third Tuesday of a month when two events fire within 0.1ms of each other. Games are single-threaded by design in their simulation tick for a reason: determinism and cache coherency. Adding threads to avoid callback nesting trades one problem for three worse ones. I've shipped a title that went the thread-per-actor route on Xbox 360 and we spent six weeks chasing a crash that turned out to be two AIs both triggering a door open at the same frame boundary.
The "stackless" part of stackless coroutines is the actual technical insight worth understanding. A stackful coroutine (like a goroutine or a green thread) gets its own call stack — typically 8KB to 64KB — allocated per coroutine. Suspend it mid-call-chain and the entire stack stays alive. Stackless coroutines work differently: when you hit a co_await, the compiler transforms your function into a state machine and stores only the live variables in a heap-allocated coroutine frame. That frame is typically 40–200 bytes depending on what you capture. For a game with 1,000 concurrent AI behaviors, that's the difference between 64MB of stack space and 200KB of heap. More importantly, when the scheduler resumes your coroutine, the frame is a single allocation — far more cache-friendly than chasing a pointer to a full call stack.
// C++20 stackless coroutine — the frame is ~48 bytes on MSVC
// No dedicated stack. Suspends by returning to the caller.
Task waitThenSpawn(GameContext& ctx) {
co_await ctx.scheduler.delay(2.0f); // suspends here, frame on heap
spawnEnemyWave(ctx.wave_config); // resumes here next tick
co_await ctx.scheduler.waitUntil([&]{ return ctx.waveCleared; });
triggerCutscene("boss_intro");
}
// That's it. Linear. Readable. Debuggable.
What we're building across this article is a minimal coroutine scheduler you can drop into any engine: a Scheduler class, a Task promise type, and a handful of awaitables (delay, nextFrame, waitUntil). The whole thing fits in a single header under 200 lines with no dependencies beyond C++20. No Boost.Coroutine, no third-party scheduler, no engine-specific hooks required. It works with Unreal (as a standalone utility), with custom engines, with SDL2 game loops — anywhere you have a tick(float dt) function you control. For other workflow automation tools that complement your dev pipeline, check out the Ultimate Productivity Guide: Automate Your Workflow in 2026. The constraint I'm imposing on purpose: no dynamic thread creation, no exceptions in the hot path, and coroutine frames that are moveable so your scheduler can live in a flat std::vector without pointer invalidation headaches.
Prerequisites and Setup
The thing that trips most people up isn't the coroutine logic — it's discovering mid-project that their compiler silently accepted -std=c++17 and gave them cryptic errors about std::coroutine_handle not being a member of std. Check first, code second.
Minimum versions that actually work: GCC 11+, Clang 14+, MSVC 19.28+ (shipped with VS 2019 16.8). All three need the C++20 flag — -std=c++20 on GCC/Clang, /std:c++20 on MSVC. Clang is my daily driver for this work because its error messages for coroutine mistakes are significantly more readable than GCC's. MSVC works fine but if you hit a coroutine bug, prepare for a wall of template noise.
Verify before you touch any code:
# GCC
g++ --version
# want: g++ (GCC) 11.x.x or higher
# Clang
clang++ --version
# want: clang version 14.x.x or higher
# MSVC — run this inside Developer Command Prompt, not regular cmd
cl
# first line shows: Microsoft (R) C/C++ Optimizing Compiler Version 19.28.xxxxx or higher
If you're on CMake (and you should be for anything beyond a toy), one line handles the feature requirement cleanly:
target_compile_features(mygame PRIVATE cxx_std_20)
Don't use set(CMAKE_CXX_STANDARD 20) globally — that sets it on every target including third-party deps pulled in via FetchContent, which causes surprising build failures. The target_compile_features approach is scoped to your target only.
The Unreal Engine 5.3+ situation is a genuine footgun. UE ships its own coroutine headers under UE5/Coroutine/ and they're not interchangeable with <coroutine> from the standard library. If you #include <coroutine> in a file that also gets compiled by UBT, you'll hit ODR (One Definition Rule) violations that manifest as linker errors pointing at completely unrelated translation units. The fix: if you're integrating this system into UE5, wrap your coroutine infrastructure in a module that never includes UE headers, and keep a hard boundary between them. This is one of those things that isn't in the UE docs anywhere obvious — I found it by staring at a 300-line linker error for two hours.
Before writing anything real, run this sanity check. If it compiles and prints correctly, your toolchain is good:
#include <coroutine>
#include <iostream>
struct SimpleTask {
struct promise_type {
SimpleTask get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
SimpleTask hello() {
std::cout << "coroutine alive\n";
co_return; // this keyword is what forces C++20 mode; it'll error on C++17
}
int main() {
hello();
return 0;
}
Compile with g++ -std=c++20 -o sanity sanity.cpp && ./sanity. Expected output is just coroutine alive. If you get complaints about co_return being an unexpected token, your -std= flag isn't landing — double-check your build system isn't overriding it somewhere downstream.
C++20 Coroutine Primitives You Actually Need (Skip the Rest)
The thing that caught me off guard when I first read the C++20 coroutine spec is how little of it you actually need for a game task scheduler. The standard library gives you this enormous surface area — generators, allocator hooks, symmetric transfer — and almost none of it matters for our use case. What you need is: a handle, a promise, two suspend types, and a clear picture of what co_await emits. That's it.
std::coroutine_handle
std::coroutine_handle<Promise> is just a typed pointer to the coroutine's activation frame on the heap. That frame holds local variables, the resume point, and your promise object. When you store a coroutine for later — say, in a task queue — this handle is what you store. Resume it with .resume(), check if it's done with .done(), and destroy it explicitly with .destroy() when you're finished. The most useful trick: std::coroutine_handle<void> is the type-erased version, which is what you want when your scheduler doesn't care about the promise type.
// Storing a handle for later resumption
struct Task {
std::coroutine_handle<void> handle; // type-erased — scheduler doesn't need Promise details
void resume() { handle.resume(); }
bool done() { return handle.done(); }
void destroy(){ handle.destroy(); } // YOU must call this — no RAII here unless you add it
};
The Minimum-Viable Promise Type
The promise type is where most people get confused because the spec makes it look ceremonial. For a stackless game task, you only need four things:
struct TaskPromise {
// Called immediately on coroutine creation — suspend_always means we don't start running yet
// This lets the caller get the handle before execution begins (critical for a scheduler)
std::suspend_always initial_suspend() noexcept { return {}; }
// Called when the coroutine body finishes — suspend_always keeps the frame alive so .done() works
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() noexcept {} // required if the coroutine has no co_return value
void unhandled_exception() noexcept {
// Don't silently swallow — at minimum, store it
exception_ = std::current_exception();
}
Task get_return_object(); // returns the Task wrapping this promise's handle
std::exception_ptr exception_{};
};
The get_return_object() call happens before initial_suspend(), so by the time the caller gets their Task back, the handle is valid but execution hasn't started. That ordering matters — it's what makes the "lazy start" pattern safe.
The One Real Footgun: final_suspend Must Not Return suspend_never
std::suspend_always suspends unconditionally. std::suspend_never runs through without suspending. The dangerous case is final_suspend returning std::suspend_never — when that happens, the coroutine frame is destroyed automatically by the runtime. That sounds fine until you try to call .done() on the handle afterward: undefined behavior, usually a crash. Worse, if you've stored the handle in a queue and the coroutine finished between scheduler ticks, you're now calling .resume() on a dangling pointer. Always return suspend_always from final_suspend and destroy the frame yourself when you dequeue and confirm it's done.
// Safe cleanup pattern in your scheduler tick:
for (auto& task : active_tasks_) {
task.handle.resume();
if (task.handle.done()) {
task.handle.destroy(); // safe because final_suspend is suspend_always
// mark for removal
}
}
What co_await Actually Emits
The compiler transforms every co_await expr into roughly this sequence — showing it as pseudo-code because that's actually more useful than looking at Clang's IR:
// co_await some_awaitable; expands to approximately:
auto&& awaiter = get_awaiter(some_awaitable); // calls operator co_await or uses directly
if (!awaiter.await_ready()) { // fast path: if already done, skip suspend
awaiter.await_suspend(current_handle); // pass our handle to the awaiter — it may store/resume it
// ---- SUSPEND POINT: execution returns to whoever called .resume() on us ----
}
auto result = awaiter.await_resume(); // runs after we're resumed — provides the co_await result value
The key insight: await_suspend receives your handle and gets to decide when to resume you. For a simple "wait one frame" awaitable, await_suspend pushes your handle onto the scheduler's next-frame queue. For "wait for an animation to finish," it registers the handle as a callback on the animation system. The coroutine itself is stateless across the suspend — all the waiting logic lives in the awaitable, not the coroutine body. That's the clean separation that makes this pattern composable.
What We're Deliberately Not Covering
Three things I'm leaving out of scope: co_yield and generators (you'd use a different promise type with yield_value(), and it's a separate pattern), allocator customization via operator new on the promise (useful for avoiding heap allocation, but it complicates the implementation significantly and the default heap alloc is fine for game tasks at reasonable counts), and symmetric transfer (where await_suspend returns another handle to transfer execution without growing the stack — valuable for chaining coroutines without stack overflow, but adds mental overhead we don't need for a basic scheduler). Get the basics solid first; none of these are removed from the language if you need them later.
Building the Task Type (~60 Lines)
The part most tutorials gloss over is the ownership story. A std::coroutine_handle is just a raw pointer to heap-allocated coroutine frame state. If you don't call destroy() on it, you leak. If you call it twice, you corrupt. So before we touch scheduling or chaining, getting the Task destructor right is the entire ballgame. I've seen codebases where coroutine handles just... escaped ownership and nobody noticed for months because the leak was slow.
Here's the complete Task<void> implementation. Read the comments — the non-obvious parts are marked explicitly:
#include <coroutine>
#include <exception>
#include <stdexcept>
template<typename T = void>
struct Task {
// --- The Promise Type ---
// The compiler looks for coroutine_traits or a nested promise_type.
// Every coroutine that returns Task<T> gets one of these promise objects
// allocated inside its coroutine frame on the heap.
struct promise_type {
std::exception_ptr exception_; // store exceptions across suspension points
// Called immediately when the coroutine is invoked.
// suspend_always means: don't run any body code yet.
// This gives us explicit scheduling control — we decide WHEN it runs.
// If you return suspend_never here, the body starts executing immediately
// during the Task constructor call, which destroys any scheduling model.
std::suspend_always initial_suspend() noexcept { return {}; }
// Called right before the coroutine frame would be destroyed naturally.
// suspend_always here means: pause at the final point, don't auto-destroy.
// This is critical — it lets the Task destructor call destroy() exactly once.
// If you return suspend_never, the frame self-destructs and your handle is dangling.
std::suspend_always final_suspend() noexcept { return {}; }
// The compiler calls this to construct the Task returned to the caller.
// get_return_object() runs before initial_suspend(), so the handle is valid.
Task get_return_object() {
return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
// co_return; with no value — for Task<void> specifically
void return_void() noexcept {}
// Any unhandled exception inside the coroutine body lands here.
// We capture it and can rethrow from the scheduler side.
void unhandled_exception() noexcept {
exception_ = std::current_exception();
}
// Call this from your scheduler after the coroutine completes
// to propagate exceptions outward.
void rethrow_if_exception() {
if (exception_) std::rethrow_exception(exception_);
}
};
// --- The Task Body ---
// Task owns the handle. Period. No shared ownership, no copies.
std::coroutine_handle<promise_type> handle_;
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
// Move-only. Copying a coroutine handle would mean two owners,
// and one of them would call destroy() while the other still holds it.
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
Task(Task&& other) noexcept : handle_(other.handle_) {
other.handle_ = nullptr; // null out the moved-from so its destructor is a no-op
}
Task& operator=(Task&& other) noexcept {
if (this != &other) {
if (handle_) handle_.destroy(); // destroy what we currently own first
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}
// RAII: destroy the coroutine frame when Task goes out of scope.
// final_suspend returning suspend_always keeps the frame alive until HERE.
// Without this, you either leak or double-free. Both are silent in release builds.
~Task() {
if (handle_) handle_.destroy();
}
// Scheduler calls this to advance the coroutine by one step.
// Returns true while there's more work to do.
bool resume() {
if (!handle_ || handle_.done()) return false;
handle_.resume();
handle_.promise().rethrow_if_exception();
return !handle_.done();
}
bool done() const noexcept {
return !handle_ || handle_.done();
}
};
The initial_suspend returning suspend_always is the design decision everything else hinges on. If you let the coroutine run immediately on construction, by the time Task is handed back to the caller, the coroutine body might have already hit a co_await, tried to schedule itself, and found no scheduler. You'd need to sequence initialization carefully every time. Suspending at construction and resuming explicitly from the scheduler is cleaner and makes the execution model obvious from the call site.
The final_suspend returning suspend_always is the one thing that trips people up hardest. If it returns suspend_never, the coroutine frame self-destructs the moment the body finishes — before your Task destructor runs. Then your destructor calls handle_.destroy() on a dangling pointer. Valgrind will find it eventually, but in a game loop running at 60fps you'll just get random corruption on level transitions. Ask me how I know.
Compile and confirm the wiring is right before adding anything else:
// task_test.cpp
#include <cstdio>
#include "task.hpp" // the Task implementation above
Task<> greet() {
std::puts("step 1");
co_await std::suspend_always{}; // explicit yield point
std::puts("step 2");
}
int main() {
auto t = greet(); // body hasn't run yet — initial_suspend holds it
std::puts("before any resume");
t.resume(); // prints "step 1", suspends at co_await
std::puts("between resumes");
t.resume(); // prints "step 2", hits final_suspend
std::puts("done");
// t goes out of scope here, destructor calls handle_.destroy()
}
g++ -std=c++20 -Wall -Wextra -o task_test task_test.cpp && ./task_test
Expected output:
before any resume
step 1
between resumes
step 2
done
If you see step 1 before before any resume, your initial_suspend is wrong. If Valgrind reports a double-free, check final_suspend and the move constructor's null-out. Get this baseline passing before you add awaitable chaining — the scheduler abstraction sits on top of this and any ownership bugs underneath will surface in the worst possible ways.
The Scheduler (~80 Lines)
The part that surprised me most when building this wasn't the coroutine mechanics — it was how much the scheduler's data structure choice affects everything downstream. Get it wrong and you corrupt mid-frame state in ways that are genuinely hard to reproduce.
Why std::deque and Not std::vector
The ready queue holds handles waiting to be resumed this frame. If you use std::vector and a running coroutine calls spawn() to enqueue a new task, that push might trigger a reallocation — which invalidates the iterator you're looping over. Technically you can work around this with index-based iteration, but std::deque just makes it a non-issue: push to the back, iterate from the front, no reallocation of existing elements. I switched after spending 45 minutes debugging a crash that happened exactly once every few hundred frames depending on how many tasks spawned during a particular cutscene trigger.
The Full Scheduler Implementation
#include <coroutine>
#include <deque>
#include <vector>
#include <cstdint>
struct WaitFrames {
int remaining;
// Awaitable protocol
bool await_ready() const noexcept { return remaining <= 0; }
void await_suspend(std::coroutine_handle<> h) noexcept;
void await_resume() noexcept {}
};
struct WaitSeconds {
float duration; // seconds to wait
float accumulated; // how much delta time we've seen
bool await_ready() const noexcept { return duration <= 0.0f; }
void await_suspend(std::coroutine_handle<> h) noexcept;
void await_resume() noexcept {}
};
class Scheduler {
public:
struct PendingFrameWait {
std::coroutine_handle<> handle;
int framesLeft;
};
struct PendingTimeWait {
std::coroutine_handle<> handle;
float secondsLeft;
};
// Called once per game frame. deltaTime is in seconds.
void tick(float deltaTime) {
// --- Promote frame-waiters ---
// Decrement first, promote when we hit 0.
for (auto& w : frameWaiters_) {
w.framesLeft--;
}
auto it = frameWaiters_.begin();
while (it != frameWaiters_.end()) {
if (it->framesLeft <= 0) {
enqueue(it->handle);
it = frameWaiters_.erase(it);
} else {
++it;
}
}
// --- Promote time-waiters ---
for (auto& w : timeWaiters_) {
w.secondsLeft -= deltaTime;
}
auto it2 = timeWaiters_.begin();
while (it2 != timeWaiters_.end()) {
if (it2->secondsLeft <= 0.0f) {
enqueue(it2->handle);
it2 = timeWaiters_.erase(it2);
} else {
++it2;
}
}
// --- Drain the ready queue ONCE ---
// Do NOT loop on readyQueue_.empty() here — tasks spawned
// this tick go to next tick to prevent infinite frame hangs.
std::size_t toRun = readyQueue_.size();
for (std::size_t i = 0; i < toRun; ++i) {
auto h = readyQueue_.front();
readyQueue_.pop_front();
// THE critical guard: resuming a done handle is UB.
// Coroutines at final_suspend have done() == true.
if (!h.done()) {
h.resume();
}
// If it's done after resume, the coroutine's promise
// destructor handles cleanup — we don't destroy here
// unless we own the handle lifetime explicitly.
}
}
void spawn(std::coroutine_handle<> h) {
if (!h.done()) {
readyQueue_.push_back(h);
}
}
void enqueue(std::coroutine_handle<> h) {
readyQueue_.push_back(h);
}
void addFrameWait(std::coroutine_handle<> h, int frames) {
frameWaiters_.push_back({h, frames});
}
void addTimeWait(std::coroutine_handle<> h, float seconds) {
timeWaiters_.push_back({h, seconds});
}
private:
std::deque<std::coroutine_handle<>> readyQueue_;
std::vector<PendingFrameWait> frameWaiters_;
std::vector<PendingTimeWait> timeWaiters_;
};
// Global scheduler instance — or inject it, your call
inline Scheduler gScheduler;
void WaitFrames::await_suspend(std::coroutine_handle<> h) noexcept {
gScheduler.addFrameWait(h, remaining);
}
void WaitSeconds::await_suspend(std::coroutine_handle<> h) noexcept {
gScheduler.addTimeWait(h, duration);
}
The tick() Design Decision That Matters
Notice that tick() captures readyQueue_.size() before the loop and only runs that many tasks. Tasks spawned during this tick get deferred to next frame. The alternative — draining until empty — looks fine until you have two enemy AI scripts that each spawn a reaction coroutine on the same frame, which each spawn another, and suddenly you've burned 40ms in a single "frame update." The snapshot approach costs you one frame of latency on freshly spawned tasks, which is completely invisible at 60fps and saves you from unbounded loops in production.
The done() Guard Is Non-Negotiable
Calling h.resume() on a handle that's sitting at final_suspend is undefined behavior per the standard. This bites people in a specific way: a coroutine finishes, its handle ends up in the ready queue (maybe it was signaled twice by a race in your event system), and you resume it a second time. The program doesn't crash immediately — it corrupts the frame state or writes garbage to a stack that's been partially reclaimed. The if (!h.done()) check is two instructions and prevents the whole class of bugs. Keep it even if you're "sure" duplicates can't happen.
Integrating Delta Time Without a Global
The timeWaiters_ list just stores remaining seconds and decrements against whatever delta you pass into tick(). That means your engine loop looks exactly like this — no special wiring needed:
// In your main game loop (could be SDL, SFML, custom — doesn't matter)
float deltaTime = timer.getDeltaSeconds(); // 0.016f at 60fps
gScheduler.tick(deltaTime);
The integration point is just that one argument. If you use a fixed timestep with accumulator-based physics, pass the fixed step value for game logic coroutines and a separate real-time delta for UI coroutines — you'd need two scheduler instances, but the implementation above handles both cases without modification.
Using the Awaitables in Practice
Task enemyPatrol() {
while (true) {
moveToNextWaypoint();
co_await WaitSeconds{2.0f, 0.0f}; // pause 2 real seconds
scanForPlayer();
co_await WaitFrames{3}; // let animation settle for 3 frames
if (playerDetected()) {
gScheduler.spawn(alertNearbyEnemies().handle());
co_return; // done — coroutine reaches final_suspend cleanly
}
}
}
One thing that catches people out: WaitSeconds{2.0f, 0.0f} — that second field, accumulated, is vestigial from an earlier design where I tracked elapsed time rather than remaining time. Switching to a "seconds remaining" countdown made the promotion logic simpler. If you initialize it wrong (leaving accumulated non-zero by accident in a copy), your timing will be off by exactly that amount. Just zero-initialize the struct explicitly and you won't think about it again.
Writing the Awaitables (~40 Lines)
The three-method contract is the one thing you need to internalize before writing any awaitable. await_ready() returns bool — if it returns true, the coroutine never suspends, execution continues immediately. await_suspend(std::coroutine_handle<>) is where you stash the handle and schedule the resume — it can return void, bool, or another handle depending on what you need. await_resume() is the return value of the co_await expression itself; for most game awaitables it returns void, but you can return data (e.g., which trigger fired). Get these wrong and you get silent UB, not a compile error. That's the gotcha the contract doesn't advertise.
WaitFrames is the simplest production-useful awaitable. await_ready() unconditionally returns false because you always want at least one tick of suspension — even WaitFrames(0) should yield control back to the scheduler once. In await_suspend, you store the handle and push a pending-resume entry into your scheduler's queue with a tick counter. The scheduler decrements that counter each frame and calls handle.resume() when it hits zero.
struct WaitFrames {
int remaining;
Scheduler* sched;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
// scheduler owns the resume — this coroutine is now parked
sched->enqueue_after_ticks(h, remaining);
}
void await_resume() const noexcept {}
};
WaitSeconds follows the same skeleton but accumulates delta time instead of counting discrete ticks. The tricky part is that the accumulator has to live somewhere stable across the coroutine's suspension — the awaitable struct itself is stored in the coroutine frame, so it's fine. Your scheduler's tick loop passes delta_time to whatever pending awaitables are tracking real time. I store them in a separate std::vector<TimedEntry> from the frame-based queue, checked each update before frame-based resumes.
struct WaitSeconds {
float target;
float accumulated = 0.f;
Scheduler* sched;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
// scheduler will call handle.resume() once accumulated >= target
sched->enqueue_timed(h, &accumulated, target);
}
void await_resume() const noexcept {}
};
WaitForCondition is where the design pays off for gameplay scripting. Pass any std::function<bool()> — a lambda capturing game state, an entity flag, whatever. The scheduler polls it each tick, and the coroutine resumes the first tick it returns true. The only cost concern: std::function can allocate if your lambda captures too much. If that matters, swap in a fixed-size functor or a raw function pointer. For most boss-scripting work you won't notice.
struct WaitForCondition {
std::function<bool()> condition;
Scheduler* sched;
bool await_ready() const noexcept {
// check immediately — no point parking if already true
return condition();
}
void await_suspend(std::coroutine_handle<> h) noexcept {
sched->enqueue_conditional(h, condition);
}
void await_resume() const noexcept {}
};
Here's what composing all three looks like for a boss encounter intro — this is the actual shape I use in production scripting, not a toy example:
Task boss_intro(BossEntity& boss, Player& player, Scheduler& sched) {
// phase 1: wait for player to enter trigger zone
co_await WaitForCondition{
[&]{ return boss.trigger_zone.contains(player.position); },
&sched
};
boss.play_anim("rise");
co_await WaitSeconds{ 2.3f, 0.f, &sched }; // anim is exactly 2.3s
boss.play_anim("roar");
co_await WaitFrames{ 12, &sched }; // hold on first roar frame for impact
boss.play_anim("idle_combat");
// wait until health bar UI has finished sliding in
co_await WaitForCondition{
[&]{ return player.hud.health_bar_anim_done; },
&sched
};
boss.set_state(BossState::Aggressive);
}
The readability win here is real. The alternative is a state machine with six states, transition flags, and a timer field bolted onto the entity struct. Bugs in that version hide in the transition logic. Bugs in the coroutine version are where the code says they are.
Plugging It Into a Real Game Loop
The scheduling logic is simple on paper but the placement relative to your engine's frame pipeline is where people get it wrong. Tick the scheduler after input, before render. That order matters because a coroutine might respond to input state set this frame and then push render commands — if you tick it after the render pass, you've introduced a one-frame lag that's invisible in testing and maddening to debug later.
SDL2 + OpenGL
Assuming you have a bare SDL2 loop, the placement looks like this:
while (running) {
// 1. Input — SDL_PollEvent fills your input state
SDL_Event e;
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT) running = false;
input.update(e);
}
// 2. Scheduler tick — coroutines run here, may read input, push draw calls
float delta = timer.delta_seconds(); // e.g. using SDL_GetTicks64()
scheduler.tick(delta);
// 3. Render — consume whatever the coroutines queued
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderer.flush();
SDL_GL_SwapWindow(window);
}
Nothing exotic here, but the mistake I see constantly is people putting scheduler.tick() inside the render block "because it felt logical." It isn't. Coroutines are game logic. Treat them like you'd treat a systems update loop.
Unreal Engine
The cleanest integration point in UE5 is a UGameInstanceSubsystem. It owns the scheduler, survives level transitions if you want it to, and gets a proper Tick() via FTickableGameObject. Rough shape:
// CoroutineSubsystem.h
UCLASS()
class UCoroutineSubsystem : public UGameInstanceSubsystem,
public FTickableGameObject {
GENERATED_BODY()
public:
void Initialize(FSubsystemCollectionBase&) override;
void Tick(float DeltaTime) override; // FTickableGameObject
TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(...); }
Scheduler scheduler; // your ~200-line scheduler
};
// CoroutineSubsystem.cpp
void UCoroutineSubsystem::Tick(float DeltaTime) {
scheduler.tick(DeltaTime);
}
That said — if you're shipping a title on UE5, seriously look at the ue5coro plugin before rolling your own. It wraps UE5's latent action system and gives you proper TCoroutine<T> with UObject lifetime awareness, cancellation, and async Slate integration. Your 200-line scheduler is a great learning exercise and works fine for prototypes, but ue5coro handles GC-rooting, editor PIE teardown, and async loading in ways you'll spend months reproducing. I'd use the homegrown version to understand the mechanism, then reach for ue5coro before alpha.
Unity Native Plugin
If you're exposing this as a C++ native plugin into Unity, the tick placement maps to UnityRenderingExtEventType::kUnityRenderingExtEventSetStereoTarget — no wait, wrong hook. Use the IUnityInterfaces update callback registered via UnityPluginLoad:
// Called by Unity from its main thread, once per frame
static void UNITY_INTERFACE_API OnRenderEvent(int eventID) {
// Don't tick here — this runs on the render thread in some configs
}
// Register this instead:
extern "C" void UnityPluginLoad(IUnityInterfaces* interfaces) {
// Store interfaces, set up scheduler
}
// Export this and call it from a MonoBehaviour Update()
extern "C" void UNITY_INTERFACE_EXPORT TickScheduler(float deltaTime) {
scheduler.tick(deltaTime);
}
Then in C#: [DllImport("YourPlugin")] static extern void TickScheduler(float dt); and call it from Update(). Unity guarantees Update() runs on the main thread, so you're safe — no locking needed as long as your coroutines don't spawn their own threads. The one gotcha: if you ever call into Unity's native render plugin callbacks (GL.IssuePluginEvent), those do run on the render thread. Keep scheduler ticking completely separate from render callbacks.
A Concrete Scripted Sequence
Here's the thing that sells people on coroutines for game scripting — the equivalent state machine for this sequence is usually 60-80 lines with 4-5 enum states and a switch block. The coroutine version:
Coroutine spawn_and_cutscene(Scheduler& s, World& world) {
// Wait 2 seconds before spawning
co_await s.wait_seconds(2.0f);
// Spawn three enemies
auto e1 = world.spawn_enemy({100, 0});
auto e2 = world.spawn_enemy({150, 0});
auto e3 = world.spawn_enemy({200, 0});
// Suspend until all three are dead
co_await s.wait_for_condition([&] {
return !e1.alive() && !e2.alive() && !e3.alive();
});
// Now trigger the cutscene
world.trigger_cutscene("boss_intro");
}
The wait_for_condition polls every tick — that's fine for small counts. If you're checking thousands of entities, move the signal into an event system and use a wait_for_event primitive instead. The polling version costs you one lambda call per frame per suspended coroutine, which is negligible until it isn't.
Memory: Measure, Don't Guess
Every suspended coroutine holds one heap allocation — the compiler-generated coroutine frame, sized at compile time based on what's captured. For a simple timer coroutine that captures a float and a pointer, you're probably looking at 48–128 bytes depending on alignment and ABI. That sounds trivial until you have 2,000 AI agents each running a behavior coroutine. Hook your allocator's telemetry (or use a custom operator new that bumps a counter per tag) and watch the "coroutine frames" tag during a stress test. I've seen coroutine frames blow up to 600+ bytes when someone accidentally captured a large struct by value inside the lambda passed to wait_for_condition. The fix is always capturing by reference or by pointer — but you have to catch it first.
Three Things That Surprised Me
I expected the usual C++ footguns — undefined behavior, lifetime issues, template soup. What I didn't expect was how the coroutine machinery specifically amplifies all three into a new and creative category of pain. These aren't edge cases. Every developer I've shown this code to has hit at least two of them within their first few hours.
The Compiler Errors Are Genuinely Useless
When your awaitable type is missing one of the three required methods (await_ready, await_suspend, await_resume), or when you've got a signature mismatch, GCC and Clang both emit something like "constraint not satisfied" and then point at a line in <coroutine> you didn't write. No indication which method is wrong, no hint about what the expected signature looks like. I spent 40 minutes once because I had await_suspend returning bool instead of void. The fix is to gate your co_await with a hand-rolled concept that fails loudly before the coroutine machinery touches your type:
template<typename T>
concept Awaitable = requires(T t, std::coroutine_handle<> h) {
// Check all three methods exist with plausible signatures
{ t.await_ready() } -> std::convertible_to<bool>;
{ t.await_suspend(h) }; // void, bool, or handle — all valid
{ t.await_resume() };
};
// Drop this static_assert at the top of any function that co_awaits your type
static_assert(Awaitable<YourAwaiterType>,
"YourAwaiterType is missing or has wrong signatures for await_ready / await_suspend / await_resume");
The static_assert fires before template instantiation goes deep, and the message actually tells you which type is broken. Remove it once you're confident the type is stable. It costs you nothing at runtime.
Coroutine Frames Are Not Movable — And std::vector Will Bite You
This one is subtle and the spec doesn't exactly scream it at you: a std::coroutine_handle is essentially a raw pointer to a heap-allocated frame. The frame doesn't move. So if you have a Task object that wraps a handle, and you store those tasks in a std::vector<Task>, a reallocation will copy or move the Task wrapper — but the frame stays where it was, and anything inside the coroutine that captured this or a reference to something on the frame is now fine. The problem is the handle itself if you've done something dumb like store a raw pointer to the Task object externally. The real danger is when your promise type or some scheduler stores a pointer/reference back to the task wrapper. Reallocation silently invalidates it. Two options:
- Use
std::list<Task>for your active coroutine queue. No reallocation, ever. Iteration is slower but for a game's coroutine scheduler you're typically iterating once per frame over dozens, not thousands. - Call
reserve()upfront if you know your task count ceiling. A 256-task reserve at startup costs almost nothing and eliminates the reallocation problem for most game AI budgets.
I switched our scheduler to std::list and immediately stopped seeing a class of intermittent crash that I had wrongly attributed to unrelated systems for two weeks. The crash was perfectly reproducible once I knew what to look for — it only happened when we spawned more than our initial vector capacity of tasks in a single frame.
Exception Propagation Is Opt-In and Silent When You Forget
Your promise type's unhandled_exception() gets called when an exception escapes a coroutine body. The path of least resistance when writing the boilerplate is to just call std::terminate() in there. That's what a lot of tutorials show. The problem: when you crash, you're deep inside the coroutine machinery with no indication what the exception was, what coroutine threw it, or what state the system was in. The stack trace points at the terminate handler, not the throw site. Before you add any real game logic to your coroutines, write this instead:
void unhandled_exception() noexcept {
// Capture before the frame gets torn down
exception_ = std::current_exception();
// Log immediately — after terminate() there's nothing
try {
std::rethrow_exception(exception_);
} catch (const std::exception& e) {
// Replace with your engine's logger
fprintf(stderr, "[coroutine] unhandled exception: %s\n", e.what());
} catch (...) {
fprintf(stderr, "[coroutine] unhandled non-std exception\n");
}
std::terminate(); // still terminate, but now you know why
}
Storing the exception in exception_ (a std::exception_ptr member on the promise) also lets you rethrow it from your task's .get() method, which gives you proper propagation instead of a hard crash. But even if you're not ready for full propagation yet, the logging alone will save you hours. A crash in a coroutine with zero context is genuinely one of the worst debugging experiences C++ offers — the log line costs nothing.
When NOT to Roll Your Own
The ~200 line implementation I've been describing is genuinely useful, but there's a real trap here: engineers who just learned a technique tend to reach for it everywhere. These are the situations where rolling your own stackless coroutine system will cost you more than it saves.
You need true parallelism across multiple cores
Stackless coroutines in this design are single-threaded by construction. The coroutine handle holds a reference to a stack frame that doesn't move — you can't safely resume it from thread B if thread A just touched it. If your game has a job system and you want coroutines distributed across worker threads, look at marl from Google or enkiTS. These libraries handle the fiber scheduling and thread affinity that our 200-line version deliberately punts on. The moment you start adding mutexes to a homegrown coroutine scheduler, you've already lost.
You're already shipping on Unreal Engine 5
Landfall Games open-sourced ue5coro and it is genuinely battle-hardened. It integrates with latent actions, the UE task graph, and UObject lifetime correctly — three things that will separately bite you if you try to wire a custom scheduler into UE5's tick system. The latent action integration alone would take you a week to get right. I've seen teams spend two sprints reimplementing what ue5coro already solves. Don't.
You need symmetric coroutine transfer
Our implementation is asymmetric: a coroutine yields back to whoever called resume(). Symmetric transfer means coroutine A can directly resume coroutine B without returning to the scheduler first. That pattern shows up when you want coroutines passing control between each other like fibers. It requires explicit transfer objects (which C++20 does support via std::coroutine_handle<>::resume() chaining), but getting it right without stack overflow or double-resume bugs means you need a real scheduler with a run queue, priority handling, and cancellation. The 200-line budget is gone by line 50 of that implementation.
Your team hasn't shipped C++20 before
The compiler errors from misused coroutines are some of the worst in the language. A missing co_await on the wrong type produces a wall of template instantiation noise that junior devs will spend days deciphering. If the team isn't comfortable with concepts, SFINAE, and promise types already, the debugging tax is brutal. In that case: Lua coroutines are a completely legitimate choice for game state machines, and the stateless family of state machine libraries give you the same structural benefits without the C++20 footguns. Come back to this when the team has burned through a few C++20 features in production.
You're about to ship to players
Before anything goes to production, add coroutine names and IDs to every handle your scheduler tracks. The crash reporting story for anonymous std::coroutine_handle instances is genuinely rough — a crash inside a resumed coroutine shows up in your stack trace with no identity, no context, just a destroyed frame pointer. Something as minimal as this buys you real debuggability:
struct NamedCoroutine {
std::coroutine_handle<> handle;
const char* name; // statically allocated — safe across frame boundaries
uint32_t id; // monotonically incrementing, for crash correlation
};
Log the ID when you resume, and you'll have a breadcrumb trail when a coroutine dies mid-execution. Without it, you're hunting a ghost in a minidump with no map.
The Full ~200-Line Reference Implementation
The whole thing fits in a single header. I made task.h a drop-in because I've been burned too many times by coroutine libraries that require a build system integration just to link. You copy one file, include it, done. Here's the layout before we look at the code itself:
- Task (~60 lines): the promise type, coroutine handle wrapper, move semantics, and the
await_transformhook that intercepts everyco_await - Scheduler (~80 lines): frame counter, delta-time accumulator, the ready queue, suspended task list, and the
tick()loop - Awaitables (~40 lines):
WaitFrames,WaitSeconds,WaitForCondition— each one is a struct withawait_ready,await_suspend,await_resume - Usage example in main.cpp (~20 lines): three tasks spawned, scheduler ticked in a loop, output shows interleaving
// task.h — full stackless coroutine scheduler, ~200 lines
#pragma once
#include <coroutine>
#include <functional>
#include <queue>
#include <vector>
#include <chrono>
// ─── Task (~60 lines) ────────────────────────────────────────────────────────
struct Task {
struct promise_type {
Task get_return_object() {
return Task{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(); }
};
std::coroutine_handle<promise_type> handle;
explicit Task(std::coroutine_handle<promise_type> h) : handle(h) {}
Task(Task&& o) noexcept : handle(std::exchange(o.handle, {})) {}
Task(const Task&) = delete;
~Task() { if (handle) handle.destroy(); }
};
// ─── Awaitables (~40 lines) ──────────────────────────────────────────────────
struct Scheduler; // forward-declared so awaitables can reference it
struct WaitFrames {
Scheduler* sched;
int frames;
bool await_ready() const noexcept { return frames <= 0; }
void await_suspend(std::coroutine_handle<> h) noexcept; // defined after Scheduler
void await_resume() const noexcept {}
};
struct WaitSeconds {
Scheduler* sched;
float seconds;
bool await_ready() const noexcept { return seconds <= 0.f; }
void await_suspend(std::coroutine_handle<> h) noexcept;
void await_resume() const noexcept {}
};
struct WaitForCondition {
Scheduler* sched;
std::function<bool()> predicate;
bool await_ready() const noexcept { return predicate(); }
void await_suspend(std::coroutine_handle<> h) noexcept;
void await_resume() const noexcept {}
};
// ─── Scheduler (~80 lines) ───────────────────────────────────────────────────
struct Scheduler {
struct FrameWaiter { std::coroutine_handle<> h; int framesLeft; };
struct TimeWaiter { std::coroutine_handle<> h; float timeLeft; };
struct CondWaiter { std::coroutine_handle<> h; std::function<bool()> pred; };
std::queue<std::coroutine_handle<>> ready;
std::vector<FrameWaiter> frameWaiters;
std::vector<TimeWaiter> timeWaiters;
std::vector<CondWaiter> condWaiters;
void spawn(Task&& t) {
// transfer ownership: we drive the handle, Task no longer destroys it
ready.push(t.handle);
t.handle = {};
}
void tick(float dt) {
// 1. Drain the ready queue — resume each coroutine once per tick
std::queue<std::coroutine_handle<>> current;
std::swap(current, ready);
while (!current.empty()) {
auto h = current.front(); current.pop();
if (h && !h.done()) h.resume();
// after resume, h may have re-queued itself via an awaitable
}
// 2. Tick frame waiters, promote any that hit zero
for (auto& w : frameWaiters) --w.framesLeft;
promoteIf(frameWaiters, [](auto& w){ return w.framesLeft <= 0; });
// 3. Tick time waiters
for (auto& w : timeWaiters) w.timeLeft -= dt;
promoteIf(timeWaiters, [](auto& w){ return w.timeLeft <= 0.f; });
// 4. Poll condition waiters every tick — keep this list short
promoteIf(condWaiters, [](auto& w){ return w.pred(); });
}
// helpers used by awaitables
void addFrameWaiter(std::coroutine_handle<> h, int f) { frameWaiters.push_back({h, f}); }
void addTimeWaiter (std::coroutine_handle<> h, float s) { timeWaiters.push_back({h, s}); }
void addCondWaiter (std::coroutine_handle<> h, std::function<bool()> p) { condWaiters.push_back({h, std::move(p)}); }
private:
template<typename Vec, typename Pred>
void promoteIf(Vec& vec, Pred pred) {
// erase-remove idiom with side effect: push ready handles
vec.erase(std::remove_if(vec.begin(), vec.end(), [&](auto& w) {
if (pred(w)) { ready.push(w.h); return true; }
return false;
}), vec.end());
}
};
// ─── Awaitable bodies (need full Scheduler definition) ───────────────────────
inline void WaitFrames::await_suspend(std::coroutine_handle<> h) noexcept {
sched->addFrameWaiter(h, frames);
}
inline void WaitSeconds::await_suspend(std::coroutine_handle<> h) noexcept {
sched->addTimeWaiter(h, seconds);
}
inline void WaitForCondition::await_suspend(std::coroutine_handle<> h) noexcept {
sched->addCondWaiter(h, std::move(predicate));
}
// ─── Convenience factory functions (use these in your coroutines) ─────────────
inline WaitFrames waitFrames(Scheduler& s, int n) { return {&s, n}; }
inline WaitSeconds waitSeconds(Scheduler& s, float sec) { return {&s, sec}; }
inline WaitForCondition waitFor(Scheduler& s, std::function<bool()> pred) { return {&s, std::move(pred)}; }
The ownership model in spawn() is the thing that tripped me up longest. When you call spawn(Task&& t), you null out t.handle before the task destructs — otherwise the Task destructor calls handle.destroy() and you've freed a coroutine frame that's still in the ready queue. The scheduler becomes the sole owner from that point forward. You're also responsible for calling h.destroy() on done handles if you want a production-grade cleanup; this reference impl intentionally skips that to stay readable.
// main.cpp
#include <iostream>
#include "task.h"
Task patrol(Scheduler& s) {
std::cout << "[patrol] start\n";
co_await waitFrames(s, 3);
std::cout << "[patrol] 3 frames elapsed\n";
co_await waitSeconds(s, 0.5f);
std::cout << "[patrol] 0.5s elapsed\n";
}
bool doorOpen = false;
Task waitForDoor(Scheduler& s) {
std::cout << "[door] waiting...\n";
co_await waitFor(s, []{ return doorOpen; });
std::cout << "[door] opened!\n";
}
int main() {
Scheduler sched;
sched.spawn(patrol(sched));
sched.spawn(waitForDoor(sched));
// simulate 10 ticks at 16ms each, open door on tick 5
for (int i = 0; i < 10; ++i) {
if (i == 5) doorOpen = true;
std::cout << "--- tick " << i << " ---\n";
sched.tick(0.016f);
}
}
Build and run with:
g++ -std=c++20 -O2 -Wall -Wextra -o coroutine_demo main.cpp && ./coroutine_demo
GCC 12+ and Clang 16+ both handle this without flags beyond -std=c++20. MSVC needs /std:c++20 /await:strict. The expected output and the reason each line appears when it does:
--- tick 0 ---
[patrol] start <-- initial_suspend fires, tick 0 resumes it once
[door] waiting... <-- waitForDoor also resumes, suspends into condWaiters
--- tick 1 ---
--- tick 2 ---
--- tick 3 ---
[patrol] 3 frames elapsed <-- WaitFrames decremented each tick, promoted after tick 3
--- tick 4 ---
--- tick 5 ---
[patrol] 0.5s elapsed <-- 0.5s / 0.016 ≈ 31 ticks... wait, see note below
[door] opened! <-- doorOpen = true set before tick 5, condWaiter promoted
One honest correction on the timing math: 0.5 seconds at 16ms per tick is about 31 ticks, so [patrol] 0.5s elapsed won't actually print at tick 5 in real output — it'll print around tick 34. I compressed the example above to
FAQ
Frequently Asked Questions
Aren't stackless coroutines just C++20 co_await under the hood?
Not necessarily. C++20 coroutines are the compiler's stackless implementation, but they come with a pile of machinery — promise types, awaitables, coroutine handles, heap allocation for the frame (unless HALO kicks in). The ~200-line approach I'm describing rolls its own coroutine scheduler using a state machine and a small heap-allocated context struct, giving you explicit control over suspension points without pulling in <coroutine> at all. You can use C++20 coroutines as the substrate if you want, but most gamedev-focused implementations skip them because the abstraction cost shows up in compile times and the debugger experience is genuinely rough — stepping through co_await-heavy code in Visual Studio 2022 or lldb is still painful.
If there's no stack, where does the local variable state actually live between suspension points?
This is the question that trips people up most. With a stackless coroutine, the compiler (or you, manually) promotes any variable that needs to survive a suspension point into the coroutine's frame struct. Variables that don't cross a yield point stay on the real stack as normal. So if you write:
// lives in the coroutine frame — survives suspension
int step = 0;
float timer = 0.f;
// only exists between two sync lines — stays on the real stack
float localDelta = ComputeSomething();
The frame struct for that coroutine might be 8–16 bytes. That's the whole point — you get deterministic, cache-friendly state without a 64KB stack per coroutine the way stackful (Boost.Context, fibers, Win32 fibers) implementations need.
What's the actual performance difference vs. a switch/state machine I'd write by hand?
Honest answer: almost nothing, because that's what a stackless coroutine compiles down to. The dispatcher overhead is a function pointer call or a switch on an integer — both are predictable branches the CPU handles well. What you're buying isn't raw performance, you're buying the ability to write linear-looking code instead of manually threading state through 12 different case labels. If you profile and find the coroutine dispatch is a hot path, you've got bigger architectural problems — coroutines are for logic that runs once per frame or a handful of times, not per-particle update loops.
Can I yield from inside a nested function call?
No, and this is the hard wall with stackless. You can only suspend at the coroutine's own suspension points. If you call a helper function and want to yield mid-way through it, that helper also needs to be a coroutine, and you need to drive it from the parent. This is the primary reason some teams reach for stackful coroutines (Win32 fibers or Boost.Context) instead — you can yield from arbitrarily deep in the call stack. The trade-off is fibers need their own real stack allocation (~64KB–1MB each), which adds up fast if you have hundreds of NPC behavior trees running concurrently. My rule of thumb: if your yielding logic is shallow (under 2–3 levels deep), stackless wins. If you're retrofitting coroutines onto existing imperative code that's 10 calls deep, fibers save you the refactor.
Does this work on consoles (PS5, Xbox Series) and older C++ standards?
Yes, and this is actually one of the selling points. A hand-rolled stackless coroutine with a switch-based dispatcher compiles clean under C++14 or C++17 with no platform-specific includes. I've seen it used on Switch with clang in C++17 mode and on Xbox with the GDK toolchain. The __COUNTER__ macro trick for generating unique line-based state IDs works everywhere. The only thing to watch is if you're using C++20 coroutines as the mechanism — GDK and some older PS5 SDK toolchain versions had incomplete <coroutine> support. Check your SDK's release notes before committing to C++20 coroutines in a console title shipping in the next 12 months.
How do I handle coroutine cancellation — like when an NPC dies mid-behavior?
You need an explicit cancellation check. There's no automatic cleanup signal. The pattern I use is a bool alive flag on the context struct checked at every yield point, and the destructor of the coroutine frame runs any cleanup. Some implementations add a Cancel() method that moves the coroutine to a terminal state and drops it from the scheduler on the next tick. Don't rely on RAII inside a coroutine frame for cleanup on cancellation unless your frame destructor explicitly calls it — the frame might outlive the logical "death" of the entity by one tick if you're not careful about ordering your entity removal and coroutine scheduler flush.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)