Table of Contents

Rogueima: the plan

A step-by-step ordering from Rogueima I MVP-4 toward something with NetWhack's shape. Each step is meant to be finishable on its own, and to leave the game playable when it lands.

For the reasoning behind the grouping, see the roadmap in programs/rogueima/ROADMAP.md. This page is the working list.

Part I — movement and the turn

Step 1. Collapse movement into one routine — DONE

move_left, move_down, move_up and move_right are four near-identical routines, each with its own edge check, its own is_walkable call and its own store. Replace them with a single

try_move  ; CL = dx, DL = dy

that computes the destination, bounds-checks it, asks is_walkable and commits. The four existing directions become four callers.

Nothing changes on screen. This is the step that makes the next one free, and it removes three copies of a bug surface.

Step 2. Eight-way movement — DONE

Add Y U B N for the diagonals, so the vi keys form the usual 3×3. With try_move in place each is two instructions and a call. Keep the arrow keys ($80-$83) mapped to the orthogonals, and consider the numpad digits as NetWhack uses them.

Four-way movement is the most noticeable difference between Rogueima and any other roguelike. This is an afternoon.

The three direction readers

Walking was not the only thing that asked for a direction. open_door and do_talk each had their own copy of the key list, so after Step 2 you could walk diagonally but not open or talk diagonally — the same bug in two more places, which is exactly what Step 1 was meant to prevent.

The fix is the Step 1 move again, one level up. Four routines now do the work for all three prompts:

get_dir_key   ; read a key, fold it to upper case
decode_dir    ; AL -> CL = dx, DL = dy, ELM -> the name, carry = valid
dir_target    ; player + CL/DL -> X, Y, carry = on the map
print_dir     ; "  OPEN " + "NORTHEAST" + newline, preserving C and D

decode_dir is now the only place in the game that knows what a movement key is. Adding a fifth prompt that wants a direction — fire, throw, kick — costs two calls.

Two details worth keeping in mind:

FLD cannot hold a pointer while D holds a delta. print_dir

  takes its prefix in ''GLK'' and consumes it before any call.
* ''dir_target'' bounds-checks, which ''open_door'' and ''do_talk'' never
  did. Reading a glyph one step off the edge of the map used to read
  whatever followed the map data.

“ OPEN DOOR ” plus “NORTHEAST” is 21 characters and the message window is 19 wide, so the prefix lost the word the prompt above it already supplies.

Step 3. A real turn loop — DONE

Today the player moves and the world does not answer. Establish the order: the player acts, every monster acts, the clock advances. The T: counter in the status panel already exists — make a successful action advance it, and give move_mon its turn immediately after.

Everything from here assumes monsters get a turn when you take one.

The turn contract

move_mon was being called from inside draw_map, so monsters moved once per redraw, before the player's key had even been read. It is a call in the loop now, and the loop is the whole of the ordering:

main_loop:
    CALL @draw_map
    CALL @get_input         ; the player acts
    JNC @main_loop          ; ...or did not: redraw and ask again
    CALL @move_mon          ; now the world answers
    CALL @inc_game_time     ; and the clock moves
    JMP @main_loop

Every command routine answers the same question in the carry flag:

^ carry ^ meaning ^
| set   | the player spent a turn |
| clear | nothing happened |

get_input tail-jumps to those routines, so their carry is its carry and no dispatch code has to know which commands cost time. Walking into a wall, naming a direction with no door in it, and pressing an unknown key are all free — the world does not get to answer a non-move. M and G conjure things out of nothing, so they are free too; they are debug commands.

Three things this uncovered

Giving monsters a real turn meant there had to be monsters, and there never had been:

instruction was STB [ELM + I], and B was never zeroed. Every slot

  was born with a non-zero ID — that is, "in use" — so the ''SCANQUE'' in
  ''spawn_monster'' and ''create_gold'' never found a free one. **''M'' and
  ''G'' had been failing silently.** Whether this bit depended on what the
  title screen happened to leave in ''B''.
* ''MOD B, KL'' mixes a 16-bit destination with an 8-bit source. ''MOD''
  takes its width from the destination, so this read ''REGS[30]'' — past the
  end of the 16-bit register file — and ''B'' came back unreduced. Monsters
  and gold were being placed at coordinates like (56242, 15977), off the map
  and out of reach. Both spawners widen the dimensions into ''I'' and ''J''
  first now.
* ''move_mon'' still had a ''SED''/''CLD'' pair bracketing its commit —
  leftover CPU-trace instrumentation, harmless only for as long as no
  monster ever moved.

Machine note

The assembler rejects mismatched widths for MOV but accepts them for arithmetic, and the CPU then indexes the register file with the raw encoding. MOD B, KL is not a typo the tools will catch. Worth a width check on the arithmetic path.

Step 4. Wait — DONE

Numpad 5 and . both reach move_wait, which is now the one move that is nothing but time: it returns carry set without touching PX/PY, so the monsters get their turn and the clock advances. That makes it the way to watch monster behaviour without moving, which is what makes the next several steps testable.

Part II — depth

Step 5. Turn the map into a structure — DONE

map1_data is literal text — 40 lines of .bytes “####…”. That is fine for one static level and blocks everything else. Introduce a tile array with one byte of glyph and one byte of flags per cell, and a loader that expands the text into it when a level is entered.

Keep the text as the source format. It is readable, it diffs well, and hand-authored levels stay easy to write.

This is the pivotal step. Stairs, generated levels and line of sight all need per-tile storage, and doing any of them first means doing them twice.

What landed

Two bytes per square, row by row, at $030000 in bank 3 – 6,400 bytes for 80×40:

tile(x, y) = TILE_BASE + (y * width + x) * 2
flag value meaning
TF_WALKABLE $01 you can stand here
TF_OPAQUE $02 you cannot see through it
TF_SEEN $04 reserved for Step 10
TF_VISIBLE $08 reserved for Step 10

SEEN and VISIBLE are defined now and set on every square, so the renderer behaves exactly as it did. Installing the machinery separately from switching it on is what keeps Step 10 small.

load_level expands the text into the structure on entry; tile_flags_for is the single place that decides what a glyph means, so the loader and put_glyph cannot disagree – which they would, the first time a door opened. is_walkable is a flag test now instead of a chain of glyph comparisons, and no longer walks the object list to get there.

The test for this step is that nothing changes on screen: the rendered display is byte-identical before and after, with the camera scrolled.

The assembler bug underneath it

AND AL, @TF_WALKABLE is the first 8-bit immediate in the tree naming a label defined in a later file, and forward-reference patching got that wrong: it wrote the low byte, then wrote a second byte unconditionally, zeroing the next instruction's opcode. Here that opcode was a JZ, so is_walkable fell through its own branch and nothing on the map could be stepped on – from an image that assembled with no errors and no warnings.

