Ticking

Using Tick() functions to update game objects independently of events

Ryan McCombe
Updated

In our previous projects, we've updated the state of our objects based on events detected in our event loop:

// Application Loop
while (shouldContinue) {
  // Handle Events
  while (SDL_PollEvent(&Event)) {
    if (Event.type == SDL_EVENT_QUIT) {
      shouldContinue = false;
    }
    GameObject.HandleEvent(Event);
  }

  // Render Objects
  GameWindow.Render();
  GameObject.Render(GameWindow.GetSurface());

  // Update Frame
  GameWindow.Update();
}

However, in more complex projects, most objects may need to update their state consistently, even when they're not receiving any events.

For example, characters not controlled by our player may need to continue to act and move, and animation, and visual effects should continue to update.

Starting Point

To keep the focus on the new concepts, we will start with a minimal project containing just a main.cpp file and our Window.h class from earlier chapters.

This provides a clean foundation for introducing the new GameObject and World classes that will be central to this chapter's lessons:

Files

src
Select a file to view its content

Tick Functions

To allow our objects to update independently of events, our applications can introduce the notion of ticking. On every iteration of our application loop, we call a function on our objects that allows them to update their state.

These functions are commonly given names like Tick() or Update():

// Application Loop
while (shouldContinue) {
  // Handle Events
  while (SDL_PollEvent(&Event)) {
    if (Event.type == SDL_EVENT_QUIT) {
      shouldContinue = false;
    }
    GameObject.HandleEvent(Event);
  }

  // Update Objects
  GameObject.Tick();

  // Render Objects
  GameWindow.Render();
  GameObject.Render(GameWindow.GetSurface());

  // Update Frame
  GameWindow.Update();
}

Game Objects and Worlds

Complex projects can have thousands or millions of objects that are ticking on every frame so, as before, we commonly use an intermediate manager-style object to manage this complexity.

Let's have all of our game objects inherit from a standard base class called GameObject:

src/GameObject.h

#pragma once
#include <SDL3/SDL.h>

class GameObject {
 public:
  virtual void HandleEvent(const SDL_Event& E) {
    // ...
  }

  virtual void Tick() {
    // ...
  }

  virtual void Render(SDL_Surface* Surface) {
    // ...
  }
};

We'll create a World class that manages all of the game objects in our world. When our main application loop prompts our world to handle an event, tick, or render, we'll forward that instruction to all of our objects:

src/World.h

#pragma once
#include <vector>
#include <memory>
#include "GameObject.h"

class World {
 public:
  void HandleEvent(const SDL_Event& E) {
    for (auto& Object : Objects) {
      Object->HandleEvent(E);
    }
  }

  void Tick() {
    for (auto& Object : Objects) {
      Object->Tick();
    }
  }

  void Render(SDL_Surface* Surface) {
    for (auto& Object : Objects) {
      Object->Render(Surface);
    }
  }

 private:
  std::vector<std::unique_ptr<GameObject>> Objects;
};

Let's update our main application loop to use our new class:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include "Window.h"
#include "World.h"

int main(int, char**){
  SDL_Init(SDL_INIT_VIDEO);

  Window GameWindow;
  World GameWorld;

  SDL_Event Event;
  bool shouldContinue{true};

  // Application Loop
  while (shouldContinue) {
    // Handle Events
    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_EVENT_QUIT) {
        shouldContinue = false;
      }
      GameWorld.HandleEvent(Event);  
    }

    // Update Objects
    GameWorld.Tick(); 

    // Render Objects
    GameWindow.Render();
    GameWorld.Render(GameWindow.GetSurface()); 

    // Update Frame
    GameWindow.Update();
  }

  SDL_Quit();
  return 0;
}

Game Object Subtypes

We can now use inheritance and runtime polymorphism (overriding virtual functions) to support a variety of GameObject types. Let's update our program to support Goblin objects:

src/Goblin.h

#pragma once
#include <string>
#include "GameObject.h"

class Goblin : public GameObject {
 public:
  Goblin(const std::string& name, int x, int y)
    : Name(name), xPosition(x), yPosition(y) {}

  std::string Name;
  int xPosition;
  int yPosition;
};

We'll add a SpawnGoblin() method to the World class, which will create a GameObject managed by that World when called. This function will also return a reference to the spawned object, so callers can access it if needed:

src/World.h

#pragma once
#include <vector>
#include <memory>
#include "GameObject.h"
#include "Goblin.h"

class World {
 public:
  void HandleEvent(const SDL_Event& E) {
    for (auto& Object : Objects) {
      Object->HandleEvent(E);
    }
  }

  void Tick() {
    for (auto& Object : Objects) {
      Object->Tick();
    }
  }

