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:
| Layer | Parts | Lines |
|---|---|---|
| Data and text | text items-data enemies-data skills-data dialogs-data maps-data data-load map-load data-load-all | 1549 |
| Input, output, drawing | random io-text buffer terminal hud | 308 |
| The screens and the stack | screen screen-stack title create-hero town shop map-screen battle-screen replay-screen target-screen overlays | 1259 |
| The world and the rules | event-bus entity inventory equip loot xp-levelup quests dialog level fov game save load | 1389 |
| Combat, effects, AI | effects bleeding combat-order attack actions replay ai-battle pathfind ai-map | 857 |
| Running and balance | simulate main node | 176 |
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.
// Input and output: io with four methods. The boot block gives the console, the page gives its own io.interface Io { void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();}
static void main(Io io, Options options) { // The seed is an unsigned 32-bit number (in JavaScript Number(...) >>> 0); here it is the same 32 bits in an int. int seed = (int) (long) options.seed; if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) { throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)"); } LANG = options.lang; if (options.memorySaves) io.save("3", tamperedSave()); loadData(); int simulateCount = (int) options.simulate; if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } Terminal terminal = options.plain ? new PlainTerminal(io) : new AnsiTerminal(io); Game game = new Game(io, seed); CellBuffer 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); String line = io.readLine(); if (line == null) { io.write(t("ui.endOfInput") + "\n"); return; } terminal.echo(line); game.rawInput = trimSpaces(line); Screen top = game.stack.top(); top.handle(game.rawInput.toLowerCase(Locale.ROOT)); 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.
// Input and output: io with four methods. The boot block gives the console, the page gives its own io.interface Io { void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();}
static void main(Io io, Options options) { // The seed is an unsigned 32-bit number (in JavaScript Number(...) >>> 0); here it is the same 32 bits in an int. int seed = (int) (long) options.seed; if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) { throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)"); } LANG = options.lang; if (options.memorySaves) io.save("3", tamperedSave()); loadData(); int simulateCount = (int) options.simulate; if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } Terminal terminal = options.plain ? new PlainTerminal(io) : new AnsiTerminal(io); Game game = new Game(io, seed); CellBuffer 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); String line = io.readLine(); if (line == null) { io.write(t("ui.endOfInput") + "\n"); return; } terminal.echo(line); game.rawInput = trimSpaces(line); Screen top = game.stack.top(); top.handle(game.rawInput.toLowerCase(Locale.ROOT)); top.update(); game.stack.apply(); } io.write(t("ui.goodbye") + "\n");}Step by step, in words
- Command 56 of the playthrough: Iva (30/48) is fighting a goblin spearman (16/16). The player types “1”: attack.
- Input, output, drawing: io.readLine() returns “1”. To this layer it is just text: it knows nothing about the fight.
- 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(…)).
- 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).”
- 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).”
- 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.
- 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.
- 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.
The loop waits for the line and does nothing else (reading blocks). 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 (int and >>>), so it// gives the same sequence in every language.static class Random { int state;
Random(int seed) { this.state = seed; }
int next32() { this.state = this.state + 0x6d2b79f5; int z = this.state; z = (z ^ (z >>> 15)) * (z | 1); z = z ^ (z + (z ^ (z >>> 7)) * (z | 61)); return z ^ (z >>> 14); }
// From 0 to n-1, with no fractions. int nextInt(int n) { return (int) (Integer.toUnsignedLong(this.next32()) % n); }
// From min to max, both included. int range(int min, int max) { return min + this.nextInt(max - min + 1); }
boolean chance(int 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 37L [##########--] 74/85 M [########] 7/7You 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/91) Warden of the Depths [###-----] 53/120 fury2) Stone servant [########] 14/143) 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 fleeThe 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:
java Arcanum.javaIn 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.
javac.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.
- The game loop: input, update, draw, turn versus frame, the seed.
- A stack of screens: the screen as an object, push, pop and replace, transparent screens.
- Screens that return an answer: choosing a target, “Are you sure?”, deferred changes.
- Drawing in the console: a buffer of cells, frames, bars, colour.
- The map and line of sight: a map from text, collision, the camera, the field of view.
- Creatures and stats: composition, stats in layers.
- The combat system: order by speed, accuracy, damage, critical hits.
- Blood, wounds and effects: bleeding, poison, burning, stacking, the trail.
- Skills and actions as commands: mana, cooldowns, fight replay.
- Enemies that think: a state machine, pathfinding, utility.
- Items, inventory, loot: data from tables, equipment, the shop.
- Levels, dialogs, quests: experience, the skill tree, the event bus.
- Saving and loading: serialisation, version, checking.
- The whole game and the balance: the simulation, the numbers, ideas to continue.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Lists · Stack, queue and linked list · HashMap and HashSet