Skip to content
wannacode

The game loop

In short: The game loop repeats the same steps until the game ends: it draws the world, reads a command, handles it and updates the state, and a seed makes every run repeatable.

Problem

A program that ends

An ordinary program runs from top to bottom and ends. It reads the numbers, computes, prints, done. A game cannot work like that. It has to wait for the player, and between two commands there is a world: where the hero is, where the goblin is, how many turns have passed. That world has to survive until the next command.

The first thing that comes to mind is a while (true) that reads a line and does everything inside. A corridor of 12 cells: you are @, the goblin g chases you, the exit is >:

hero = 4
goblin = 0
while True:
command = input()
if command == "q":
break
if command == "a":
hero -= 1
if command == "d":
hero += 1
row = ""
for x in range(12):
row += "@" if x == hero else "g" if x == goblin else ">" if x == 11 else "."
print(row)
if hero == 11:
print("Exit!")
break
goblin += 1 if goblin < hero else -1
if abs(hero - goblin) <= 1:
print("The goblin caught you.")
break

The player types x (wrong key) and then a:

Output
g...@......>
.g.@.......>
The goblin caught you.

Three things are wrong:

  • Before the first command the screen is empty: the program draws only after it reads a line.
  • The typo x is a turn: the goblin has stepped. The program cannot tell "the player did something" from "the player hit the wrong key".
  • The last frame is .g.@: there is a free cell between you, and the goblin catches you. The frame was printed after your step and before the goblin's. Nobody ever saw the position in which the game ended.

And two things are impossible. You cannot draw the screen again without a turn: after the ? help or the inventory, the drawing has to be copied. You cannot check the rule "the goblin steps toward the hero" without typing commands and reading text. There is one cause: the world, the rules and the drawing are mixed in one place.

The idea

Four steps in a circle

We split them:

  • The state is everything the world is, in one object: the hero, the goblin, the exit, the turn counter, the message, the end. The screen is not part of it. The screen is computed from it.
  • draw only reads the state and prints a frame. You can call it as many times as you like.
  • handle turns the command into an intention: move is -1, 1 or 0. It moves nobody.
  • update changes the world: the turn, the hero, the goblin, the end. It prints nothing.

The loop calls them in a circle: draw, read, handle, update. This is the game loop. Every game has one, from this corridor to Arcanum.

class State:
def __init__(self):
self.hero = 4
self.goblin = 0
self.exit = 11
self.turn = 0
self.move = 0
self.message = ""
self.over = False
state = State()
while not state.over:
draw(state)
command = read()
handle(state, command)
update(state)
draw(state)

over stops the loop, and the last draw after it shows the end: the exit or the goblin, with the message.

Reading is a separate function, because the input can run out:

def read():
try:
line = input()
except EOFError:
return None
print(f"> {line}")
return line

At the end of the input, input() raises EOFError. read catches it and returns None, and handle treats it as the end of the game. The echo > a is there so the output shows which command came in. The player's console shows it by itself.

More precisely, if you are curious

Without the end-of-input check, every language breaks in its own way. Java throws an exception, and so does Python. C# is the sneakiest: Console.ReadLine() returns null, the comparison command == "q" is false, and the first attempt does not notice that the input has ended: it prints frames of empty turns until the goblin reaches the hero on its own. JavaScript gets undefined and does the same. Input runs out more often than you think: a file of commands, a closed terminal, the site's check.

See it

One turn up close

Here are the three functions. The whole game is in them:

def draw(s):
row = ""
for x in range(s.exit + 1):
if x == s.hero:
row += "@"
elif x == s.goblin:
row += "g"
elif x == s.exit:
row += ">"
else:
row += "."
print(f"Turn {s.turn} {row}")
if s.message:
print(s.message)
def handle(s, command):
s.move = 0
s.message = ""
if command is None:
s.message = "The input ran out."
s.over = True
elif command == "a":
s.move = -1
elif command == "d":
s.move = 1
elif command == "q":
s.message = "You left."
s.over = True
else:
s.message = f"Unknown command '{command}': no turn passed."
def update(s):
if s.move == 0:
return
s.turn += 1
s.hero = max(0, s.hero + s.move)
if s.hero == s.exit:
s.message = f"Exit! You got away in {s.turn} turns."
s.over = True
return
if abs(s.hero - s.goblin) > 1:
s.goblin += 1 if s.goblin < s.hero else -1
if abs(s.hero - s.goblin) <= 1:
s.message = "The goblin caught you."
s.over = True