Worth remembering as a shape: a program that assembles cleanly and behaves absurdly is worth a look at the emitted bytes, and then at the trace.

Step 6. A level table — DONE

map1_id, map1_name, map1_up, map1_down and map1_dim already exist — the structure anticipates several levels and the stair coordinates are already declared. Generalise them into an array of level descriptors: id, name, dimensions, up and down stair positions, and a pointer to the tile data. One entry to begin with.

Step 7. Stairs — DONE

< and >. Entering a staircase switches the active level descriptor and places the player on the matching stair of the destination. The coordinates are already in the data.

What landed

The descriptor, which is the old map1_* fields plus the two pointers they were missing:

offset field
0 LV_ID 1 byte
1 LV_NAME 9 bytes
10 LV_DIM width, height
12 LV_UP x, y of the staircase up
14 LV_DOWN x, y of the staircase down
16 LV_SRC → the source text
19 LV_TILES → this level's tile array

Every level is built at startup and keeps its own tile array, so levels persist – a door you opened is still open when you come back. There is nothing to gain by discarding one: a 26-level dungeon is under 170 KB.

enter_level mirrors the dimensions and tile pointer into level_dim and level_tiles, because tile_addr and draw_world read them per square and per cell.

Both commands check you are standing on the staircase, switch the descriptor, and put you on the matching stair of the destination – down arrives at the level's up stair, and the reverse. Both honour the turn contract: a flight of stairs is a turn, refusing to move is not.

place_stairs stamps < and > onto each level as it is built. Those coordinates had been in the data since the game was written and nothing had ever drawn them.

The table has two rows. Level 2 borrows level 1's text as a placeholder – Step 8 replaces that single pointer. Its tiles are already separate, which is what made the persistence testable: open a door on level 1, and the same square on level 2 is still shut.

Step 8. A second level — DONE

Hand-authored, in the same text format. Proves the machinery of Steps 5-7 before any of it depends on a generator.

The machinery is already in place and exercised – what is left is content. One pointer in level_table row two changes from @map1_data to @map2_data.

What landed

BrynnWell, the well maze from NetWhack's src/netwhack/world/mapgen/BrynnWell.java – 20 x 30. Exactly one pointer in the table changed, which is what Steps 6 and 7 were for.

Glyph for glyph: NetWhack's . is our '' , ''# and + carry over, and its six secret doors (s) are ordinary doors here. There is no search command yet, so a secret door would be a wall you could never get through; they can go back to being secret once something can find them.

It is a 20 x 30 level, not a 20 x 30 maze padded out to match level 1. That needed two fixes in draw_world, which was the last place still assuming one map size:

when the map is the narrower of the two. A map that fits has nowhere to

  scroll to, so the answer is zero.
* the render loop drew 58 x 23 squares unconditionally, so past the right
  edge of a narrow map it carried on into the start of the next row.
  Squares outside the map draw as nothing now.

Maps larger than the view always worked – that is the scrolling case. Everything else had carried per-level dimensions since Step 6. Level 1 is unaffected and provably so: its rendered screen is byte-identical before and after, walked to the same square with the same door open.

Verified by rebuilding the level out of its tile array and diffing against the Java source: all 30 rows match, with < and > stamped where the descriptor says.

Step 9. DungeonMaker — DONE

Rooms and corridors, seeded from the RNG so a level is reproducible from its depth and seed. Appendix III of Writing Games in Assembly Language sketches this already, and NetWhack's DungeonMaker.java (633 lines) is the working reference.

With 16 MB there is no reason to discard a level once made: an 80×40 map is 3,200 bytes, so a 26-level dungeon fits inside a single bank. Levels can simply persist.

What landed

programs/rogueima/dungeon.sda. Fill the level with rock, mine one room in the middle, then repeatedly find a wall with exactly one floor square beside it and build on the far side, leaving a door in the wall you dug through.

That one-floor-neighbour rule is doing two jobs. It stops a level collapsing into a single cave, and it is also why every level comes out connected: each new piece is reachable through the door that made it. Connectivity is a property of the construction, not something checked afterwards.

Levels 3, 4 and 5 are generated, 78 x 22. LV_SRC of 0 in the level table means “there is no text to expand, build it”, and the generator writes LV_UP and LV_DOWN into the descriptor itself – a level that does not exist yet cannot say where its stairs are. Generated levels persist exactly like the hand-made ones.

The corridors NetWhack never built

addfeature in DungeonMaker.java reads:

switch (feature_type) {
    case 1:  success = makeshop(...); break;
    case 2:
    default: success = makeroom(...); break;
}

case 2: is an empty label falling straight into default:, so it builds a room like everything else. TileData.CORRIDOR exists and readmapline can produce one, but nothing ever generated one. The branch was never written rather than broken – which is why BDungeon levels are rooms hanging off rooms.

Writing it costs almost nothing, because a corridor is a room with one dimension collapsed to zero, and makeroom's geometry already handles that: building EAST with height 0 gives a.y = b.y = yloc, a single row. So rooms and corridors are one routine called with different sizes, and the feature roll now actually branches.

Verification

Dump the tile array and analyse it rather than looking at it. Level 3 came out 310 floor squares with all 310 reachable by flood fill; level 4, 388 of 388. Solid border, 18 and 20 doors, both staircases placed, and the two levels different from each other. Corridor squares – floor with exactly two opposite floor neighbours – numbered 70 on level 3.

Part III — sight

Step 10. Per-tile seen and visible bits — DONE

Add the two flags to the tile structure from Step 5, and teach the renderer three states: visible (bright), seen but not visible (dim), unseen (blank). Then mark every tile seen and visible, so nothing changes on screen yet.

Installing the machinery separately from switching it on keeps the next step small and makes a regression obvious.

What landed

The flags have existed since Step 5, set on every square. The renderer reads them now:

state drawn colour
TF_VISIBLE lit $07 light grey on black
TF_SEEN only dim $08 dark grey on black
neither not at all

They are still set on every square, so nothing changes on screen – which is the point. Step 11 only has to start clearing TF_VISIBLE.

Colour arrives with this step, because “dim” needs one. Palette 5, CGA as an IBM 5153 showed it, and the indices are NetWhack's own: its ColorMap puts light grey at 7 and dark grey at 8 in the same order, so a colour there is a colour here. TileData also colours by tile kind – doors BROWN, staircases WHITE – which maps onto the same palette and is worth adding.

Two traps worth writing down:

the colour plane with the mode's default. The colours have to go back on

  after it, not once at startup.
