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 >:
let hero = 4;let goblin = 0;while (true) { const command = (await lines.next()).value; if (command === "q") break; if (command === "a") hero--; if (command === "d") hero++; let row = ""; for (let x = 0; x <= 11; x++) { row += x === hero ? "@" : x === goblin ? "g" : x === 11 ? ">" : "."; } console.log(row); if (hero === 11) { console.log("Exit!"); break; } goblin += goblin < hero ? 1 : -1; if (Math.abs(hero - goblin) <= 1) { console.log("The goblin caught you."); break; }}rl.close();The player types x (wrong key) and then a:
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
xis 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.
drawonly reads the state and prints a frame. You can call it as many times as you like.handleturns the command into an intention:moveis -1, 1 or 0. It moves nobody.updatechanges 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 { constructor() { this.hero = 4; this.goblin = 0; this.exit = 11; this.turn = 0; this.move = 0; this.message = ""; this.over = false; }}
const state = new State();while (!state.over) { draw(state); const command = await read(); handle(state, command); update(state);}draw(state);rl.close();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:
async function read() { const { value, done } = await lines.next(); if (done) return null; console.log(`> ${value}`); return value;}lines is the reader of input lines (createInterface from node:readline at the top of the program). When the input runs out, next() returns done: true. The function is async because the line comes when it comes: await waits without stopping Node. 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:
function draw(s) { let row = ""; for (let x = 0; x <= s.exit; x++) { if (x === s.hero) row += "@"; else if (x === s.goblin) row += "g"; else if (x === s.exit) row += ">"; else row += "."; } console.log(`Turn ${s.turn} ${row}`); if (s.message !== "") console.log(s.message);}
function handle(s, command) { s.move = 0; s.message = ""; if (command === null) { s.message = "The input ran out."; s.over = true; } else if (command === "a") { s.move = -1; } else if (command === "d") { s.move = 1; } else if (command === "q") { s.message = "You left."; s.over = true; } else { s.message = `Unknown command '${command}': no turn passed.`; }}
function update(s) { if (s.move === 0) return; s.turn++; s.hero = Math.max(0, s.hero + s.move); if (s.hero === s.exit) { s.message = `Exit! You got away in ${s.turn} turns.`; s.over = true; return; } if (Math.abs(s.hero - s.goblin) > 1) s.goblin += s.goblin < s.hero ? 1 : -1; if (Math.abs(s.hero - s.goblin) <= 1) { s.message = "The goblin caught you."; s.over = true; }}const state = new State();while (!state.over) { draw(state); const command = await read(); handle(state, command); update(state);}draw(state);
function draw(s) { let row = ""; for (let x = 0; x <= s.exit; x++) { if (x === s.hero) row += "@"; else if (x === s.goblin) row += "g"; else if (x === s.exit) row += ">"; else row += "."; } console.log(`Turn ${s.turn} ${row}`); if (s.message !== "") console.log(s.message);}
function handle(s, command) { s.move = 0; s.message = ""; if (command === null) { s.message = "The input ran out."; s.over = true; } else if (command === "a") { s.move = -1; } else if (command === "d") { s.move = 1; } else if (command === "q") { s.message = "You left."; s.over = true; } else { s.message = `Unknown command '${command}': no turn passed.`; }}
function update(s) { if (s.move === 0) return; s.turn++; s.hero = Math.max(0, s.hero + s.move); if (s.hero === s.exit) { s.message = `Exit! You got away in ${s.turn} turns.`; s.over = true; return; } if (Math.abs(s.hero - s.goblin) > 1) s.goblin += s.goblin < s.hero ? 1 : -1; if (Math.abs(s.hero - s.goblin) <= 1) { s.message = "The goblin caught you."; s.over = true; }}Step by step, in words
- 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.
- draw walks over the 12 cells and prints the frame .g...@.....>. It only reads the state: nothing in the panel changes.
- read waits. The world stands still until the player presses Enter. The line "a" arrives.
- handle only turns the command into an intention: move = -1. Nobody has moved yet.
- 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.
- 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.
- 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:
Turn 0 g...@......>> dTurn 1 .g...@.....>> aTurn 2 ..g.@......>> xTurn 2 ..g.@......>Unknown command 'x': no turn passed.> dTurn 3 ...g.@.....>> dTurn 4 ....g.@....>> dTurn 5 .....g.@...>> dTurn 6 ......g.@..>> dTurn 7 .......g.@.>> dTurn 8 ........g.@>> dTurn 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:
const near = new State();near.hero = 5;near.goblin = 3;near.move = -1;update(near);console.log(near.hero, near.goblin, near.message);
const far = new State();far.move = 1;update(far);console.log(far.hero, far.goblin, far.turn);4 3 The goblin caught you.5 1 1This 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:
}update(state);draw(state);const command = await read();handle(state, command);while (!state.over) {
Try it
Predict the output
The same program gets the commands a, x, a. These are the rules:
function handle(s, command) { s.move = 0; s.message = ""; if (command === null) { s.message = "The input ran out."; s.over = true; } else if (command === "a") { s.move = -1; } else if (command === "d") { s.move = 1; } else if (command === "q") { s.message = "You left."; s.over = true; } else { s.message = `Unknown command '${command}': no turn passed.`; }}
function update(s) { if (s.move === 0) return; s.turn++; s.hero = Math.max(0, s.hero + s.move); if (s.hero === s.exit) { s.message = `Exit! You got away in ${s.turn} turns.`; s.over = true; return; } if (Math.abs(s.hero - s.goblin) > 1) s.goblin += s.goblin < s.hero ? 1 : -1; if (Math.abs(s.hero - s.goblin) <= 1) { s.message = "The goblin caught you."; s.over = true; }}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:
Turn 0 .g..@......>> dTurn 1 ..g..@.....>> xTurn 1 ...g.@.....>Unknown command 'x': no turn passed.> xTurn 1 ....g@.....>Unknown command 'x': no turn passed.> dTurn 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 goblin's step changes the world, and draw is called on every frame, not on every turn: on a typo and at the end too. That is why the goblin moves when there is no turn. And the catch check is in update, before the step, so it does not see the goblin next to you. The line belongs in update, between the hero's move and the check.
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:
function run(name, times) { const step = 50; let acc = 0; let x = 0; let last = times[0]; for (const now of times) { acc += now - last; last = now; let steps = 0; while (acc >= step) { x += 3; steps++; acc -= step; } console.log(`${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]);fast 0 ms: steps 0, x = 0, left over 0 msfast 16 ms: steps 0, x = 0, left over 16 msfast 40 ms: steps 0, x = 0, left over 40 msfast 45 ms: steps 0, x = 0, left over 45 msfast 110 ms: steps 2, x = 6, left over 10 msfast 126 ms: steps 0, x = 6, left over 26 msfast 150 ms: steps 1, x = 9, left over 0 msslow 0 ms: steps 0, x = 0, left over 0 msslow 90 ms: steps 1, x = 3, left over 40 msslow 150 ms: steps 2, x = 9, left over 0 msAt 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.
process.stdin.setRawMode(true) and every key arrives as a data event. The event loop does not block, so the game runs its own loop with setInterval and reads the last key from a variable. In the browser the same thing is keydown and requestAnimationFrame.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 (Math.imul and >>> 0), so it// gives the same sequence in every language.export class Random { constructor(seed) { this.state = seed >>> 0; }
next32() { this.state = (this.state + 0x6d2b79f5) >>> 0; let z = this.state; z = Math.imul(z ^ (z >>> 15), z | 1) >>> 0; z = (z ^ (z + Math.imul(z ^ (z >>> 7), z | 61))) >>> 0; return (z ^ (z >>> 14)) >>> 0; }
// From 0 to n-1, with no fractions. nextInt(n) { return this.next32() % n; }
// From min to max, both included. range(min, max) { return min + this.nextInt(max - min + 1); }
chance(percent) { return this.nextInt(100) < percent; }}The generator remembers one single number, state. Every next32() moves it by one big constant and shuffles its bits. nextInt(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:
function roll(seed) { const dice = new Random(seed); let line = `seed ${seed}:`; for (let i = 0; i < 5; i++) line += ` ${dice.range(1, 20)}`; console.log(line);}
roll(2026);roll(2026);roll(7);seed 2026: 16 19 3 17 18seed 2026: 16 19 3 17 18seed 7: 13 11 15 11 16Same seed, same numbers. In all four languages.
range(min, max) {
return min + this.nextInt();
}From min to max inclusive there are max - min + 1 numbers. The remainder gives 0 up to that count minus one, and adding min shifts it into place. Without the + 1, the 20 never comes up.
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. A number in JavaScript is a floating-point value, so Math.imul multiplies as a 32-bit integer, and >>> 0 turns the result back into an unsigned 32-bit number.
The idea
Integers only
The seed is not enough. Take the same division in the four languages:
console.log(Math.trunc(-7 / 2));console.log(10 / 2);-35In the four languages the output is:
| Language | integer division of −7 by 2 | 10 / 2 as a fraction |
|---|---|---|
| Java | -3 | 5.0 |
| C# | -3 | 5 |
| Python | -4 | 5.0 |
| JavaScript | -3 | 5 |
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.export async function main(io, options) { const seed = options.seed === undefined ? 2026 : Number(options.seed) >>> 0; const lang = options.lang === undefined ? 'bg' : options.lang; if (lang !== 'bg' && lang !== 'en' && lang !== 'de' && lang !== 'es') { throw new Error('unknown language: ' + lang + ' (use bg, en, de or es)'); } LANG = lang; if (options.memorySaves) io.save('3', tamperedSave()); loadData(); const simulateCount = options.simulate === undefined ? 0 : Number(options.simulate); if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } const terminal = options.plain === true ? new PlainTerminal(io) : new AnsiTerminal(io); const game = new Game(io, seed); const buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT); game.stack.push(new TitleScreen(game)); game.stack.apply(); while (!game.stack.isEmpty()) { buffer.clear(); game.stack.draw(buffer); terminal.present(buffer); const line = await io.readLine(); if (line === null) { io.write(t('ui.endOfInput') + '\n'); return; } terminal.echo(line); game.rawInput = trimSpaces(line); const top = game.stack.top(); top.handle(game.rawInput.toLowerCase()); top.update(); game.stack.apply(); } io.write(t('ui.goodbye') + '\n');}Line by line:
game.stack.push(new TitleScreen(game))puts the first screen in place. The whole world is ingame, just as in the corridor it is instate.- 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
null. 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.
main is async, and the line is await io.readLine(): in the browser the command comes from the field under the screen when the player types it, and the page must not freeze until then.Where is the world's turn? In the corridor it is in update. In Arcanum the map calls worldTurn() 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.worldTurn() { const level = this.level(); const hero = this.hero; this.turn += 1; this.stats.turns += 1; computeFov(level, hero.x, hero.y); tickEffects(hero, this); if (!hero.isAlive()) { this.heroDied(); return; } if (this.turn % 3 === 0 && !hero.hasEffect('bleed') && !hero.hasEffect('poison')) { hero.heal(1); if (hero.mana < hero.maxMana()) hero.mana += 1; } for (const enemy of level.enemies.slice()) { if (!enemy.isAlive()) continue; enemyTurnOnMap(this, level, enemy); const visible = level.visible[enemy.y][enemy.x]; tickEffects(enemy, visible ? this : this.quiet); if (!enemy.isAlive()) { enemy.dead = true; if (visible) this.say(t('map.bledOut', { name: enemy.name })); // An enemy that bleeds out outside battle drops no loot: no one was there to loot it. this.enemyKilled(enemy, { gold: 0, drop: null }); } } this.pruneDead(); computeFov(level, hero.x, hero.y); for (const enemy of level.enemies) { if (enemy.state === 'chase' && distance(enemy.x, enemy.y, hero.x, hero.y) <= 1) { this.startBattle(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:
ddaaaaTurn 0 z...@......>> dTurn 1 z....@.....>> dTurn 2 z.....@....>> aTurn 3 z....@.....>> aTurn 4 z...@......>> aTurn 5 g..@.......>> aTurn 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" inhandle? - 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
Related lessons
Section titled "Related lessons"- Builds on: Constructors and this · Instance methods · Exceptions · Stack, queue and linked list
- Tasks on this topic: Chronicles of Arcanum