A basic computer game -- really, any interactive system -- involves running code to do the following three things:
- take input from the player
- update over time
- display output to the player
We'll tend to think of the input as coming from a computer mouse, keyboard, or gamepad, and the display being some sort of raster graphics drawn to a computer monitor.
What is a computer game you have seen or played that uses an atypical input or output device?
Thinking in C++ for a moment, this means that a Game
's overall structure must look something like this:
struct Game {
Game();
~Game();
//Handle input:
void handle_input(Input i);
//Update over time:
void update(float elapsed_time);
//Draw current state:
void draw();
/* ... */
};
The Main Loop
Now that we've got these basic functions figured out, let's talk about how the code outside the Game
should go about calling them.
We'll call this outside code the "main loop" both because it's the outer loop that runs the rest of the code, and because -- in a simple game -- it generally lives in main
.
First Attempt: The Inferno
Given game code above, and a support library that allows reading input and computing elapsed time, we can write our first attempt at a main loop.
//library functions:
//poll_input reads input from a queue of input events:
// if the queue is empty it returns false
// otherwise it fills *input with the next event and returns true
bool poll_input(Input *input);
//get_elapsed returns the elapsed time, in seconds, since it was last called:
// NOTE: first call always returns 0.0f
float get_elapsed();
//Loop:
int main(int argc, char **argv) {
Game game;
//the main loop:
while (true) {
Input input;
while (poll_input(&input)) {
game.handle_input(input);
}
game.update(get_elapsed());
game.draw();
}
}
This will run our game admirably, but there's a problem: if you write games like this, anyone trying to play them on a laptop or phone is going to be very annoyed. Why?
Because the game is always running as fast as it possibly can, regardless of whether this update rate actually improves the experience.
Second Attempt: The Black Hole
To solve our inferno problem, we need to slow the game down. But how slow should it go? The classic solution to this question is "as fast as the monitor lets it." In other words, our game should only draw frames as fast as the display device it is attached to can display them.
To revise our main loop to address this problem, we'll need another library function:
//vsync_and_swap waits until swapping buffers would not cause tearing, swaps the buffers, and returns
void vsync_and_swap();
To understand this function, we'll need to take a moment to discuss framebuffers. A framebuffer is an area of memory (generally on the GPU) that holds a "frame" (or image) to be displayed. When the GPU needs to transmit image data to a monitor (generally every 60th of second, though display "refresh rates" can vary), the GPU reads data from the framebuffer and sends that data out over the cable connecting it to the monitor.
Of course, it would look really weird if the GPU were to send data from a frame that wasn't finished rendering. So how can a game draw to the framebuffer while being sure that it isn't getting sent to the monitor? The key is that there is a period of time -- the "vertical blanking interval" -- during which no video is being sent to the monitor by the GPU. The start of this period of time is indicated (in old-school NTSC video) by the "vertical synchronization" pulse, or simply "vsync". In modern parlance, we use the term "vsync" to mean "wait for the GPU to indicate that the previous frame is done sending" -- regardless of whether the frame was sent as NTSC video or something a bit more modern.
So now we have some idea of what the vsync
part of our new library function's name means, what about the swap
part?
Well, back when memory was expensive, games would draw directly to the very same memory that the GPU was sending to the monitor.
This "single-buffered" style of drawing was limited, because all drawing operations would need to complete inside the vertical blanking interval -- no drawing code could be running while the frame was actually being sent without resulting in visual artifacts.
The fix? More memory and a layer of indirection. (And, yes, "more memory and a layer of indirection" is a pretty common way for computer science to solve problems.) Once GPUs could afford to have storage for two framebuffers, games could do a thing called "double-buffering", where code would draw to one buffer (the "back buffer") while the GPU was showing another buffer (the "front buffer"). Then, during the vertical blanking interval, the GPU's "front buffer" and "back buffer" pointers could be swapped, and the process could repeat -- game code drawing to the (new) "back buffer", the GPU showing the (new) "front buffer", and so on.
(By the way, swapping during the middle of a frame being sent to the monitor generally means that vertical edges in moving objects become disconnected; this artifact is colloquially known as "tearing".)
By the way, the code we write in this class will all be using double-buffered graphics output.
So we can safely assume that Game::draw()
is drawing to the back buffer and revise our game loop as follows:
int main(int argc, char **argv) {
Game game;
//the main loop:
while (true) {
Input input;
while (poll_input(&input)) {
game.handle_input(input);
}
game.update(get_elapsed());
game.draw();
vsync_and_swap(); //wait until frame is shown
}
}
It's a small change, but your friends' laptop batteries will definitely thank you.
Unfortunately, our code still may suffer from a "frame rate black hole" -- so called because it involves the frame rate getting pulled into an abyss from which it will never return.
This problem occurs if the game ever ends up in a situation where Game::update(t)
ends up taking, say, 1.2 * t
seconds to execute.
This is actually not a crazy situation for a game to find itself in -- we'll talk about one common situation that can lead to this sort of behavior in the next section. For now, just take this timing model as given and think about the consequences:
That's an exponential growth in frame times.
Thankfully the fix for the frame rate black hole is surprisingly easy. Here it is:
int main(int argc, char **argv) {
Game game;
//the main loop:
while (true) {
Input input;
while (poll_input(&input)) {
game.handle_input(input);
}
//The fix is in this line:
game.update(std::min(get_elapsed(), 1.0f / 10.0f));
game.draw();
vsync_and_swap();
}
}
Final Refinement: Ticks
Now that we have a nicely functioning main loop, I want to step inside the Game
object for just a moment to discuss variable-time vs fixed-time updates.
Up until this point, we've been talking about a Game::update
function that performs variable time updates.
This means that it must be able to advance the game state for 0.001 seconds or 0.1 seconds (though not much more than that -- see fix above).
For some tasks, like seeing if a countdown timer expired this frame, or computing the new position of an object moving at a fixed velocity, a variable time update isn't a problem; it's easy to reason about the quantities and write correct formulas.
In fact, here's a chance to write a correct variable-time update yourself:
Exercise: Fast Friends
A confused programmer with a 60Hz monitor writes the following code. It works great for them, but their friend who has a fancy 240Hz gaming monitor says the game is "way too fast". Fix this update code so it works for both the programmer and their friend:
Current status:
- 60Hz programmer says "Works for me!"
- 240Hz friend says "Way too fast!"
But for other things, like friction, collision resolution, and gravity, the closed-form solutions aren't as easy to reason about. Further, sometimes you want to make sure that your game always runs exactly the same way, for example, because you need all the players in a networked game to have the same state, and that means that variable-time updates are definitely a bad idea.
The solution is to introduce a second, fixed-time update function to Game
, and call this function from within update
.
struct Game {
Game();
~Game();
//Handle input:
void handle_input(Input i);
//Variable-time update:
void update(float elapsed_time);
//Fixed-time update helper (called by 'update'):
void tick();
static constexpr float Tick = 0.01f; //timestep used for tick()
float tick_acc = 0.0f; //accumulated time toward next tick
//Draw current state:
void draw();
/* ... */
struct {
float position = 0.0f;
float velocity = 1.0f;
} player;
};
void Game::update(float elapsed) {
//handle fixed-time updates:
tick_acc += elapsed;
while (tick_acc > Tick) {
tick_acc -= Tick;
tick();
}
//handle variable-time updates:
//...
}
Implementing a tick
function like this can make it a lot easier to handle things like -- for example -- (viscous) damping, since you can just multiply by a constant.
Of course, there's nothing wrong with using regular continuous-style update rules either, and defining a Tick
constant make those easy to write.
void Game::tick() {
//always called every Tick seconds, so you can write things like this and it will perform consistently:
player.velocity *= 0.95f;
//of course, you can always use continuous-style update formulas with the fixed "Tick" constant:
player.position += player.velocity * Tick;
}
Wrapping Up
The main loop as described above is pretty much exactly what we'll use in the course base code to run your games.
That said, there are a few things I wanted to go over that didn't fit into the flow of the lesson above, so they are contained in this section.
Aside: Event-Driven Programs
We talk about main loops in this class because it fits the model of program execution that you are familiar with from desktop OSes:
a program starts up, enters its main
function, spends as much time there as it needs, and -- once main
returns -- is done.
But if you work in other paradigms (e.g., HTML5+JavaScript games) you may encounter a different execution model: event-driven execution.
An event-driven program doesn't have a main
function.
Instead, it registers callbacks with the surrounding code (e.g., the OS; the browser), and each of these callbacks executes and returns.
In other words, only at specific times (during event callbacks) is the game code even running.
This may seem a bit odd at first, but realize that our main
function above is basically serving as an adaptor between the desktop execution model and a simple event-driven model with three events (with our various timing revisions changing when and how the update
event is fired).
So fear not! Even though you'll be writing C++ code that uses a main loop in this class, pretty much everything you learn will still work just fine in, e.g., JavaScript code with various keydown
events and requestAnimationFrame
callbacks.
Quitting
Notice that our Game
doesn't have any facility for quitting.
Modify the Game
structure and the main
loop as needed to support this functionality, and comment your modification explaining how it should be used.
(Note: if you add additional functions, you do not need to implement them.)
There are a lot of ways to do this, most of which are -- in some sense -- correct.