FIT CTU

Adam Vesecký

vesecky.adam@gmail.com

Lecture 5

Patterns

Architecture of Computer Games

Adam Vesecký
If game programmers ever cracked open Design Patterns at all, never got past Singleton. Gang of Four’s is inapplicable to games in its original version.Robert Nystrom

Games and S.O.L.I.D principles

  • principles and patterns were built for software engineering, not game development

Single Responsibility Principle

  • actual meaning: every class should have only one job
  • in game dev: every component should have a narrow scope of responsibilities

Open-Closed Principle

  • actual meaning: objects should be open for extension, but closed for modification
  • in game dev: adding new mechanics means adding new components, not modifying the old ones

Liskov Substitution Principle

  • actual meaning: subclasses are substitutable for their parent classes
  • in game dev: specialized components are substitutable for their base components

Dependency-Inversion Principle

  • actual meaning: entities must depend on abstractions not on concretions
  • in game dev: components shouldn't care about what object is but rather what it has

Interface-Segregation Principle

  • actual meaning: class shouldn't depend on methods it doesn't need
  • in game dev: components shouldn't subscribe messages they don't need to react to

Design patterns

Design patterns in applications

Design patterns in games

Action Patterns

Data-Passing Components

  • ~visual programming
  • thinks solely in terms of sending streams of data from one object to another
  • every component has a set of ports to which a data stream can be connected
  • requires a visual editor
  • good for dynamic data processing (shaders, animations, AI decisions)

Unreal Blueprints

Unity FlowCanvas

Example: Godot Editor

Event System

  • many games are event-based
  • event system of a game is usually more complex than built-in event emitter

What we need

  • built-in event emitter
  • a good way of how to define events for levels (declarative and imperative)
  • structures for evaluation of conditional events (e.g. history of recent events)
  • a good visualisation, if the events are branching
Avoid placing conditions in declarative languages