* ''print_char'' takes its colour from ''VIDEO_CHAR_COLOR'', not from the
  plane. That is how the status panel is painted, and without setting it the
  panel comes out dark grey on brown under a CGA palette.

Verified by dumping both planes: glyphs unchanged, colour uniformly $07 across all 2000 cells. Clearing TF_VISIBLE on one square by hand drew it dim with its glyph intact; clearing TF_SEEN as well drew nothing, leaving a gap in the wall.

Step 11. Line of sight — DONE

Each turn, clear visible, then walk a line from the player to every tile within a radius, stopping at anything opaque; mark what is reached visible and seen. NetWhack's Level.visline() and makevis() are the reference.

Integer Bresenham over tiles — not the PPU's line primitive, which draws pixels.

This is the step that changes how the game feels more than any other on this list. A lit corridor ahead and a remembered room behind is the difference between a map and a dungeon.

What landed

programs/rogueima/los.sda. NetWhack's visline() unchanged, but cast to a radius of 8 rather than to the edge of the map.

makevis() there casts to every square on the map boundary – about 200 rays of up to 78 steps, which its own comment calls “a terrible waste of resources”. A radius is the same code with nearer endpoints: about 68 rays of at most 11 steps, some thirty times less work, and it gives a torch instead of sight to the far wall. LOS_RADIUS is one constant.

The radius is circular: a square is in range only if dx*dx + dy*dy ⇐ 64, so the corners of the box do not see further than the sides. Two MULs per square.

Bresenham is the error-accumulator form, which keeps every quantity non-negative – the signs live in LOS_SX/LOS_SY and are applied as 8-bit wraparound. No signed comparisons, which on this machine would have meant testing N against V by hand.

Switching it on was one line: TF_FLOOR and TF_WALL stop carrying SEEN and VISIBLE, so a square starts unknown. That is what Step 10 was for.

Three things came with it:

what a square is, not whether you have been there.

blank square is what “never seen” looks like – lit floor was invisible.

them through walls.

Verification

Dump the glyph and colour planes together and separate lit from remembered by colour. In the open: a clean circle of lit floor spanning exactly px±8 and py±8, with a dim crescent trailing from where the player walked. In BrynnWell's maze, standing in a corridor:

  1. #

+….@.+

  1. #

the corridor, the walls above and below it, the closed doors at each end, and nothing through them.

Part IV — things to carry

Steps 12–14. Inventory, item classes, wield and wear — DONE

Planned as three steps and built as one, because none of them is any use alone: an item you cannot carry, a pack you cannot look in, or a suit you cannot put on. All of it lives in the new item.sda.

The item table

NetWhack knows what an item is from its class: Armor extends Item, carries an acmod, and takes its name and numbers from ArmorData[kind]. Here the class is the OBJ_TYPE tag and the per-class payload is OBJ_DATA1 — a tagged union, which is the same shape without the inheritance. What the tag does not give you is the data, and that is the table:

.equ IT_TYPE  0     ; which class this is
.equ IT_GLYPH 1
.equ IT_STAT  2     ; the class's one number
.equ IT_NAME  4     ; -> the name
.equ IT_SIZE  7
item_table:
    .bytes 3, ')',  3, 0, @str_dagger     ; WEAPON, 1d3
    .bytes 4, '[', 10, 0, @str_leather    ; ARMOR,  acmod 10

IT_STAT is the one number the class needs — a weapon's damage sides, a suit's ac modifier — exactly as WeaponInfo carries dmg and ArmorInfo carries acmod. A new item is a row here, not a branch in make_item.

The pack

A second queue over the same fixed node array the world uses. Picking something up is REMQUE from the world list and INSQUE into the pack; the node itself never moves and nothing is copied. That is what INSQUE/REMQUE are for, and it is why draw_items stops drawing something the moment you take it — it walks the world queue, and the node is no longer on it.

The screen

ADOM by way of NetWhack: a full page, grouped by category, one letter per item. Letters are the item's position in the pack, so an item keeps its letter whichever heading it appears under.

  INVENTORY
  Weapons
    a - dagger (wielded)
  Armour
    b - leather armor (worn)
  1. - press any key –

I to look, E to wield, W to wear, R to take everything off, D to drop, and the existing extended to pick items up. X is a debug command that drops one of each at your feet.

What it does to combat

do_combat used to land every time and take off exactly 1. Now it rolls 2d(attack) against 2d(defense), as NetWhack does, with ties to the defender; wielding anything at all is worth +10 attack, armour adds its acmod, and damage is the wielded weapon's die.

Verification

A miss proves nothing, so the rolls are called directly rather than played. test_combat.sda runs a thousand trials each way and leaves four totals in memory:

hit by a monster, unarmoured      458 / 1000
hit by a monster, in leather      148 / 1000
damage over 1000 swings, fist    1000        (a fist is always 1)
damage over 1000 swings, dagger  2006        (1d3, mean 2)

Armour makes you harder to hit and the weapon changes the damage, which were the two things this step set out to show.

The memory map, which moved

The step also flushed out a real bug. The game sat at $02C000 with the object array 12 KB above it at $02F000. The code grew past that line, so init_objects cleared the node array over the game's own instructions — which presents as the player being unable to move, with the assembler reporting nothing.

The game now loads at $020100: USER_ORIGIN, the address the shell and INT $20 already load programs to, so an assembled Rogueima is a program the shell can run by name like any other. Everything it allocates is a whole bank away, one bank per kind:

$020100   bank 2: code and data, growing up   (55 KB to the FS command block)
$030000   bank 3: game data -- the objects
$040000   bank 4: the maps -- one tile array per level

The bases are labels and everything derives from them, so the second map bank a real dungeon will need is one line. .equ learned @label + N to make that possible.

Two smaller fixes fell out of testing: draw_items only ever drew gold, so a dropped dagger vanished (it now draws every object that is not a monster), and inv_ask compared item letters against lowercase a while get_dir_key folds keys to upper case, so no item could ever be selected.

Since then: the whole item table

Steps 12-14 shipped with two items written by hand. itemtable.sda is now NetWhack\'s five item arrays – ArmorData, WeaponData, FoodData, PotionData, ScrollData, 41 items – generated by tools/gen_itemtable.py. Five arrays of five Info classes with five different constructor signatures come out as one table with a class tag and a per-class payload.

IT_SLOTS is the field that earned it. ItemInfo.eqslots is the set of equipment slots an item may occupy: gloves name GLOVES and nothing else, a dagger names WEAPON and OFFHAND. There was one eq_weapon and one eq_armor, and between them they cannot express a pair of gloves – putting gloves on took your plate off, and the only symptom was a defence number that went down when you added armour. There is an equipment rack now, one pointer per slot, and defense_rating sums every worn piece.

