One record shape for everything
The player and every monster share one record. The first eight bytes describe the thing itself and mean the same for both; what the second eight hold depends on which kind it is.
OffsetEvery record
+$00sprite, which also selects the handler
+$01room
+$02a flag
+$03, +$04x and y — the point its feet stand on, since sprites are drawn upwards
+$05drawing mode: top three bits pick the drawing routine, low two how the sprite combines with the screen, bit 6 a doorway's facing
For a monster the rest is how it moves — +$08 and +$09 are x and y velocity, nudged one step at a time towards a limit of plus or minus 2, and +$0D and +$0F are countdowns. For the player the rest is the weapon it has in flight: +$08 its type ($00 when there is nothing in the air), +$09 its room, +$0B and +$0C its position and +$0E its signed velocity. The same offsets, read two different ways depending on whose record it is. The title screen's pictures and the scratch records the interface is drawn from are eight bytes only — the common header and nothing after it. A door, or a pair of pieces of furniture, is one sixteen-byte record whose two eight-byte halves stand in the two rooms it belongs to; for those, +$06 and +$07 are a walk box (see TEST_ROOM_BOXES). The player's record is at 0xEA90 and the monsters occupy eight from 0xEE60 to 0xEED0. None of it appears in this disassembly: it is all above the loaded block, so it is runtime state.
One loop, three tables
MAIN_LOOP is the whole of the game's structure. It walks two tables of records and the player's room's list, and hands each record to the dispatcher; nothing sits above it.
TableRecordWhat is in itDispatched
$EA90-$EAA78 bytesthe player, its weapon, the sound slotonce per frame, by FRAME_TICK
$EAA8-$EE5F8 bytesobjects, collectables, soundsonly if in the player's room
$EE60-$EEDF16 bytesthe monstersevery pass, wherever they are
$EEE0 up16 bytes, a half per roomdoors and furnitureonly through the player's room's list, from ROOM_LIST_PASS
The first of those four is not walked by the loop itself. FRAME_TICK does it, and only when the ROM's frame counter has moved on, so the player and its weapon are updated at a steady 50Hz while everything below them is dispatched as fast as the loop can go — about 23 passes a second in the starting room, measured in the simulator. A crowded room slows the monsters down; it does not slow the player. Monsters being dispatched without a room test is what keeps the castle alive behind you: they go on moving through rooms you cannot see, and are where you left them when you come back. Two instructions at the top of the loop carry more weight than their size suggests. LD SP,$5E00 resets the stack on every pass, so no handler has to leave it as it found it — which is what lets a routine like LOSE_FOOD_SIXTEEN abandon its caller and jump straight to the death routine when the life force runs out. And the EI beside it is where the DI from the entry point is finally lifted; everything before this runs with interrupts off. The dispatch itself is a jump, not a call. The loop puts the address to come back to in HL, and DISPATCH_ACTOR pushes it before jumping to the handler, so the handler's own RET lands back in the loop.
There is no switch statement
Each actor's sprite byte is an index into a 202-entry table of handler addresses, and DISPATCH_ACTOR jumps to whichever it finds:
LD HL,ACTOR_HANDLERS
LD C,(IX+$00)      ; the sprite byte doubles as the type
SLA C / RL B       ; two bytes per entry
ADD HL,BC
LD A,(HL) / INC HL / LD H,(HL) / LD L,A
JP $5CB0           ; the JP (HL) the tape poked into place
That is why so many of the per-creature routines have nothing at all referencing them: they are reachable only through a table which, to a disassembler, is data.
What the sprite codes mean
Entries run in groups, because the low bits of the sprite byte are the animation frame rather than part of the identity. The first three groups are the playable characters, sixteen codes each:
Sprite codesHandlerWhat it is
$01-$10UPDATE_KNIGHTthe knight
$11-$20UPDATE_WIZARDthe wizard
$21-$30UPDATE_SERFthe serf
$4C-$4DMOVE_ACTORthe pumpkin
$5C-$5DMOVE_ACTORthe spider — the same routine, a different creature
$66, $67MATERIALISING, DYINGthe player rising and sinking
This is what makes the threshold in CHECK_HIT meaningful: codes below $31 are exactly the three playable characters, so "non-zero and below $31" is a test for "the player is currently in play". Dying does not move or hide the player — it changes this one byte to $67, and the dispatcher does the rest, sinking them into the floor and raising them again as $66. Both are outside the player band, so the same test that finds a player also gives the invulnerability while they sink and rise, without a line of code anywhere saying so.
What is in a room
A room's furniture is not searched for. ROOM_CONTENTS holds one pointer per room to a $0000-terminated list of the records that belong to it, and the main loop walks that list on every pass, from ROOM_LIST_PASS, dispatching each record through DISPATCH_FROM_LIST; TEST_ROOM_BOXES walks it too, twice a frame, to test the player's step against each door's and table's box. Room $00's list names $EEE0, $EEF0 and $EF00, which are the three door records a running game really does have there. The addresses in the lists are stored with ROOM_CONTENTS added to them, so each has to have it taken off again. That looks like obfuscation and is not: BC still holds ROOM_CONTENTS from the lookup two instructions earlier, so both the bias and its removal are free. It is the same habit as the tile source at $5E01 and the sprite bases at FURNITURE_SPRITES — a pointer's base made to carry information rather than a separate field doing it.
A door is two records
Every door exists twice, once in each room it joins, and the two halves are put eight bytes apart on purpose. Finding the far side is then one instruction:
LD A,L / XOR $08 / LD L,A     ; IX now points at the other half
No pointer and no lookup. Each half carries the destination room and the position you arrive at going that way, so stepping through a door means reading the half you did not touch — which is the first thing ENTER_ROOM does. The open-or-shut state is bit 3 of the drawing mode, and OPEN_DOOR and SHUT_DOOR always set or clear it on both halves at once, so the two sides can never disagree. A locked door is not a separate kind of thing: it is an ordinary door whose handler shuts it again unless the player is carrying a key of the matching colour — and once the player has walked through it, the handler turns both halves into a plain door for good. The key is not used up.
Doors that know who you are
Three kinds of door will only open for one character: the grandfather clock (type $10, graphic $B1) for the knight, the bookcase ($17, $B8) for the wizard and the barrel ($1A, $BB) for the serf. The test costs four instructions and no extra state, because a character's identity is already in the player's sprite byte -- each owns sixteen consecutive codes, so subtracting a character's first code and asking whether what is left is under $10 answers "is the player this one".
LD A,($EA90)     ; the player's sprite
SUB $21          ; the serf's first code -- or $11, or 1
CP $10           ; sixteen codes per character
JR NC,shut
Fail it and the door is still drawn; it simply does not open. The same three lines with a different constant give three different doors, which is the whole implementation.
Controls
READ_CONTROLS returns one byte for all three control methods, in Kempston's bit order — bit 0 right, 1 left, 2 down, 3 up, 4 fire, a 0 bit meaning pressed. The keyboard needs its bits shuffled to get there, because Q and W sit in the opposite order to left and right, and that shuffle is what lets the movement code be written once instead of three times. The keys are Q left, W right, E down, R up and T fire.
How this was worked out
The code/data split is not a heuristic. The build plays the game in SkoolKit's Z80 simulator, once per character and control method, and records the address of every instruction actually executed; anything it never reached stays marked as data, so unreached code appears as DEFBs rather than as invented instructions. That leaves a gap, and it is worth knowing how it is closed. Code the playthroughs never ran stays marked as data -- the collectables handler at PICK_UP is one, because mashing keys at random never walks the player onto an object. But those routines are not unreachable: they are entries in ACTOR_HANDLERS or in one of the sprite-drawer tables, which is proof they are code even though nothing executed them. Checking every entry in those tables against the block map found fifteen such addresses, and declaring them code recovered about 550 bytes of readable disassembly. The declaration is not taken on trust: if any one of them were not really code, the round trip below would stop reassembling the game byte for byte. The comments were then checked against a running emulator — breaking on a routine and reading its registers and memory back — which is how the record layout, the handler table and the key map above were each confirmed rather than guessed. As a correctness check the generated assembly is fed back through sjasmplus and compared against the decrypted memory it came from. All 30208 bytes match byte for byte, and the build fails if they ever stop matching.
Eight ways to draw a sprite
There are eight drawing routines in each of the two families, and the reason is that a piece of furniture is only ever stored once. They are the eight ways to lay a rectangle down — as stored, mirrored, upside down, a half turn, and the same four turned through a right angle — and the top three bits of a door's or a piece of furniture's +$05 choose between them. A door's mode is its wall, which is how one door picture serves all four walls. Mirroring costs two changes to the copying loop: the row is walked with DEC DE instead of INC DE, and every byte passes through REVERSE_BITS, which rolls its eight bits out of one register and into another in the opposite order. Turning a picture over costs one call before the loop starts, to move the data pointer to the last row. Turning it through a right angle (DRAW_TURNED_RIGHT and its three relatives) builds each screen byte a bit at a time from one bit column of eight successive rows. Creatures do not use this: each heading of a character or creature is its own picture, and +$05 is simply its colour. It is the same economy as the room outlines, where three shapes share an edge list and differ only in their vertices.
Sounds are creatures too
There is no sound scheduler. A sound effect is spawned into a record like any other actor, with one of the sprite codes that draws nothing -- $64, $65 or $A0 -- and a frame count where a creature would keep its room number. The dispatcher then finds it every frame and calls its handler, which beeps once, counts down, and zeroes its own sprite when it reaches nothing, at which point the dispatcher stops finding it. That is what lets a note slide: each handler works its pitch out from however much of the countdown is left, so a sound sweeps over the frames it lasts without anything having to remember where it had got to. It also explains why the handler table has entries that draw no picture. They are not gaps -- they are sounds.
The game writes its own instructions
Four separate places assemble an instruction at run time rather than branching, and they are four different tricks rather than one habit applied four times.
WhereWhat is writtenWhy
MARK_ROOM_VISITEDthe operand of SET b,(HL)the bit number is a room number, not known until it runs
DRAW_OUTLINEthe displacement in LD r,(IX+d)the vertex index comes out of the room's edge list
BLIT_SPRITENOP, OR (HL), XOR (HL) or AND (HL)how a sprite meets the screen, decided once instead of per byte
SETUP_SPRITE_DRAWthe displacement of a JRpicks how far down an unrolled chain of shifts to enter, which positions a sprite to the pixel
The last is the neatest. A sprite is two bytes wide but lands at a pixel x, so it has to be shifted; instead of storing eight pre-shifted copies or running a loop with a counter, SHIFT_CHAIN holds seven copies of ADD HL,HL / ADC A,A and the patched jump lands part-way down it. Entering n pairs in performs 7 - n shifts, with no counter and no branch.