Chain

  • Process - something that requires more than one frame to finish
    • basically anything that involves animations, mini cut-scenes, delayed actions, sounds
  • Chain - a set of commands, events and processes that need to be evaluated in a given order
  • implementation

    • callbacks - basically in every language, very bad robustness
    • listener chaining - any language with closures (Java, JavaScript, C#,..)
    • iterator blocks - C#
    • promises and generators - JavaScript
    • coroutines - Kotlin, Ruby, Lua,...

Chain Example

1
2 public async Task EnterDoorAction(Door door) {
3 this.Context.Player.BlockInput();
4 await new DoorAnimation(door).Open();
5 await new WalkAnimation(this.Context.Player).Walk(this.Context.Player.direction);
6 this.Context.Player.Hide(); // hide the sprite once it approaches the house
7 await new DoorAnimation(door).Close();
8 await Delay(500); // wait for 500 ms
9 }
10
11 .......
12
13 public async Task OnPlayerDoorApproached(Door door) {
14 await new EnterDoorAction(door);
15 await new SceneLoader(door.TargetScene);
16 }

Delay

  • an action/event that should happen after a given amount of time
  • can be implemented by the same facilities as the chain
  • always prefer an approach the engine recommends over features built into the scripting language
    • e.g. setTimeout() in JavaScript is invoked from within the event loop, not during an update loop
  • example: Unity Delayed Invocation
    1 IEnumerator Spawn () {
    2 // Create a random wait time before the prop is instantiated.
    3 float waitTime = Random.Range(minTimeBetweenSpawns, maxTimeBetweenSpawns);
    4 // Wait for the designated period.
    5 yield return new WaitForSeconds(waitTime);
    6
    7 // Instantiate the prop at the desired position.
    8 Rigidbody2D propInstance = Instantiate(backgroundProp, spawnPos, Quaternion.identity) as Rigidbody2D;
    9 // Restart the coroutine to spawn another prop.
    10 StartCoroutine(Spawn());
    11 }

Separation of concerns

  • common misuse is to handle complex events in one place
  • solution: send events and delegate the processing to handlers
  • in one place
    1 if(asteroid.position.distance(rocket.position) <= MIN_PROXIMITY) { // detect proximity
    2 rocket.runAnimation(ANIM_EXPLOSION); // react instantly and handle everything
    3 asteroid.runAnimation(ANIM_EXPLOSION);
    4 playSound(SOUND_EXPLOSION);
    5 asteroid.destroy();
    6 rocket.destroy();
    7 }
  • separated
    1 // collision-system.ts
    2 let collisions = this.collisionSystem.checkProximity(allGameObjects);
    3 collisions.forEach(colliding => this.sendEvent(COLLISION_TRIGGERED, colliding));
    4 // rocket-handler.ts
    5 onCollisionTriggered(colliding) {
    6 this.destroy();
    7 this.sendEvent(ROCKET_DESTROYED);
    8 }
    9 // sound-component.ts
    10 onGameObjectDestroyed() {
    11 this.playSound(SOUND_EXPLOSION);
    12 }

Example: Quake death script

1 void() PlayerDie = {
2 DropBackpack();
3
4 self.weaponmodel="";
5 self.view_ofs = '0 0 -8';
6 self.deadflag = DEAD_DYING;
7 self.solid = SOLID_NOT;
8 self.flags = self.flags - (self.flags & FL_ONGROUND);
9 self.movetype = MOVETYPE_TOSS;
10
11 if (self.velocity_z < 10)
12 self.velocity_z = self.velocity_z + random()*300;
13
14 DeathSound();
15
16 if (self.weapon == IT_AXE) {
17 player_die_ax1 ();
18 return;
19 }
20
21 i = 1 + floor(random()*6);
22 if (i == 1)
23 player_diea1();
24 else if (i == 2)
25 player_dieb1();
26 else player_diec1();
27 };

Responsibility ownership

  • determines which component should be responsible for given scope/action/decision
  • there is no bulletproof recipe, yet it should be unified
  • if the scope affects only one entity, it should be a component attached to that entity
    • example: a worker that goes to the forest for some wood
  • if the scope affects more entities, it's often a component/system attached either to an abstract entity up the scene graph (e.g. the root object)
    • example: battle formation controller, duel controller (who wins, who loses)

Individual units

Battle formation

Optimizing Patterns

Memory issues

Memory caching issues

  • CPU first tries to find data in the L1 cache
  • then it tries the larger but higher-latency L2 cache
  • then it tries L3 cache and DDR memory

Avoiding cache miss

  • arrange your data in RAM in such a way that min cache misses occur
  • organise data in contiguous blocks that are as small as possible
  • avoid calling functions from within a performance-critical section of code

Avoiding branch misprediction

  • branch = using an IF statement
  • pipelined CPU tries to guess which branch is going to be taken
  • if the guess is wrong, the pipeline must be flushed
1 // iterating inside out -> SLOW
2 for (i = 0 to size)
3 for (j = 0 to size)
4 do something with array[j][i]
1 // iterating outside in -> FAST
2 for (i = 0 to size)
3 for (j = 0 to size)
4 do something with array[i][j]
1 // assume only 50% active/visible objects
2 gameLoop(delta, absolute) {
3 for(let entity in this.entities) {
4 // 50% mispredictions
5 if(entity.ACTIVE) {
6 entity.update(delta, absolute);
7 }
8 if(entity.VISIBLE) {
9 entity.draw();
10 }
11 }
12 }

Data storing

Randomly

Sequentially

String Hash

  • in C++, strings are expensive to work with at runtime, strcmp has O(n) complexity
  • many scripting engines use string interning
  • game engines widely use string hash which maps a string onto a semi-unique integer
  • algorithms: djb2, sdbm, lose lose,...
  • example: sdbm
1 // hashing function
2 inline unsigned SDBMHash(unsigned hash, unsigned char c) {
3 return c + (hash << 6) + (hash << 16) - hash;
4 }
5
6 unsigned calc(const char* str, unsigned hash = 0) {
7 while (*str) {
8 // Perform the current hashing as case-insensitive
9 char c = *str;
10 hash = SDBMHash(hash, (unsigned char)tolower(c));
11 ++str;
12 }
13 return hash;
14 }

Flyweight

  • an object keeps shared data to support large number of fine-grained objects
  • e.g. instanced rendering, geometry hashing, particle systems
  • here we move a position and a tile index (Sprite) into an array

Structural Patterns

Variant

  • a class that is designed to store a variety of other types
  • engines use variants to track all scripting API variables
  • VariantMap - a generic structure that is often used for message processing
  • features
    • can store any datatype
    • can be hashed and compared to other variants
    • can be used to safely convert between datatypes
    • can be serialized as binary or text and stored to disk
1 #define GODOT_VARIANT_SIZE (16 + sizeof(int64_t))
2
3 typedef struct {
4 uint8_t _dont_touch_that[GODOT_VARIANT_SIZE];
5 } godot_variant;
6
7 typedef enum godot_variant_type {
8 // atomic types
9 GODOT_VARIANT_TYPE_NIL,
10 GODOT_VARIANT_TYPE_BOOL,
11 GODOT_VARIANT_TYPE_INT,
12 ...

Two-stage initialization

  • avoids passing everything through the constructor
  • constructor creates an object, init method initializes it
  • objects can be initialized several times
  • objects can be allocated in-advance in a pool
1 class Brainbot extends Unit {
2
3 private damage: number;
4 private currentWeapon: WeaponType;
5
6 constructor() {
7 super(UnitType.BRAIN_BOT);
8 }
9
10 init(damage: number, currentWeapons: WeaponType) {
11 this.damage = damage;
12 this.currentWeapon = currentWeapons;
13 }
14 }

Locator and Blackboard

Locator

  • provides global or scoped services
  • in component-based engines, the whole scene may work as a locator
  • we can initialize the locator to provide a given set of services
  • we can implement null service to disable certain behavior (e.g. sound)

Blackboard (Context)

  • shared data structure for a scope (or the whole game)
  • provides global or scoped data (e.g. player score, money, number of lives)
  • often used in behavioral trees
1 public void OnTriggerEvent(Event evt, Context ctx) {
2
3 if(evt.Key == "LIFE_LOST") {
4 ctx.Lives--; // access the context
5 if(ctx.Lives <= 0) {
6 this.FireEvent("GAME_OVER");
7 }
8 }
9 }
Please, avoid using SINGLETONS

Selector

  • a function that returns a value
  • centralizes the knowledge of how to find an entity/attribute/component
  • can be used by components to access dynamic data
  • can form a hierarchy from other selectors
1 const getPlayer(scene: Scene) => scene.findObjectByName('player');
2
3 const getAllUnits(scene: Scene) => scene.findObjectsByTag('unit_basic');
4
5 const getAllUnitsWithinRadius(scene: Scene, pos: Vector, radius: number) => {
6 return getAllUnits(scene).filter(unit => unit.pos.distance(pos) <= radius);
7 }
8
9 const getAllExits(scene: Scene) => {
10 const doors = scene.findObjectsByTag('door');
11 return doors.filter(door => !door.locked);
12 }

State Patterns

Dirty Flag

  • marks changed objects that need to be recalculated
  • can be applied to various attributes (animation, physics, transformation)
  • you have to make sure to set the flag every time the state changes

Cleaning

  • When the result is needed
    • avoids doing recalculation if the result is never used
    • game can freeze for expensive calculations
  • At well-defined checkpoints
    • less impact on user experience
    • you never know, when it happens
  • On the background
    • you can do more redundant work
    • danger of race-condition

Example: Godot Cache

1 void AnimationCache::_clear_cache() {
2 while (connected_nodes.size()) {
3 connected_nodes.front()->get()
4 ->disconnect("tree_exiting", callable_mp(this, &AnimationCache::_node_exit_tree));
5 connected_nodes.erase(connected_nodes.front());
6 }
7 path_cache.clear();
8 cache_valid = false;
9 cache_dirty = true;
10 }
11
12 void AnimationCache::_update_cache() {
13 cache_valid = false;
14
15 for (int i = 0; i < animation->get_track_count(); i++) {
16 // ... 100 lines of code
17 }
18
19 cache_dirty = false;
20 cache_valid = true;
21 }
22

Mutability

  • immutable state is a luxury only simple games can afford
  • we should assume that everything can be mutable
  • selectors can help us access properties that are deep in the hierarchy
  • dirty flag can help us find out if an entity has changed during the update
  • chain can help us centralize complex modifications and handle side effects
  • messages can help us discover if any important structure has changed

ID generator

  • a simple way to generate consecutive integers
  • Java (Thread safe)
  • 1 public class Generator {
    2 private final static AtomicInteger counter = new AtomicInteger();
    3
    4 public static int getId() {
    5 return counter.incrementAndGet();
    6 }
    7 }
  • TypeScript, using a generator
  • 1 function* generateId() {
    2 let id = 0;
    3 while(true) {
    4 yield id;
    5 id++;
    6 }
    7 }
    8
    9 let newId = generateId().next();
  • TypeScript, using a static variable
  • 1 class Generator {
    2 static idCounter = 0;
    3
    4 getId(): number {
    5 return Generator.idCounter++;
    6 }
    7 }

Flag

  • bit array that stores binary properties of game objects
  • may be used for queries (e.g. find all MOVABLE objects)
  • similar to a state machine but the use-case is different
  • if we maintain all flags within one single structure, we can search very fast

Example: Flag Table

Numeric state

  • the most basic state of an entity
  • allows us to set conditions upon which a transition to other states is possible
1 // stateless, the creature will jump each frame
2 updateCreature() {
3 if(eventSystem.isPressed(KeyCode.UP)) {
4 this.creature.jump();
5 }
6 }
7
8 // introduction of a state
9 updateCreature() {
10 if(eventSystem.isPressed(KeyCode.UP) && this.creature.state !== STATE_JUMPING) {
11 this.creature.changeState(STATE_JUMPING);
12 this.creature.jump();
13 }
14 }

Creational Patterns

Builder

  • a template that keeps attributes from which it can build new objects
  • each method returns back the builder itself, so it can be chained
1 class Builder {
2 private _position: Vector;
3 private _scale: Vector;
4
5 position(pos: Vector) {
6 this.position = pos;
7 return this;
8 }
9
10 scale(scale: Vector) {
11 this.scale = scale;
12 return this;
13 }
14
15 build() {
16 return new GameObject(this._position, this._scale);
17 }
18 }
19
20 new Builder().position(new Vector(12, 54)).scale(new Vector(2, 1)).build();

Prototype

  • Builder builds new objects from scratch, Prototype creates new objects by copying their attributes
  • in some implementations, the prototype is linked to its objects - if we change the prototype, it will affect all derived entities
  • e.g. templates in Godot, linked prefabs in Unity and Unreal engine

Prefabs in Unity

Transmuter

  • modifies a state and behavior of an object
  • useful when the change is not trivial
  • we can move the modification process from components to separate functions
1 const superBallTransmuter = (entity: GameObject) => {
2 entity.removeComponent<BallBehavior>();
3 entity.addComponent(new SuperBallBehavior());
4 entity.state.speed = SUPER_BALL_SPEED;
5 entity.state.size = SUPER_BALL_SIZE;
6 return entity;
7 }

Factory

  • Builder assembles an object, factory manages the assembling
  • Factory creates an object according to the parameters but with respect to the context
1 class UnitFactory {
2
3 private pikemanBuilder: Builder; // preconfigured to build pikemans
4 private musketeerBuilder: Builder; // preconfigured to build musketeers
5 private archerBuilder: Builder; // preconfigured to build archers
6
7 public spawnPikeman(position: Vector, faction: FactionType): GameObject {
8 return this.pikeman.position(position).faction(faction).build();
9 }
10
11 public spawnMusketeer(position: Vector, faction: FactionType): GameObject {
12 return this.musketeerBuilder.position(position).faction(faction).build();
13 }
14
15 public spawnArcher(position: Vector, faction: FactionType): GameObject {
16 return this.archerBuilder.position(position).faction(faction).build();
17 }
18 }

Simulation Patterns

Sandbox

  • full simulation takes place within a space close to the player
  • simulation in an area further away is either omitted or simplified
  • often used in racing games and open-world games with persistent objects
  • can be implemented as a separate branch of the scene - the same objects are used, but certain components (rigidbody, animator,...) are disabled

Replay

  • allows to reproduce any state of a game at any time
  • all game entities must have a reproducible behavior (similar to multiplayer facility)
  • Solution a)
    • store all input events from the player and re-play them in the same order
    • not robust, may break on other platforms
  • Solution b)
    • reversible counterpart for each function that modifies the game state
    • too complicated, random access will be difficult
  • Solution c)
    • snapshot the game state every frame (or by keyframes and interpolate)

Example: Doom Demo File

  • Lump file (*.LMP)
  • fixed time-loop at a rate of 35 FPS (handled by tic command)
  • the file contains only keyboard inputs at each tick
  • the game plays the demo, injecting input commands from the demo file
  • 13B header + 4B data for each tick ~140B/s

Design practices: Summary

  • use builder to build new objects
  • use factory to manage construction of new objects
  • use prototype to clone already existing objects
  • use chain to chain up complex actions/processes
  • use selectors to access attributes that are deep in the scene hierarchy
  • use flag to collect a set of features of an object
  • use numeric state for a simple finite state machine
  • use transmuter to change a composition of components upon an object
  • use blackboard to store global game data

Lecture Summary

  • I know how chain works
  • I know something about responsibility ownership in component architecture
  • I know flyweight pattern
  • I know selector pattern
  • I know flag pattern
  • I know numeric state pattern
  • I know builder pattern
  • I know prototype pattern
  • I know factory pattern

Goodbye Quote

Machines aren't capable of evil. Humans make them that wayChrono Trigger