defence 10 bare  ->  60 in field plate  ->  64 in plate and gloves

The equipment page

Inventory.imEquipment(). w opens a page listing all thirteen slots at once, lettered A to M. Press a slot: something in it comes off, an empty one offers the things that fit that slot and nothing else, which is getitem_byslot(). A single “wear what?” prompt has to guess where a thing goes and cannot show you what is empty, which is the question a player has.

Identification

Only potions and scrolls have a fake name – ArmorInfo, WeaponInfo and FoodInfo take no such argument, because a sword looks like a sword.

Two things about PotionData.randomize() that are easy to get wrong. It swaps the fakename and the colour together, so an appearance is a (name, colour) pair – get it wrong and you have a milky potion drawn in green. And identified is a field of the DATA row, not of the object: drink one potion of healing and every potion of healing is named for the rest of the game. Neither can live in the generated table, so both are a byte per kind in the data bank.

Names are therefore built, not stored: “potion of moonshine”, or “milky potion” when the appearance begins with a capital, as Potion.name() does.

Paging

Inventory.getitem_step2/step3 – the one screen every “choose something” goes through, and, since the shops use it too, worth having once. Letters restart at a on every page, as NetWhack does: the letter means “the third thing I can see” and must not depend on how long the list is.

A bug worth writing down

Two commits of new variables were placed on top of existing ones in the hand-managed variable page. inv_more sat on item_kind, so the class filter was overwritten halfway through drawing the inventory and the weapons and armour vanished from it. Fourteen overlaps in all, no assembler warning, and the symptom in a routine that looked innocent.

tools/check_vars.py reads the .equ declarations and their “; N bytes” comments and reports overlaps. Run it after adding one.

Step 15. Consumables and an effects hook — HALF DONE

Food, potions, scrolls, and a small effect dispatch table they trigger.

The things all exist: importing NetWhack's item tables brought 7 foods, 5 potions and 5 scrolls with their real numbers, and with them the identification system (see below). e eats and q quaffs, and a potion gives you its nutrition – which for poison is negative.

What is left is the effects: healing, blindness, speed, teleport, identify, town portal. NetWhack has an Effect class with a trigger type, and Food.event_eat already calls fxlist.transferByItemTrigger(this, Effect.T_FOOD). Until that exists, a potion of speed is a drink of water with a different name on it.

Part V — pressure

Step 16. Hunger, as an energy meter — DONE

A food clock, and e to eat. Taken ahead of Step 15 because it is the one that changes how the game is played: it is the reason to go down rather than explore forever.

Inverted

This is NetWhack's Engine.pc_hunger() with the sign flipped. There, pc.hunger counts up from zero and every test asks how big it has got:

pc.hunger++;
if (pc.hunger > 5000) { ... }
if (pc.hunger > 4000) { ... } else if (pc.hunger > 3200) { ... }

Here the same number counts down from EN_MAX and is called energy. They are the same meter — every threshold is 5000 minus NetWhack's — but the down-counting one can go on the status panel and be read without explaining. Energy: 800 says you are nearly out of something. Hunger: 4200 does not.

NetWhack Rogueima says odds
hunger > 800 energy < 4200 You're feeling a might peckish. 1 in 500
hunger > 1600 energy < 3400 Your stomach is grumbling. 1 in 500
hunger > 2400 energy < 2600 You are feeling hungry. 1 in 500
hunger > 3200 energy < 1800 You are feeling very hungry. 1 in 400
hunger > 4000 energy < 1000 You are starving! 1 in 250
hunger > 5000 energy == 0 starving.. TO DEATH! (-1 hp) 1 in 50

Two consequences of the inversion, both small:

so the bug to avoid is an unsigned overflow rather than an underflow.

  NetWhack lets hunger go negative on a big meal; a meter has a maximum.
* starving is energy **at** zero rather than hunger past 5000. NetWhack's
  hunger keeps climbing past its own limit and nothing reads it up there, so
  nothing is lost by parking at the bottom instead.

The clock

NetWhack calls pc_hunger when (gametime % TICKS_PER_TURN) == 0, because its gametime is fine-grained and ticks far faster than the player acts. Rogueima's game_time already counts player turns and only advances when one is spent, so the modulo is the identity here — main_loop just calls it once a turn.

At one point of drain per turn, a full meter is five thousand turns. That is deliberately a minor concern for now: it is there, it works, and the numbers are the thing to argue about when balance is the concern.

Eating

e eats. A ration is FoodData[2] — 500 nutrition, glyph % — and it is a row in item_table, not a branch anywhere, which is what Step 13 bought. NetWhack's Food.event_eat also heals a point when Dice.roll(1, nutrition) > 200, so a ration is a three-in-five chance of one hp back.

Disposal is the fiddly part, and it is not a REMQUE alone. make_item finds a free node by scanning the world queue for one with a zero id, so an item taken off the pack queue and put on no queue at all is leaked rather than freed. Eating therefore puts the node back on the world queue and only then zeroes the id — the same disposal pick_up_gold does, from the other end.

Verification

Five thousand turns of drain is not something you can sit through, and a complaint that fires one turn in five hundred is not something you can see by playing. test_hunger.sda calls pc_hunger directly with the meter pinned at a chosen level, which holds it inside one band of the chain, and asks whether that band says anything — by clearing the message buffer first and counting the non-zero bytes after.

drain past the bottom                  0        (floors, does not wrap)
hp lost at empty over 5000 turns       105      (1 in 50, so about 100)
messages at energy 4300                0 bytes  (above every band: silent)
messages at 4100/3300/2500/1700/900    247 bytes each

The silent row is the one that matters as much as the loud ones: it pins the EN_PECKISH boundary from above, so an inverted comparison could not pass.

And in the real loop, poked down to 30 and left to walk: energy floored at 0 and hp went 10 → 6 while starving.

A bug it turned up

pick_up_items listed the types that could be carried, so the ration was un-pickable the day it was added. That is the same shape as draw_items only ever drawing gold, found in Step 12. Both now name the one type they exclude — a new kind of item should not need a line in either place.

Step 17. Regeneration and death — HALF DONE

Regeneration is in. Mobile.per_turn()\'s healing half: one turn in a hundred you mend a point, and NetWhack\'s hunger++ beside it becomes a second point off the energy meter. That is the link that makes food matter for something other than eventually dying of it — a wound is paid for in rations.

One addition to NetWhack: it needs EN_REGEN (500) in the tank. Starving and mending at once is the single combination that lets a player wait out any wound for free, because the waiting is what heals them. Verified: 0 hit points healed over ten thousand turns below the line.

