The Gameplay Layer
The engine can render, animate, and simulate, and now a designer asks for a door that can be shot open, a torch you can pick up, and an enemy type by Friday, without a programmer. This is the layer where the engine becomes a game: how a "thing in the world" is modeled, how its parts talk without turning into a tangle, how it's spawned and destroyed safely, and how its definition and behavior move out of compiled code into data and script. Most of this module is architecture, the patterns that decide whether a codebase stays workable at scale.
- Explain why deep inheritance breaks on cross-cutting behavior, and choose between Unity-style, Unreal-style and ECS composition.
- Wire components together with direct references, observers or an event queue, knowing each one's failure mode.
- Handle update-order dependencies with dependency ordering or a double buffer, and know which lag each leaves.
- Destroy and spawn objects mid-tick without crashing the loop that's iterating them.
- Hold cross-frame references with generational handles instead of raw pointers.
- Define enemy variants as prefabs that inherit and override, and build them with a component factory.
- Embed Lua: call scripts safely each tick, write multi-tick behavior as coroutines, hot-reload without losing state, and know what not to script.
01The gameplay layer
Every game object question (what is a door, how does it hear a switch, when does it update, who decides it exists) lands in one layer of the engine. Gregory calls this the gameplay foundation layer: the game object model, the world structure, the event system, and the scripting system, all sitting on top of the low-level engine[1]. It's distinct from ECS-as-storage: ECS is how component data is laid out and iterated; the gameplay object model is how you think about and wire up a thing in the world. An ECS is one way to implement the object model, not the only one.
Worked example
The designer's shootable door, built from parts: a Transform (where it is), a Mesh (what it looks like), a Collider (what it blocks), a Door (open and closed states), and a Health (how many hits it takes). Five components, none of them new code; the only new thing is the combination.
How is a game object modeled (§2–3)? How do its parts communicate without hard coupling (§4)? When and in what order does it update, and how does it live and die safely (§5–6)? And how is its definition and behavior moved out of compiled code into data and script (§7–8)? The loop itself (accumulator, fixed dt, alpha interpolation) and the storage layout (ECS) are prerequisites, not re-covered here.
02Why inheritance failed
Every new kind of object used to mean a new class, and the tree of classes grew until nobody could place the next one. The historical model was a deep class tree: GameObject → Character → Player, GameObject → Item → Weapon. It breaks because behavior cuts across the tree. Where does a door that is also damageable go? An item that is also a light source? A flying enemy that needs AI, health, and a flight model? Single inheritance can't express "is-a" two ways, and multiple inheritance gives you the diamond and a layout nobody can whiteboard.
Try to express every combination by subclassing and N cross-cutting behaviors needs up to 2N leaf classes (FlyingDamageable, FlyingLight, FlyingDamageableLight…). Everything also drifts up into a fat base GameObject. But inheritance is not "always wrong": the failure is deep, behavior-bearing hierarchies for cross-cutting concerns. Shallow, purposeful inheritance ships AAA games; Unreal's ActorComponent → SceneComponent → PrimitiveComponent spine is deliberate (§3). (The ECS tutorial makes the cache-layout case against the OOP version; this is the architecture case.)
Worked example
With four cross-cutting behaviors (Damageable, Flying, LightSource, Networked), every non-empty combination needs its own class: 24 − 1 = 15. Add six more behaviors and it's 1,023 classes. As components it stays 10, one per behavior, attached in whatever combination an object needs.
The widget counts both as you toggle behaviors:
03Composition: three models
Once objects are built from parts, the next decision is what a part is and who calls it. The fix: the entity becomes a container of components, each owning one domain (transform, render, physics, AI)[2]. But splits into three different models, each shipping AAA games, and they should not be collapsed into one:
AddComponent<T>(); components are heap C# objects[3]Easy, flexible; polymorphic + pointer-chased (not data-oriented)ECS is one of three live models, not "the modern/correct" one. Unreal is composition with inheritance; Unity is GameObject+component (with DOTS/ECS optional for hot paths). And Unity-style "everything is a component" composition is not the same as data-oriented ECS: those components are still polymorphic, heap-allocated, and pointer-chased; the ECS cache win comes from storage layout, not from "using components." Pick the model that fits the game.
Worked example
Ten thousand objects with four components each, in the Unity-style listing below, cost 40,000 virtual update calls a frame, each on a separately allocated component. The same behavior in an ECS is four system loops, each walking one packed array. Both are composition; the difference is where the data lives and who loops over it.
struct Component { virtual void update(float dt) = 0; virtual ~Component() = default; };
class GameObject {
std::vector<std::unique_ptr<Component>> components;
public:
template<class T, class... A> T* add(A&&... a) { // composition: attach behavior
auto c = std::make_unique<T>(std::forward<A>(a)...);
T* raw = c.get(); components.push_back(std::move(c)); return raw;
}
void update(float dt) { for (auto& c : components) c->update(dt); }
}; // Unity-style: clear, but polymorphic + heap-allocated (not the ECS layout)
trait Component { fn update(&mut self, dt: f32); }
struct GameObject { components: Vec<Box<dyn Component>> }
impl GameObject {
fn add(&mut self, c: Box<dyn Component>) { self.components.push(c); }
fn update(&mut self, dt: f32) { for c in &mut self.components { c.update(dt); } }
}
// In Rust, one component reaching into another fights the borrow checker,
// which pushes designs toward an ECS (see the ECS tutorial). Shown for parity.
04Component communication
Components need to talk (a health component tells the UI bar, a trigger tells a door) without every component holding a hard pointer to every other, which would bring back the coupling composition was meant to remove. Three options that coexist[2]: direct references (fast, fine within one entity, dangerous across), the observer pattern, and an .
The observer (immediate): the subject fires onNotify synchronously and blocks until every observer returns[5]. Simple and debuggable, but watch the lapsed-listener leak (a destroyed observer never unsubscribed becomes a zombie), notification order dependence, and reentrancy (a handler that mutates the subject mid-notify). The event queue (deferred): enqueue and return, drain once per frame[6]. Decoupled and batchable, but adds latency, ordering questions, and stale-world risk (the world changed since you sent it). A subtle one: the queue hides reentrancy by unwinding the stack, so spurious events keep circulating from frame to frame without ever crashing, and the bug goes unnoticed. Neither model is "correct"; real engines use both.
Worked example
A Health component fires "damaged" to three listeners: the HUD bar, the hit-sound player and the AI. Immediate, all three run inside takeDamage() before it returns, so the AI can react in the same tick. Deferred at 60 fps, the event waits in the queue and the three run when it drains, up to 16.7 ms later, and the AI reacts a frame late.
The widget runs the same bus both ways:
template<class E>
class Channel {
std::vector<std::function<void(const E&)>> listeners;
std::vector<E> pending;
public:
void subscribe(std::function<void(const E&)> fn) { listeners.push_back(std::move(fn)); }
void emit(const E& e) { // IMMEDIATE: synchronous, blocks
auto snapshot = listeners; // copy: a handler that subscribes mid-emit would
// otherwise invalidate this loop's iterators
for (auto& fn : snapshot) fn(e);
}
void enqueue(E e) { pending.push_back(std::move(e)); } // DEFERRED
void drain() { // once per frame
auto batch = std::move(pending); pending.clear(); // new events wait for next frame
for (auto& e : batch) emit(e);
}
};
struct Channel<E> {
listeners: Vec<Box<dyn Fn(&E)>>,
pending: Vec<E>,
}
impl<E: Clone> Channel<E> {
fn subscribe(&mut self, f: Box<dyn Fn(&E)>) { self.listeners.push(f); }
// IMMEDIATE. Takes &self, so no handler can subscribe mid-emit and the
// C++ pane's defensive copy is unnecessary here.
fn emit(&self, e: &E) { for f in &self.listeners { f(e); } }
fn enqueue(&mut self, e: E) { self.pending.push(e); } // DEFERRED
fn drain(&mut self) { // once per frame
let batch = std::mem::take(&mut self.pending);
for e in &batch { self.emit(e); }
}
}
05Update order
A turret aims at a moving target, and whether it aims at where the target is or where it was depends on which of them updated first. Gameplay ticks inside the fixed-step block of the game loop (input, AI, gameplay, physics run at the constant dt; rendering interpolates with alpha outside it). The Update Method pattern gives each object an update(dt) called once per tick[7].
The loop calls those methods one at a time, so a tick is a sequence of turns. If A comes before B in the list, A sees B's state from the previous tick and B sees A's new state[7]. Neither object has a bug; the order decides what each one reads. It shows up whenever one object reads another's state during its own update: a turret reading its target, a trailer following a truck, a camera following the player.
Worked example
A target runs at 10 m/s with a 60 Hz tick. If the turret updates before the target, it aims at last tick's position, 10 ÷ 60 = 0.17 m behind where the target ends the tick. Fix the order so the target always moves first, or have the turret lead its shot.
A chain of dependent objects makes it worse, because the lag adds up link by link. The widget is a convoy: a lead truck and five trailers, each placed 3 m behind the one ahead at the moment it updates, at 60 ticks a second.
Worked example
At 10 m/s the truck moves 10 ÷ 60 = 0.17 m per tick. Update the convoy front to back and each trailer reads a position that has already moved this tick, so each sits exactly 3 m behind the one ahead. Update it back to front and each trailer reads the one ahead before that one has moved, one tick out of date: 0.17 m per link, and the fifth trailer ends up 5 × 0.17 = 0.83 m behind its proper spot.
There are three fixes, and engines use all of them:
- Order by dependency. Update whatever is read before whatever reads it. Unreal sorts ticks into groups that run in a fixed order (pre-physics, during physics, post-physics, post-update work), finishes each group before starting the next, and lets one actor or component declare that it must tick after another with a tick prerequisite[8]. Unity's Script Execution Order setting runs script classes from the lowest number to the highest[9]; it ranks classes, so it can't put trailer 2 before trailer 3 when both run the same script.
- Read last tick's state. Keep a copy of each object's state from the previous tick and have every reader use the copy, the Double Buffer pattern. Order stops mattering because everyone reads the same snapshot, but every link now lags exactly one tick. In Nystrom's version, "an actor will only see a slap in the frame after it was actually slapped"[10]. In the convoy that's the same 0.83 m as back to front, in any order.
- Accept the lag where nobody can see it. Unreal's own example puts a HUD meter in the during-physics group and notes the HUD will be one frame behind the game[8]. For a meter, nobody notices.
If nothing defines the order, nothing stops it changing between ticks as objects are added, removed or moved between slots. Then a link is 0 ticks behind on one tick and 2 on the next, which is the shuffled case: a fixed wrong order lags smoothly, an unspecified one jitters. Two more rules come with update methods. The code must store its own cross-frame state, because returning from update() loses the execution position[7] (§8 shows how coroutines keep it). And don't modify the object list while iterating it, which is the lifetime problem in §6.
06Lifetime & handles
A rocket kills an enemy in the middle of the update loop, while three other objects still hold references to it. Two lifetime hazards, two standard fixes. Destroying an object mid-update (while the loop iterates the object list) invalidates iterators and leaves dangling references mid-frame, the classic gameplay crash. Deferred destruction is the fix: mark for death now, actually destroy at a safe point (end of frame, before render). Unity does exactly this: Destroy() delays actual destruction until after the Update loop, before rendering[11].
Gameplay holds long-lived cross-frame references (an AI's target, a projectile's owner). A raw pointer dangles when the target is destroyed and its slot reused, a silent use-after-free with no way to detect staleness[12]. A ({index, generation}) bumps the slot's generation on free; a stale handle fails the generation check on resolve and returns null instead of corrupt memory. The handle is never dereferenced blindly; every use goes through that check. The ECS tutorial builds the full allocator; here it's how a gameplay reference survives the target's death.
Worked example
An AI saves a handle {index 2, generation 3} to its target. The target dies, so slot 2's generation becomes 4, and a pickup spawns into slot 2. On the next resolve the handle's 3 doesn't match the slot's 4, so it returns null and the AI picks a new target. A raw pointer to slot 2 would quietly hand back the pickup.
The widget plays that sequence with both kinds of reference:
Spawning has the mirror-image problem. An object added to the list during the update loop gets updated later in the same loop, so it acts in the tick it was spawned, before the player has had a chance to see it[7]. Nystrom's simple fix caches the list length when the loop starts, so anything appended after that waits for the next tick.
The program below puts the three rules together: handles for every cross-frame reference, destruction that marks now and frees at the end of the tick, and spawning that waits a tick (a flag holds each new object back until the tick ends, the same trick as the death mark). The update loop indexes the slot array and re-reads it each pass, because a spawn can grow the array and move it in memory, which would leave a held reference or iterator pointing at the old block.
// A tiny game-object world: generational handles, deferred destruction,
// and objects spawned mid-tick that wait for the next tick to act.
#include <cstdint>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
struct Handle { std::uint32_t index = 0; std::uint32_t generation = 0; };
enum class Kind { Rocket, Goblin, Archer, Loot };
struct GameObject {
Kind kind;
std::string name;
Handle target; // a cross-frame reference is a handle, never a pointer
};
class World {
struct Slot {
GameObject object{Kind::Loot, "", {}};
std::uint32_t generation = 0;
bool alive = false;
bool dying = false; // destroyed this tick, freed at the end of it
bool spawnedThisTick = false;
};
std::vector<Slot> slots;
std::vector<std::uint32_t> freeSlots;
public:
Handle spawn(GameObject object) {
std::uint32_t index;
if (!freeSlots.empty()) { index = freeSlots.back(); freeSlots.pop_back(); }
else { index = static_cast<std::uint32_t>(slots.size()); slots.emplace_back(); }
Slot& slot = slots[index];
slot.object = std::move(object);
slot.alive = true;
slot.dying = false;
slot.spawnedThisTick = true; // don't let it act before anyone has seen it
return {index, slot.generation};
}
// Null for a handle whose object was freed, or marked for death this tick.
GameObject* resolve(Handle handle) {
if (handle.index >= slots.size()) return nullptr;
Slot& slot = slots[handle.index];
if (!slot.alive || slot.dying || slot.generation != handle.generation) return nullptr;
return &slot.object;
}
// Mark now, free later: the memory stays valid until endTick().
void destroy(Handle handle) {
if (resolve(handle)) slots[handle.index].dying = true;
}
template <class UpdateFn>
void tick(UpdateFn&& updateObject) {
// Index loop, re-reading slots[i] each pass: spawn() may grow the
// vector, which would invalidate a reference or iterator held across it.
for (std::uint32_t i = 0; i < slots.size(); ++i) {
if (!slots[i].alive || slots[i].dying || slots[i].spawnedThisTick) continue;
updateObject(*this, Handle{i, slots[i].generation});
}
endTick();
}
private:
void endTick() {
for (std::uint32_t i = 0; i < slots.size(); ++i) {
Slot& slot = slots[i];
if (slot.dying) {
slot.alive = false;
slot.dying = false;
++slot.generation; // every old handle to this slot is now stale
freeSlots.push_back(i);
}
slot.spawnedThisTick = false;
}
}
};
void updateObject(World& world, Handle self) {
GameObject* me = world.resolve(self);
switch (me->kind) {
case Kind::Rocket:
if (GameObject* victim = world.resolve(me->target)) {
std::printf(" %s hits %s\n", me->name.c_str(), victim->name.c_str());
world.destroy(me->target);
world.spawn({Kind::Loot, "loot", {}}); // may reallocate: don't use 'me' after this
world.destroy(self);
}
break;
case Kind::Archer:
if (GameObject* target = world.resolve(me->target))
std::printf(" %s shoots at %s\n", me->name.c_str(), target->name.c_str());
else
std::printf(" %s: target is gone, holding fire\n", me->name.c_str());
break;
case Kind::Goblin:
std::printf(" %s growls\n", me->name.c_str());
break;
case Kind::Loot:
std::printf(" %s sparkles\n", me->name.c_str());
break;
}
}
int main() {
World world;
Handle goblin = world.spawn({Kind::Goblin, "goblin", {}});
world.spawn({Kind::Rocket, "rocket", goblin});
world.spawn({Kind::Archer, "archer", goblin});
world.tick([](World&, Handle) {}); // end the spawn tick so everything is active
for (int tickNumber = 1; tickNumber <= 2; ++tickNumber) {
std::printf("tick %d\n", tickNumber);
world.tick(updateObject);
}
// Two newcomers reuse the two freed slots, one of them the goblin's.
Handle rat = world.spawn({Kind::Goblin, "rat", {}});
Handle bat = world.spawn({Kind::Goblin, "bat", {}});
std::printf("rat in slot %u, bat in slot %u\n", rat.index, bat.index);
std::printf("old goblin handle {%u, %u} resolves to %s\n", goblin.index,
goblin.generation, world.resolve(goblin) ? "an object" : "null");
}
// A tiny game-object world: generational handles, deferred destruction,
// and objects spawned mid-tick that wait for the next tick to act.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Handle { index: u32, generation: u32 }
#[derive(Clone, Copy, PartialEq)]
enum Kind { Rocket, Goblin, Archer, Loot }
struct GameObject {
kind: Kind,
name: String,
target: Handle, // a cross-frame reference is a handle, never a reference
}
struct Slot {
object: Option<GameObject>,
generation: u32,
dying: bool, // destroyed this tick, freed at the end of it
spawned_this_tick: bool,
}
#[derive(Default)]
struct World { slots: Vec<Slot>, free_slots: Vec<u32> }
impl World {
fn spawn(&mut self, object: GameObject) -> Handle {
let index = match self.free_slots.pop() {
Some(index) => index,
None => {
self.slots.push(Slot { object: None, generation: 0, dying: false, spawned_this_tick: false });
(self.slots.len() - 1) as u32
}
};
let slot = &mut self.slots[index as usize];
slot.object = Some(object);
slot.dying = false;
slot.spawned_this_tick = true; // don't let it act before anyone has seen it
Handle { index, generation: slot.generation }
}
// None for a handle whose object was freed, or marked for death this tick.
fn resolve(&mut self, handle: Handle) -> Option<&mut GameObject> {
let slot = self.slots.get_mut(handle.index as usize)?;
if slot.dying || slot.generation != handle.generation { return None; }
slot.object.as_mut()
}
// Mark now, free later: the object stays in its slot until end_tick().
fn destroy(&mut self, handle: Handle) {
if self.resolve(handle).is_some() { self.slots[handle.index as usize].dying = true; }
}
fn tick(&mut self, update_object: fn(&mut World, Handle)) {
// Index loop: update_object needs &mut World to spawn and destroy,
// so no borrow of self.slots can be held across the call.
for i in 0..self.slots.len() {
let slot = &self.slots[i];
if slot.object.is_none() || slot.dying || slot.spawned_this_tick { continue; }
let handle = Handle { index: i as u32, generation: slot.generation };
update_object(self, handle);
}
self.end_tick();
}
fn end_tick(&mut self) {
for (i, slot) in self.slots.iter_mut().enumerate() {
if slot.dying {
slot.object = None;
slot.dying = false;
slot.generation += 1; // every old handle to this slot is now stale
self.free_slots.push(i as u32);
}
slot.spawned_this_tick = false;
}
}
}
fn update_object(world: &mut World, self_handle: Handle) {
let (kind, name, target) = {
let me = world.resolve(self_handle).expect("tick only visits live objects");
(me.kind, me.name.clone(), me.target)
};
match kind {
Kind::Rocket => {
let victim_name = world.resolve(target).map(|victim| victim.name.clone());
if let Some(victim_name) = victim_name {
println!(" {name} hits {victim_name}");
world.destroy(target);
world.spawn(GameObject { kind: Kind::Loot, name: "loot".into(), target: Handle::default() });
world.destroy(self_handle);
}
}
Kind::Archer => match world.resolve(target) {
Some(target_object) => println!(" {name} shoots at {}", target_object.name),
None => println!(" {name}: target is gone, holding fire"),
},
Kind::Goblin => println!(" {name} growls"),
Kind::Loot => println!(" {name} sparkles"),
}
}
fn main() {
let mut world = World::default();
let goblin = world.spawn(GameObject { kind: Kind::Goblin, name: "goblin".into(), target: Handle::default() });
world.spawn(GameObject { kind: Kind::Rocket, name: "rocket".into(), target: goblin });
world.spawn(GameObject { kind: Kind::Archer, name: "archer".into(), target: goblin });
world.tick(|_, _| {}); // end the spawn tick so everything is active
for tick_number in 1..=2 {
println!("tick {tick_number}");
world.tick(update_object);
}
// Two newcomers reuse the two freed slots, one of them the goblin's.
let rat = world.spawn(GameObject { kind: Kind::Goblin, name: "rat".into(), target: Handle::default() });
let bat = world.spawn(GameObject { kind: Kind::Goblin, name: "bat".into(), target: Handle::default() });
println!("rat in slot {}, bat in slot {}", rat.index, bat.index);
let verdict = if world.resolve(goblin).is_some() { "an object" } else { "null" };
println!("old goblin handle {{{}, {}}} resolves to {verdict}", goblin.index, goblin.generation);
}
Both versions print the same trace. On tick 1 the goblin growls, then the rocket hits it, marks the goblin and itself for death, and spawns loot. The archer updates after the rocket in the same tick and already gets null for its target, while the goblin's memory stays valid until the tick ends. The loot first acts on tick 2. Afterwards a rat and a bat take the two freed slots, the bat landing in slot 0 where the goblin lived, and the goblin's old handle {0, 0} still resolves to null because slot 0 is now on generation 1.
- Components. Behavior is a
switchon a kind to keep the listing short; a real world dispatches to the components from §3. - Destruction callbacks. A dying object usually tells others (drop loot, unsubscribe its listeners to avoid §4's lapsed-listener leak) from a hook that runs at the free.
- Generation wraparound. A 32-bit generation wraps after 232 frees of one slot, after which a very old handle could match again. Some allocators retire a slot instead of wrapping.
- Dense iteration. Dead slots stay in the array and the loop skips them. An ECS packs live components together instead (the ECS tutorial).
- Threads. Everything runs on one thread. A parallel update needs the spawn and destroy lists to be per-thread or locked.
07Data-driven design
A designer wants a goblin archer that's the goblin grunt with a bow, less health and more range, and shouldn't have to wait for an engine build to get it. Define entity types (prefabs, blueprints, archetypes) as data, which components, with which parameters, instantiated by a factory, so a designer makes a new enemy variant without an engineer or a recompile[13]. Benefits: iteration speed, designer independence, modding. Unity has prefabs + variants and ScriptableObjects; Unreal has Blueprint classes over a C++ base; Bilas's Dungeon Siege ran over 7,300 object types from data.
Most systems also let one definition inherit from another, so the archer lists only what differs from the grunt. Dungeon Siege's templates did it with a specializes field: each template was compiled on top of its base, overriding the base's fields[13]. Nystrom's prototype example uses the same goblins: the archer names the grunt as its prototype, and any property the archer doesn't have falls back to being looked up on the prototype[14]. Unity's prefab variants follow the same rule: a variant inherits from its base prefab, its overrides take precedence, and a variant can itself be the base of another variant[15].
Worked example
The grunt sets Health.max = 30, Mover.speed = 3.0 and Melee.damage = 6. The archer names the grunt as its prototype and sets only Health.max = 20 and Bow.range = 14. The archer captain names the archer and sets only Health.max = 45. The captain's speed isn't in the captain or the archer, so the lookup reaches the grunt on the third step: 3.0. Patch the grunt's speed to 3.5 and the captain moves at 3.5 without anyone touching the captain's data. Bilas lists the same benefit for placed objects that save only the fields they change: you can "make global changes easily by modifying templates"[13].
The program below runs the whole path from text to objects. The designer's file names each prefab and its prototype; the parser builds a table; resolveFields walks the chain once per field; and the factory looks each component name up in a registry, the one place where data names meet code.
// Prefabs as data: a variant names its prototype and lists only what it changes.
// A factory resolves each field through the prototype chain and builds components.
#include <cstdio>
#include <functional>
#include <map>
#include <memory>
#include <set>
#include <sstream>
#include <string>
#include <vector>
// What a designer edits. In a game this is a file loaded at startup.
const char* kPrefabText = R"(
[goblin_grunt]
Health.max = 30
Mover.speed = 3.0
Melee.damage = 6
[goblin_archer : goblin_grunt]
Health.max = 20
Bow.range = 14
[archer_captain : goblin_archer]
Health.max = 45
)";
struct Prefab {
std::string prototype; // empty for a root prefab
std::map<std::string, double> fields; // "Component.field" -> value
};
using PrefabTable = std::map<std::string, Prefab>;
std::string trim(const std::string& text) {
std::size_t first = text.find_first_not_of(' '), last = text.find_last_not_of(' ');
return first == std::string::npos ? "" : text.substr(first, last - first + 1);
}
PrefabTable parsePrefabs(const std::string& text) {
PrefabTable table;
Prefab* current = nullptr;
std::istringstream lines(text);
for (std::string line; std::getline(lines, line);) {
if (line.empty()) continue;
if (line.front() == '[') { // [name] or [name : prototype]
std::string header = line.substr(1, line.find(']') - 1);
std::size_t colon = header.find(':');
current = &table[trim(header.substr(0, colon))];
if (colon != std::string::npos) current->prototype = trim(header.substr(colon + 1));
} else if (current) { // Component.field = value
std::size_t equals = line.find('=');
current->fields[trim(line.substr(0, equals))] = std::stod(line.substr(equals + 1));
}
}
return table;
}
// Look in the prefab, then its prototype, then that one's prototype, and so on.
// The first prefab that sets the field wins, so a variant's value overrides its base.
std::map<std::string, double> resolveFields(const PrefabTable& table, const std::string& name) {
std::map<std::string, double> resolved;
for (const Prefab* prefab = &table.at(name); prefab;
prefab = prefab->prototype.empty() ? nullptr : &table.at(prefab->prototype)) {
for (const auto& [key, value] : prefab->fields)
resolved.try_emplace(key, value); // no-op if a nearer prefab set it
}
return resolved;
}
struct Component {
virtual ~Component() = default;
virtual void describe() const = 0;
};
struct Health : Component {
double max;
explicit Health(double maxHealth) : max(maxHealth) {}
void describe() const override { std::printf(" Health max=%g\n", max); }
};
struct Mover : Component {
double speed;
explicit Mover(double moveSpeed) : speed(moveSpeed) {}
void describe() const override { std::printf(" Mover speed=%g\n", speed); }
};
struct Melee : Component {
double damage;
explicit Melee(double hitDamage) : damage(hitDamage) {}
void describe() const override { std::printf(" Melee damage=%g\n", damage); }
};
struct Bow : Component {
double range;
explicit Bow(double bowRange) : range(bowRange) {}
void describe() const override { std::printf(" Bow range=%g\n", range); }
};
using Fields = std::map<std::string, double>;
using ComponentFactory = std::function<std::unique_ptr<Component>(const Fields&)>;
// The only place that maps data names to code. A new component type registers here once;
// after that, designers can attach it to any prefab without touching C++.
const std::map<std::string, ComponentFactory> kComponentRegistry = {
{"Health", [](const Fields& fields) { return std::make_unique<Health>(fields.at("Health.max")); }},
{"Mover", [](const Fields& fields) { return std::make_unique<Mover>(fields.at("Mover.speed")); }},
{"Melee", [](const Fields& fields) { return std::make_unique<Melee>(fields.at("Melee.damage")); }},
{"Bow", [](const Fields& fields) { return std::make_unique<Bow>(fields.at("Bow.range")); }},
};
std::vector<std::unique_ptr<Component>> instantiate(const PrefabTable& table, const std::string& name) {
Fields fields = resolveFields(table, name);
std::set<std::string> componentNames; // "Health.max" -> component "Health"
for (const auto& [key, value] : fields) componentNames.insert(key.substr(0, key.find('.')));
std::vector<std::unique_ptr<Component>> components;
for (const std::string& componentName : componentNames)
components.push_back(kComponentRegistry.at(componentName)(fields));
return components;
}
int main() {
PrefabTable table = parsePrefabs(kPrefabText);
for (const char* name : {"goblin_grunt", "goblin_archer", "archer_captain"}) {
std::printf("%s\n", name);
for (const auto& component : instantiate(table, name)) component->describe();
}
// Patch the base: every variant that doesn't override the field picks it up.
table["goblin_grunt"].fields["Mover.speed"] = 3.5;
std::printf("after the patch, archer_captain speed = %g\n",
resolveFields(table, "archer_captain").at("Mover.speed"));
}
// Prefabs as data: a variant names its prototype and lists only what it changes.
// A factory resolves each field through the prototype chain and builds components.
use std::collections::{BTreeMap, BTreeSet};
// What a designer edits. In a game this is a file loaded at startup.
const PREFAB_TEXT: &str = "
[goblin_grunt]
Health.max = 30
Mover.speed = 3.0
Melee.damage = 6
[goblin_archer : goblin_grunt]
Health.max = 20
Bow.range = 14
[archer_captain : goblin_archer]
Health.max = 45
";
#[derive(Default)]
struct Prefab {
prototype: Option<String>, // None for a root prefab
fields: BTreeMap<String, f64>, // "Component.field" -> value
}
type PrefabTable = BTreeMap<String, Prefab>;
type Fields = BTreeMap<String, f64>;
fn parse_prefabs(text: &str) -> PrefabTable {
let mut table = PrefabTable::new();
let mut current: Option<String> = None;
for line in text.lines().filter(|line| !line.is_empty()) {
if let Some(header) = line.strip_prefix('[') { // [name] or [name : prototype]
let header = header.trim_end_matches(']');
let (name, prototype) = match header.split_once(':') {
Some((name, prototype)) => (name.trim(), Some(prototype.trim().to_string())),
None => (header.trim(), None),
};
table.entry(name.to_string()).or_default().prototype = prototype;
current = Some(name.to_string());
} else if let (Some(name), Some((key, value))) = (¤t, line.split_once('=')) {
let value: f64 = value.trim().parse().expect("field values are numbers");
table.get_mut(name).unwrap().fields.insert(key.trim().to_string(), value);
}
}
table
}
// Look in the prefab, then its prototype, then that one's prototype, and so on.
// The first prefab that sets the field wins, so a variant's value overrides its base.
fn resolve_fields(table: &PrefabTable, name: &str) -> Fields {
let mut resolved = Fields::new();
let mut prefab = table.get(name);
while let Some(current) = prefab {
for (key, value) in ¤t.fields {
resolved.entry(key.clone()).or_insert(*value); // no-op if a nearer prefab set it
}
prefab = current.prototype.as_deref().map(|prototype| &table[prototype]);
}
resolved
}
trait Component { fn describe(&self); }
struct Health { max: f64 }
struct Mover { speed: f64 }
struct Melee { damage: f64 }
struct Bow { range: f64 }
impl Component for Health { fn describe(&self) { println!(" Health max={}", self.max); } }
impl Component for Mover { fn describe(&self) { println!(" Mover speed={}", self.speed); } }
impl Component for Melee { fn describe(&self) { println!(" Melee damage={}", self.damage); } }
impl Component for Bow { fn describe(&self) { println!(" Bow range={}", self.range); } }
type ComponentFactory = fn(&Fields) -> Box<dyn Component>;
// The only place that maps data names to code. A new component type registers here once;
// after that, designers can attach it to any prefab without touching Rust.
fn component_registry() -> BTreeMap<&'static str, ComponentFactory> {
let mut registry: BTreeMap<&'static str, ComponentFactory> = BTreeMap::new();
registry.insert("Health", |fields| Box::new(Health { max: fields["Health.max"] }));
registry.insert("Mover", |fields| Box::new(Mover { speed: fields["Mover.speed"] }));
registry.insert("Melee", |fields| Box::new(Melee { damage: fields["Melee.damage"] }));
registry.insert("Bow", |fields| Box::new(Bow { range: fields["Bow.range"] }));
registry
}
fn instantiate(table: &PrefabTable, name: &str) -> Vec<Box<dyn Component>> {
let fields = resolve_fields(table, name);
let registry = component_registry();
// "Health.max" -> component "Health"
let component_names: BTreeSet<&str> =
fields.keys().map(|key| key.split('.').next().unwrap()).collect();
component_names.iter().map(|component_name| registry[component_name](&fields)).collect()
}
fn main() {
let mut table = parse_prefabs(PREFAB_TEXT);
for name in ["goblin_grunt", "goblin_archer", "archer_captain"] {
println!("{name}");
for component in instantiate(&table, name) { component.describe(); }
}
// Patch the base: every variant that doesn't override the field picks it up.
table.get_mut("goblin_grunt").unwrap().fields.insert("Mover.speed".into(), 3.5);
println!("after the patch, archer_captain speed = {}",
resolve_fields(&table, "archer_captain")["Mover.speed"]);
}
The output lists each prefab's components: the grunt gets Health, Melee and Mover; the archer and the captain add Bow and keep the grunt's melee and speed; and after the patch the captain's speed reads 3.5. The factory resolves each field once, when it builds the object, so nothing walks the chain during gameplay. Dungeon Siege resolved per template instead of per object: it compiled each template root-down, each one overriding its base's fields, and Bilas recommends keeping the tree flat for performance[13].
- Error reporting. A typo'd prototype or component name, or a value that isn't a number, throws (or panics in Rust) instead of naming the file and line a designer needs to fix.
- Cycle detection. A prefab whose chain leads back to itself makes
resolveFieldsloop forever. A real loader checks the chain when it loads the file. - A schema. Every field here is a
double. Real systems declare each component's fields with types, defaults and docs, which also drives the editor's property sheet (Bilas's system did this). - Removing inherited parts. A variant here can only add or override. Unity's variants can also remove components and child objects[15].
- Live objects after an edit. Patching the table changes what the factory builds next; objects already in the world keep the values they were built with.
is not all-or-nothing. Over-data-driving (every constant in config, logic expressed as data graphs) costs debuggability: a crash in a data-defined behavior has no stack frame in your code, no breakpoint, no compile-time type checking until runtime. "No engineer required" is the aspiration; the production reality is a moving line between engine and content. Serializing live game objects (save games) is object-graph serialization with handle remapping on load; the Asset Pipeline tutorial covers the format and versioning machinery.
08Embedded scripting
Tuning a boss fight means changing its behavior dozens of times an hour, and a C++ rebuild per change kills that loop. Embed a scripting VM for fast iteration without a recompile, hot-reload, modding, and designer access. Lua is the canonical embedded choice: small, fast, and designed to be embedded (LuaJIT is usually much faster and ships in engines such as Defold). The C boundary is a virtual stack: you push args, call, and read results by index[16].
The program below is a complete host. It registers one engine function, spawn, that scripts can call; runs the script's on_update every tick in protected mode, so a script error is logged instead of crashing the game; drives a door sequence that spans several ticks as a coroutine; and hot-reloads the script partway through, once with a good edit and once with a broken one. The Rust version uses the mlua crate[17], which wraps the same C API.
// Embedding Lua 5.4: expose an engine function, call the script every tick in
// protected mode, drive a multi-tick door sequence as a coroutine, and hot-reload.
#include <lauxlib.h>
#include <lua.h>
#include <lualib.h>
#include <stdio.h>
#include <string.h>
static int nextEntityId = 1;
// Callable from Lua as spawn(prefab, x, y). Arguments arrive on the stack; the
// return value is pushed, and the C function returns how many it pushed.
static int luaSpawn(lua_State* L) {
const char* prefab = luaL_checkstring(L, 1); // raises a Lua error on a bad argument
double x = luaL_checknumber(L, 2);
double y = luaL_checknumber(L, 3);
printf(" engine: spawn %s at (%g, %g) -> id %d\n", prefab, x, y, nextEntityId);
lua_pushinteger(L, nextEntityId++);
return 1;
}
// Compile and run a chunk. On a syntax or runtime error, report it and keep the
// definitions already loaded, so a bad edit doesn't take the game down.
static void loadScript(lua_State* L, const char* source, const char* chunkName) {
if (luaL_loadbuffer(L, source, strlen(source), chunkName) != LUA_OK ||
lua_pcall(L, 0, 0, 0) != LUA_OK) {
printf(" script error, keeping old code: %s\n", lua_tostring(L, -1));
lua_pop(L, 1); // keep the stack balanced
}
}
// Call the global on_update(dt) in protected mode: a script error is logged, not fatal.
static void callOnUpdate(lua_State* L, double dt) {
lua_getglobal(L, "on_update");
lua_pushnumber(L, dt);
if (lua_pcall(L, 1, 0, 0) != LUA_OK) {
printf(" on_update failed: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
}
}
static const char* kScriptV1 =
"state = state or { ticks = 0 } -- survives a reload: the table outlives the chunk\n"
"local calls = 0 -- does not: a reload runs this line again\n"
"function wait(seconds) -- yields once per tick until the time has passed\n"
" local elapsed = 0\n"
" while elapsed < seconds do elapsed = elapsed + coroutine.yield() end\n"
"end\n"
"function door_sequence()\n"
" print(' door: opening'); wait(1.0)\n"
" print(' door: open'); spawn('goblin_grunt', 4, 2); wait(1.5)\n"
" print(' door: closing'); wait(1.0)\n"
" print(' door: closed')\n"
"end\n"
"function on_update(dt)\n"
" state.ticks = state.ticks + 1; calls = calls + 1\n"
"end\n"
"function report() print((' v1: state.ticks=%d calls=%d'):format(state.ticks, calls)) end\n";
static const char* kScriptV2 = // the designer's edit, loaded while the game runs
"state = state or { ticks = 0 }\n"
"local calls = 0\n"
"function on_update(dt) state.ticks = state.ticks + 1; calls = calls + 1 end\n"
"function report() print((' v2: state.ticks=%d calls=%d'):format(state.ticks, calls)) end\n";
static const char* kScriptBroken = "function on_update(dt) state.ticks = end\n";
int main(void) {
lua_State* L = luaL_newstate();
luaL_openlibs(L); // every standard library: fine for your own scripts, not for mods
lua_register(L, "spawn", luaSpawn);
loadScript(L, kScriptV1, "=level.lua");
// A coroutine is a Lua thread with its own stack. The registry reference
// keeps the garbage collector from freeing it while the host still resumes it.
lua_State* door = lua_newthread(L);
int doorRef = luaL_ref(L, LUA_REGISTRYINDEX);
lua_getglobal(door, "door_sequence");
int doorStatus = LUA_YIELD;
const double dt = 0.5;
for (int tick = 1; tick <= 8; ++tick) {
printf("tick %d\n", tick);
callOnUpdate(L, dt);
if (doorStatus == LUA_YIELD) { // resume where the script left off
int resultCount = 0;
lua_pushnumber(door, dt); // becomes coroutine.yield()'s return value
doorStatus = lua_resume(door, L, 1, &resultCount);
lua_pop(door, resultCount);
if (doorStatus != LUA_OK && doorStatus != LUA_YIELD)
printf(" door script failed: %s\n", lua_tostring(door, -1));
}
if (tick == 3) {
lua_getglobal(L, "report"); lua_pcall(L, 0, 0, 0);
loadScript(L, kScriptV2, "=level.lua"); // hot reload
loadScript(L, kScriptBroken, "=level.lua"); // a bad save in the editor
}
}
lua_getglobal(L, "report"); lua_pcall(L, 0, 0, 0);
luaL_unref(L, LUA_REGISTRYINDEX, doorRef);
lua_close(L);
return 0;
}
// Embedding Lua 5.4 through the mlua crate: expose an engine function, call the
// script every tick, drive a multi-tick door sequence as a coroutine, and hot-reload.
// Cargo.toml: mlua = { version = "0.10", features = ["lua54", "vendored"] }
use mlua::{Function, Lua, ThreadStatus};
use std::cell::Cell;
// Compile and run a chunk. On a syntax or runtime error, report it and keep the
// definitions already loaded, so a bad edit doesn't take the game down.
fn load_script(lua: &Lua, source: &str) {
if let Err(error) = lua.load(source).set_name("=level.lua").exec() {
println!(" script error, keeping old code: {error}");
}
}
// Call the global on_update(dt). mlua runs every call in protected mode and
// turns a script error into an Err, so it is logged, not fatal.
fn call_on_update(lua: &Lua, dt: f64) {
let result = lua.globals().get::<Function>("on_update").and_then(|on_update| on_update.call::<()>(dt));
if let Err(error) = result {
println!(" on_update failed: {error}");
}
}
const SCRIPT_V1: &str = r#"
state = state or { ticks = 0 } -- survives a reload: the table outlives the chunk
local calls = 0 -- does not: a reload runs this line again
function wait(seconds) -- yields once per tick until the time has passed
local elapsed = 0
while elapsed < seconds do elapsed = elapsed + coroutine.yield() end
end
function door_sequence()
print(' door: opening'); wait(1.0)
print(' door: open'); spawn('goblin_grunt', 4, 2); wait(1.5)
print(' door: closing'); wait(1.0)
print(' door: closed')
end
function on_update(dt)
state.ticks = state.ticks + 1; calls = calls + 1
end
function report() print((' v1: state.ticks=%d calls=%d'):format(state.ticks, calls)) end
"#;
const SCRIPT_V2: &str = r#"
state = state or { ticks = 0 }
local calls = 0
function on_update(dt) state.ticks = state.ticks + 1; calls = calls + 1 end
function report() print((' v2: state.ticks=%d calls=%d'):format(state.ticks, calls)) end
"#;
const SCRIPT_BROKEN: &str = "function on_update(dt) state.ticks = end";
fn main() -> mlua::Result<()> {
let lua = Lua::new(); // mlua's "safe" set still includes io and os: too much for mods
let next_entity_id = Cell::new(1_i64);
// Callable from Lua as spawn(prefab, x, y). mlua converts the arguments and
// raises a Lua error if they have the wrong types.
let spawn = lua.create_function(move |_, (prefab, x, y): (String, f64, f64)| {
let id = next_entity_id.get();
println!(" engine: spawn {prefab} at ({x}, {y}) -> id {id}");
next_entity_id.set(id + 1);
Ok(id)
})?;
lua.globals().set("spawn", spawn)?;
load_script(&lua, SCRIPT_V1);
// A coroutine is a Lua thread with its own stack; mlua keeps it alive for us.
let door = lua.create_thread(lua.globals().get::<Function>("door_sequence")?)?;
let dt = 0.5;
for tick in 1..=8 {
println!("tick {tick}");
call_on_update(&lua, dt);
if door.status() == ThreadStatus::Resumable { // resume where the script left off
// dt becomes coroutine.yield()'s return value
if let Err(error) = door.resume::<()>(dt) {
println!(" door script failed: {error}");
}
}
if tick == 3 {
lua.globals().get::<Function>("report")?.call::<()>(())?;
load_script(&lua, SCRIPT_V2); // hot reload
load_script(&lua, SCRIPT_BROKEN); // a bad save in the editor
}
}
lua.globals().get::<Function>("report")?.call::<()>(())
}
Run either one at half a second per tick and the door starts opening on tick 1, the goblin spawns on tick 3 when the 1 s wait runs out, and the door shuts on tick 8. After tick 3 the good edit loads and the broken one fails to compile, so the good edit keeps running. At the end state.ticks reads 8 and calls reads 5. The next two subsections explain why.
- A sandbox. Both hosts load
ioandos. Mod scripts need a restricted environment (the callout at the end of this section). - Per-entity scripts. One global script drives everything. Engines usually give each entity its own table or environment and pass it the entity's handle, never a raw pointer.
- Runaway scripts. Nothing stops an infinite loop or unbounded allocation. The C API can install a hook with
lua_sethookthat fires every N instructions, and a custom allocator can cap memory[19]. - File watching. The reload is triggered by a tick number. An editor build watches the script files and reloads on save.
- Coroutine recovery. A coroutine that errors is reported and then dead; a real host restarts it or removes the behavior that owned it.
Behavior that spans ticks: coroutines
The door has to open, wait and close over several seconds, but update() must return every tick. Written as an update method, the code has to record where it is (a state and a timer) and pick up from there next tick, because when it returns, "the execution position is lost"[7]. A coroutine keeps the position for you: it's a function that can pause partway, called yielding, and later continue from the same line with its local variables intact[18]. In Lua, coroutine.yield returns whatever the next resume passes in, so the listing's wait() yields once per tick and adds that tick's dt each time it resumes. From C, lua_newthread creates the coroutine's own stack and lua_resume runs it until it yields or finishes[19]. Unity coroutines are the same idea in C#: yield return null resumes on the next frame[18].
Worked example
In the widget a tick is 0.125 s, a step that binary floating point adds up exactly (ten steps of 0.1 sum to slightly less than 1.0, so a 1 s wait would take an eleventh tick). After the switch the door opens for 8 ticks, holds for 12 and closes for 8, 28 ticks in all. The state-machine version stores a state (one of five) and a timer between those ticks, and needs a case per state. The coroutine stores neither: its local elapsed and the line it's paused on live on its suspended stack.
The paused position lives on a Lua stack, not in fields you can inspect or write to disk. Lua's standard library has no way to save a suspended coroutine, while a state and a timer are two numbers a save game can store. The host also has to keep the coroutine reachable: threads are garbage-collected like any Lua object[19], which is why the C listing stores it in the registry with luaL_ref. In Unity, each coroutine is an allocated enumerator that holds references to its owner and captured variables until it ends or is stopped[18].
Hot reload
Hot reload means running the edited script again inside the live VM, so a designer sees a change without restarting the level. Lua compiles each chunk (one file or string of source, loaded in one go) as the body of an anonymous function[19], so running it again re-executes every top-level statement: each function on_update definition replaces the global, and each top-level local starts over. That's the listing's calls = 5: the reload re-ran local calls = 0, and the new on_update counted only ticks 4 to 8. State that has to survive lives in a table the new chunk reuses, state = state or { ticks = 0 }, which is why state.ticks reached 8.
- A running coroutine can mix old and new code. It keeps running the function it started with, so the door finishes the old
door_sequenceeven after a reload defines a new one. But a global it calls, such aswait, is looked up at the call, so from the reload on it gets whatever that global holds: the new chunk's version if the chunk redefined it, the old one if not (the listing's v2 leaveswaitalone). - A syntax error changes nothing.
luaL_loadbuffercompiles the whole chunk before any of it runs, so the broken edit is rejected and the old definitions stay. A chunk that fails halfway through running has already replaced everything it defined before the error. - Data in tables, behavior in functions. The reload swaps functions safely when the state they act on lives somewhere the reload doesn't reset.
The boundary has per-call overhead (stack marshaling, and for VM languages, interpretation). Epic's own guidance on Blueprint: the gap to C++ is usually insignificant and matters most in tight loops, systems that process large data sets, and tick-driven classes with many instances[20], so keep hot per-entity math native and script decisions and events. Don't claim Lua is "as fast as C" (LuaJIT narrows the gap, doesn't erase it); and Lua is the dominant embedded choice, not universally best (C# in Unity and Blueprint in Unreal ship more by install base). Hot-reload is easy to swap code but hard to migrate live state. Sandbox untrusted mods by whitelisting safe functions (not blacklisting), stripping io/os/package; beware load/setfenv/require escapes (and setfenv is gone in Lua 5.2+)[21].
Worked example
Give 10,000 entities a scripted on_update and the engine crosses the C-to-Lua boundary 10,000 times a frame, 600,000 times a second at 60 fps, each crossing pushing arguments and reading results through the stack. Script the boss's decisions instead (a handful of calls a frame) and keep the 10,000 entities' movement in one native loop.
Wrong answers, and why: ECS is one of three live models (don't crown it; inheritance isn't always wrong); a dangling reference is fixed by deferred destruction + generational handles (a null check can't catch a reused slot); a double buffer makes lag consistent, not zero; and a variant delegates to its base instead of copying it.
09Pitfalls
10What's next
The game has objects that communicate, spawn and die safely, and run data-driven, scriptable behavior. The next pages give them brains: Graph Traversal (pathfinding) comes first, then Behavior Trees for decisions, and navigation meshes and steering for movement, followed by networking, tooling, and the 3D-game capstone. The full path is on the series hub.
- Jason Gregory. Game Engine Architecture, 3rd ed., ch. 15–16 (the gameplay foundation layer and runtime object model). gameenginebook.com. The object-centric vs property-centric taxonomy and the layer's scope.
- Robert Nystrom. Game Programming Patterns, "Component." gameprogrammingpatterns.com. The entity-as-component-container model and the three communication options.
- Unity Technologies. "GameObject.AddComponent" / "MonoBehaviour." docs.unity3d.com. Components attached at runtime; MonoBehaviours exist only as components.
- Epic Games. "Components" / "Actors" (Unreal Engine). dev.epicgames.com. Actor as a component container with the ActorComponent → SceneComponent → PrimitiveComponent spine.
- Robert Nystrom. Game Programming Patterns, "Observer." gameprogrammingpatterns.com. Synchronous notification, the lapsed-listener leak, and the reentrancy assumption.
- Robert Nystrom. Game Programming Patterns, "Event Queue." gameprogrammingpatterns.com. Deferred FIFO events, stale-world, feedback loops, and that the queue hides reentrancy.
- Robert Nystrom. Game Programming Patterns, "Update Method." gameprogrammingpatterns.com. Per-frame update, cross-frame state, order significance, and "be careful modifying the object list."
- Epic Games. "Actor Ticking in Unreal Engine." dev.epicgames.com. Tick groups in frame order, each finishing before the next; tick prerequisites; the HUD that runs one frame behind.
- Unity Technologies. "Script Execution Order project settings reference." docs.unity3d.com. Execution order is set per script class, lowest value first.
- Robert Nystrom. Game Programming Patterns, "Double Buffer." gameprogrammingpatterns.com. The slapstick example: update order changes the outcome until each actor reads last frame's buffered state.
- Unity Technologies. "Object.Destroy." docs.unity3d.com. Destruction delayed until after the Update loop, before rendering, the canonical deferred-destruction behavior.
- Andre Weissflog. "Handles are the better pointers." floooh.github.io. Why pointers dangle, the {index, generation} handle, and the generation-checked resolve.
- Scott Bilas. "A Data-Driven Game Object System." GDC 2002. gamedevs.org. The Dungeon Siege component database, its 7,300+ object types, and templates that specialize a base and override its fields.
- Robert Nystrom. Game Programming Patterns, "Prototype." gameprogrammingpatterns.com. Prototypes for data modeling: goblin variants that delegate missing properties to a prototype.
- Unity Technologies. "Create variations of prefabs" (Prefab Variants). docs.unity3d.com. A variant inherits from a base prefab, its overrides take precedence, overrides can add or remove components, and variants can be bases of other variants.
- Roberto Ierusalimschy. Programming in Lua, 1st ed., §24.2 "The Stack." lua.org/pil. Why Lua and C exchange values through a virtual stack instead of a shared value type.
- mlua contributors. "mlua" crate documentation. docs.rs. The Rust bindings used in the Rust host:
create_function,create_thread,resume, and the safe standard-library set loaded byLua::new. - Unity Technologies. "Split tasks across frames with coroutines." docs.unity3d.com. A coroutine suspends and resumes later with its locals preserved;
yield return nullresumes next frame; allocation and lifetime costs. - Roberto Ierusalimschy, Luiz Henrique de Figueiredo, Waldemar Celes. Lua 5.4 Reference Manual. lua.org. Coroutines and
coroutine.yield,lua_newthread,lua_resume, threads as collectable objects, chunks as anonymous functions, count hooks and custom allocators. - Epic Games. "Blueprint vs C++" (Unreal Engine). dev.epicgames.com. Blueprint runs as bytecode on a VM; the C++ gap is usually insignificant and matters most in tight loops, large data sets and tick-dependent classes with many instances.
- lua-users wiki. "Sandboxes." lua-users.org. Whitelisting over blacklisting, stripping io/os/package, and the
load/setfenv/requireescape vectors.