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.
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.
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.
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:
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:
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 (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.
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 .
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:
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].
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].
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:
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.
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].
// 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); }
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
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.
- 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."
- 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 and data-driving the object schema.
- 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. - lua-users wiki. "Sandboxes." lua-users.org. Whitelisting over blacklisting, stripping io/os/package, and the
load/setfenv/requireescape vectors. - 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.