Borg#
The Borg is an “Automatic Angband Player”.
It was first written for about 2.8.0, separate from the game as distributed. It was pulled into the game in around 3.3. It was removed in 4.0 as there was too much conflict with the big changes made then. It was reincorporated in around 4.2.3.
The name comes from “The Borg” in Star Trek which, in turn, comes from cyborg.
The primary use of the Borg is entertainment. It is fun to watch the Borg play the game, and it can be amusing to see how it handles situations. It has also been used to test the game and find bugs.
ZangbandTK#
This file is upstream’s, and everything below this section describes Angband — its town, its bestiary, its single dungeon of one hundred levels with Morgoth at the bottom. Most of it still applies. What follows is what does not, and what this variant added, so that a reader is not misled by the rest (DEC-17, BRG-21).
The borg is test infrastructure here, not entertainment. It exists because
there was no test in the repository that played the game: 112 unit suites test
rules in isolation and none of them walks a character out of a town, into a
dungeon and back. See .claude/plans/borg-development-plan.md for the
requirements, BRG-01 to BRG-22.
Running it without a keyboard#
The borg’s only entry point upstream is ^z then z, through the UI, and
its only exit is a keypress. Neither is available to a test. The test front end
(-mtest, built when SUPPORT_TEST_FRONTEND is on) adds commands that
drive it headlessly:
borg-seed [N] seed the run; without N, reads ZTK_TEST_SEED
borg-run N play for N game turns, then hand control back
borg-status? one machine-readable line: turn, depth, maxdepth,
character level, hit points, armour, gold, deaths,
wielded weapon, and why it stopped
borg-notes? [N] the last N things the borg said
borg-kills? its target list, and whether any ally is on it
borg-shops? which shops it has found
borg-mouths? every dungeon mouth, its band, and whether it is
inside the current surface window
borg-terrain? what lies on the straight line to each deeper mouth
borg-pet <race> place an ally beside the player
borg-pets? how many pets survive, and how many turned hostile
borg-roundtrip save, reload and compare, mid-run
borg-cheat L G grant character level L and G gold (see below)
borg-jump D place the character at depth D, in a dungeon whose
band contains it
A character is born without the birth menus by setting ZTK_HEADLESS=1 and
ZTK_HEADLESS_CLASS; the menus consume a number of keys that depends on the
roll, which is why key injection was not reproducible.
Two scripts wrap this. scripts/borg-smoke plays fixed seeds and exits
non-zero on a crash, an abort or a wedge, printing the seed to repeat it.
scripts/borg-progress reports best depth and character level per class,
which is the regression signal BRG-18 asks for.
A dead character is not a failure. The borg reports death down the same
channel it uses for defects, and it dies often at low character level. Death
is reported as result=died with a zero exit; only crashes, aborts and
wedges fail.
A run always ends. As well as the turn budget there is a decision budget, because a borg waiting for a prompt it cannot see makes decisions for ever without the clock moving — which neither crashes nor aborts, it hangs, and a hung CI job is a red build with no diagnosis.
Prompts, and the one mistake that looks like four#
Read this before adding anything to the harness that answers a prompt.
A prompt in this game reads its input with inkey_ex(), which consults
inkey_hack – the borg’s hook – before it polls the terminal. So while
the borg is active, the borg answers every prompt in the game, not just the
ones it meant to raise. A key pushed into the terminal with Term_keypress()
is never read: internal_borg_inkey() only peeks at the terminal, for the
user-abort check, and a headless run ignores that peek by design.
This harness got that wrong four separate times, and each cost most of an hour to diagnose by sampling a wedged process:
borg_init()’s own prompts, answered out of the command script.Level generation’s prompts, likewise – a
borg-status?ended up executing in the middle ofprepare_next_level().A store.
4.2’s object context menu, reached when a stray keypress lands on
do_cmd_equip(). This one wedges a run outright: the borg keeps thinking, the game turn never advances, and the process sits at 0% CPU.
They look like four problems and they are one. The first three appeared to be fixed by answering at the terminal layer, and that worked only because in those cases the borg was not yet active – so the terminal layer genuinely was the reader. The moment a prompt appears mid-run the same approach silently stops working, which is why the fourth resisted three separate fixes.
Two conclusions worth carrying:
Answer at the borg’s layer, not the terminal’s, whenever the borg is active.
borg_keypress()puts a key whereinternal_borg_inkey()will hand it over;Term_keypress()does not.“ESCAPE does not clear that menu” was a fact about the harness, not the menu.
menu_dynamic_select()honours ESCAPE and returns -1, so a human player is not trapped there. It was raised as a possible game-side interface fault and it is not one.
The harness detects the general case rather than each prompt: if the game keeps asking for input while the game turn does not advance – a borg that is playing moves the clock, one trapped in a menu does not, however busy it looks – it dismisses and logs, and fails the run as wedged after a bounded number of attempts. That detection is sound and reports the turn and seed; what to send so the prompt actually clears is still open at the time of writing.
What this variant changes underneath it#
Depth 0 is not a town. It is a wilderness surface of 144 by 144 grids, a
window onto a world of roughly fourteen windows square, rebuilt and re-anchored
as the character crosses it. The borg’s arrays were sized for Angband’s
66-by-198 dungeon and it segfaulted on the first turn of every game; they are
now sized by a checked ceiling, and borg_init_cave() refuses to start if
the game’s largest level does not fit.
There are thirteen dungeons, not one. Each has its own depth band, from the
Vaults of Amber at 1-15 to the Abyss at 90-127, and dungeon_get_next_level()
clamps a descent to the dungeon’s floor rather than refusing it — so a borg
that does not know about floors reads each clamp as a successful dive and loops
for ever. borg_prepared() now reports “no deeper in this dungeon”.
Reaching a deeper dungeon means crossing the world, because no mouth is inside
the starting window and a town staircase always leads to the shallowest dungeon
there is. borg_flow_world() walks to a mouth, holding the destination in
world coordinates so it survives the window being rebuilt, and following the
roads — wild_place_roads() lays a spur to every mouth, so a road is
passable by construction and routes around the mountains.
Seven magic realms replaced Angband’s spell lists. The borg casts through
ninety-five hardcoded borg_spell(ENUM) calls against Angband’s
enum borg_spells, so 184 of this game’s 224 realm spells cannot be cast
at all — Nature has one. borg_best_spell_with_effect() and
borg_spell_by_index() are the beginning of an answer; the rest is BRG-07.
Monsters have three allegiances. A pet is not a target: nothing that is not
hostile enters borg_kills[], which covers targeting, fear and pursuit
together.
Several constants were sized for Angband and quietly outgrown — the map
arrays, the spell ratings tables, book_idx/amt_book against a Mage’s 28
books, and num_book indexed by an unbounded object sval. Each is now a
named ceiling with a startup check that refuses to run rather than corrupting
memory. If you add a constant here that mirrors something the game knows, give
it a guard.
The scoped route, and the cheats#
Standard play does not reach the depths this game’s content lives at, and the
early-game grind is not what the borg exists to verify. The scoped route is a
single Warrior-Mage — all seven realms, so every spell and every source of pets
is reachable by one character — taken to depth 30 with four levers:
borg-cheat grants character level and gold, borg-jump places it at a
dungeon using the map, and BORG_CHEAT_DEATH removes the attrition.
The cheats remove attrition, not decisions. The borg still chooses what to buy, what to wield, what to cast, what to fight and when to descend, and every level is generated and played. Grants happen at the start, never in reaction to danger; the jump goes to a dungeon, not to the target depth. Deaths are counted and reported even though they are cheated, because “reached depth 30, died fourteen times” says something and the depth alone does not.
Running The Borg#
It is not recommended to run the Borg on a live game, as it could cause unexpected behavior or even crashes. It is best to run the Borg on its own save file, or on a copy of your save file.
To run the Borg:
Ensure Angband is compiled with borg support (this is controlled by
ALLOW_BORG). This is done by default in most distributions.Start or load a game
Press
^z(Ctrl-Z) to access the Borg command interfacePress
zto activate the BorgWatch the Borg play automatically
Press any key to stop the Borg when desired
Borg Command Interface#
The Borg command interface is only available when Angband is compiled with borg support.
To access the Borg command interface, press ^z (Ctrl-Z) during
gameplay. When you first run the command you’ll be presented with a warning
message you can continue through. The most common command is z which
starts the Borg.
Pressing any key while the Borg is running will stop the Borg.
Main Commands#
|
Display Help |
|
Toggle cheat death flag |
|
List count of ‘nasties’ |
|
Toggle flags |
|
Fear levels of current location |
|
Display selected grid Features |
|
Borg_Has function |
|
Display selected grid Information |
|
Display selected grid Danger |
|
Create a snapshot log file |
|
Map information |
|
Object Information |
|
Borg Power |
|
Level preparation information |
|
Respawn Borg |
|
Search mode |
|
Dump spell info |
|
Display targeting |
|
Update the Borg’s variables (as if taking zero steps) |
|
Version stamp |
|
My Swap Weapon/Armor |
|
Step the Borg |
|
Last 75 steps |
|
Activate the Borg |
|
Time |
|
Reload borg.txt |
|
Borg LOS |
|
Flow Pathway |
|
Borg stats (str/int etc) |
Map Information#
After pressing m from the main borg interface you enter map information
display mode. This is map information as the borg understands it. The
following selections can be made.
|
Avoidances - dangerous areas with level of danger. |
|
Features with subselection of which feature to show. |
|
Glyph locations |
|
Monsters |
|
Objects |
Flag Commands#
After pressing f from the main borg interface you enter flag toggle mode.
You will be able to select any borg configuration and change its runtime value.
Borg_has Commands#
After pressing h from the main borg interface you enter “has”
display mode. These are things the borg has. The list is put in the games
messages.
|
Any |
|
Inventory |
|
Worn items |
|
Artifacts |
|
Skills |
Search Mode#
After pressing s from the main borg interface you enter a search string.
If the borg sees that string in the messages it will stop. Default is
“plain gold ring” for The One Ring.
Customizing The Borg#
The Borg’s behavior is primarily configured through the borg.txt file.
This allows for extensive customization of the Borg’s decision-making without
needing to recompile the game. If you do not have a borg.txt file a
stripped down borg.txt will be generated when the borg is first started.
For source distributions a sample borg.txt file is provided in the
src/borg directory of the source code. To use it, copy this file to the
user preferences directory for your operating system, and then customize it.
Windows: Copy
src/borg/borg.txttolib/user/borg.txtLinuxUnix: Copy
src/borg/borg.txtto~/.angband/ZangbandTK/borg.txtmacOS with the Cocoa front end:: Copy
src/borg/borg.txttoDocuments/ZangbandTKwithin your home directory
If you are using a binary distribution of ZangbandTK, the default borg.txt file needs to copied from the spot where it was put in the distribution for that platform.
Windows: the
borg.txtfile is already in the user preferences directory.macOS with the Cocoa front end:
borg.txtis included in the top level directory of the dmg file from which you installed the game.Linux/Unix: the
borg.txtmay not have been included in the distribution or it might be in/usr/share/doc/angband/borg.txt. If it is not found it can be copied from the source distribution insrc/borg/borg.txtor downloaded from github.
To download the borg.txt from github (angband/angband)
select the <> Code tab. Then select the src directory, the borg
subdirectory and download the borg.txt from there. If you need an older
version use the History link for that file to find the correct version.
Once copied, you can edit borg.txt to change the Borg’s behavior. To apply
changes while the game is running, use the $ command from the Borg command
interface (^z).
How you customize the Borg depends on whether you are using a pre-compiled build or compiling from source.
Configuration Options#
The borg.txt file offers a wide range of options to customize the Borg’s
behavior. Below is a summary of the key settings. For a complete list and
detailed explanations, refer to the comments within the borg.txt file
itself.
Adjusting Borg Speed#
When you first run the Borg it may move very slowly. This is often due to the
game’s base delay factor, a general setting that affects all animations. To
speed up the Borg you can decrease this value:
Press
=to open the main options menuPress
dto change thedelay factorDecrease the value
Conversely, if the Borg is moving too quickly to follow, you can increase this
value. You can also add a Borg-specific delay by setting borg_delay_factor
in borg.txt.
Worships#
These settings (e.g., borg_worships_damage, borg_worships_gold)
influence the Borg’s priorities and decision-making by assigning value to
different actions and items. For example, they can make the Borg favor
powerful weapons, seek out treasure, or prioritize speed.
Play Style#
borg_plays_risky: Makes the Borg dive deeper faster and be more aggressive in combatborg_kills_uniques: Forces the Borg to defeat uniques before proceeding deeper into the dungeon
Item Management#
borg_uses_swaps: Allows the Borg to carry and use swap items for situational resistances and abilitiesborg_worships_gold: Causes the Borg to return to town frequently to sell items for gold, especially at lower levels
Respawn and Continuous Play#
borg_cheat_death: If enabled, the Borg will not die and will continue playing, enabling continuous play. This can be set inborg.txtor toggled via the Borg command interface (^z, thenc, thend)borg_respawn_raceandborg_respawn_class: Specify the race and class for the next character when the Borg respawnsborg_respawn_winners: If enabled, the Borg will create a new character after defeating Morgoth
Dynamic Formulas#
The Borg can use either its internal hard-coded logic for decision-making
or a more flexible system of dynamic formulas defined in borg.txt. To
enable the formula-based system, set the following in borg.txt:
borg_uses_dynamic_calcs = TRUE
The dynamic calculations are more customizable but may be slower and are not always as up-to-date as the internal code logic.
Using Official Builds#
In most official builds, Borg support is already included and enabled. You just
need to copy and configure the borg.txt file in the correct location as
described above.
Compiling Yourself#
When compiling from source, the Borg is enabled by default on most platforms. For starter instructions on how to compile, see the Compiling Instructions guide.
If you find the Borg is disabled in your build configuration, you can typically enable it by:
Uncommenting an
allow_borgline in a configuration file (likeconfig.h)Passing a
-DALLOW_BORGflag to the compiler
When compiling, you can also enable the SCORE_BORGS flag to allow Borg
characters to appear in the high score list. This is disabled by default.
Refer to the compilation instructions for your specific platform for details.
After compiling with Borg support, place your borg.txt file in the correct
location as described above.
Borg Logging#
The Borg suppresses most messages by default. To see what the Borg is doing, you’ll want to use multi-window support to display additional information windows.
Window Configuration#
For optimal Borg monitoring, open additional terminal windows to display:
Equipment: See what the Borg is wearing and wielding
Messages: View game messages and Borg status updates
Monster Recall: See information about monsters the Borg encounters
Inventory: Monitor what items the Borg is carrying
Set these up through the window menu before activating the Borg. Borg-specific messages will appear in the Messages window when verbose mode is enabled.
Verbose Mode#
Enable verbose mode to get detailed output about the Borg’s decision-making process, including calculations, target selection, danger assessment, and action decisions.
Via Flag Command#
Press
^zto access the Borg command interfacePress
fto enter flag toggle modeSelect
borg_verboseto toggle verbose mode on/off
Via Configuration#
Set borg_verbose = TRUE in the borg.txt configuration file, then
reload with ^z $.
Log Snapshot#
Create a detailed snapshot of the current game state for debugging:
Press
^zto access the Borg command interfacePress
lto create a snapshot log file
This generates a comprehensive .map file (e.g., player_name.map) in
your Angband archive directory containing:
ASCII dungeon map: Current level layout showing terrain, monsters (
&), items, and player (@) positionRecent game messages: Last actions, movements, and events
Complete character state: Equipment, inventory, quiver, and home contents
Borg configuration: Current swap items and borg settings
Detailed statistics: All internal borg trait values, resistances, and assessments
The snapshot provides a complete picture of both the game state and the Borg’s internal knowledge at that moment, useful for understanding its behavior or debugging issues.
Borg Screensaver#
The Borg can be configured to run as a Windows screensaver that automatically plays the game in continuous play mode, automatically restarting with new characters when the current character dies.
WARNING: The Angband display is not always dynamic. While modern LCD monitors are not susceptible to burn-in, OLED displays may still experience image retention with prolonged static content. Configure energy saving settings to turn off your monitor after inactivity. The screensaver keeps the processor and hard disk busy, preventing power-saving features that depend on inactivity.
Installation#
Copy
angband.scrand the includedangband.iniinto your Windows directoryEnsure you have the Windows version of Angband installed with all supporting files in the
libdirectoryEdit
angband.iniwith a text editor:Set
AngbandPathto point to your Angband installation directory (must end with a backslash\)Set
SaverFileto the character name you want to use for the screensaver (a random character will be automatically created if the character doesn’t exist)
Example configuration:
[Angband] AngbandPath="c:\games\angband-4.2.5\" SaverFile="Saver"
Test the screensaver in Windows Display Properties
It’s recommended to create a normal character first using regular Angband,
set up your terminal windows as desired, save that file, and use that filename
as the SaverFile for your screensaver.
Technical Details#
The screensaver is a renamed Windows Angband executable with modified
main-win.cNormal Borgs get highscore entries, but screensaver Borgs (continuous play mode) do not
Uses low priority processing to avoid slowing down other processes
Can be toggled via “Options/Low priority” menu when using as normal executable for background Borg play
Uses the normal Angband installation’s
angband.inifor screen layout, graphics, and sound settingsCan be used as a normal Angband executable by renaming to
angband.exe
Known Limitations#
No preview in Windows Display Properties
Password protection not implemented
Configuration requires manual
inifile editing“Show scores” while Borg is running may cause crashes
Cannot run the same savefile simultaneously (e.g., normal game and screensaver)
Info window sizes may increase when exiting pseudo-screensaver mode from options menu
The nightly run, and how to read it#
.github/workflows/borg.yaml plays the borg every night at 06:00 UTC —
twelve runs, four classes against three fixed seeds, unaided: no cheats, no
grants, no pets. Change the cadence by changing the cron line and nothing else.
Where the results are#
On the run’s own page, as a rendered summary rather than sixty lines of log to
expand. The full output is also kept as the borg-nightly artifact for
ninety days, alongside the borg’s own death log, so a bad night can be examined
after the fact.
Why pass and fail is not enough#
Every run dies. That is what unaided play looks like at these character levels, so a death is data and not a failure; a run that hits the wall clock has not failed either. The job goes red only for a crash, an abort or a wedge.
Which means the numbers that matter — best depth, best character level, spells cast — could halve overnight and the job would stay green. That is exactly the regression the nightly exists to catch, so it is checked against a committed baseline instead.
Why a run stopped where it did#
Every run’s line is followed by the borg’s own account of its depth limit:
Priest 13 20001 1 1 ok
allowed to depth 1, held back by: Clevel < depth
and the summary counts the causes across the fleet:
borg-progress: what held the fleet back:
3 5 Food
2 2 cure
2 30 hp
That comes from borg_prepared(), which has always returned a reason in one
word — 5 Food, 30 hp, 2 cure — and which nothing recorded. It
matters more than it looks: on 9 September ten of the twelve runs turned out to
be forbidden from leaving the first floor rather than choosing to stay
there, and establishing that took an afternoon of tracing depth over time. The
tally is the part that distinguishes one fleet-wide cause from twelve unrelated
ones — six runs blocked on supplies the shops stock is a shopping bug, not a
balance question.
The status line carries the same two fields, allowed= and blocked=.
They replaced ready=, which reported whether a level existed and was
therefore true of every run that had started.
The baseline#
tests/borg/BASELINE records what the borg currently manages. Same idea as
tests/saves/EXPECTED-FAILURES: a statement of what is true now, which
somebody has to change on purpose.
scripts/borg-progress -b tests/borg/BASELINE compares a sweep against it
and exits non-zero if a number has dropped materially. The thresholds are in
borg-progress beside the comparison, with the reasoning:
Number |
Fails when |
|---|---|
spells cast |
less than half the baseline |
spells learned |
less than half the baseline |
total character levels |
down by more than a third |
total depth |
down by more than two fifths |
Totals, not maxima, and the twelve per-run rows decide nothing. The rows in the baseline exist so that a failure can say “Warrior seed 1: depth 8 to 3” rather than leaving a reader to diff two files; a row moving is never by itself a regression.
That split was measured rather than assumed. The nightly used to gate on best depth and best character level, and a maximum over twelve runs reports whichever run got luckiest. Eight of the twelve never leave depth 1 on any night, so the whole depth signal lives in four runs and best depth lived in one. On 9 September that run’s random stream shifted, the headline fell from 8 to 3, and the job went red for a change that left the fleet’s character levels at 39 against 41 and its casting at 14 against 15 — the only false alarm in four nights of real runs.
Both directions were checked before replacing it, against those four nights
plus deliberate regressions: casting collapsing the way the
BORG_SPELL_UNKNOWN bug made it (4 cast against 15), nothing learned, the
fleet ceasing to level, the fleet ceasing to descend, every character level
halved. The totals raise no false alarm and miss none of those. The old rules
missed none either — they simply cried wolf twice, which is its own kind of
failure: a job that goes red for nothing gets ignored.
Depth is the loosest of the four at two fifths, deliberately, because it has the least room to move — with two thirds of the runs stuck on the first floor it is close to a dead metric. Tighten it when the borg reliably gets below depth 1; until then the character levels carry the weight.
They are not tighter than that for a specific reason. For one build, one set of seeds and one machine the runs are exact — the same seed gives the same turn count every time, verified three runs over and later reproduced at two optimisation levels — so none of this is noise. But any change to the game’s data reshuffles the random stream and moves which seed happens to do well: the same seed that died at character level 1 one night reached level 13 the next, on a change to starting equipment. Measured across the three seeds the spread is depth 1 to 7 and level 1 to 13, so a reshuffle can plausibly cost a couple of levels. Three cannot be explained that way.
The runs are not portable between machines#
This cost a night, so it is written down. The first baseline was taken from a local sweep and compared against CI, the job called a regression, and the code was fine.
At the same commit, with the same seed, class and flags, Warrior seed 13 played 280,358 turns to character level 13 on Darwin arm64 and 5,252 turns to character level 1 on Linux x86_64. Both machines are internally exact and reproduce their own figures every time. Neither is wrong; they are not the same measurement.
Build type is not the cause — the local figure reproduces identically at -O0
and under RelWithDebInfo, which is what CI builds — and the RNG is fixed
width and deterministically seeded, so the same seed and the same sequence of
calls give the same answer anywhere. Something in the sequence of calls differs
by platform, and what it is has not been chased down, because it does not change
what to do about it: a baseline has to be generated the same way it will be
measured.
So borg-progress records platform: in the baseline, prints the platform
it is running on, and skips the comparison entirely when the two differ
rather than comparing figures that cannot be compared. Running a sweep locally
against the committed baseline is still useful — you get the table and the
exercise line — it just will not judge you against CI’s numbers.
A useful corollary: a data change scoped to something the runs do not touch moves nothing at all. These runs play a Human, so the four races finished in 3.110.0 left every turn count byte-identical across two nightlies. That is what finally separated the platform difference from a real regression — a genuine regression would have moved one number, not every number, and would not have left the Warrior rows identical to the digit.
A run that hit the clock is excluded from the comparison, and the job says
so, because a capped run stopped where the clock landed rather than where the
borg did and the clock depends on how busy the runner is. If that starts
happening, raise -m or lower -n until every run finishes on its own.
Updating the baseline when a number improves#
This is the step to get right, because the tempting thing to do with a red night is to make it green.
Take it from a nightly, not from your own machine. Run the workflow by hand
— Actions → Borg → Run workflow — and copy the figures out of its log:
the four totals from the summary, and the twelve rows from the run table in the
form run:CLASS:SEED:DEPTH:CLEVEL.
A local sweep measures your laptop, and the nightly is what will be compared against this file; see The runs are not portable between machines above.
Updating the rows alone is free — they decide nothing, so a night where the stream reshuffled can have its rows refreshed without argument. Changing one of the four totals is the part that wants a reason in the commit message.
Copy the figures into tests/borg/BASELINE, update the notes
saying which run reached each best, and say in the commit message why the
number moved. A baseline that rises without an explanation is one nobody
trusts to fall.
Four things worth knowing:
Check the platform before you believe a drop. If every number fell at once, suspect the measurement rather than the game: a real regression usually moves one thing. The comparison names the platform it ran on.
Read the rows before the verdict. The failure output lists every run that moved. Four of twelve moving with the totals holding is a reshuffle; the totals falling is the fleet getting worse. Only the second is red, and the output says which it is.
Do not update it to silence a red night. If a number dropped and you do not know why, that is the finding, not an inconvenience. The failure output names the metric, both values, the run that reached today’s best and the command to reproduce it, which is enough to start from.
Re-baseline in the same commit as the change that moved the numbers, so the diff shows the cause and the effect together.
The class list, the seeds and the turn budget are part of the measurement. They live in the workflow. Changing any of them invalidates the file, so change them and re-baseline together.
Options go before the seeds. -b written after them is a seed as far as the
argument parser is concerned; it will refuse rather than run, but it is an easy
one to trip over.