The cost is only taken when a point actually goes back. NetWhack rolls, calls heal() and does hunger++ regardless — heal() clamps, so an unwounded player pays a point of food for nothing. That is an artifact of two statements sitting next to each other, not a design.

Healing needed something to stop at, so the player has an hp_max and every path that gives points back goes through one clamping routine.

What is left: resting to pass turns safely, and a proper death — tombstone, final score, and a return to the prompt. Starvation and monsters both drop into kill_player, which prints “You died.” and returns.

Part VI — a world worth returning to

Step 18. A monster table — DONE

There used to be make_rat, make_snake and make_spider: three routines, each with its own hit-point roll and its own hard-coded glyph, and a random_monster that rolled 1..3 and branched to one of them. Adding a fourth monster meant writing a fourth routine.

Now there is one routine and a table. montable.sda is all 28 rows of NetWhack's MobData.mobdata[], with every field it carries.

Generated, not transcribed

The table is produced by tools/gen_montable.py, which reads NetWhack's Java directly — resolving monsym symbols to glyphs, ColorMap names to CGA indices, Attack/Damage constants to numbers, and dice strings like “1d2-1” to (n, sides, bonus). Twenty-eight rows of symbolic fields is exactly what gets copied wrong by hand, and NetWhack's table will change again. Re-run it; do not edit the output.

ColorMap turned out to be the standard CGA order already, so the colours came across as-is.

The record

MO_GLYPH  0   MO_AC    3   MO_MSPEED 6   MO_NATK 13
MO_COLOR  1   MO_PROB  4   MO_ASPEED 8   MO_ATK  14  (3 x 5 bytes)
MO_LEVEL  2   MO_ALIGN 5   MO_NAME  10   MO_SIZE 29

An instance keeps two things: OBJ_DATA1 is current hit points, which change, and OBJ_DATA2 is the kind, which does not. Everything else is read back out of the table from that kind, so an instance costs no more than it did.

What is wired

glyphs, and without it a dog and a wolf are the same d.

10 x level is the attack rating. getAttackRating() is

  ''10*xp_level + 3*DEX + STR + bonuses''; monsters here have no stat block,
  so the level term is what is left.
* **armour class**, added to the defence, as ''getDefenseRating()'' does.
* **probability**, and **the attacks**.

Every monster used to rate a flat 10 — which is exactly what 10 x level comes to at level 1. So the first floor plays as it always did and the deeper ones get harder, which is what importing the levels was for.

Spawning follows randomkind(plevel, zlevel): the window is (plevel + zlevel) / 6 to / 2, by rejection sampling as NetWhack does it. Floors 1 and 2 hold level-1 monsters only; by floor 9 it is anything up to level 5. There is no experience system yet, so the player's level is 1 and depth widens the window alone.

What was left behind

All of it is imported; these are the fields nothing reads yet:

1500 and a phase rat at 400 currently move at the same rate. This is the

  big one, and it is a scheduler, not a data problem — which is the point of
  importing the numbers now.
* ''MO_ALIGN'' — no alignment, no peaceful monsters, no ''Really attack?''
* ''MOA_DMGT'' — no damage types, so a salamander's fire is ordinary.
* ''MOA_TYPE'' is stored and half-used: ''do_attack'' picks **one attack at
  random** from the list, which this does, so a wererat's two attacks and a
  salamander's three already work. What is unused is the claw/bite/spit
  distinction in the message.

Row 0 is the player. Its probability is 0 so it can never be picked; it is kept so an index here is an index into MobData.

Verification

A wrong offset in a 29-byte record reads as a monster that is subtly too strong, not as a crash, so test_mon.sda asks questions with known answers:

depth 1 level window              1 .. 1
depth 10 level window             1 .. 5
salamander hit points             14 .. 57   (8d8)
sewer rat damage                  0 .. 1     (1d2-1)
battle orc damage, highest        6          (2d3)
salamander attack / defence       80 / 81    (level 8, ac 1)
wererat attacks in the table      2
orc damage over 200 real swings   804        (through do_combat)

The last one goes through do_combat rather than calling mon_damage directly, because that is where a register clobbered across a call shows up. And one did: FLD is FL:D, so the pair aliases the D register, and using D as scratch while walking a row silently overwrote the low sixteen bits of the pointer. Every field then read as zero, the code took its “no attacks” early-out, and every monster in the game did no damage at all — with no crash and no assembler warning. mon_damage uses C.

Step 19. Pathfinding

NetWhack's PathMap.java is a Dijkstra map — flood the distance-to-player across walkable tiles, and every monster moves downhill. It replaces “step toward the player” with something that goes around corners, and it costs one pass per turn rather than one search per monster.

Step 20. Save, restore, and scores

S to save and resume. The file services already exist behind INT $15. A high score table after that.

Part VII — saying it properly

Step 21. Item names in messages - DONE

Everything says “You wield it.” The item knows its name; the message does not ask. NetWhack has a small naming layer that every message goes through:

so msg.You(“wield ” + tname()) is “You wield the dagger.” We already build names for potions and scrolls; this generalises that and routes the messages through it.

Do this first. It is small, it is the difference between a prototype and a game every single turn, and everything below it wants to say something.

The message window is 19 columns and wraps mid-word, so this also wants a word-wrapping print_msg — the hand-broken strings do not survive a name being spliced into the middle of them.

Part VIII — things that do something

Step 22. Effects, as three things instead of one - DONE

Why not NetWhack's Effect class

Effect.java is the only major system in NetWhack that is not a table. There is an ArmorData, a MobData, a PotionData, a TileData and a WeaponData. There is no EffectData. Instead there is one class with twelve fields –

ticks  uses  trigger  type  expired  index  value  extra
parent  item  mobile  engine

– of which three (index, value, extra) are untyped registers whose meaning changes with the type, and four separate switch (type) statements in the same file: on_transfer, process, on_remove and changekind. One effect's behaviour is smeared across four places, and adding one means editing all four.

It is also inconsistent with itself about the same job. E_E_MODSTAT is computed on read – its on_transfer and on_remove are empty, with a comment saying it is handled in Mobile.get_attribute(). E_E_DEFENSE, three cases away, caches: eAC += value on transfer and -= value on remove. Two opposite strategies for the same job in one switch.

A table row tells you when you are finished: the row is full. An object with three general-purpose registers and no schema never does. That is what makes the design feel open-ended – there is no point at which it says done, so every new effect reopens the whole question.

It was doing three unrelated jobs

NetWhack's what it actually is
MODSTAT, DEFENSE, PRE_ATP derived state, not an event
SPEED, BLIND, TEMPORAL_SUSPENSION genuinely “for N turns, X”
EDNAS_PIES seven lines of prose – a cutscene in an effect costume