state = State()
while not state.over:
draw(state)
command = read()
handle(state, command)
update(state)
draw(state)
def draw(s):
row = ""
for x in range(s.exit + 1):
if x == s.hero:
row += "@"
elif x == s.goblin:
row += "g"
elif x == s.exit:
row += ">"
else:
row += "."
print(f"Turn {s.turn} {row}")
if s.message:
print(s.message)
def handle(s, command):
s.move = 0
s.message = ""
if command is None:
s.message = "The input ran out."
s.over = True
elif command == "a":
s.move = -1
elif command == "d":
s.move = 1
elif command == "q":
s.message = "You left."
s.over = True
else:
s.message = f"Unknown command '{command}': no turn passed."
def update(s):
if s.move == 0:
return
s.turn += 1
s.hero = max(0, s.hero + s.move)
if s.hero == s.exit:
s.message = f"Exit! You got away in {s.turn} turns."
s.over = True
return
if abs(s.hero - s.goblin) > 1:
s.goblin += 1 if s.goblin < s.hero else -1
if abs(s.hero - s.goblin) <= 1:
s.message = "The goblin caught you."
s.over = True
Step by step, in words
  1. Turn 1 is done: the hero is on cell 5, the goblin on cell 1. The whole world is the state object; the loop is back at the top.
  2. draw walks over the 12 cells and prints the frame .g...@.....>. It only reads the state: nothing in the panel changes.
  3. read waits. The world stands still until the player presses Enter. The line "a" arrives.
  4. handle only turns the command into an intention: move = -1. Nobody has moved yet.
  5. update changes the world. The turn counter goes to 2 and the hero steps from 5 to 4. Not the exit, so the turn goes on.
  6. The goblin is 3 cells away, so it steps toward the hero: from 1 to 2. Now 2 cells: not caught, the game goes on.
  7. Back at the top, draw prints ..g.@......>: the frame comes after the whole turn, so the player sees where the goblin really is.

With the commands d, a, x and seven times d, the program prints:

Output
Turn 0 g...@......>
> d
Turn 1 .g...@.....>
> a
Turn 2 ..g.@......>
> x
Turn 2 ..g.@......>
Unknown command 'x': no turn passed.
> d
Turn 3 ...g.@.....>
> d
Turn 4 ....g.@....>
> d
Turn 5 .....g.@...>
> d
Turn 6 ......g.@..>
> d
Turn 7 .......g.@.>
> d
Turn 8 ........g.@>
> d
Turn 9 ........g..@
Exit! You got away in 9 turns.

On x the frame is drawn again, but the turn stays 2 and the goblin stands still: update sees move = 0 and does nothing. That works because draw does not touch the world. The goblin steps after the hero, and the frame comes after the whole turn, so you see exactly where it is. At the exit the goblin does not step: the game ends before its step.

Now the rules can be checked without a screen and without typing commands. You build a state by hand, call update and look at the numbers:

near = State()
near.hero = 5
near.goblin = 3
near.move = -1
update(near)
print(near.hero, near.goblin, near.message)
far = State()
far.move = 1
update(far)
print(far.hero, far.goblin, far.turn)
Output
4 3 The goblin caught you.
5 1 1

This is a test of the rule "the goblin catches you if it is next to you". A real game has hundreds of checks like this. With the first attempt you could not write a single one.

More precisely, if you are curious

Why doesn't handle move the hero directly? In the corridor it could. But in Arcanum the same a means "left" on the map, "back" in the menu and "nothing" in a dialog. The screen understands the command, and the rules change the world. Second: a command that is not a turn (a typo, opening the inventory, help) must not move the world. So the intention is stored separately, and update decides whether there is a turn at all.

Try it

Put the loop in order

Put the lines of the loop in order:

  1. while not state.over:
  2. draw(state)
  3. command = read()
  4. update(state)
  5. handle(state, command)

Try it

Predict the output

The same program gets the commands a, x, a. These are the rules:

def handle(s, command):
s.move = 0
s.message = ""
if command is None:
s.message = "The input ran out."
s.over = True
elif command == "a":
s.move = -1
elif command == "d":
s.move = 1
elif command == "q":
s.message = "You left."
s.over = True
else:
s.message = f"Unknown command '{command}': no turn passed."
def update(s):
if s.move == 0:
return
s.turn += 1
s.hero = max(0, s.hero + s.move)
if s.hero == s.exit:
s.message = f"Exit! You got away in {s.turn} turns."
s.over = True
return
if abs(s.hero - s.goblin) > 1:
s.goblin += 1 if s.goblin < s.hero else -1
if abs(s.hero - s.goblin) <= 1:
s.message = "The goblin caught you."
s.over = True
Answer