  void Render(SDL_Surface* Surface) {
    for (auto& Object : Objects) {
      Object->Render(Surface);
    }
  }

  Goblin& SpawnGoblin(
    const std::string& Name, int x, int y
  ) {
    Objects.emplace_back(
      std::make_unique<Goblin>(Name, x, y)
    );
    return static_cast<Goblin&>(*Objects.back());
  }

 private:
  std::vector<std::unique_ptr<GameObject>> Objects;
};

We can now add Goblins to our World using this function:

// main.cpp
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>
#include "Window.h"
#include "World.h"
#include "Goblin.h"

int main(int, char**){
  // ...
  World GameWorld;
  Goblin& Enemy{GameWorld.SpawnGoblin(
    "Goblin Rogue", 100, 200)};
  std::cout << "A " << Enemy.Name
    << " was spawned in the world";

  // ...
}
A Goblin Rogue was spawned in the world

Finally, we can override the Tick() function for our Goblin objects. Let's allow our goblins to move regardless of whether any events are happening:

src/Goblin.h

#pragma once
#include <string>
#include <iostream>
#include "GameObject.h"

class Goblin : public GameObject {
 public:
  Goblin(const std::string& name, int x, int y)
      : Name(name), xPosition(x), yPosition(y) {}

  std::string Name;
  int xPosition;
  int yPosition;

  void Tick() override {
    std::cout << "\nTick() updating position";
    xPosition += 1;
  }

  void Render(SDL_Surface* Surface) override {
    std::cout
      << " - Rendering at x = " << xPosition;
    // ...
  }
};
A Goblin Rogue was spawned in the world
Tick() updating position - Rendering at x = 101
Tick() updating position - Rendering at x = 102
Tick() updating position - Rendering at x = 103
...

If needed, they can also react to events in addition to ticking. When the player presses an arrow key, let's change the direction our Goblin moves on each tick:

src/Goblin.h

#pragma once
#include <SDL3/SDL.h>
#include <string>
#include <iostream>
#include "GameObject.h"

class Goblin : public GameObject {
 public:
  Goblin(const std::string& name, int x, int y)
    : Name(name), xPosition(x), yPosition(y) {}

  std::string Name;
  int xPosition;
  int yPosition;
  int Velocity{1};

  void HandleEvent(const SDL_Event& E) override {
    if (E.type != SDL_EVENT_KEY_DOWN) return;
    if (E.key.key == SDLK_RIGHT) {
      std::cout << "\nHandleEvent() setting "
        "velocity to 1";
      Velocity = 1;
    } else if (E.key.key == SDLK_LEFT) {
      std::cout << "\nHandleEvent() setting "
        "velocity to -1";
      Velocity = -1;
    }
  }

  void Tick() override {
    std::cout << "\nTick() updating position";
    xPosition += Velocity;
  }

  void Render(SDL_Surface* Surface) override {
    std::cout
      << " - Rendering at x = " << xPosition;
    // ...
  }
};
A Goblin Rogue was spawned in the world
Tick() updating position - Rendering at x = 101
Tick() updating position - Rendering at x = 102
HandleEvent() setting velocity to -1
Tick() updating position - Rendering at x = 101
...

Complete Code

A complete version of the architecture we built in this lesson is available below:

Files

src
Select a file to view its content

Advanced: Tick Dependencies

In complex projects, updating objects often depends on the state of other objects in the world. For example, consider a UI element that appears attached to one of our game objects:

src/NameTag.h

#pragma once
#include "GameObject.h"
#include "Goblin.h"

class NameTag : public GameObject {
 public:
  Goblin& Parent;

  void Tick() override {
    // Are these arguments correct?
    // It's unclear whether Parent has ticked yet
    SomeFunction(
      Parent.xPosition,
      Parent.yPosition
    );
  }
};

The issue here is that we don't know the order in which our objects' Tick() functions are called. If Parent ticks before NameTag, there's no problem. However, if NameTag ticks first, the Parent.xPosition and Parent.yPosition values will not have been updated yet, resulting in stale data.

Using these stale values means our NameTag's position will be based on where the Goblin was in the previous frame, not the current frame. As a result, our NameTag will lag one frame behind the Goblin object it's supposed to be attached to.

These off-by-one-frame issues are extremely common, even in major released projects. They're difficult to notice, especially when many things are happening on-screen simultaneously. However, they contribute to a general feeling that our game is less responsive than it should be, so it's worth preventing these problems where possible.

In complex projects, the architecture to manage these inter-object dependencies can get quite elaborate. A common and simple first step involves breaking our tick process into multiple phases and establishing a convention on what type of updates should be performed in each phase.

For example, we could split our ticking into two phases: TickPhysics(), followed by TickUI():

