part 23
From one game, more games: realms, rulesets and a refactor that must not change anything
Four games under one roof and 896 places in the code that ask which game is running. How the terms ruleset and realm came about, what the contract between them is, and how five ladders of conditions were rewritten in one day without changing a rule.
On 26 October 2026 another mode came into js/ui/app.js. Colony, a game on a hexagonal board that a separate article describes, first asked for a list of features that must not see it: analysis, the picture of the game, music, statistics, achievements, experience. Each of them got one condition of the kind !g.colony. The file had 3,546 lines as of 26 October and asked about the kind of game 41 times through mode === '...', 22 times through .puzzle, 16 times through .watch and 8 times through .colony (before that .leap).
Every further mode would repeat this pattern. So on 26 and 27 October I decided to arrange the game differently: not as one game with exceptions, but as several games that share one profile. This article is a record of that decision and of the first five steps (M0 to M4).
Two layers and one table of terms
Comparing the shape the game was taking with games where one title offers several separate modes, I arrived at two layers and a vocabulary that applies to every conversation, document and line of code:
| Term | What it is | Where the player sees it |
|---|---|---|
| ruleset | rules, board, AI and record: the code that plays the game. Developer names stacks, colony, chains | nowhere, only in the code and the documents |
| realm | what the player lives in: name, level, coin, the dice wardrobe of the Avatar[1], achievements, statistics, modes and content. It uses one ruleset and a set of its features | the cards of the home screen |
| mode | a way of playing inside a realm: Principalities[2], free play, daily challenge, minigames, campaign… | the tiles of the realm |
| main level | the sum of experience from all realms; the Avatar and the hero's name belong to it | the header |
One ruleset can have more realms, which is why the layers are kept apart. The Campaign is a realm of its own, but it runs on the same rules as the first one:
| Realm | Id | Ruleset | Name for the player |
|---|---|---|---|
| dice and territories | stacks | stacks | Kastgard |
| story | campaign | stacks | Campaign (working name) |
| copying and jumping on hexagons | colony | colony | Sporgard |
| dice in a row, similar to gomoku | chains | chains | Radgard; the rules are not decided and the code and the documents have nothing yet |
The names Sporgard and Radgard are my choice from 27 October. The key point is that an identifier never changes together with the name (the same rule as in the renaming of the game). The ruleset chains has an awkward collision: the module of achievement chains is called Chains and the keys chain.* have 1,602 occurrences in 21 files of the code. The id chains stayed (my choice), so the globals of the new ruleset must not be called Chains and its texts get the prefix realm.chains.*.
The division also carries decisions about the content, so far only in the plan (none of it is built yet): the level and the coin are per realm, the hero, the narrator and the main level are global, the Golden Dice world is to become the wardrobe of Kastgard, and the worlds Stone and Iron the wardrobe of the campaign. The parts of a realm (coin, wardrobe, minigames, puzzles, tutorial) are optional; a realm may be empty and a mode without content is not shown. A realm is therefore not “an extra game”, but a list of what it has.
A test: what is a realm and what is only a mode
So that every idea does not become a realm, a test was written down (a proposal, not decided yet). A candidate is a realm if it meets points 1 to 3:
- has its own family of rules that the restricted configurator of another realm does not express;
- has its own configurator;
- has an AI or a puzzle solver, so that watching the game, puzzles and the arena of AI opponents are possible.
Content for several modes and depth for the level and the coin are optional. A candidate that meets only points 1 and 2 is a mode or a realm with few parts. The two other ideas of mine from 27 October are hop (a family of games with jumping over the board: draughts, halma, solitaire) and pexeso (finding pairs). Neither is decided or planned, but both are useful as a thought test. Hop is a family of rules defined by data, where each game is a variant. Pexeso is the opposite: hidden information, no board, and an AI whose level is the length of memory. If the contract between a ruleset and a realm is to hold for it too, it is a good test.
The most interesting thing about this layer is the rule of the configurator: the content of a realm may use only the features that the configurator of its ruleset offers. The configurator of Kastgard has 29 game settings (Settings.GAME_KEYS; 7 basic, 13 rules, 6 map and 3 about the players). Towers, barracks, chests, portals and the abilities of the hero are not among them, and so they belong to the campaign. The test is mechanical: the tool tools/realms.js reads each minigame, puzzle and lesson and lists the features it uses:
| Content | Kastgard | Campaign | Total |
|---|---|---|---|
| Minigames | 13 | 5 | 18 |
| Puzzle Labs puzzles | 5 | 23 | 28 |
| Lessons | 6 | 1 (sharpshooter) | 7 |
| Kinds of the daily puzzle generator | 6 | 9 | 15 |
The inventory: 896 places that ask which game is running
The first step (the document REALMS-R1.md, without a single change of code) was to count where the code asks about the kind of game. The result as of 27 October:
- 732 lines in 86 files of
js/ask which game is running. They fall into 896 places (decisions, blocks and states) in 114 files. - The classes: 179 places are decisions of the ruleset, 166 of the mode, 43 engine flags (those stay as they are), 380 are things of the realm, 54 are global and 74 are false hits.
app.jshas 280 places. By the map of lines, 34 % is the campaign, 22 % the shell of the application, 20 % Stacks[3]. Colony and Watch are not blocks but checks of flags (20 lines in all).- **The list of modes is written out by hand five times in
app.js**: atshowEnd,restartGame, the buttons “Play again” and “Back”, and atsetEndButtons. - Colony is not independent of Stacks. It calls only
Rules.recolorandEngine.pushLog(10 and 4 lines), but because of them its worker imports nine files of the Stacks engine. - The profile cannot keep what it does not know: 27 of the 30 probes of unknown parts were discarded by
Profile.migrate, and a backup withprofile.vother than 1 was restored as an empty profile and reported success. - On 157 files and the proposed division into packages, the direction of dependencies breaks 141 of 726 edges.
The same places by class are shown in the column chart:
The last number matters: the contract of realms does not mean that everything must be clean at once. A list of exceptions is written down, and it may only shrink.
The contract: three declarations and one formula
The inventory showed that between the ruleset and the realm a third table is needed, because most of the 166 mode places are about neither the realm nor the ruleset, but about the way of playing: what the end card is, what “Play again” does, what save slot it has.
| Declaration | Says | Owned by |
|---|---|---|
| ruleset adapter | how the game is played and what a finished game says; members: configurator, start, flow of the game, snapshot and restore, record, replay, AI, facts, rules text, tutorial, solver | ruleset |
| mode entry | slot, record, end card, “again”, “back”, panel | the realm that offers the mode |
| realm entry | ruleset, features, modes, optional parts, metadata for the core, export of static data | realm |
What the code may do is decided by a capability according to a simple formula: a capability sees the game if the ruleset can it (a member of the adapter), the mode wants it (its record) and the realm has it (its manifest). For example, the analysis of a finished game needs the record of the game and the value of a position (the ruleset can), the mode must leave the record and the realm must list the analysis. Today this trio is one condition scattered through the code (!g.colony, g.watch, g.puzzle). 14 capabilities are proposed, for example slot, record, replay, analysis, picture, music, watch, finish, stats, xp, achievements, daily, share, tutorial.
The whole is held together by the realm manager in the core (js/core/realms.js). It knows no game. It reads the setting of the build (which realms a build contains is a setting of the build, not a property of the realm), checks the dependencies, starts the realms and talks to each one through a small API of hooks: start, stop, hub, saves and the objects daily and weekly. A realm sees only the context ctx and the shared functions of the core. The rule I confirmed on 27 October applies: realms do not depend on each other, the only exception being the Campaign on Stacks. What two realms have in common goes to the core, but only once a second user exists, so that the core does not turn into a dumping ground.
Five steps in one day
The plan in the document divides the work into small steps, each of which is a separate change, is merged on its own and must not change any rule of any game. The first five:
| Step | What | Version after merging |
|---|---|---|
| M0 | Fix of saving, independent of the realms: a backup with an unreadable profile is refused, and an unreadable saved profile is copied before it is overwritten | 0.50.16 (together with M1 and M2) |
| M1 | Colony depends only on the core: Seats.recolor, GameLog.push, Results (the old names stay as aliases); the worker imports 11 scripts and none from the Stacks engine | 0.50.16 |
| M2 | Safeguards: tools/realms.js, ten “pins” (the fixed answers) for tests that would quietly stop guarding anything, and a table of modes in the tests | 0.50.16 |
| M3 | The realm manager, the register of rulesets, the manifests of three realms. Nothing reads them yet except the tests | 0.50.19 |
| M4 | app.js reads the table of modes and the first four capabilities; hits in app.js 177 → 154 | 0.50.26 |
M0 is done first and on its own, because the later promised “the backup survives a move between editions” stands on it. The errors were first reproduced by tests that failed on the old code: importAll restored an empty profile and said ok when the backup had profile.v other than 1, and loadProfile, given a damaged text, let the first save overwrite the original with an empty profile. The fix is small: a new test Profile.isReadable, the refusal of the backup and a copy of the unreadable text byte for byte under the key diceWars.profile.unreadable. The alternative, refusing to overwrite the original key, was rejected: the player would play a whole session that cannot be saved.
M2 belongs to those steps that at first sight do not look like work. During the inventory ten tests or helper files stayed green, although they would no longer guard anything once the home screen was rewritten. So the pinning comes first: while nothing changes, what the code answers today for all 18 ways of playing is recorded. The same record later serves as a check that the table of modes gives the same answers.
M3 went through an independent review, done by an agent that only read. It found seven faults in the first version of the manager; all were fixed in their own commit, and each is held by a test that passed mutation testing. A typical example: a thrown value that String() could not turn into text broke the isolation of one realm's failure, so the failure of one realm could take the others down with it. Others: start and stop were not safe when called twice, an array with a toJSON property passed as clean data, and the manager remembered the output of toJSON rather than what had been checked, and a deadline that could not be set was started at once.
M4 rewrites the source of truth one function per commit. Of the first four capabilities (slot, stats, remarks, restart), each is computed once in begin() and stored with the game as a property that is not enumerated, so it does not get into a copy, a JSON or a snapshot. The table of modes needed one new field, restart.start, because Restart and “Play again” start different games in Principalities and in the campaign. One part was deliberately not turned into a field of the table: the guard for a game in which the AI played. It is a test of the metadata of the game and not a property of the mode, because the AI can finish a snapshot of a daily game, a minigame or a mission as well, and reading it from the table would score and record such a game. Three pins written first against the old code protect this.
How it is proven that nothing changed
A refactoring of this size has a single positive requirement: no game may behave differently. The proof consists of several layers, all measured on 27 October:
node tools/baseline.js: all 172 fingerprints match.node tools/calibrate.js --verify: 18 measurements identical, 0 within the noise, 0 changed (before and after).- The golden records (
tools/replay.js --golden) written again are identical byte for byte; thegit diffof the file is empty. Record.RULES_VERSION(2) andRules.GEN_VERSIONare unchanged, and none ofrules.js,engine.js,flow.js,ai.js,mods.js,scenario.js,objectives.js,puzzle.jsandrecord.jsis in the diff.- The old code as the oracle. The new function
factsmay not be connected until its result matches the old path throughshowEnd: the test calls both side by side on 12 golden records and 32 games from 16 configurations, and compares also the strictest thing, the profile:Profile.recordGameandAchievements.evaluatewith the arguments of the old path and with the arguments fromfactsleave the profiles identical byte for byte. The test also searches the source ofapp.js, so that the old path does not go stale unnoticed. - The tests of the manager and of the table were tried by mutation: a changed field of the record, a changed text of a card, a missing function, a skipped requirement, a timer not removed, a cycle that was not refused. Each mutation had to fail on the right test.
- In the browser pane, with real clicks, the following were gone through: Principalities (win and loss), free play, the daily challenge, minigames, Puzzle Labs, a mission of the campaign, Colony, Watch and the lesson “Your first game”.
The full suite after M4 had 2,508 tests: 2,506 passed, 1 was skipped, and 1 failed. The failing one was tests/rollwin.test.js (“a move to make”), which fails about once in thirty runs even without any change (1 in 30 on a clean working tree, 1 in 12 in this run). It was not part of the change and was left untouched.
A fault that M4 found by itself
During M4 an interesting test showed up: tests/mode-table.test.js for the watched game failed about once in ten (4 of 40 runs, one after another, without load). The cause was not the load. In a watched game there is no player who would have fixed rolls, so Flow draws a random salt of the rolls. The map is fixed by the seed, but the game lasted 13 to 29 rounds (138 to over 600 virtual seconds, median 343), while the test waited 600 virtual seconds. The side of the mode got a fixed salt, and after the fix 40 runs passed without a failure. More about tests that fail once in ten is in the article on the faults of the week.
What remains
M0 to M4 of the ten steps (M0 to M9) are finished. What remains is the home screen reading the cards of the realms (M6), the marking of content by realm (M7), the division of mixed files such as settings.js, profile.js, store.js (M8) and the physical move of files into packages, which is only git mv (M9). 154 lines in app.js remain as well: the flags of the watched game, the only real branching of a ruleset when choosing the flow of the game, the flags of the puzzle in the engine (those stay), the whole campaign and several files that were not moved, because their tests call games without capabilities. Then comes phase R2 (the second stage of Colony, already on the register), then the level, the coin and the wardrobe per realm, quests and finally the realm Campaign and the editions. On the website the decision for now is that the Campaign is switched off.
The document names the risks that did not change: the surface of the API of the manager (it grows hook by hook until it is a “god object”), the core as a dumping ground, the small number of puzzles in Kastgard, and the fact that the wrapper facts may somewhere not match the old path. Each has a recommendation in the document. All ten questions of section 4 received my confirmation in their original form on 27 October.
What I take from it
- The inventory before the contract. The numbers from the chapter on realms (41, 22, 16 and 8 lines) were right when recounted, but “38, 20 and 18 other files” also counted the nine language catalogues. The real code files were 29, 11 and 8. A number without a description of what was counted leads to a wrong estimate of the work.
- The proof is written before the change. The pin first against the old code, then the rewrite, then the same pin. The pins of M2 made it possible for each of the next steps to be “green” and “identical”.
- A table that does not know an exception is not finished. The guard for a game finished by the AI ended up as a test of metadata, because the table would have said something untrue about it. What mattered was that the pins written in advance revealed it, and not a later fault in the game.
- A flaky test is usually information. A failure once in ten was not noise: in it was a random salt of rolls, which made the game longer than the time the test waited.
- Limiting the scope helps. The realm manager may not know the name of any game (a test searches its code for the names of games). A limit that can be tested holds longer than an agreement.
After 27 October: from the register to packages
The table of realms above is the state of 27 October, and a lot has changed since then: the Campaign is no longer a realm but a section of Kastgard, the names Sporgard and Radgard have fallen away, and the board games have one realm, Taflgard[4]. This section adds the state of 31 October 2026 (version 0.65.6); the dates and versions are from the git log of the game.
| Step | Merged | Version | What |
|---|---|---|---|
| M5, M7 | 27 Oct | 0.50.28, 0.50.29 | saved games from the table of modes; the mark realm on the content |
| M6 | 28 Oct | 0.51.0 | a card and a hub for each realm |
| R2a | 28 Oct | 0.51.2 | tools/move.js, tools/packages.js, Colony as the first package |
| Taflgard | 28 Oct | 0.51.4 | one realm of board games |
| R1b | 29 Oct | 0.51.5 | the Campaign is a section of Kastgard |
| M8a | 29 Oct | 0.53.12 | the profile and Hero Dice from the parts of packages |
| T1a to T3 | 30 Oct | no version | tests and tools by package |
tools/paths.js | 31 Oct | no version | the tools find a file through the table of packages |
| K1 to K6 | 31 Oct | 0.64.5 | Kastgard in js/realms/kastgard/ |
A table of modes instead of the manager. The realm manager could not be asked about saved games: the manifest requires the ruleset, the ruleset requires Slots, and 95 files of tests and tools load Profile without the manifest. So the data of the modes moved into three files without dependencies, and they are read by the module ModeTable (DESIGN §55.2). The marks of M7 are carried by 18 minigames, 28 puzzles and 90 chains of achievements (§57.1).
Packages and Taflgard. Colony was the first to get its own folder (9 files), and the move revealed a trap without an error message: a worker four folders deep read the language files from a path that held only for js/worker/, and the game would quietly fall back to the main thread. The worker now finds its path from self.location (§60.5). After the decision of 28 October, Colony moved to js/realms/taflgard/games/colony/, and the old card id colony is read as taflgard (§61). The Campaign became a section: a data record registered by its own file, without a card, with a block in the hub of Kastgard (§62). The second game of the realm, Lines (Čiary), got its configurator on 31 October, and its line length was limited to at most 5; the reason is in the article on the length of a line.
Tests and tools. The run node tools/test.js --changed chooses from 186 test files only those that the change touches: with the rules of Colony 24 files in 49 s out of 296 s of the whole suite, with rng.js, which every game uses, 177 files and 95 % of the time (§73.3, 30 Oct). Before a merge the whole suite runs; today it has 251 test files (find tests -name '*.test.js', 31 Oct 2026).
The move of Kastgard. Kastgard could be moved only after tools/paths.js, because 74 tools name about 640 files of the game and build paths to them in five different ways (§94.1). The move (§95) has three parts: the realm (4 files), the game Stacks (67) and the game Campaign (20). The player sees nothing. In the work it was found what the analysis did not know: the writing of the table of packages forgot fields it did not know, so tools/move.js would have turned ten dependencies of the Campaign on Stacks into violations of the rule. The whole suite ran twice in a row: 3,268 tests, 3,267 passed, one is skipped.
Three realms in the plan. The analysis docs/SOLO-AND-HINTS.md (31 Oct, sections 4.2 and 4.3) proposes, next to Kastgard and Taflgard, a third realm for games without an opponent, working name logic: mathematical puzzles. According to my answer of 31 October these are tasks on a board with numbers, which have nothing in common with placing and moving pieces, so they belong to a realm of their own. The skeleton of the realm is to stand like Taflgard, that is 3 files. None of it is built and the name of the realm is not decided.
What remains. The core still knows Kastgard: 65 dependencies on Stacks from 12 files and 45 on the Campaign from 13 (step M8b). Waiting is the content registered by the game and the general hubs (K7, K8), and the division of the catalogues of texts by package, whose first step (Taflgard, 6 commits) is not merged yet. The home screen still shows a spinning die with Golden Dice in every realm (my observation of 31 October; steps R3c1 and R3e), the Collection is not divided, and the Talents with the Trophies are not moved to the Campaign (R5). The move of Kastgard is discussed in a separate article, the level, coins and achievements per realm in the next one.
Sources and notes
- Terms and inventory:
docs/ROADMAP.md, chapter 26 (table of terms, decisions 1 to 12, the collision of the nameChains) anddocs/REALMS-R1.mdsection 0 (896 places in 114 files; classes 179, 166, 43, 380, 54 and 74) and section 2 (the contract). - Steps M0 to M4 (versions 0.50.16 to 0.50.26):
docs/DESIGN.md§52 to §54; the testtests/store-unreadable-profile.test.jsfor M0; the independent review of M3 is in §53. - Packages and the move of Kastgard:
docs/DESIGN.md§60 to §62 and §94 to §95;node tools/packages.js --check(31 Oct 2026): 983 dependencies, 120 breaks of the direction in 22 files. - Content counts:
node tools/realms.js --check(27 Oct 2026). - Tests:
docs/DESIGN.md§95.4 (31 Oct 2026): 3,268 tests, 3,267 passed, one skipped. - What could not be verified: the numbers from the documents were not repeated after 27 October, and neither the whole suite of tests nor the heavy tools were run. Not verified is what made
js/ui/app.jsgrow from 3,546 to 3,867 lines, the content of the unfinished text catalogues and the spinning die on the home screen (my observation of 31 October; nobody looked at it in a browser).
Notes
- Until version 0.80.1 of 1 November 2026 the Avatar was called Hero Dice (see From Hero Dice to Avatar (forthcoming article no. 39)). ↩︎
- Up to version 0.65.6 of 31 October 2026 it was called Classic. ↩︎
- Stacks (in Slovak stohy) is the rules module of the game on which the mode Principalities runs. In the code it is the folder
js/realms/kastgard/games/stacks/. ↩︎ - Taflgard is the name of the realm of board games from 28 October 2026. From 31 October 2026 this realm is called Rokgard (today
js/realms/rokgard/). ↩︎