The pie is the tell. It became an Effect because Effect was the only hook available; the system attracted things that did not belong in it.

So the three are split, and each goes where the game already keeps that kind of thing.

1. Continuous modifiers: computed, never stored

attack_rating and defense_rating already walk the equipment rack adding terms up. A condition is one more term in that walk. Nothing is applied and nothing has to be un-applied.

That deletes a whole class of bug. eAC += value / eAC -= value is wrong for ever if the effect is removed twice, or if the value changes while it is worn, or if a save reorders things. A number computed on read cannot desynchronise. NetHack computes AC from worn armour every time for exactly this reason.

2. Timed conditions: a flat array of counters

cond.sda. One slot per kind, holding turns remaining – NetHack's u.uprops[]. Setting one is a store, ticking them is one loop in sched_turn, expiring one is a compare against zero. No objects, no list, no expired flag, no gc().

.equ CD_NAME   0    ; 3 bytes: what to call it
.equ CD_START  3    ; 3 bytes: what to say when it begins
.equ CD_END    6    ; 3 bytes: ... and when it wears off
.equ CD_SIZE   9
.equ C_BLIND   0    ; Effect.E_P_BLIND
.equ C_FAST    1    ; Effect.E_P_SPEED

That is the schema the Effect class never had, and a row is finished when those three pointers are filled in.

The API is four routines: cond_set(kind, turns), cond_on(kind), cond_tick() and cond_slot(kind). cond_set only says the start message when the condition was not already true, so a second potion of speed lengthens the haste rather than announcing it twice.

Read where it matters, applied nowhere

This is the part worth keeping hold of. A condition is never pushed into anything – the one place that cares asks:

square you stand on. NetWhack does mobile.blind++ on transfer and

  ''blind--'' on remove, and has to get both right for ever.
* **Haste** is asked about in ''player_charge'', where an action costs half
  the clock. NetWhack's ''E_P_SPEED'' instead does ''action_time -= value''
  every turn //from inside the effect// -- the same idea pushed the other
  way round, the effect reaching into the mobile rather than the mobile
  asking the effect.

3. One-shot moments: the item's own routine

potion_effect in food.sda: a dispatch on kind, like mon_damage and item_name already are. A potion of healing heals you at the point where you drank it, and does not need to become an object with a lifecycle first. Each potion's whole behaviour is readable in one piece.

Nutrition is not in there – every potion has some and do_quaff has already applied it, which is why a potion of water does nothing else at all.

Verification

blind on after cond_set        1      turns after 2 of 5      3
blind on after 5 ticks         0      action, normal       1000
action, hasted               500      healing, 2d6 from 1     9
poison, 2d6 from 20           17      blind from potion     250
speed from potion            100      second potion         200

and in the real game, poked blind and stepped: lit floor 194 to 0, all 195 squares still remembered in dark grey, items drawn 2 to 0. You keep the map you know and see none of it.

What is left

TEMPORAL_SUSPENSION and the pie have no slots yet; both are one row and a case when they are wanted. The nine NetWhack effect types are otherwise covered, with less machinery than the Effect class alone.

Step 23. The speed system - DONE

MobInfo carries mspeed and aspeed and has since Step 18 — a rock mole is 1500, a phase rat 400, the player 1000 — and nothing reads either. Every mobile moves once per turn, so a phase rat is exactly as quick as a rock mole.

NetWhack's own comment says how it is meant to work: “relative speed can be added by increasing a speed variable and only allowing movement when it reaches a certain value (then resetting it)”. An energy counter per mobile, topped up each turn by its speed, and it acts while it can afford to. That is also what turns gametime into the fine-grained clock its % TICKS_PER_TURN assumes — ours counts player turns because nothing needed finer.

This is the one imported field that changes how the game plays rather than how it reads, and the potion of speed has nothing to do without it.

Taken before Step 22, because half of the Effect triggers – T_PERTICK, T_PERTURN, T_PERSTEP – have nothing to fire against until a clock exists.

There is no event queue

Worth writing down, because it is easy to misremember: NetWhack has no global event list at all. No event class, nothing scheduled centrally. Engine.do_tick(m) is

m.per_tick();
m.action_time--;
if (m.action_time > 0) return;   // no energy yet
...act...
m.action_time += m.movespeed;    // and pay for it

per mobile per gametick, and effects live in m.fxlist on the mobile that owns them, processed by trigger and swept by gc(). Nothing reaches out of an item into the engine. So the counter-per-mobile is kept exactly as it is.

What is not kept: the ticking

do_tick runs for every mobile on every one of the thousand gameticks. In Java that is free. Twenty mobiles x a thousand ticks x ten instructions is 200,000 instructions a turn – about 154ms at the 1.30M instructions/sec this machine measures at, on top of the 48ms Step 11 already costs, and it grows with the monster count.

So time does not advance one unit at a time. The smallest counter is found, that much is taken off every counter at once, and whoever reaches zero acts. Identical arithmetic – 900 still acts more often than 1000 – for one pass per action instead of a thousand passes per turn. The 1000 scale is kept; the resolution lives in the arithmetic, not in the loop count.

A field, not a list

OBJ_READY is a field in each record rather than an entry in a central list, and that is the important choice. A list needs entries removed when a monster dies, a level changes, or a node is recycled – and a missed removal is a stale event pointing at whatever now occupies that node. A counter in the record dies with the record. It is also nearly free: the world queue is already walked every turn by move_mon, draw_items and pick_up_items, so this is one more comparison on a scan that was happening anyway. Only OT_MONSTER records are scanned; items on the floor do not act.

move_mon charges the monster before it moves, so every way out of the routine has been paid for. Otherwise one wedged against a wall never advances its counter and the scheduler hands it every turn for ever.

A turn is still SPD_PLAYER units of clock, so hunger, regeneration and the wandering-monster roll fire exactly as often as they did – a loop rather than an if, since one slow action can cross two turns.

Verification

The claim is a ratio, so it is counted rather than looked at. 400 scheduler steps with three actors:

player     (1000)   120 acts
phase rat   (400)   300 acts     = 120 x 1000/400
rock mole  (1500)    80 acts     = 120 x 1000/1500

and in the real loop with twenty monsters running, 97 turns left the energy meter at exactly 5000 - 97.

OBJ_LEN went 37 to 39 for the counter, so the 4 KB node array holds 105 rather than 110.

Step 24. Potions that do what they say - DONE

Healing, poison, blindness and speed, all four landed with Step 22, since potion_effect is the shape that step decided on: a dispatch on kind, one case per potion, each readable in one piece.