Try it

Find the bug

A colleague decided the goblin should move "on every frame" and moved its step from update into draw. With the commands d, x, x, d, the program prints:

Output
Turn 0 .g..@......>
> d
Turn 1 ..g..@.....>
> x
Turn 1 ...g.@.....>
Unknown command 'x': no turn passed.
> x
Turn 1 ....g@.....>
Unknown command 'x': no turn passed.
> d
Turn 2 .....g@....>
Turn 2 .....g@....>
The input ran out.

Already on frame 0 the goblin is on cell 1. On the two typos the turn stays 1, and the goblin runs. In the end it is next to the hero and nothing happens. Which line has the bug?

Pick the line with the bug.

The idea

Turn and frame

The corridor is turn-based: the world stands still until a command comes. One turn, one frame. The console hands over a whole line only after Enter, and reading waits, so a console game is turn-based by nature. So are chess, roguelikes (Rogue, NetHack) and the battles in many strategy games.

A real-time game does not wait. The world moves by the clock: if you do nothing, the goblin still comes. The loop runs dozens of times per second: it checks whether a key is pressed (without waiting), updates the world for the time that has passed and draws. One drawing is a frame, and one step of the world is a separate thing.

The four parts are the same. What changes is who drives the loop: the player's line or the clock. There, separating draw from update matters even more: a fast monitor draws 144 frames per second, but the world has to step at the same pace on every computer.

More precisely, if you are curious

A fixed time step. If the world steps by "however much time has passed since the last frame", the physics depends on the frames: on a slow frame the arrow skips over the wall, and two computers never give the same result. So the world steps in equal slices, for example 50 ms. The elapsed time is accumulated (an accumulator), and the loop takes as many steps as there are whole slices. The rest waits for the next frame.

Here the clock is made up (a list of frame times), so the output is always the same. The fast computer draws at uneven intervals, the slow one rarely:

def run(name, times):
step = 50
acc = 0
x = 0
last = times[0]
for now in times:
acc += now - last
last = now
steps = 0
while acc >= step:
x += 3
steps += 1
acc -= step
print(f"{name} {now} ms: steps {steps}, x = {x}, left over {acc} ms")
run("fast", [0, 16, 40, 45, 110, 126, 150])
run("slow", [0, 90, 150])
Output
fast 0 ms: steps 0, x = 0, left over 0 ms
fast 16 ms: steps 0, x = 0, left over 16 ms
fast 40 ms: steps 0, x = 0, left over 40 ms
fast 45 ms: steps 0, x = 0, left over 45 ms
fast 110 ms: steps 2, x = 6, left over 10 ms
fast 126 ms: steps 0, x = 6, left over 26 ms
fast 150 ms: steps 1, x = 9, left over 0 ms
slow 0 ms: steps 0, x = 0, left over 0 ms
slow 90 ms: steps 1, x = 3, left over 40 ms
slow 150 ms: steps 2, x = 9, left over 0 ms

At 150 ms both have taken 3 steps and the arrow is at 9. The frames differ, the world is the same. The left-over time is also used for drawing: with 10 ms out of 50, the arrow is drawn 20% of the way to its next position (interpolation), so it does not jitter.

Reading without waiting, and raw keys. The terminal itself holds the line until you press Enter. The program gets nothing until the whole line arrives. For real time the terminal has to switch to "raw" mode, where every key arrives at once, and the program has to ask "is there a key?" without waiting.

On Windows there are msvcrt.kbhit() and msvcrt.getwch(). On Linux and macOS the terminal is switched with tty.setcbreak, and select.select([sys.stdin], [], [], 0) asks whether there is input without waiting. The curses module does both: with nodelay(True), getch() returns -1 when nothing is pressed.

Arcanum stays turn-based. The same code has to run in the browser, in the check (the commands come from a file) and in anyone's terminal. A line of text works everywhere. Raw mode does not exist for a file.

The idea

Seed and repeatability

A game needs randomness: a hit or a miss, what loot drops, where the wolf goes. But a bug that depends on randomness cannot be repeated. So the generator has a seed: the same seed gives the same numbers.

This is the generator of Arcanum:

# mulberry32: a small seeded generator. Only 32-bit integers (a & 0xFFFFFFFF mask and a hand-made
# imul), so it gives the same sequence in every language.
MASK32 = 0xFFFFFFFF
# Math.imul from JavaScript: the product, of which only the low 32 bits are kept.
def imul(a, b):
return (a * b) & MASK32
# "x >>> 0" from JavaScript: a number to unsigned 32 bits (a non-number and infinity become 0).
def to_uint32(value):
if value != value or value == float('inf') or value == float('-inf'):
return 0
return int(value) & MASK32
class Random:
def __init__(self, seed):
self.state = seed & MASK32
def next32(self):
self.state = (self.state + 0x6D2B79F5) & MASK32
z = self.state
z = imul(z ^ (z >> 15), z | 1)
z = (z ^ (z + imul(z ^ (z >> 7), z | 61))) & MASK32
return (z ^ (z >> 14)) & MASK32
# From 0 to n-1, with no fractions.
def next_int(self, n):
return self.next32() % n
# From min to max, both included.
def range(self, low, high):
return low + self.next_int(high - low + 1)
def chance(self, percent):
return self.next_int(100) < percent

The generator remembers one single number, state. Every next32() moves it by one big constant and shuffles its bits. next_int(n) takes the remainder after dividing by n, that is, a number from 0 to n − 1. A d20 die is range(1, 20). We roll it five times with seed 2026, then with 2026 again, then with 7:

def roll(seed):
dice = Random(seed)
line = f"seed {seed}:"
for _ in range(5):
line += f" {dice.range(1, 20)}"
print(line)
roll(2026)
roll(2026)
roll(7)
Output
seed 2026: 16 19 3 17 18
seed 2026: 16 19 3 17 18
seed 7: 13 11 15 11 16

Same seed, same numbers. In all four languages.

def range(self, low, high):
    return low + self.next_int()
More precisely, if you are curious

Why not the built-in Random? Every language has its own algorithm. Java uses a 48-bit linear congruential generator, .NET another one, Python the Mersenne Twister, and Math.random() in JavaScript takes no seed at all. The same seed gives different numbers in different languages, and in JavaScript you cannot even set it. mulberry32 is about ten lines, so it is written four times in the same way.

The numbers have to be unsigned 32-bit everywhere. An integer in Python grows without limit, so every computation is cut with & 0xFFFFFFFF, and imul is the JavaScript multiplication that keeps only the low 32 bits.

The idea

Integers only

The seed is not enough. Take the same division in the four languages:

print(-7 // 2)
print(10 / 2)
Output
-4
5.0

In the four languages the output is:

Languageinteger division of −7 by 210 / 2 as a fraction
Java-35.0
C#-35
Python-45.0
JavaScript-35

Python rounds integer division down, the others toward zero. A floating-point number is printed with .0 or without. One different character in the output, and the Python battle is no longer the same as the Java one. That is why the canons compute with integers only, divide explicitly and never divide negative numbers. Sorting always has a second rule, and collections keep the order of insertion.

The reward is big. With the seed, the commands and the version of the game, a run is a function: the same input gives the same output. A player writes: "On turn 412 the wolf hit me through the wall." If the game keeps their seed and commands, you run them and reach exactly turn 412, as many times as you need, in a debugger. The site's check does the same: the recorded commands and seed 2026 give byte for byte the same output in all four languages.

The idea

The real loop of Arcanum

Here is the game loop, with nothing cut:

# The game loop: draw → read a command → handle it → update the world → apply the changes
# to the stack. It repeats until the stack is empty.
def main(io, options):
global LANG
seed = 2026 if options.seed is None else to_uint32(options.seed)
if options.lang != 'bg' and options.lang != 'en' and options.lang != 'de' and options.lang != 'es':
raise RuntimeError('unknown language: ' + str(options.lang) + ' (use bg, en, de or es)')
LANG = options.lang
if options.memory_saves:
io.save('3', tampered_save())
load_data()
simulate_count = 0 if options.simulate is None else options.simulate
if simulate_count > 0:
simulate(io, simulate_count, seed)
return
terminal = PlainTerminal(io) if options.plain is True else AnsiTerminal(io)
game = Game(io, seed)
buffer = CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT)
game.stack.push(TitleScreen(game))
game.stack.apply()
while not game.stack.is_empty():
buffer.clear()
game.stack.draw(buffer)
terminal.present(buffer)
line = io.read_line()
if line is None:
io.write(t('ui.endOfInput') + '\n')
return
terminal.echo(line)
game.raw_input = trim_spaces(line)
top = game.stack.top()
top.handle(game.raw_input.lower())
top.update()
game.stack.apply()
io.write(t('ui.goodbye') + '\n')