src/main.cpp

// Application Loop
while (shouldContinue) {
  // Handle Events
  while (SDL_PollEvent(&Event)) {
    // ...
  }

  // Update Objects
  GameWorld.TickPhysics();  
  GameWorld.TickUI();  

  // Render Objects
  GameWindow.Render();
  GameWorld.Render(GameWindow.GetSurface());

  // Update Frame
  GameWindow.Update();
}

We can then establish the convention that any code that updates the physical state of our world belongs in TickPhysics().

Our TickUI() functions are called after all objects have had their TickPhysics() functions called, so any code in a TickUI() function can be confident that our object's positions in the world are up to date:

src/NameTag.h

#pragma once
#include "GameObject.h"
#include "Goblin.h"

class NameTag : public GameObject {
 public:
  NameTag(const Goblin& Parent)
  : Parent(Parent) {}

  Goblin& Parent;
  int xPosition;
  int yPosition;

  void TickUI() override {
    // We know these values have been updated
    // because TickPhysics() happens before TickUI()
    xPosition = Parent.xPosition;
    yPosition = Parent.yPosition;
  }
};

Advanced: Generalizing Object Creation

The GameWorld.SpawnGoblin(Name, x, y) approach in this section was used to simplify memory management. It absolves consumers from needing to manage the lifecycle of their GameObject instances, as it is all handled by the World class.

However, larger projects can have hundreds or thousands of GameObject subtypes. Therefore, scaling this technique would involve adding an additional method to our World class for each of these subtypes, and potentially multiple methods if the type has multiple constructors.

src/World.h

// World.h
// ...
class World {
public:
  Goblin& SpawnGoblin(int Arg1, int Arg2) {
    // ...
  }

  Dragon& SpawnDragon(int Arg1) {
    // ...
  }

  // The Dragon type has multiple constructors
  Dragon& SpawnDragon(int Arg1, float Arg2) {
    // ...
  }

  // ...
};

We could solve this by creating objects outside of our World, and then transferring ownership:

src/World.h

// World.h
// ...
class World {
public:
  void AddObject(std::unique_ptr<GameObject> Object) {
    // ...
  }
};
auto Enemy1{std::make_unique<Goblin>(100, 200)};
World.AddObject(std::move(Enemy1));

auto Enemy2{std::make_unique<Dragon>(50)};
World.AddObject(std::move(Enemy2));

auto Enemy3{std::make_unique<Dragon>(100, 0.5f)};
World.AddObject(std::move(Enemy3));

We can make this API simpler and less error-prone. For comparison, the Unreal Engine API for creating objects in the world looks like this:

GetWorld()->SpawnActor<Goblin>(100, 200);
GetWorld()->SpawnActor<Dragon>(50);
GetWorld()->SpawnActor<Dragon>(100, 0.5f);

However, implementing this requires more complex C++ techniques that may be unfamiliar, so don't worry if this is difficult to follow. It's not required for this course and is only included as a reference:

src/World.h

// ...
class World {
 public:
  template <typename T, typename... Args>
  T& SpawnObject(Args&&... args) {
    Objects.emplace_back(std::make_unique<T>(
      std::forward<Args>(args)...));

    return static_cast<T&>(*Objects.back());
  }
  // ...
};

We introduce these techniques and cover them in more detail in our advanced course. They include:

  • , which enable the compiler to create functions at compile time, based on template arguments we provide between < and > tokens. In the following example, we're using the Player and Goblin types as a template argument.
  • The ... syntax, which allows us to create . Variadic functions can handle a variable number of parameters. In this example, we're collecting an unknown number of parameters to forward to the constructor of the type we passed as a template argument.
  • The std::forward function, which implements . This allows arguments to be forwarded from one function to another in a way that respects their value category.

Our more flexible API can be used like this:

// main.cpp
// ...
int main(int, char**){
  // ...
  World GameWorld;

  Player& PlayerOne{
    GameWorld.SpawnObject<Player>(
      "Player One"
    )};

  Goblin& Enemy{
    GameWorld.SpawnObject<Goblin>(
      "Goblin", 100, 200
    )};
  // ...
}

Summary

This lesson introduced the concept of ticking:

  • We introduced tick functions, and how we can use them to allow our objects to update their state on every frame.
  • We created GameObject and World classes to manage multiple game objects and remove complexity from our application loop.
  • We discussed potential issues like off-by-one-frame errors and how to mitigate them.
  • Finally, we explored advanced techniques for generalizing object creation in larger projects.
Next Lesson
Lesson 39 of 52

Tick Rate and Time Deltas

Learn how to create smooth, time-aware game loops that behave consistently across different hardware configurations

Have a question about this lesson?
Answers are generated by AI models and may not be accurate