Skip to content
wannacode

The game we are going to make

In short: Chronicles of Arcanum is a whole console RPG built lesson by lesson: a map with field of view, combat ordered by speed, wounds, items, quests and saving, in one file in each of the four languages.

Problem

A game you would play

So far the games were exercises: races that inherit from Character, a ten-turn fight, rooms with commands. They taught the language, and the game was an excuse. Nobody would play them twice.

Now we do the opposite: a whole game that you play, and every technique comes in because the game needs it. A map that opens up as you walk. A fight where the order depends on speed and a wound keeps bleeding on the next turns. Enemies that chase you and run when they are hurt. Items, a shop, levels, dialogs, quests, saving. And underneath, a stack of screens that keeps it all in order.

It is called Chronicles of Arcanum. The miners of the old mine below Greyford are disappearing, and there is blood on the path to it. You go three levels down. At the bottom the Warden of the Depths is waiting.

Try it

Play

This is the real game: the JavaScript version, running in the browser. Pick a class, talk to the elder, buy something and go down into the mine. On the map you are @, and the letters are enemies. Walk into an enemy and a fight starts.

Chronicles of Arcanum

The mine below Greyford has been silent for three days. There is blood on the path.

Click the screen to play with the keys (arrows or WASD, I, M, digits); Enter goes to the command line. Saves stay in this browser.

On the map the commands are w a s d (or the arrow keys once you click the screen), g takes, i is the inventory, c is the hero, m is the menu with save and load. In a fight: 1 attack, 2 skill, 3 item, 4 flee, and you pick the target with a digit. ? shows help.

More precisely, if you are curious

The game in the browser is the same file you will see in the lessons and run on your computer. Only the block that reads the arguments in Node and writes saves to files is replaced: here input comes from the field under the screen, and saves are in the browser’s storage (localStorage). Every new game has a new seed, so the world is different.

The idea

What is in it

  • Three classes. The barbarian (lots of health, an axe that makes wounds bleed, “Rage”), the amazon (a spear, more critical hits, “Double shot”) and the witch (little health, mana, fire, poison and healing). Each has a skill tree with two branches.
  • A map with a field of view. You see only what you have a straight line to, and what you have seen stays remembered (fog). Three levels, stairs, water, doors, items on the floor.
  • Fights where the order goes by speed. Accuracy against evasion, damage from the weapon, strength and defense, critical hits, kinds of damage: slashing, piercing, blunt, fire, poison.
  • Wounds and effects. A slashing weapon opens a wound that bleeds every turn, and wounds stack. A bleeding enemy leaves a trail on the map, and you can follow it to its lair. There are also poison, burning, stun, regeneration, rage and shield.
  • Enemies that think. On the map they sleep, wander, chase you along the shortest path and run when they are hurt. In a fight they weigh what helps them most: an attack, a skill or healing.
  • Items from tables. Weapons, armour, rings and potions, weighted loot, a shop, an inventory with slots.
  • Levels, dialogs, three quests, an event bus, saving and loading (a corrupted save is refused with an explanation), fight replay and a simulation for balance.

The whole playthrough that checks the game is 230 commands and 4395 lines of output: from creating the hero to beating the boss.

The idea

The map of the code

The game is one file of about 5500 lines in each language. One file, so it runs with one command. Inside it is split into parts (regions), and every part is in one of six layers:

LayerPartsLines
Data and texttext items-data enemies-data skills-data dialogs-data maps-data data-load map-load data-load-all1549
Input, output, drawingrandom io-text buffer terminal hud308
The screens and the stackscreen screen-stack title create-hero town shop map-screen battle-screen replay-screen target-screen overlays1259
The world and the rulesevent-bus entity inventory equip loot xp-levelup quests dialog level fov game save load1389
Combat, effects, AIeffects bleeding combat-order attack actions replay ai-battle pathfind ai-map857
Running and balancesimulate main node176

The numbers are from the JavaScript version and are counted when the site is built. Nearly half the lines are data and text: items, enemies, maps and dialogs are tables, and every text in the game is in four languages. The logic does not know the name of a single sword.

More precisely, if you are curious

In a real project the parts are in separate files and packages: screens/, world/, combat/, data/. Here they are in one file for the lessons, but the boundaries are the same: screen code does not touch the combat rules, and combat prints nothing itself. If you want to split it, cut along the regions.

The idea

The game loop

The heart of every game is a loop that runs until the game is over. Here it has two parts: the screen stack says which screen is on top, and the loop draws it, reads a command and hands it over:

// 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');
}

Every command is one turn. The loop knows nothing about maps, fights or shops: it calls draw, handle and update on the top screen, then applies the changes to the stack. The screen decides the rest: the map moves the hero, the fight runs an attack, the menu saves. That is why a new screen does not touch the loop.

Here is one real turn of the playthrough, followed from the inside: the command “1” in the fight with the goblin goes down through the six layers of the code map and comes back up as the new frame.

