All tutorials Mighty Professional
Build a Game Engine · Gameplay

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.

Time~80 min LevelSenior PrereqsHelpful, not required: the ECS tutorial (component storage, generational indices) and The Game Loop (the fixed-step tick). StackC++ & Rust · Lua
◂ Build a Game Engine Phase 10 · Gameplay Next · Graph Traversal ▸
What you'll be able to do by the end

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.

The four questions this module answers

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.

The combinatorial explosion, and what inheritance is not

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:

toggle behaviors:
Toggle behaviors on and watch the two counters: classes on the left, components on the right. With all four on they read 15 and 4. Left, the inheritance tree must add a class for every combination of enabled behaviors (the count races toward 2N). Right, the entity just adds one component chip and stays flat. The counters diverge, exponential vs linear, which is why composition won for cross-cutting behavior. (Shallow purposeful inheritance, like Unreal's component spine, is still fine.)

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:

ModelShapeTradeoff
Unity: GameObject + MonoBehaviourAddComponent<T>(); components are heap C# objects[3]Easy, flexible; polymorphic + pointer-chased (not data-oriented)
Unreal: Actor + ActorComponentComposition plus a shallow inheritance spine[4]Purposeful inheritance + components; ships many of the largest AAA games
Pure ECSEntity = ID, component = data, system = functionCache-friendly storage; see the ECS tutorial
Don't crown ECS

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.

A component-based game object
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 .

Immediate vs deferred is a tradeoff, not a winner

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:

An emitter, the bus, and its subscribers. Fire an event in each mode, then tick reentrant handler and lapsed listener and fire again. Immediate: the event is a synchronous call chain (watch the stack depth). Deferred: it queues and drains next frame (watch the latency). Turn on reentrant handler and immediate mode grows the call stack while deferred quietly sloshes events across frames (hidden, not fixed). Turn on lapsed listener and a destroyed-but-subscribed node lingers as a zombie still counting events.
An event bus with immediate emit and a deferred queue
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.

The convoy drives right; the number above each vehicle is its turn in this tick's update order, and the number below each trailer is how far it sits behind its zero-lag spot (the dashed outline). Switch between the four orders, then drag the speed. Leader first reads 0 ticks of lag on every link. Reverse reads 1 per link, and at 10 m/s the last trailer is 0.83 m behind. Shuffled picks a new order every tick, so each link's lag jumps between 0 and several ticks (a trailer that reads a fresh position can catch up a whole backlog at once) and the trailers jitter. Read last tick updates front to back but has every trailer read the previous tick's copy, so it shows the same 1-per-link lag as reverse, now independent of the order. Lag in metres grows with speed: at 30 m/s the last trailer is 2.5 m back, almost a whole gap.

There are three fixes, and engines use all of them:

An undefined order can change every tick

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

Raw pointers to game objects dangle, use generational handles

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:

A row of slots with their values, generations and alive flags, plus a raw-pointer card and a handle card. Press free slot 2, then allocate, then resolve refs. Both references point at slot 2. Free it (generation bumps), allocate again (the slot is reused by a new object), then resolve: the handle sees the generation mismatch and safely returns null; the raw pointer silently resolves to the new object, a use-after-free reading the wrong entity.

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 small object world: handles, deferred destruction, deferred spawning (complete program)
// 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.

What's intentionally missing

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

Three prefabs, each card showing every field's resolved value and whether the prefab sets it or inherits it (the arrow names the prefab it came from). The highlight walks the lookup for the traced field, starting at the captain. Drag grunt speed and watch both variants follow it; then turn on archer overrides speed and the captain stops at the archer on the second step, and the grunt's slider no longer reaches it. Turn off captain overrides health and the captain inherits the archer's 20. A copy-pasted captain would have kept every old value.

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.

Prefab variants: parse, resolve through the prototype chain, build components (complete program)
// 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))) = (&current, 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 &current.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].

What's intentionally missing
It's a spectrum, with a debuggability cost

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: an engine function, a protected call each tick, a coroutine, a hot reload (complete program)
// 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.

What's intentionally missing

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.

Coroutine (Lua)
function door_sequence()
  wait_until(switch_pressed)
  open_door();  wait(1.0)
  spawn_goblin()
  wait(1.5)
  close_door(); wait(1.0)
end

Stored between ticks:

State machine (C++)
switch (state) {
case Idle:
  if (switchPressed) { openDoor(); enter(Opening); }
  break;
case Opening:
  timer += dt;
  if (timer >= 1.0) { spawnGoblin(); enter(Holding); }
  break;
case Holding:
  timer += dt;
  if (timer >= 1.5) { closeDoor(); enter(Closing); }
  break;
case Closing:
  timer += dt;
  if (timer >= 1.0) enter(Done);
  break;
case Done:
  break;
}
// enter(next) sets state = next and timer = 0

Stored between ticks:

Two copies of the same door behavior, each driving its own door at 8 ticks a second; the highlighted line is where each one stands. Press the switch, then pause auto-play and step one tick at a time. The coroutine sits on a wait line while time passes and remembers its position on its own stack; the state machine re-enters the switch every tick and finds its place from the state and timer it stored. Both doors move identically and the disagreement count stays at 0. The widget's coroutine is a JavaScript generator. A generator can pause only in its own body, so it needs yield* to pause inside a helper like wait; a Lua coroutine can yield from any function it calls.
What coroutines cost

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.

Don't script the inner loop; hot-reload's hard part is state; sandbox mods

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

Deep inheritance for behaviorCross-cutting concerns explode to 2^N classes. Compose instead.
"ECS is the only right model"Three live models ship AAA. Unreal is composition + shallow inheritance.
Lapsed-listener leakUnsubscribe on destroy, or the observer list keeps zombies alive.
"Event queue fixes reentrancy"It hides it (stack unwinds). Spurious events still slosh around.
Deleting mid-iterationCrash. Defer destruction to a frame boundary.
Raw pointers across framesDangle on reuse. Use {index, generation} handles.
Undefined update orderReaders see fresh state one tick and stale the next. Order by dependency, or double-buffer.
"The buffer removes the lag"It makes the lag one tick per link in any order. Only dependency order removes it.
Spawns acting at onceAn object added mid-loop updates in the tick it was born. Activate it at the end of the tick.
Copy-pasted prefabsA fix to the base misses every copy. Inherit from a prototype and override only what differs.
Over-data-drivingLogic-as-data loses breakpoints and type checks. Keep a sane line.
Scripting the inner loopBoundary overhead bites in hot per-entity logic. Keep it native.
State in chunk localsA hot reload re-runs the chunk and resets them. Keep state in a table the reload reuses.
Unanchored coroutineA Lua thread nobody references can be collected. Keep it in the registry while the host resumes it.

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.

  1. 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.
  2. Robert Nystrom. Game Programming Patterns, "Component." gameprogrammingpatterns.com. The entity-as-component-container model and the three communication options.
  3. Unity Technologies. "GameObject.AddComponent" / "MonoBehaviour." docs.unity3d.com. Components attached at runtime; MonoBehaviours exist only as components.
  4. Epic Games. "Components" / "Actors" (Unreal Engine). dev.epicgames.com. Actor as a component container with the ActorComponent → SceneComponent → PrimitiveComponent spine.
  5. Robert Nystrom. Game Programming Patterns, "Observer." gameprogrammingpatterns.com. Synchronous notification, the lapsed-listener leak, and the reentrancy assumption.
  6. Robert Nystrom. Game Programming Patterns, "Event Queue." gameprogrammingpatterns.com. Deferred FIFO events, stale-world, feedback loops, and that the queue hides reentrancy.
  7. Robert Nystrom. Game Programming Patterns, "Update Method." gameprogrammingpatterns.com. Per-frame update, cross-frame state, order significance, and "be careful modifying the object list."
  8. 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.
  9. Unity Technologies. "Script Execution Order project settings reference." docs.unity3d.com. Execution order is set per script class, lowest value first.
  10. 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.
  11. Unity Technologies. "Object.Destroy." docs.unity3d.com. Destruction delayed until after the Update loop, before rendering, the canonical deferred-destruction behavior.
  12. Andre Weissflog. "Handles are the better pointers." floooh.github.io. Why pointers dangle, the {index, generation} handle, and the generation-checked resolve.
  13. 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.
  14. Robert Nystrom. Game Programming Patterns, "Prototype." gameprogrammingpatterns.com. Prototypes for data modeling: goblin variants that delegate missing properties to a prototype.
  15. 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.
  16. 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.
  17. 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 by Lua::new.
  18. Unity Technologies. "Split tasks across frames with coroutines." docs.unity3d.com. A coroutine suspends and resumes later with its locals preserved; yield return null resumes next frame; allocation and lifetime costs.
  19. 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.
  20. 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.
  21. lua-users wiki. "Sandboxes." lua-users.org. Whitelisting over blacklisting, stripping io/os/package, and the load/setfenv/require escape vectors.

See also