All tutorials Mighty Professional
Build a Game Engine · Gameplay

The Gameplay Layer

The engine can render, animate, and simulate. This is the layer where it 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~55 min LevelSenior PrereqsThe 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 · Game AI ▸

01The gameplay layer

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.

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 (§5)? And how is its definition and behavior moved out of compiled code into data and script (§6–8)? The loop itself (accumulator, fixed dt, alpha interpolation) and the storage layout (ECS) are prerequisites, not re-covered here.

02Why inheritance failed

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

Add cross-cutting behaviors and the inheritance tree branches combinatorially while the component set stays flat:

toggle behaviors:
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

The fix: the entity becomes a container of components, each owning one domain (transform, render, physics, AI)[2]. But splits into three genuinely different models, each shipping AAA games, don't collapse them 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 the biggest 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 (the most-licensed AAA engine) 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.

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); } }
}
// NOTE: in Rust, cross-component access fights the borrow checker -
// this pushes you 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 (the coupling inheritance was meant to avoid, creeping back). 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, spurious events slosh around without crashing, looks fine, isn't. Neither is "correct"; real engines use both.

Send events through the bus; toggle immediate vs deferred and trigger the reentrancy and lapsed-listener bugs:

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 guards against reentrant unsubscribe
        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); }
    fn emit(&self, e: &E) { for f in &self.listeners { f(e); } }   // IMMEDIATE
    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); }
    }
}

05The gameplay update

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

Order is significant, and don't mutate the list mid-iteration

Update-method code must slice work across frames and store its own cross-frame state (the execution position is lost between ticks). Order within a frame matters: if A updates before B, B sees A's new state, and order between objects is undefined unless you define it (Unreal uses tick groups; Overwatch fixes system order by design; Unity gives no guaranteed inter-object order without explicit config). And don't modify the object list while iterating it[7], which is exactly the lifetime problem in §6.

06Lifetime & handles

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). This is real engine behavior, Unity's Destroy() delays actual destruction until after the Update loop, before rendering[8].

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[9]. 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 isn't a pointer you dereference blindly, the validity check is the whole point. The ECS tutorial builds the full allocator; here it's how a gameplay reference survives the target's death.

Free a slot, let it be reused, then resolve both references, the handle is safely rejected, the raw pointer silently reads the wrong object:

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. The generation counter is what makes the stale reference detectable.

07Data-driven design

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[10]. Benefits: iteration speed, designer empowerment, 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.

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 owns the format/versioning machinery.

08Embedded scripting

Embed a scripting VM for fast iteration without a recompile, hot-reload, modding, and designer access. Lua is the canonical embedded choice, small, fast, designed to be embedded (LuaJIT is dramatically faster and ships in engines like Defold). The C boundary is a virtual stack: you push args, call, and read results by index[11].

The Lua C boundary: register a C function, call Lua each frame
// A C function callable from Lua: reads args off the stack, pushes results, returns the count.
static int l_spawn(lua_State* L) {
    const char* prefab = luaL_checkstring(L, 1);          // arg 1
    float x = luaL_checknumber(L, 2), y = luaL_checknumber(L, 3);
    int id = engine_spawn(prefab, x, y);
    lua_pushinteger(L, id);                            // push result
    return 1;                                          // one return value
}
// setup
lua_State* L = luaL_newstate(); luaL_openlibs(L);
lua_register(L, "spawn", l_spawn);                        // expose to scripts

// call a Lua function from C each frame, PROTECTED so a script error can't crash the engine
lua_getglobal(L, "on_update");
lua_pushnumber(L, dt);
if (lua_pcall(L, 1, 0, 0) != LUA_OK) { log(lua_tostring(L, -1)); lua_pop(L, 1); }
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: the gap matters most in tight loops and thousands of objects running complex per-frame logic[13], 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+)[12].

Wrong answers, and why: ECS is one of three live models (don't crown it; inheritance isn't always wrong); and a dangling reference is fixed by deferred destruction + generational handles (a null check can't catch a reused slot).

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

10What's next

The game has objects that communicate, spawn and die safely, and run data-driven, scriptable behavior. The next module gives them brains that move: Game AI, navigation meshes and steering (building on the pathfinding and behavior-tree tutorials), then 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. Unity Technologies. "Object.Destroy." docs.unity3d.com. Destruction delayed until after the Update loop, before rendering, the canonical deferred-destruction behavior.
  9. Andre Weissflog. "Handles are the better pointers." floooh.github.io. Why pointers dangle, the {index, generation} handle, and the generation-checked resolve.
  10. Scott Bilas. "A Data-Driven Game Object System." GDC 2002. gamedevs.org. The Dungeon Siege component database and data-driving the object schema.
  11. Roberto Ierusalimschy. Programming in Lua (the C API), and the Lua 5.4 Reference Manual. lua.org/pil. The virtual stack, registering C functions, and lua_pcall.
  12. lua-users wiki. "Sandboxes." lua-users.org. Whitelisting over blacklisting, stripping io/os/package, and the load/setfenv/require escape vectors.
  13. Epic Games. "Blueprint vs C++" (Unreal Engine). dev.epicgames.com. Blueprint runs as bytecode on a VM; the C++ gap matters most in tight loops and thousands of per-frame objects.

See also