// 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');
}
Step by step, in words
  1. Command 56 of the playthrough: Iva (30/48) is fighting a goblin spearman (16/16). The player types “1”: attack.
  2. Input, output, drawing: io.readLine() returns “1”. To this layer it is just text: it knows nothing about the fight.
  3. The screens and the stack: the battle is on top, the map under it. The top screen gets “1”, and with one enemy left it calls battle.heroAct(new AttackAction(…)).
  4. Still inside top.handle: the rules. The seeded dice for the hand axe (4–7 in the table) give 6. 6 + strength 7 − defence 1 = 12: “Iva smashes goblin spearman (12).”
  5. The axe cuts and opens a wound (35% chance): “Goblin spearman is bleeding.” On the goblin’s turn it takes 1 (16 → 4 → 3): “Goblin spearman is badly wounded!” It strikes back: “Goblin spearman scratches Iva (1).”
  6. update and apply: nobody called push or pop, so the battle stays on top. The fight printed nothing: its 5 new lines wait in its log.
  7. Next round: the screen draws into the 60 × 18 buffer (Iva 29/48, goblin 3/16, “bld:2” = 2 more turns, the last 7 log lines), the terminal prints it.
  8. One command, six layers, each doing only its own job. The fight knows nothing about the screen, so it can also run with no screen at all: that is the simulation.

await io.readLine() waits for the line without freezing the browser: that is why main is async. When the input ends (no more lines), the game finishes cleanly.

The idea

One code, four languages, one output

The game is written four times: in Java, C#, Python and JavaScript, part by part and with the same names. The site’s check runs it in all four languages with the recorded 230 commands and requires byte-for-byte the same output. If one version rolls a die differently, the fight takes another path and the check fails.

How, when every language has its own Random? The game does not use the built-in generator. It has its own, small and the same everywhere, in whole numbers only:

// 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 same seed gives the same numbers, and the same numbers give the same fight. That is useful beyond the check. A bug a player reports (“on turn 412 the wolf hit me through a wall”) repeats exactly if you have the seed and the commands. The fight replay in the game works the same way.

More precisely, if you are curious

The rules for the same output: division is whole only; collections keep insertion order; sorting always has a second rule (two enemies with the same speed are ordered by number), because not every sort is stable. Numbers are padded by hand, with no formatting that depends on the system’s language. It looks small, but each of these rules has caught a difference between the versions.

A frame from the playthrough, on the second level of the mine:

#.# #####.##### The silver veins
. ~~~~~~.~~~~~~ Level 2 of 3
~~~~~~.~~~~~~
~~~~~~.~~~~~~ Around:
#####.##### G ore golem
###########.#############
..................#......#
..................#......#
.G....@...........'......#
..................#......#
..................#....!.#
..................#......#
################# #######
Iva, barbarian lv4 xp 148/170 gold 37
L [##########--] 74/85 M [########] 7/7
You take: silver ore.
Silver for the smith: Bring the ore to Vela the smith.
Ore golem wakes up!

And the fight with the boss after it has summoned its helpers:

=== Battle ===
Iva, barbarian lv6
L [#######-----] 63/103 M [###-----] 4/9
1) Warden of the Depths [###-----] 53/120 fury
2) Stone servant [########] 14/14
3) Stone servant [########] 14/14
Warden of the Depths scratches Iva (7).
Iva hits Warden of the Depths (17).
Warden of the Depths misses Iva.
Iva hits Warden of the Depths (14).
Warden of the Depths cracks and roars. Two stone servants
climb out of the cracks!
Shield wears off Iva.
1 attack 2 skill 3 item 4 flee

The idea

Run it on your computer

The whole file is here: [Arcanum.java](/games/arcanum/en/Arcanum.java), [Arcanum.cs](/games/arcanum/en/Arcanum.cs), [arcanum.py](/games/arcanum/en/arcanum.py), [arcanum.js](/games/arcanum/en/arcanum.js). Put it in an empty folder and run it:

Terminal
node arcanum.js

In a terminal the game clears the screen and uses colour: blood is red, mana blue, poison green. With --plain it prints the frames as plain text. With --seed=42 the world is always the same, and with --lang=bg it is in Bulgarian (de and es work too). Saves go in a saves folder next to the game.

You need Node 20 or newer. The file is an ES module (import), so if Node complains, rename it to arcanum.mjs.

The idea

How we will build it

Each next lesson takes one part of the game, explains why it is the way it is and builds it with smaller programs you can run. Then it shows the same part in the real game.

  1. The game loop: input, update, draw, turn versus frame, the seed.
  2. A stack of screens: the screen as an object, push, pop and replace, transparent screens.
  3. Screens that return an answer: choosing a target, “Are you sure?”, deferred changes.
  4. Drawing in the console: a buffer of cells, frames, bars, colour.
  5. The map and line of sight: a map from text, collision, the camera, the field of view.
  6. Creatures and stats: composition, stats in layers.
  7. The combat system: order by speed, accuracy, damage, critical hits.
  8. Blood, wounds and effects: bleeding, poison, burning, stacking, the trail.
  9. Skills and actions as commands: mana, cooldowns, fight replay.
  10. Enemies that think: a state machine, pathfinding, utility.
  11. Items, inventory, loot: data from tables, equipment, the shop.
  12. Levels, dialogs, quests: experience, the skill tree, the event bus.
  13. Saving and loading: serialisation, version, checking.
  14. The whole game and the balance: the simulation, the numbers, ideas to continue.

Test

Check yourself

1. The player opens the inventory while on the map. What happens to the screen stack?
2. Why does the same game in Java, C#, Python and JavaScript give byte-for-byte the same output?
3. What is true about the game loop here?
4. The game has a simulation (--simulate): thousands of fights without a screen. Why is that possible?

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