This is an old revision of the document! —-
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:
ELMandFLDare paired registers (EL:M,FL:D), so
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:
init_objectssaid“Write id = 0 for this record”but the
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:
- the camera clamp computed
(map_width - view_width), which underflows
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:
draw_bordersclears the screen every redraw, and the clear repaints
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:
put_glyphpreservesSEENandVISIBLE. Opening a door changes
what a square is, not whether you have been there.
- a known floor draws as
., because floor is stored as a space and a
blank square is what “never seen” looks like – lit floor was invisible.
- monsters and items are only drawn where
TF_VISIBLEis set, or you see
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:
- #
+….@.+
- #
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)
- - 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.
Step 15. Consumables and an effects hook
Food, potions, scrolls, and a small effect dispatch table they trigger. NetWhack keeps 12 of each and an Effect class; a dozen effects is plenty to start.
Part V — pressure
Step 16. Hunger
A food clock, and e to eat. This is what turns wandering into a game: it is the reason to go down rather than explore forever.
Step 17. Regeneration and death
HP returning slowly with time, resting to pass turns safely, and a proper death — tombstone, final score, and a return to the prompt rather than a hang.
Part VI — a world worth returning to
Step 18. A monster table
Replace hardcoded monsters with data: glyph, name, hit points, damage, speed, depth range, behaviour flags. NetWhack's MobData has 60 entries; a dozen would already transform the game. Spawn by depth so descending means something.
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.
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.