healing      2d6 back, clamped by heal_player
poison       2d6 off, floored at 0, and -250 nutrition from the table
blindness    C_BLIND for 250 turns
speed        C_FAST for 100 turns
water        nothing at all -- its 100 nutrition is the whole of it

Blindness did not want LOS_RADIUS set to 0, as this step guessed before it was written. los_update asks cond_on(C_BLIND) and marks only the square you stand on – a temporarily-modified global would have to be put back, and putting things back is the failure mode Step 22 exists to avoid.

Step 25. Scrolls, and ''r'' to read - DONE

Scroll.event_read, in the shape Step 22 settled on: a dispatch on kind, one case per scroll, each readable in one piece.

scroll what it does
identify learn what one thing in your pack is
teleport somewhere else on this level
town portal says so – there is no town until Step 30
crumpled note reads it, and does not vanish
temporal suspension everything else waits 5 to 20 turns

Reading identifies the scroll, so the message uses the name it had before you read it: “You read the scroll of gibberish.” NetWhack only self-identifies the teleport one, inside do_teleport, which looks like an omission rather than a decision – you plainly learn what a scroll was by watching what it did.

Temporal suspension is not a condition

NetWhack does action_time -= Dice.roll(5,20) * 1000, giving the player credit so that everyone else has to wait. Our counters are unsigned and count down, so the same thing is expressed from the other side: everybody else is pushed back by that much. Identical in effect, and it cannot go negative.

It also must not be a condition that makes the player's actions free. sched_find would then return zero for ever, so the clock would never advance, so sched_turn would never fire, so cond_tick would never run – and the suspension would never end. A condition has to be something the clock can outlive.

A bug inherited and not copied

sc_identify does what NetWhack's do_id meant to do. That one builds its list of candidates with

if (i.identified == false);
    a_list.add(i);

– a stray semicolon, so the add is unconditional and the scroll can spend itself telling you about something you already knew.

Verification

unknown kinds in the pack     2 -> 1
teleport moved the player     yes, and to somewhere walkable (3 runs)
clock added by suspension     8000 / 18000 / 11000

Two hazards of the assembler, found the hard way

LDBL AL and LDAL XL are not register moves, and the assembler takes both without a word. The first made identify choose nothing; the second stored garbage into PX and PY, so teleport put the player inside a wall – and only showed up because the test asked whether the destination was walkable rather than only whether he had moved. MOV is the register move; LDxx loads an immediate or from memory.

Step 26. Blessed, cursed, uncursed

Three bits per item and bcstatus() in front of the name. Cursed armour that will not come off is the first thing in the game that can go wrong in an interesting way.

Part IX — a world

Step 27. Tiles store their kind - DONE

A square used to store the character it looked like. It stores its kind now, and the glyph is one column of a generated table along with the colour, the flags, the name and the description. Same two bytes per square.

Why this had to come before the village

TileData draws chair, bridge, bed, road, table and throne all as =, and wall, secret door and altar **all as ''#''. A map that stores glyphs cannot tell a bridge from a chair – so it cannot say whether you may walk on it, what colour to draw it, or what it is called when you look at it.

The map SOURCE alphabet is a different thing and is unambiguous: r road, B shop counter, d bed, f flowers, b bridge, = chair, s secret door, * water, T tree. That is why a level can be written as text at all. src_to_kind is DungeonMaker's switch, one character to one kind; only the DISPLAY collides.

The table

tiletable.sda, generated by tools/gen_tiletable.py:

.equ TI_GLYPH  0    ; 1 byte : what it is drawn as
.equ TI_COLOR  1    ; 1 byte : and in what colour
.equ TI_FLAGS  2    ; 1 byte : TF_WALKABLE | TF_OPAQUE
.equ TI_TNAME  3    ; 3 bytes: -> its name, for looking at it
.equ TI_DESC   6    ; 3 bytes: -> the long description. ON TAP
.equ TI_TSIZE  9

The glyph, colour, name and description come from TileData. The flags do not – walkable and vblock are set by a switch in Tile.changekind() and are not in the table at all. The generator reads both files, so the two cannot drift apart.

Two deliberate departures, both made in the generator where they are visible rather than in the data where they would look like the source:

unseen square looks like – a shop floor drawn as one would be invisible.

  Those six get stated ASCII stand-ins.
