This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
NextHack is a NetHack-inspired roguelike written in C, built with z88dk (the
zsdcc/SDCC compiler) and tested in the ZEsarUX emulator. It is a fresh
engine on NetHack's design, not a recompile of NetHack's source — sized to fit
the Z80.
One codebase builds two targets: the ZX Spectrum Next (Z80N, +zxn —
hardware tilemap, 4bpp colour tiles, Layer 2 art, banked through the MMU) and the
plain ZX Spectrum 128K (+zx — ULA bitmap, 1-bit UDGs, a 32-column viewport
scrolling over the 80-wide map, banked through port 0x7FFD). They share all
game logic; only the platform/render layer differs, selected at compile time by
#ifdef __ZXNEXT. A change is not done until it is verified on both — most
of the gotchas below are one target behaving differently from the other.
Project status & roadmap: see CHANGELOG.md (one entry per release) and
git log --oneline — each commit is one phase. 1.0.0 shipped 2026-07-31, so
the line is 1.x.y under SemVer now; the 0.x.y tags are the pre-1.0 history. Start
a session from this folder so this file is auto-loaded.
The z88dk SDK and the ZEsarUX emulator live one directory up (..\z88dk,
..\ZEsarUX); they are not in this repo. The build needs a nightly z88dk
(..\z88dk, the v2.5-dev line, 2026-07 or newer; zsdcc base SDCC 4.5.0) for
its __banked trampoline — the game is code-banked (see Memory budget).
Verified: a nightly swap-in rebuilds clean with identical resident sizes (small
codegen drift in the banked pages is normal). Build/run are scripts run from
this folder:
build.bat REM builds the whole Next game (forwards to build.ps1)
build.bat foo.c REM builds a single .c file -> foo.nex
run-next.bat REM runs nexthack.nex in ZEsarUX (Next; esxDOS auto-mounted, so 'S' save works)
run-next.bat foo.nex REM runs a specific .nex
run-zx128.bat REM runs nexthack128.tap in ZEsarUX (128K; auto-loads the tape).\build.ps1 # incremental + parallel Next build -> nexthack.nex (preferred)
.\build.ps1 -Clean # force a full rebuild
.\build-zx128.ps1 # builds the ZX 128K target -> nexthack128.tapBoth targets run in ZEsarUX (..\ZEsarUX, ZRCP on TCP :10000) -- CSpect and
the SD-image dance are gone. The 128K MUST be the .tap: appmake's .sna
boots the resident title but crashes on the first banked call (it doesn't carry
the code-banked RAM banks). ZEsarUX auto-mounts esxDOS onto the .nex's folder, so
save/restore works with no SD image (the old run-sd.bat, now removed).
The scripts set ZCCCFG/PATH (to ..\z88dk), compile each .c to a .o and
then link:
zcc +zxn -clib=sdcc_iy -SO3 --max-allocs-per-node200000 -pragma-include:zpragma.inc <bank flags> -c src/foo.c -o src/foo.o
zcc +zxn -subtype=nex -vn -clib=sdcc_iy -startup=1 -pragma-include:zpragma.inc -m <objs> -o nexthack -create-app
zpragma.inc (REGISTER_SP=0xBFF0, CRT_APPEND_MMAP=1, CLIB_BANKING_SEGMENT=3)
and mmap.inc (the PAGE_20/22/26/28_CODE section ORGs) drive the banking;
<bank flags> are the per-module --codeseg/--constseg read from banks.json
(below). build.ps1 skips untouched modules and parallelises across cores:
clean ~75s, one-module edit ~25s, no-op ~2s. build.bat forwards the full build
to it — per-module segment flags mean the old single zcc pass can no longer
express the banking — and keeps its own single-file build.bat foo.c mode.
Which bank a module's code and consts go in is declared once per target
in banks.json at the repo root, with the reason for each placement. banks.ps1
turns it into zcc's --codeseg/--constseg, and both build scripts read it. The
sources therefore carry no #pragma codeseg/constseg — an in-file pragma
silently overrides the command line (verified), so re-adding one takes that
module out of the manifest's control.
- Moving a module between banks is one edit in
banks.json. A.segstamp written next to each.orecords the flags it was built with, so exactly the re-banked module recompiles (~25 s), not the tree. - No
code/constkey = resident.codealone banks the code and leaves the string/const literals resident; addingconstbanks those too. - The build refuses an undeclared module (it would land resident and silently
eat stack-floor headroom) and checks the
colocategroups: modules that hand consts or literals to each other's code must share one bank. That is the one bank mistake no size check can catch — it compiles, links, passes the 16 KB guards, then reads garbage at runtime with the bank paged out.
When adding a new .c module, put it in src/, add it to $srcs in build.ps1
and/or $csrcs in build-zx128.ps1, declare it in banks.json for each target
that builds it, and decide resident vs banked (see Memory budget).
banks.json made moving a module one edit; bankpack.py decides which bank
instead of choosing by hand under pressure (that is how the 128K got two banks at
26 and 42 free bytes while two others held 21 KB between them). It reads the
.o files with z88dk-z80nm, so every size is what the linker will actually
place — build first; a missing .o makes it refuse rather than estimate.
python tools/bankpack.py plan zx128 --grow monster_ai=2000measure— per-module banked footprint.report— the current packing + slack.plan— the fewest moves that make everything fit (each move costs a recompile and a fresh emulator verification, so minimising moves beats minimising banks).--grow mod=BYTESasks the real question: a bank is about to overflow, what is the smallest fix?--free BANK_4asks the other one: give that bank headroom now, before it overflows.--consolidatepacks into the fewest banks and prints the churn it costs.compressranks every module by how well its banked bytes pack, using the game's own LZ. A hint, not a plan: only data read once into a destination that already exists can spend it — code never can, so a well-packingcode onlyrow is a mirage. It is what found the templates at 13%.- On the 128K it also knows the contended banks (1/3/5/7, where the ULA steals
cycles):
banks.jsonmarks per-turn modules"hot": trueand a hot unit is steered to an uncontended bank when one has room. A preference, not a rule —nexthackitself sits in contended BANK_3 because its group has nowhere else. apply— writes that plan intobanks.json, and is the only subcommand that writes anything. The edit is surgical (one line per moved module), so the hand-writtenwhytext survives — which the tool then tells you to fix, since it now describes the old bank. Rebuild and re-verify in the emulator after applying: every address in the touched banks moves, so a latent bank-discipline bug surfaces there and nowhere else.tools/mktap128.pyderives the tape's bank blocks frombanks.jsontoo, so a repack that empties a bank drops that block by itself. The two failure modes it closes are opposite: a bank holding code but missing from the tape is absent at runtime (the first__bankedcall into it crashes), and a bank on the tape that no module was assigned to has no.binfor the loader to find.- Colocate groups are packed as one indivisible unit, including groups that share
a module: on the Next,
nexthack+classes+spellsandnexthack+the two Layer 2 palettes overlap, so all five move together or not at all.
What it found (2026-08-30), and what was done about it. Nothing was badly
packed: everything fitted, and the binding constraint was not free space. The wall
was the nexthack colocate group — one indivisible 16358 B unit on the 128K,
26 B under a full bank. No packing can help a unit that already sits alone in its
bank, so the only lever left was the other one: nexthack_lvl.c was split out of
nexthack.c (2026-08-31), taking the cold level half. BANK_3 went 26 B → 2229 B
free and PAGE_22 414 B → 2592 B. On the Next the new module took its own bank 15
(PAGE_30) instead of PAGE_20, which it would have squeezed to 344 B — the Next has
spare banks, so the split buys headroom on both targets instead of moving the
shortage from one bank to another. That is the shape of the next such fix too:
when a colocate group fills a bank, split a module or break the group (reach the
data through a __banked accessor instead of a pointer); never relocate.
item.c was the second (2026-09-21): alone in its bank at 163 B (Next) and
240 B (128K), it gave up the use-verbs to item_use.c and went to 4376 / 4465 B
free. It is the first split that needed the accessor half of that rule. Its
catalogue is const-banked in item.c's bank, so item_use.c may never hold a
pointer into it: what crosses is values -- item_obj_prop(otyp),
item_obj_cls(otyp), a slot, a class char. The item menu is the instructive
case. Its callers passed their prompt as a string, which after the split would
have been a pointer into item_use.c's bank read with item.c's mapped; since
each class had exactly one prompt, select_item(cls) now derives it itself and
only the char crosses. Two more habits worth keeping: the shared definitions
went into a private item_int.h rather than item.h (a #define inv in a
header half the game includes would rewrite any local of that name), and the
helpers item.c calls a lot got thin __banked wrappers instead of becoming
banked themselves -- a __banked call is the long trampoline form even inside
one bank, and recompute_gear alone has 14 internal callers.
There are no automated behaviour tests, and CI does not add any. Verification is manual: build, then run in ZEsarUX and observe. The build agent can drive ZEsarUX itself over ZRCP (read memory, inject keys) to verify most behaviour; the human confirms the wall-clock feel (which ZRCP's CPU-pausing reads distort).
.github/workflows/checks.yml guards the invariants instead — no toolchain,
no emulator, seconds to run: the generated sources in src/ still match their
tools, banks.json still matches what the builds compile (declared modules,
intact colocate groups, real bank names), balance.py can still parse the C
tables, and the LZ streams still round-trip. Run any of them locally the same
way. What CI cannot catch is the failure this architecture actually produces --
the bank loaded garbage -- which is why a bank change is still re-verified in
the emulator.
Two skills in .claude/skills/ carry the procedures this repo keeps needing:
zrcp-verify— the emulator harness (zrcp.ps1: launch per target, symbol lookup from the.map, read/poke, key injection, message-line decode on both renderers) plus the trap list. Use it to prove any change; do not hand-roll the plumbing.bank-budget—python tools/bankmap.pyprints the resident half, every code bank's free tail and the Bank-5 tenant map with overlap detection; the skill holds the relocation procedure for a full bank. Run it before adding code, data, tiles or strings, and after any change toNTILES,FOV_SLOTSorMAXINV.
python tools/balance.py report models the difficulty curve offline (a minute,
no emulator). It exists because 1.0 shipped without the balance pass and the
questions it answers -- how much HP a fight costs at depth N, how many fights a
full HP bar buys, where runs actually die -- cannot be answered by playing.
Three rules make its numbers trustworthy, and any change to the tool must keep
them: (1) montypes[], objtypes[] and classes[] are parsed out of the C,
never retyped, and a table that stops parsing makes the tool refuse to run
rather than report stale numbers; (2) every formula carries the file:line it
mirrors (balance.py formulas prints them side by side) -- line numbers, so they
rot whenever code above them moves (by 1.4, 37 of the 40 pointed at the wrong
line); re-point them in the commit that moves the code; (3) the dice are the
game's own xorshift16 and item hashes, bit-exact. So after tuning a table,
re-running the tool measures the change immediately -- that is the point of it.
Subcommands: tables curves duel gear rest gauntlet runs sweep report
(--class, --depths, --turns, --engage, --pet, --csv). sweep exists
to show how much a conclusion depends on the three things the model has to guess
(turns per level, fraction of a level fought, whether the dog lives) -- quote it
whenever quoting a death depth. The 2026-08-29 findings are in the Armour
cliff report: defence plateaus at armor_def 7-8 by Dlvl 10 while the bite's
+eff_depth()/4 grows forever, so absorption falls from 86% of blows to zero by
Dlvl 28; no monster can kill a full-HP hero one-on-one at any depth, and
nothing converted time into HP. R (rest, rest_step in nexthack.c) answered
that the next day, and balance.py rest prices it: it mends no faster than
waiting, and every rested turn rolls a wanderer, so it pays only while an
average fight costs under wander_period / regen_period (3.5-5 HP by Co). That
holds through Dlvl 25 for every class and fails by Dlvl 30 (Wizard, Tourist) or
40 (Valkyrie, Rogue): deeper, the wanderers a rest attracts cost more HP than
it heals.
The .tap is expected to work not only on a real 128K but on a 48K + external
RAM interface (e.g. tkmem128-ii), which is how many people get 128K RAM.
Those boards implement the 0x7FFD latch but not the machine around it. Two
standing rules, both already satisfied — do not regress them:
- Never enable the shadow screen (bit 3 of
0x7FFD). An external interface physically cannot do it (the ULA's display RAM is inside the machine), so it is the one documented incompatibility of such boards. We are immune because the renderer is dirty-cell and draws to the normal ULA bitmap; the only runtime writer of the port isbanked_call.asm, whoseor 16leaves bits 3/5/6/7 clear. Bank 7 is therefore plain RAM to us (seebank-budget). - Never depend on 128K-ROM system variables. Such a board usually has no
128K ROM, so
BANKM(23388) is just the printer buffer full of garbage. The tape loader reads it to preserve the ROM-select bit and now masks it to bit 4 only: letting bit 5 through would hit the paging lock and freeze the latch before the banks finish loading. When writing0x7FFD, write the bits you need (banks 0-2, ROM 4) and force the rest to 0.
- 128K: the
.tapis the only artifact. A.sna(appmake can still make one by hand) boots the resident title then crashes on the first banked call — it doesn't carry the code-banked RAM banks — so the build no longer emits one.run-zx128.batneeds--noconfigfileor the shared.zesaruxrc(left byrun-next.bat) forces the Next machine and reloads the last.nexover the tape (see thezesarux-zrcp-debuggingmemory). - SDCC's
warning 110 ... "EVELYN the modified DOG"is a harmless peephole-optimizer message; ignore it. - SDCC
intis 16-bit. Watch for overflow;longworks but is slow. - A pointer plus a negative constant can assemble WRONG, with only a warning.
SDCC sometimes writes
p[-81]asld a,+((0xffffffaf) & 0xFF)/ld a,+((0xffffffaf) / 256), and this z80asm clamps any literal above0x7FFFFFFFto 2147483647 -- so the offset came out as -1, not -81 (and a plain(-81)/256divides signed, giving 0 for the high byte). The BFS flood read the wrong wall byte and leaked through rock until ZEsarUX caught it (2026-09-27). The assembler's warning names2147483647; both build scripts now refuse a module whose log has one. Write such offsets from a lower base pointer (base + 0/1/2, seeFLOOD_TRY) instead of a negative index. - In PowerShell,
Set-Locationdoes not change .NET's cwd:[IO.File]::ReadAllBytesneeds an absolute path.
Strict split between the Next hardware platform layer and game logic:
All source modules live in src/; the build scripts, mmap.inc and
zpragma.inc stay at the repo root (the build runs from root, where z88dk's
CRT_APPEND_MMAP looks for mmap.inc). tools/ holds the asset converters
(title art, level templates), docs/ the README images.
Modules are also split by resident vs banked (see Memory budget). Header .h
files declare the interface; the .c is resident (R) or banked (B):
| File | R/B | Responsibility |
|---|---|---|
mainentry.c |
R | main() only: the turn loop / dispatcher (CRT entry — can't be banked) |
platform.c/.h |
R | hot Next hardware: tilemap draw primitives, text/messages, keyboard, file I/O; master/inkcol palette tables |
platform_init.c |
B | one-time setup: tilemap/font/tile/palette init (tm_init) + the gfx[] tile table (const-banked) |
rng.c/.h |
R | xorshift16 PRNG + world_seed |
level.c/.h |
R | terrain buffer + the per-cell leaves terrain/walkable/tile_for; .h declares the whole level interface |
levelgen.c |
B | procedural generation + gold/item persistence (owns room table + masks) |
levelfov.c |
B | field of view + save/restore (owns the fog-of-war pool) |
leveltmpl.c |
B | loader for the hand-drawn special-level templates (generated leveltmpl_data.h, LZ-packed and const-banked beside it) |
monster.c/.h |
R | monster arrays + per-monster leaves (monster_at, mon_find, mon_tile, pick_mon); catalogue |
monster_ai.c |
B | BFS chase and combat (the per-turn half) |
monster_spawn.c |
B | the cold third: level-entry spawning + the killed-monster mask and its save (split off when monster_ai's bank filled) |
classes.c |
B | the class picker; banked including its consts and literals |
spells.c |
B | spellbooks and casting (r learns a book, Z casts) |
music.c |
B | the AY title theme — a 3-voice sequencer, not a tracker replayer; the title plays it twice through before the demo |
attract.c/.h |
B | the title's attract demo: ~15 s of magic-mapped levels walked to the stairs by a random class (see Title & victory screens) |
item.c/.h |
B | the inventory, the object catalogue and its names, equipment (wield/wear/put-on), the floor and its stashes, pick up/drop |
item_use.c |
B | the verbs that activate or consume an item: quaff (and fountains), eat (and corpses), read, throw, zap |
item_int.h |
— | item.c's internals shared with item_use.c only: obj_t, inv, the O_* ids, and the __banked value API between the two |
sfx.c/.h |
B | beeper sound effects |
titlegfx0/1/2.c, victorygfx0/1/2.c |
B | the Layer 2 title / victory images (generated): 3×16 KB framebuffer thirds const-banked into banks 16/17/18 and 19/20/21 (see Title & victory screens) |
titlepal.c, victorypal.c |
B | each image's 9-bit palette, const-banked in PAGE_22_CODE next to the code that streams it |
scr.c |
B | 128K only — blitter for the title/victory SCR screens; must sit in the same bank as their consts |
title_scr.c, victory_scr.c |
B | 128K only — the generated 6912 B SCR images (tools/png2scr.py), banked consts |
puttile_asm.asm |
R | 128K only — the ULA cell blits in hand-written Z80 |
esxdetect.asm |
R | 128K only — probes for a DivMMC/esxDOS before a save is attempted |
banked_call.asm |
R | 128K only — the vendored banking trampoline (the Next uses the SDK's) |
nexthack.c/.h |
B | game-state globals (resident DATA) + rendering, turn step, screens; .h declares its __banked entry points for mainentry.c (and save.c's) |
save.c |
B | save & restore, split off it (2026-09-27): the file format, its length+sum trailer, the two-pass load and the prompt for a save that cannot be loaded |
nexthack_lvl.c |
B | the cold level half split off it: build_level, the altar/fountain/pet/follower placement, and the stairs |
game.h |
— | shared player/run state (externs defined in nexthack.c) |
Shared mutable state (hero_x/y, dlvl, php, gold, ac, xp, nutrition, …)
is declared extern in game.h and defined once in nexthack.c (as resident
DATA — banked code's data is resident too). Modules include game.h to read/write it.
- Renders on the Next hardware tilemap, 80×32 chars (
TM_W×TM_H). The ULA layer is disabled (NextReg 0x68); only the tilemap shows. Runs at 28 MHz (NextReg 0x07). - Tile/map data lives in Bank 5 (fixed at CPU
0x4000-0x7FFF, free because the program is at0x8000+): tile definitions at0x4000, tilemap at0x6000(NextReg 0x6F/0x6E). Tile definitions must stay below0x5C00(NextZXOS sysvars). - Tiles 0..127 = ROM font (expanded from
0x3C00), used for text and coloured per cell via the attribute's palette offset (see palette below). Tiles 128+ (T_*inplatform.h) are 4bpp colour graphic tiles (gfx[]inplatform_init.c, const-banked) for map cells. Adding a graphic tile: add aT_*number, an entry ingfx[], and bump the count inload_gfx_tiles(). - Palette offsets (tilemap palette, NextReg 0x43=0x30):
- offset 0 (indices 0..15) = full-colour master palette for graphic tiles in view.
- offset 1 (indices 16..31) = dimmed master (channels halved) for remembered, out-of-sight terrain. Half is the dimmest a grey stays neutral on 3-3-2 colour.
- offsets 2..15 = black-paper/ink pairs for coloured text (
inkcol[]). - Gold in the status bar draws a graphic
T_DOLLARtile instead of the ROM$. That started as a workaround for CSpect, whose ROM had no$glyph; the ROM ZEsarUX loads does have one (checked), so the tile is now only a colour choice. Don't repeat the claim that the glyph is missing.
- Rendering is a single full redraw per turn (
draw_mapinnexthack.c) using a running tilemap pointer and inline FOV bit-tests for speed; writing each cell exactly once (rather than clear-then-fill) is what keeps it flicker-free. Status/message lines follow the same write-once-then-pad rule.
The only use of the Next's Layer 2 framebuffer (256×192, 8bpp); everything
else is the tilemap. title_screen() shows the loading image; victory_screen()
shows the win image (both in nexthack.c), then the game switches back to the
tilemap.
- The images are generated:
tools/png2layer2.py(Pillow) converts each source in itsIMAGEStable —tools/title.png→titlegfx0/1/2.c(framebuffer, row-majory*256+x, three 16 KB thirds) +titlepal.c, andtools/victory.png→victorygfx0/1/2.c+victorypal.c.python tools/png2layer2.py [name]regenerates one or all. It picks the path by source size: a 256×192 source (hand-edited final-res art) is packed pixel-exact (each pixel snapped to the nearest Next 9-bit RGB333 colour, palette = the distinct colours — no resampling); a larger render is resized to 256×192 and quantized (MAXCOVERAGE, so small saturated areas keep a slot). To edit the art, edit a 256×192 PNG in the Next palette and re-run the tool; the generated.cfiles are committed. - The thirds are const-banked (title 16/17/18, victory 19/20/21) so the
.nexloader writes them where Layer 2 reads them in place — no runtime copy, zero resident cost (banks outside the CPU window). The sharedshow_layer2(pal,bank)streams the palette (NextReg 0x43/0x40/0x44), points Layer 2 at the image's first bank (NextReg 0x12), turns the tilemap off (0x6B=0) + Layer 2 on (0x69 bit7);hide_layer2()reverses it (0x69=0, 0x6B=0xC0). Each palette must live inPAGE_22_CODE(bank 11) because the code that streams it runs from there. - The attract loop (
title_screen, both targets): the title plays the whole themeTITLE_PLAYS(2) times (music_title_wait(plays)returns 0 when it runs out), thenattract_demo()(attract.c) shows about 15 s (DEMO_FRAMES) of consecutive levels of a random world -- magic-mapped, a random class with its kit on, the dog at heel, walking to>down monster_ai's own chase field flooded from the stairs, under a blinking DEMO line -- then the art returns. The 128K times the showing by FRAMES; the Next, which has no frame clock the code trusts, by a count of the frames it waited plus a per-step estimate calibrated under ZEsarUX. A key at either begins the game and seeds the world; the demo also reads the ROM's key latch (FLAGS bit 5), so a tap during a slow redraw is not lost. The demo draws with the realbuild_level/fov_update/draw_mapon the real globals, which is safe only because the title is never reached with an unsaved run in RAM (boot, or afterS); it never callstry_move/monsters_turn/upkeep, so no mask, pack or conduct moves, and it puts backdlvl,max_dlvl,turns,hero_faceandst_blind. Since 2026-09-27main()'s fresh start is a wholenew_game(0), so a failed restore afterS(or after the demo) can no longer begin a "new" game on the saved run's depth with its gold and kill masks;hero_faceis the one value there thatnew_gamedoes not set. - Two banking gotchas this exposed: (a) a translation unit gets one const
section, so each bank's array needs its own
.c(hence three files); which bank that is comes frombanks.json, not from a pragma in the file. (b) z88dk predefinesBANK_nnsections already ORG'd at0x__C000(bank 16 = 0x20C000, i.e.(page8k<<16)|0xC000), so reference them fromconstsegbut do not re-ORGthem inmmap.inc(that errors "ORG redefined").
- The map is a
char lvl[MAPH][MAPW]grid ('.'floor,#corridor,-/|wall,+door,</>stairs,$/)/[/!/%/?/=/"items,' 'rock). Monsters are not in this buffer (they live inmonster.carrays and are drawn on top). - Persistence is deterministic, not stored maps:
gen_level()callsrng_set(level_seed(dlvl))so a given depth always regenerates identically. Player changes (gold taken, monsters killed, items picked up) are kept in tiny per-depth bitmasks and re-applied after regeneration. Do not change the order/number ofrn2()calls inside generation casually — it changes every level and can desync the persistence bit indices. build_level()(innexthack.c) orchestrates:gen_level()→ spawn monsters → apply gold/monster/item persistence. Gold/items are placed before monsters so spawning sees the same map each visit.- Special levels (Phases 22-24,
levelgen.c): certain depths are landmark levels, decided by side hashes (neverrn2, so ordinary levels stay byte-identical and persistence stays in sync).special_gen()at the top ofgen_levelfully replaces a level — the Big Room (every 11th depth) is one giant lit chamber filling the playable area (rcount=1, room inr_*[0]), and a hand-drawn template (≈1/9 of depths ≥ 3) stamps a fixed map. The treasure vault (some depths ≥ 4, in the loot block, precedence over shops) instead augments a normal level: it packs a leaf room (one door, never a through-route, holds no stairs — so filling it never splits the map) with gold + items,monster_ai.cposts tough guards in it (the first few spawns, kept ≤MAXMON), anditem.cresolves its loot at a deeper effective depth (dlvl + VAULT_DEPTH_BONUS) for richer gear.level_vault_room()/in_vault_room()expose it to the spawner and toitem.c. - Templates (Phase 24): hand-drawn 21×80 ASCII maps in
tools/templates/*.txt(with a;rooms:metadata line) are packed bytools/txt2template.pyinto the generatedsrc/leveltmpl_data.h—constgrids + room rects, const-banked with the loader (banks.jsonsays which bank;load_template()runs from that same one, so it reads the const in place). It stamps the grid intolvl[][], finds</>, and fillsr_*[]/rcountfrom the metadata so FOV lights the chambers. To add or edit a template: edit a.txt, re-run the tool; the generated.his committed. There are 6 templates (cavern/crypt/fortress/maze/temple/oracle). - The grids are LZ-packed (
tools/lztmpl.py): 8400 B of ASCII becomes 864 B, becauselz_expand()unpacks each stream straight intolvl[][]— the 1680 bytesload_templatewas going to write anyway, so the destination is free and the bank keeps the difference.leveltmplcosts 1459 B instead of 8801. That is the general rule for compressing anything here: only where the thing it unpacks into already exists and the data is read once (python tools/bankpack.py compressranks the candidates, with the same caveat). The generator refuses to emit a stream that does not round-trip, and a 6th template now costs ~170 B rather than a bank. Any early-returning special level must rely ongen_levelresettingshop_room/vault_roomat the top —special_genskips the loot block, so without that reset a special level inherits the previous level's shop/vault. - Win condition: the deepest level (
DLVL_AMULET, currently 50) has no down-stairs — the Amulet of Yendor (") sits on that cell instead. Picking it up setshas_amulet; climbing<on Dlvl 1 while carrying it setswon(victory screen, then restart). The amulet is placed without RNG (on the would-be down-stairs cell), so it cannot desync the deterministic per-depth generation. No way down may exist onAT_BOTTOMlevels (Dlvl 50 and Mine:4,game.h): no>, digging is refused, and a trap door hashes into a dart trap there. A trap door on Dlvl 50 used to drop the hero into internal level 51 = Mine:1, one climb from Dlvl 2 with the Amulet.IN_MINEShas no upper bound, so any new way to changedlvlmust keep it inside 1..54. - FOV remembers explored cells in an LRU pool (
fov_pool: theFOV_SLOTSmost recently visited levels' 1-bit-per-cell maps; entering a new level evicts and forgets the least-recently-used one) plus a recomputed-each-turnvis_nowbitmap. Visibility = the hero's room (lit on entry)- radius 1 + line-of-sight rays down corridors (walls/rock/doors are opaque, so
rooms are only revealed on entry).
draw_mapshows unseen=black, in-sight=full, seen-but-not-visible=dimmed.
- radius 1 + line-of-sight rays down corridors (walls/rock/doors are opaque, so
rooms are only revealed on entry).
- Model: NetHack-style save & quit.
Swrites the whole game tonexthack.sav(a magic+version header, the player struct, then each module's state, then a 4-byte trailer: the count and 16-bit sum of every byte before it) and returns to the title; the boot path callsload_game(), which restores and then deletes the file (no save-scumming), else starts fresh. - Nothing is trusted until the whole file is.
load_gamefirst reads the file through once (save_check) and compares length and sum with the trailer; only then does a second pass read into the live globals. A short or damaged file gets the same y/n as another version's (save_refused), and onnstays on the card. On the save sidefile_writelatches a short write andfile_closea failed flush (file_bad,platform.c); a failed save deletes its stump and reports, and the run in RAM plays on. There is no temp-file+rename: the load consumes the file, so atStime there is no older good save to protect. - The play RNG is saved (
SAVE_VER30) andbuild_levelsets it aside around the deterministic part (gen_levelreseeds per depth; the spawns that follow draw from that stream). Before, every level entry and every restore left the play dice at the same per-level point. - The current level is not saved:
build_level()regenerates it deterministically from the restoredworld_seed+ persistence bitmasks, exactly as a revisit does. Saved state =world_seed, the player globals, the inventory (item.c), and the per-depthgold_taken/item_taken/mon_deadmasks plus thefov_poolLRU fog-of-war and the genocide mask (level.c/monster.c). - Any save-format change bumps
SAVE_VER, and an older save gets the incompatible-save prompt (1.3.1): name it, ask, and leave it onn. The identification bitmap is(NUMOBJ+7)/8bytes, so appending catalogue types breaks the format every timeNUMOBJcrosses a multiple of 8. - File I/O lives in the platform layer (
file_*inplatform.c, wrapping esxDOSesx_f_*). It needs a mounted writable filesystem, whichrun-next.batgives for free: ZEsarUX auto-mounts esxDOS onto the .nex's folder, soSsaves tonexthack.savthere and the next boot restores it. (No SD image /hdfmonkey.) - Memory budget: the cheap per-level masks scale to all 50 levels (
MAXLVL = DLVL_AMULET, ~3 bytes/level), but the fog-of-war does not — it is a fixedFOV_SLOTS-entry LRU pool, so RAM is independent of dungeon depth. esxDOS itself adds ~1.5 KB of BSS (sector buffers), soFOV_SLOTS(12) is kept below the RAM max (~18) to reserve headroom for future features.
- The inventory is an
obj_t[](anotypinto theobjtypes[]catalogue, anenchenchantment, aneroerosion level and awornflag) — up toMAXINV(26, one per menu letter a..z), drawn in two columns on the inventory screen. - The floor only stores an item's class char (
)[!%?=), so generation/persistence stays untouched. The specific object (which weapon, what enchantment) is resolved when the cell is looked at or picked up, deterministically from(dlvl, x, y, world_seed)via a side hash that does not touch the RNG stream — so a floor item is always the same thing and stays in sync with the deterministic generation. Better/enchanted items appear deeper (mindepper catalogue entry). - Generation is weighted (
probper catalogue entry;resolve_otypdraws in proportion within the class). A class whose types all weigh 1 resolves exactly ash % n— weapons and armour stay that way, so the combat ladder is untouched by new types elsewhere. When you add a type, choose its weight so the existing ones keep their share, and measure it withtools/balance.py: the healing potions weigh 3 against 2 because equal weights cut their share from a third to a quarter, which alone halved the Valkyrie's wins. - Four classes wear per-game looks (
!?=/): a Fisher-Yates shuffle seeded offworld_seedwith its OWN xorshift (neverrn2, which would shift the game's stream every time an item is named). The pools hold only the word ("ruby";obj_descadds the noun), must stay at least as long as their class and at mostSHUF_MAX, and the per-class ordinal is counted, not hand-kept. A type is learned by use, by watching it work (a wand that hit something, a ring that acted —ring_noticed), or by a scroll of identify. w/Wequip the best carried weapon/armour (highestprop + ench - ero).Pasks which ring or amulet — choosing for the player would reveal an unknown ring's worth. The combat globals (weapon_dmg,armor_def,ac) are recomputed byrecompute_gear()from the worn items; only the ring of protection adds to them. The other rings are pure effects, read through the residentring_fx(RF_*bits, whose order IS the order ofO_RSLOWDIG..O_RTPORT), recomputed with the worn set and never saved.q/e/r/Puseselect_item(): silent when you carry one type, but it pops a letter menu when two different types are present, and derives its prompt from the class (pseudo-classes:'P'rings + wearable amulets,'C'wands for charging). These setacted/turnsthemselves, so a cancel or a no-op costs no turn.- Every teleport of the hero goes through
hero_teleport()(nexthack.c): the scroll, the trap, the spell and teleportitis. It is where teleport control asks; assigninghero_x/yfromlevel_random_flooranywhere else would silently bypass the ring.level_random_flooronly returns a squaretele_okaccepts -- ground, no door, nobody there -- because a room rect is FOV metadata, not a promise: the cavern/crypt/temple rects hold rock and walls, and the mines scatter pillars. Twelve refused rolls fall back to a map sweep. - Items the hero owned never just vanish.
floor_dropsays no to a taken cell or a full floor (MAXFLOOR8); for a thrown weapon or a nymph's loot usefloor_place(item_floor_placefromitem_use.c), which tries the neighbours and evicts the oldest corpse before failing -- and callers keep the object until it is down. Corpses and death loot stay on plainfloor_drop. - A cursed piece the hero has on stays on. There is no remove command, so
every way an item leaves the pack is a way out of a curse:
d(drop and sell) askscursed_on()initem.c, andtchecks the weld itself initem_use.c. A new verb that parts with an item must ask too. From BUC's arrival until the 1.4 bug hunt,ddid not, and every curse was a formality. - Any 128K list goes through
list_row(). The ULA has 23 rows under a header and the pack holds 26: the inventory and drop lists used to stop at the 23rd item, and an item menu with no stop drew below row 23, into the printer buffer and the system variables.list_rowpages with--More--. The Next's one-per-row menus run past the status bar into rows 24-31, which nothing repaints, so they end withmenu_tail_clear().
- Monster types are a table (
montypes[]: char, hp, damage, xp, min depth, tile,corr, name);spawn_level_monsters()draws from the depth-appropriate pool. HP/damage scale with depth. Acorr(corrosive) monster — the acid blob — callscorrode_worn()(initem.c) to rust the hero's armour when it bites and the wielded weapon when struck; erosion raisesobj_t.ero, capped at 3. - Genocide is a resident 8-byte mask by monster char (
mon_geno, saved with the kill masks). Its filter ispick_living()inmonster_spawn.c, which re-rolls a genocidedpick_mon()draw — NOT inside the residentpick_mon, where three expansions of the mask test cost the Next 150 B of a resident half that had 285. Every random spawn is drawn inmonster_spawn.c; a new spawn site must go throughpick_living()too. - Pathfinding is a per-turn BFS "Dijkstra map" from the hero (
compute_dist_map) over walkable cells; each monster steps to the lowest-distance neighbour. One search serves all monsters. The frontier queuebfsqis a ring of 256 (BFSQ_SIZE, uint8_t slot) -- a flood only ever holds about two distance layers, at most 67 cells on any shipped map, where the old linear 696-entry queue counted every cell ever enqueued and stopped: 101-578 walkable cells of the maze, the cavern and the Big Room never got a distance. The flood takes a mask of the monsters that will READ it this turn (who), marks their cellsTARGETand stops when the last is labelled -- every closer layer is complete by then, so the moves are exactly a whole-map field's; no reader, no flood. The eight neighbours are unrolled (FLOOD_TRY) -- the dx/dy loops kept their ints in IX memory. Measured on the 128K Big Room, a wall-boxed chaser 10 squares off: 0.9 s of flood before, ~0.25 s after (FRAMES-timed under ZEsarUX); 20 squares off, 0.5 s where the old flood never reached it at all. - Kill persistence names spawn identities, not slot numbers.
m_track(bit i: slot i holds the monster the level spawned there) gates everymon_deadwrite: keeper, pet and followers are appended above the spawns, and a wanderer or summon that moves into a dead slot clears its bit. A new spawn or kill site must keep it.
- Loop: read key (
getkey_rpt) → act → if the action took a turn,upkeep()(hunger/regen) thenmonsters_turn()→ recompute FOV → redraw → handle death. - Held keys pace via typematic repeat in
getkey_rpt(platform.c): a tap is exactly one step, a hold repeats after ~260 ms then every ~80 ms. Do NOT try to throttle within_pause— it returns immediately while any key is down. The 128K times by the FRAMES sysvar; the bare.nexruns with interrupts off (FRAMES dead), so there the bounded poll loop itself is the ~20 ms clock (RPT_GUARD). - Movement: cursor keys and vi-keys (
hjkl+yubn). Commands are NetHack-style single letters (,iwWPqerS,>/</Enter for stairs). Uppercase is not folded to lowercase (sowwield vsWwear are distinct).
- Beeper effects via z88dk's
bit_beepfx. The effects are cycle-timed for 3.5 MHz, sosfx_*drops the CPU to 3.5 MHz for the effect and restores 28 MHz after.
The game broke the 64 KB ceiling by code-banking. Layout:
- Resident (
0x8000-0xBFF0, one 16 KB bank): hot code + all data/BSS + the 512 B stack (REGISTER_SP=0xBFF0). This is the tight half. - Banked (
0xC000-0xFFFF,CLIB_BANKING_SEGMENT=3): cold code in five pages on the Next —PAGE_20/22/26/28/30_CODE(banks 10/11/13/14/15) — and six on the 128K (BANK_0/1/3/4/6/7), mapped in on demand by the z88dk__bankedtrampoline. They are not interchangeable free space:banks.jsonsays which module goes where, and some are full while others are half empty. Askbankmap.py, never guess. - Bank 5 (
0x4000-0x7FFF, always mapped): tilemap + tile defs, and its free tail holds the BFS scratchdist[]+bfsq[](0x7400-0x7C90, data-banked out of resident);0x7C90-0x8000(880 B) has been free on both targets since the queue became a ring.
The resident half is the constraint. Everything resident (code+data+BSS) must end
below 0xBDF0 — REGISTER_SP is 0xBFF0 and the stack wants the 512 B under it — or
the stack corrupts and the machine resets to BASIC.
Do not trust a number written here: run python tools/bankmap.py. It reads both
.map files and prints the resident half, every bank's free tail and the Bank-5
tenant map. Any figure in this document is a snapshot of the commit that wrote it, and
this is the one place where a stale number costs a debugging session. Its headroom
figure is measured to $BDF0 and reaches 0 exactly where the stack reserve begins,
so it is the number you can spend directly. As of 2026-09-27 the Next sat at
__BSS_END=$BCFE — 242 B — and the 128K at $B4E9, ~2.3 KB.
Adding a feature:
- New code → make it banked (there is room, though not in every bank): declare the
module in
banks.jsonand mark its entry points__banked.python tools/bankpack.py plan <target>picks the bank for you — that is what it is for. Cold/per-turn code banks freely (the trampoline cost is negligible off the per-cell path). - New resident DATA is still the scarce resource. Banked code's
staticdata — and its string/const literals (resident rodata) — stay resident, so data/text-heavy features eat the few hundred bytes fast (the shops' message strings did). Levers when it overflows: (a) const-bank read-once tables —gfx[](~3 KB, read only byload_gfx_tilesat startup) lives inplatform_init.c, whoseconstbanks.jsonputs inPAGE_20_CODE, so it sits next to its reader (which runs with that page mapped); (b) data-bank scratch arrays into Bank 5's free tail (likedist/bfsq); (c) trim strings. --max-allocs-per-node200000is load-bearing for resident code size (the SDCC allocator's thoroughness shrinks code); don't lower it.
Partitioning rules (how the split is done — see nextzxos-banking-findings memory):
- Split a mixed module into wholly-hot/wholly-cold files — the assignment is
per-module (one
banks.jsonentry per.c), and an in-file#pragma codesegdoes NOT partition by position either (it scrambles sections). A file is the unit. - A module containing
main()→ bank the module, move onlymain()to a tiny resident file (mainentry.c); the CRT jumps straight to main, so it can't be banked. - Keep the per-cell/per-move leaves resident (
platform.cdraw primitives,level.cterrain/walkable/tile_for,monster.cmonster_at/mon_find/…) so banked callers reach them by direct calls; banked entry points are__banked, intra-page static helpers stay plain. (Read-once tables likegfx[]are not leaves — bank them.) - Data shared across a split is defined in one file,
externin the other (DATA is resident regardless of which file's code is banked). - Resident modules:
mainentry.c,platform.c,level.c,monster.c,rng.c(they carry nocode/constkey). Everything else is banked —banks.jsonis the authoritative list, per target, with the reason for each placement; read it rather than a list here, which goes stale every time a bank fills up.