Line by line:

  • game.stack.push(TitleScreen(game)) puts the first screen in place. The whole world is in game, just as in the corridor it is in state.
  • The loop runs while there is something in the screen stack. Exiting from the menu removes all screens and the game ends. That is the topic of the next lesson.
  • Draw. The screens draw into a buffer of cells (a character and a color), and the terminal decides how to show it: as plain text or with colors. That is in the lesson on drawing in the console.
  • Read. If the input has run out, the game says "end of input" and stops. The same as our None. Echoing the line is like our > a.
  • Handle and update. Only the screen on top gets the command: the map, the battle or the menu.
  • Apply. The fifth step, which the corridor does not have. Screens ask for changes to the stack while they handle a command (start a battle, close the menu), and those changes are applied only at the end of the turn. Why not right away is in the lesson on screens that return an answer.

Where is the world's turn? In the corridor it is in update. In Arcanum the map calls world_turn() from its own handle, only when the hero really did something: stepped, opened a door, waited. On a typo or on i for the inventory, the world does not move. This is our "move is 0, no turn":

# The world's turn after the hero's turn: effects, slow recovery, enemies, the ambush.
def world_turn(self):
level = self.level()
hero = self.hero
self.turn += 1
self.stats.turns += 1
compute_fov(level, hero.x, hero.y)
tick_effects(hero, self)
if not hero.is_alive():
self.hero_died()
return
if self.turn % 3 == 0 and not hero.has_effect('bleed') and not hero.has_effect('poison'):
hero.heal(1)
if hero.mana < hero.max_mana():
hero.mana += 1
for enemy in list(level.enemies):
if not enemy.is_alive():
continue
enemy_turn_on_map(self, level, enemy)
visible = level.visible[enemy.y][enemy.x]
tick_effects(enemy, self if visible else self.quiet)
if not enemy.is_alive():
enemy.dead = True
if visible:
self.say(t('map.bledOut', {'name': enemy.name}))
# An enemy that bleeds out outside battle drops no loot: no one was there to loot it.
self.enemy_killed(enemy, SimpleNamespace(gold=0, drop=None))
self.prune_dead()
compute_fov(level, hero.x, hero.y)
for enemy in level.enemies:
if enemy.state == 'chase' and distance(enemy.x, enemy.y, hero.x, hero.y) <= 1:
self.start_battle(enemy)
return

The turn counter, the effects (bleeding runs every turn), slow recovery every third turn, every enemy's turn, enemies that have bled out. And the end, just like the goblin: an enemy that is chasing and is next to the hero starts a battle. In battle the turns follow another rule, by speed, so each screen decides for itself when there is a turn, and update on the map only recomputes what can be seen.

Exercise

The sleeping goblin

Solve this in your editor.

The sleeping goblin. At the start the goblin is asleep and is drawn as z. It wakes up when the hero is 3 cells away or closer. On the turn it wakes up it does not move; after that it chases as before and is drawn as g. The loop does not change: you need one field in the state, one character in draw and a few lines in update.

The input:

Input
d
d
a
a
a
a
Output
Turn 0 z...@......>
> d
Turn 1 z....@.....>
> d
Turn 2 z.....@....>
> a
Turn 3 z....@.....>
> a
Turn 4 z...@......>
> a
Turn 5 g..@.......>
> a
Turn 6 .g@........>
The goblin caught you.

More exercises

  • Wait. Add a command s: the hero stays, but the turn passes and the goblin steps. How will you tell "stay" from "typo" in handle?
  • Record the commands. Keep every command you read in a list and at the end write it to a file, one command per line. Run the program again with that file as input: the output must be the same. That is how the battle replay in Arcanum works.
  • Two goblins. A second goblin starts on cell 11 and guards the exit. Who steps first when both are next to the hero?

Test

Check yourself

1. Why does draw in the corridor only read the state and change nothing?
2. The player types x in the corridor. What happens?
3. Why is a console game turn-based?
4. A player reports: "On turn 412 the wolf hit me through the wall." What do you need to see the same thing?
5. A real-time loop steps the physics every 50 ms. 10 ms are left over from the last frame, and the new frame comes 120 ms later. How many steps does the physics take?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·