* A **wall is light grey**, not ''TileData'''s ''DARK_GRAY'', which is
  exactly the colour a remembered square is drawn in. A secret door matches
  the wall it is pretending to be.

What it cost

tile_flags_for used to test for # and + and call everything else floor – as far as two glyphs could take it. get_glyph looks the glyph up; put_glyph became put_kind; draw_world, open_door, place_stairs, expand_level and the whole dungeon maker read and write kinds. tiletable.sda has to be assembled before map.sda, because .equ has no forward references and TK_TEMP is derived from TK_COUNT.

And Brynn

The village is the first level: Brynn.java, 84 x 30, verbatim but for trimming each row to the 84 columns it declares – the Java rows are one character longer. popfreq 0, because nothing wanders into a town.

Its well is not in the map text; NetWhack adds it in code. Read the code and not the comment above it: the comment says “Add the well at 24,4” and the three lines below say s.xpos = 4; s.ypos = 28. Lower left. That is the level's LV_DOWN.

Where does NetWhack put the player? Nowhere

This is worth writing down because it cannot be found by looking for it. Level.java declares

public int px_last = 0, py_last = 0;

and line 519 does pc.xpos = px_last. Brynn never assigns either. So the answer is the field initialiser: NetWhack starts you in the top-left corner. Ours starts on the road at 2,6, which is a decision rather than an accident.

That needed a new field. LV_UP was doing two jobs – where the stairs up are, and where you appear – and Brynn has no stairs up, so the two had to come apart. LV_START is where a new game begins; LV_SIZE 23 to 25.

0,0 means “no such staircase.” place_stairs skips one whose coordinates are 0,0, that being the one square no real staircase can occupy – the map corner on any level with a border. This replaced a test on level_index, which did not work: load_all_levels walks the levels with TL and never updated level_index while building, so the test read a stale value. (It does now.)

Checked on the tile array rather than by eye: exactly one staircase on Brynn, kind 7 at (4,28), and no kind 6 anywhere.

The 80 x 40 scratch room is gone, and so are the starting dagger, armour and ration – scaffolding for testing the item system, which the shops will replace. x still conjures one item from the whole table.

Trees draw green, water blue, roads brown, flowers grey, each in a lit and a remembered shade. None of that was expressible before.

What Brynn still has not got

The map only. The DENIZEN and SHOPKEEPER lines that follow it in the Java are Step 28, and the people they describe are Steps 31 and 32. The village is a place; it is not yet inhabited.

Step 28. A level script, and people who answer questions - DONE

A level's map text is now followed by directives:

DENIZEN Farmer_Jim 19 17
CHAT  The_apples_look_lovely_this_year.
REPLY job   I_work_the_orchard._Apples,_mostly._It's_a_living.
REPLY name  I'm_Jim._Farmer_Jim,_on_account_of_the_farm.

DENIZEN, its inline chats and NOMONSTERS are NetWhack's, read the way DungeonMaker reads them – split on spaces, _ standing in for a space inside a token – so Brynn's own four denizens parse as written. CHAT and REPLY are ours.

Why a directive and not punctuation

job:I'm_a_farmer reads well and would work. Split on the first colon and the answer can contain as many more as it likes, so no escape is needed for the colon at all – a keyword is one word and cannot contain one.

Two things argued against it:

backslash rule would be a second one to learn, and every directive added

  later would inherit both.
* Inferring the //kind// of speech from a punctuation mark cannot tell a
  keyword from a random line that happens to contain a colon.

A directive says which it is instead of leaving it to be guessed, and the next thing we want – GIVE, QUEST, SHOP – is another word rather than another mark.

Talking: Ultima IV's three cases

NetWhack has only the middle one. An NPC there holds a list of lines and says one at random; there is no interactive speech in it anywhere. The other two are Rogueima's.

case what happens
nothing to say “They do not seem to want to talk.”
random speech Farmer Jim says, “The apples look lovely this year.”
keyword replies a page of its own, and you type words at it
Farmer Jim -- and you are talking to them.
  I work the orchard. Apples, mostly. It's a living.
  Ask about a subject -- try NAME, or JOB. BYE to stop.
  Say:

An unknown word gets “They shrug, and say nothing about that.”; BYE or an empty line ends it. Matching folds case, so JOB and job are the same question.

npc_mode makes the three-way choice a value rather than a shape of code, so it can be checked without reading a screen. Replies win over chats: somebody who will hold a conversation should not be reduced to muttering one line at you.

Nothing copies text

The script stays in the program image and an NPC holds pointers into it. print_script turns the underscores back into spaces on the way out, rather than rewriting the source the way replace('_',' ') does – so a level can be built twice without its script having been consumed. A keyword ends at the space that follows it in the script, since it has no terminator of its own.

Verification

denizens created          4        chat lines             21
keyword replies          20        "JOB" finds an answer   yes
"wombat" does not       yes        modes: 2, then 1, then 0

The three modes are read off one denizen stripped in stages: with both, it converses; with its replies taken away it falls back to random; with its chats gone too it has nothing to say.

An ordering bug

init_objects and npc_init ran after load_all_levels, so the script put four denizens into the node array and init_objects immediately emptied it again – npc_count read 0 and the village was deserted. Everything a level writes into has to exist before the level is built.

Step 29. Branches, and stairs that know where they go

The dungeon is not one stack. A staircase carries a destination branch:

Brynn --(the well)--> BrynnWell --> LCave
                                --> BDungeon --(depth 9)--> Croky Castle

LevelFactory.createlevel takes a branch name and a depth and dispatches on it; LevelLibrary keeps the levels already built so going back finds them as you left them. Our level_table is a flat list of five and will need to become that.

Step 30. The village of Brynn

Depth 0, popfreq 0 — no wandering monsters, which is what a town is. The well in the middle is a staircase into BrynnWell. Needs Steps 27, 28 and 29 first, and then it is mostly data.

Step 31. NPCs and chat

mobile/NPC.java. A denizen has a name, a position and a list of things to say; t picks one at random. Fortune supplies a rumour when the script says RANDOM. Rufus the dog lives here too — the first mobile that is neither the player nor an enemy.

The four denizens of Brynn are how the sunsword quest is told: Farmer Jim, the Mayor, Edna and Father Monoly each know a piece of it.

Step 32. Shopkeepers

engine/Shop.java. Buy, sell, and a shopkeeper who objects when you leave with something you have not paid for. The paged list it uses is item_pagedisplaythe screen we already built in Step 12's paging, so this is the shop logic and not the shop interface. IT_VALUE has been sitting in the item table unread since the import, waiting for exactly this.

Part X — the quest

Step 33. Croky Castle

The goal level, and the only one with no stairs down. Reached from BDungeon depth 9, but only while the sunsword has not been found.

Step 34. The sunsword

WeaponData[15], probability 0 so it is never generated at random — it is placed. Wielding it sets GameFlags.has_sunsword, says “The sunsword seems to glow and gleam with an unearthly light!”, and from then on it attracts monsters: Engine.gametick pops an extra one every hundred turns with “You've got a bad feeling about this…”

The first unique object in the game, and the first item whose being carried changes the rules.

Step 35. The endgame

Carry it back to the surface. Engine checks, every time you take a staircase, whether you have the sunsword and are standing in Start — and if so, +1000 and “You have escaped the dungeons of doom!” That is the win condition, and it is four lines. It needs Step 17's death screen to exist, because winning and dying print the same tombstone.

Part XI — depth, once it is a game

Step 36. Traps

Six: trapdoor, bear trap, teleport, dart, sleeping gas, rust. A tile flag, a kind, and a hidden bit.

Step 37. Search, and secret doors

s, and TileData.SECDOOR. BrynnWell was hand-converted with its secret doors turned into ordinary ones back in Step 8 because there was no search command; this is the step that lets them go back.

Step 38. Experience, and the stat block

Stats: STR, DEX, INT. The ratings are already the right shape and simply have the terms missing — getAttackRating() is 10*xp_level + 3*DEX + STR + bonuses, and ours is player_str standing in for all of it. Killing things should raise a level, and the monster spawn window already reads plevel and has been given a hard-coded 1 since Step 18.

Step 39. LCave, and a third kind of level

The cave generator, skipped in Step 9. Gives BrynnWell somewhere to branch to that is not more of the same.

Step 40. Saving

S to save and resume, and ObjSaver for the shape of it. The file services already exist behind INT $15. The queues make this harder than it looks: what is saved is a graph of pointers into a fixed node array, so it saves as indices or not at all.

Step 41. The rest of the flavour

Fortune rumours, Edna's pies, the dogfood, the well, the altar and the throne. None of it is systems work; all of it is what makes the place feel like somewhere rather than a grid.

If only three steps get done

Steps 2, 7 and 11 — eight-way movement, stairs to a second level, and line of sight. That is the difference between a demo and a roguelike, and none of the three is large.

And if only three MORE get done

Steps 21, 22 and 29 — names in messages, the effects hook, and branching stairs. The first makes every turn read like a game; the second is what every consumable in the table is waiting for; the third is the shape the whole rest of the world hangs off.