On Real Iron
The last truth: does it run on a real Spectrum? Build the finished game to a tape with an auto-running loader, watch the loading stripes, and verify Gloaming behaves on the metal exactly as it did on the emulator.
Gloaming is finished — a complete game, played from a title and back again. There is one truth left, and it is the one that matters most: does it run on a real Spectrum? This unit takes the game off the emulator and onto the metal. No new mechanics — just the oldest ritual in Spectrum software: building a tape, watching the stripes, and seeing your game appear on actual hardware.
Where we start
Unit 19's finished game — the whole of Gloaming, but living as a .sna snapshot, a format only an emulator loads. We package the exact same code as a tape and load it the way a Spectrum has always loaded software.
The snapshot was a convenience
We have used .sna snapshots all along, because they are instant — the emulator pours the whole of memory into place and starts. A real Spectrum has no such shortcut. It loads from tape (or, on a Spectrum Next, from storage), through the ROM's loader, byte by byte. So to run on real iron, the finished game needs to be a tape, not a snapshot.
Building a tape
pasmonext builds one for us, complete with a tiny BASIC loader that loads the code and runs it:
# the snapshot — emulators, and the Next loads these too
pasmonext --sna gloaming.asm gloaming.sna
# a real tape image, with an auto-running BASIC loader
pasmonext --tapbas gloaming.asm gloaming.tap
--tapbas writes a .tap containing two blocks: a short BASIC program — CLEAR, LOAD ""CODE, RANDOMIZE USR 32768 — and the machine code itself. The loader auto-runs, so on a real machine you type LOAD "", press play, and the game loads and starts on its own. The entry address comes straight from our end start directive — the assembler and the loader agree on where the game begins.
Load it through the ROM and the screen tells you it is working the way Spectrum screens always have — the border filling with stripes as the bytes come in:
When the stripes stop, the border goes black and the game starts itself — the same title you built, now running from a tape instead of a snapshot:
Loading it for real
- 48K / 128K Spectrum — write the
.tapto tape (or use a DivMMC / tape-replay device),LOAD "", and watch the stripes. - Spectrum Next — copy
gloaming.tap(or the.sna) to the SD card and load it from the browser; the Next runs 48K software faithfully. This is the most approachable "real hardware" today, and the one we target for the final sign-off.
Why it behaves the same
Here is the quiet reward for building carefully. Gloaming makes no assumptions that hold only on an emulator:
- It is frame-locked —
IM 1+HALTpace it to the real 50 Hz interrupt, so it runs at exactly the right speed on any Spectrum, not "however fast the host is". - It reads the keyboard through the documented port
$FE, and makes sound through the documented beeper bit — no undocumented tricks, no exact-contention timing, no host-specific behaviour.
So what you saw on the emulator is what you get on the metal. That is not luck; it is the result of every "do it the honest way" choice across the last nineteen units. A game built on quirks surprises you on real hardware. This one does not.
The finished game, entire
This is the whole of Gloaming in one file — the title and state machine, the lamplighter and the draught, lamps and tally and lives, sound and atmosphere, win, loss, and the loop back to the start. Everything you built, from Unit 1's first attribute write to here:
; ============================================================================
; GLOAMING — Unit 19: Again
; ============================================================================
; Gloaming is a finished game — but it ends and stays ended. A real game loops:
; you finish, and it offers you another go. This unit closes that loop. Win or
; lose, a key press returns you to the title, and pressing start there builds a
; brand-new game. Title -> play -> win/lose -> title -> play, round and round.
;
; The state machine (Unit 16) makes this almost free. The WIN and LOSE states
; were dead ends that just held their screen; now they each watch for a key and
; hand control back to TITLE. The crucial part is that starting again must give
; a *fresh* game — and it does, because init_game already rebuilds everything
; from scratch: lamps unlit, three lives, the walls cold and blue, the sprites
; at their starts. "Play again" and "start" are the same act.
;
; One real-world detail: DEBOUNCE. When you win, the key you were holding to
; play is probably still down; without care it would dismiss the win screen and
; start a new game in the same instant. So on entering any screen that waits for
; a key, we set a short input_lock countdown and ignore the key until it expires
; — a beat to let go and read the screen.
; ============================================================================
org 32768
COBBLE equ %00000001
WALL equ %00001111
LAMP_ATTR equ %01000111
LAMP_UNLIT equ %00000101
LAMP_LIT equ %01000110
WALL_BIT equ 3
DRAUGHT_ATTR equ %01000101
DRAUGHT_SPEED equ 8
PIP_UNLIT equ %00101000
PIP_LIT equ %01110000
PIP_BASE equ $5800 + 12
NUM_LAMPS equ 8
LIVES equ 3
LIFE_PIP equ %01010000
LIFE_BASE equ $5800 + 28
MSG_ATTR equ %01000111
MSG_ROW equ 11
WIN_COL equ 7
LOSE_COL equ 10
TITLE_COL equ 12
PROMPT_COL equ 10
TITLE_ROW equ 8
PROMPT_ROW equ 14
CONT_ROW equ 16 ; "PRESS SPACE" under a win/lose line
FONT equ $3C00
SPEAKER equ %00010000
STATE_TITLE equ 0
STATE_PLAY equ 1
STATE_WIN equ 2
STATE_LOSE equ 3
LOCK equ 25 ; input-lock frames after entering a screen
START_COL equ 15
START_ROW equ 11
DRAUGHT_COL0 equ 18
DRAUGHT_ROW0 equ 3
KEYS_OP equ $DFFE
KEYS_Q equ $FBFE
KEYS_A equ $FDFE
KEYS_SPACE equ $7FFE
; ============================================================================
; SETUP.
; ============================================================================
start:
ld a, 0
out ($FE), a
ld a, STATE_TITLE
ld (game_state), a
call draw_title_screen ; no startup lock — nothing to debounce yet
im 1
ei
main_loop:
halt
ld a, (game_state)
cp STATE_TITLE
jr z, .do_title
cp STATE_PLAY
jr z, .do_play
call end_step ; WIN or LOSE — wait for a key, then title
jr main_loop
.do_title:
call title_step
jr main_loop
.do_play:
call play_step
jr main_loop
; ----------------------------------------------------------------------------
; title_step — after the lock, SPACE starts a fresh game.
; ----------------------------------------------------------------------------
title_step:
ld a, (input_lock)
or a
jr z, .tready
dec a
ld (input_lock), a
ret
.tready:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
ret nz
call init_game
ld a, STATE_PLAY
ld (game_state), a
ret
; ----------------------------------------------------------------------------
; end_step — WIN/LOSE: after the lock, SPACE returns to the title.
; ----------------------------------------------------------------------------
end_step:
ld a, (input_lock)
or a
jr z, .eready
dec a
ld (input_lock), a
ret
.eready:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
ret nz
call draw_title_screen
ld a, LOCK
ld (input_lock), a
ld a, STATE_TITLE
ld (game_state), a
ret
play_step:
call player_step
ld a, (game_state)
cp STATE_PLAY
ret nz
call draught_step
ld a, (game_state)
cp STATE_PLAY
ret nz
ld a, (lit_count)
cp NUM_LAMPS
ret nz
call draw_win_screen
ld a, LOCK
ld (input_lock), a
ld a, STATE_WIN
ld (game_state), a
ret
init_game:
xor a
ld (lit_count), a
ld a, LIVES
ld (lives), a
ld a, START_COL
ld (lamp_col), a
ld a, START_ROW
ld (lamp_row), a
ld a, DRAUGHT_COL0
ld (draught_col), a
ld a, DRAUGHT_ROW0
ld (draught_row), a
ld a, 1
ld (draught_dx), a
ld (draught_dy), a
ld a, DRAUGHT_SPEED
ld (draught_timer), a
call clear_bitmap
ld hl, $5800
ld de, $5801
ld (hl), COBBLE
ld bc, 767
ldir
call warm_walls
call draw_pips
call draw_lives
call draw_lamps
call save_under
call draw_lamp
call save_draught
call draw_draught
ret
warm_walls:
ld a, (lit_count)
ld e, a
ld d, 0
ld hl, wall_ramp
add hl, de
ld c, (hl)
ld hl, $5820
ld b, 32
.wt:
ld (hl), c
inc hl
djnz .wt
ld hl, $5AE0
ld b, 32
.wb:
ld (hl), c
inc hl
djnz .wb
ld hl, $5820
ld b, 23
.ws:
ld (hl), c
push hl
ld de, 31
add hl, de
ld (hl), c
pop hl
ld de, 32
add hl, de
djnz .ws
ret
beep:
di
.bcyc:
ld a, SPEAKER
out ($FE), a
ld a, c
.bd1:
dec a
jr nz, .bd1
xor a
out ($FE), a
ld a, c
.bd2:
dec a
jr nz, .bd2
djnz .bcyc
ei
ret
blip_lit:
ld b, $20
ld c, $18
jp beep
blip_snuff:
ld b, $1A
ld c, $40
jp beep
; ----------------------------------------------------------------------------
; Screens. Win and lose now invite another go.
; ----------------------------------------------------------------------------
draw_title_screen:
call clear_bitmap
ld hl, $5800
ld de, $5801
ld (hl), %00000000
ld bc, 767
ldir
ld hl, title_text
ld b, TITLE_ROW
ld c, TITLE_COL
call print_string
ld hl, prompt_text
ld b, PROMPT_ROW
ld c, PROMPT_COL
call print_string
ret
draw_win_screen:
call restore_under
ld hl, win_text
ld b, MSG_ROW
ld c, WIN_COL
call print_string
ld hl, prompt_text
ld b, CONT_ROW
ld c, PROMPT_COL
call print_string
ret
draw_lose_screen:
ld hl, $5800
ld de, $5801
ld (hl), %00000000
ld bc, 767
ldir
ld hl, lose_text
ld b, MSG_ROW
ld c, LOSE_COL
call print_string
ld hl, prompt_text
ld b, CONT_ROW
ld c, PROMPT_COL
call print_string
ret
clear_bitmap:
ld hl, $4000
ld de, $4001
ld (hl), 0
ld bc, 6143
ldir
ret
; ----------------------------------------------------------------------------
; player_step.
; ----------------------------------------------------------------------------
player_step:
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
ld bc, KEYS_OP
in a, (c)
bit 1, a
jr z, .pleft
bit 0, a
jr z, .pright
ld bc, KEYS_Q
in a, (c)
bit 0, a
jr z, .pup
ld bc, KEYS_A
in a, (c)
bit 0, a
jr z, .pdown
ret
.pleft:
ld hl, tcol
dec (hl)
jr .pmove
.pright:
ld hl, tcol
inc (hl)
jr .pmove
.pup:
ld hl, trow
dec (hl)
jr .pmove
.pdown:
ld hl, trow
inc (hl)
.pmove:
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
ld a, (tcol)
ld hl, draught_col
cp (hl)
jr nz, .pcommit
ld a, (trow)
ld hl, draught_row
cp (hl)
jr nz, .pcommit
call lose_life
ret
.pcommit:
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
ld a, (under_lamp + 8)
cp LAMP_UNLIT
jr nz, .pdrawn
ld a, LAMP_LIT
ld (under_lamp + 8), a
call light_pip
call blip_lit
call warm_walls
.pdrawn:
call draw_lamp
ret
; ----------------------------------------------------------------------------
; draught_step.
; ----------------------------------------------------------------------------
draught_step:
ld a, (draught_timer)
dec a
ld (draught_timer), a
ret nz
ld a, DRAUGHT_SPEED
ld (draught_timer), a
ld a, (draught_col)
ld b, a
ld a, (draught_dx)
add a, b
ld c, a
ld a, (draught_row)
ld b, a
call wall_at
jr z, .hok
ld a, (draught_dx)
neg
ld (draught_dx), a
.hok:
ld a, (draught_row)
ld b, a
ld a, (draught_dy)
add a, b
ld b, a
ld a, (draught_col)
ld c, a
call wall_at
jr z, .vok
ld a, (draught_dy)
neg
ld (draught_dy), a
.vok:
ld a, (draught_col)
ld b, a
ld a, (draught_dx)
add a, b
ld (dtcol), a
ld a, (draught_row)
ld b, a
ld a, (draught_dy)
add a, b
ld (dtrow), a
ld a, (dtcol)
ld hl, lamp_col
cp (hl)
jr nz, .dmove
ld a, (dtrow)
ld hl, lamp_row
cp (hl)
jr nz, .dmove
call lose_life
ret
.dmove:
call restore_draught
ld a, (dtcol)
ld (draught_col), a
ld a, (dtrow)
ld (draught_row), a
call save_draught
ld a, (under_draught + 8)
cp LAMP_LIT
jr nz, .nosnuff
ld a, LAMP_UNLIT
ld (under_draught + 8), a
call unlight_pip
call blip_snuff
call warm_walls
.nosnuff:
call draw_draught
ret
lose_life:
ld a, (lives)
dec a
ld (lives), a
ld e, a
ld d, 0
ld hl, LIFE_BASE
add hl, de
ld (hl), COBBLE
ld a, (lives)
or a
jr z, .gone
call restore_under
ld a, START_COL
ld (lamp_col), a
ld a, START_ROW
ld (lamp_row), a
call save_under
call draw_lamp
ret
.gone:
call draw_lose_screen
ld a, LOCK
ld (input_lock), a
ld a, STATE_LOSE
ld (game_state), a
ret
; ----------------------------------------------------------------------------
; print_string / print_char.
; ----------------------------------------------------------------------------
print_string:
.ps:
ld a, (hl)
cp $FF
ret z
push hl
push bc
call print_char
pop bc
pop hl
inc hl
inc c
jr .ps
print_char:
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
ld de, FONT
add hl, de
ex de, hl
push de
call attr_addr_cr
ld (hl), MSG_ATTR
call scr_addr_cr
pop de
ld b, 8
.pc:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .pc
ret
; ----------------------------------------------------------------------------
; light_pip / unlight_pip / draw_pips / draw_lives.
; ----------------------------------------------------------------------------
light_pip:
ld a, (lit_count)
ld e, a
ld d, 0
inc a
ld (lit_count), a
ld hl, PIP_BASE
add hl, de
ld (hl), PIP_LIT
ret
unlight_pip:
ld a, (lit_count)
dec a
ld (lit_count), a
ld e, a
ld d, 0
ld hl, PIP_BASE
add hl, de
ld (hl), PIP_UNLIT
ret
draw_pips:
ld hl, PIP_BASE
ld b, NUM_LAMPS
ld a, PIP_UNLIT
.dp:
ld (hl), a
inc hl
djnz .dp
ret
draw_lives:
ld hl, LIFE_BASE
ld b, LIVES
ld a, LIFE_PIP
.dlv:
ld (hl), a
inc hl
djnz .dlv
ret
; ----------------------------------------------------------------------------
; draw_lamps / draw_lantern.
; ----------------------------------------------------------------------------
draw_lamps:
ld hl, lamp_data
.next:
ld a, (hl)
cp $FF
ret z
ld c, a
inc hl
ld b, (hl)
inc hl
push hl
call draw_lantern
pop hl
jr .next
draw_lantern:
call attr_addr_cr
ld (hl), LAMP_UNLIT
call scr_addr_cr
ld de, lantern
ld b, 8
.dlt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dlt
ret
; ----------------------------------------------------------------------------
; scr_addr_cr / attr_addr_cr / wall_at.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000
or %01000000
ld h, a
ld a, b
and %00000111
rrca
rrca
rrca
or c
ld l, a
ret
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de
ret
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; The lamplighter's save / restore / draw.
; ----------------------------------------------------------------------------
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; The draught's save / restore / draw.
; ----------------------------------------------------------------------------
dpos_bc:
ld a, (draught_row)
ld b, a
ld a, (draught_col)
ld c, a
ret
save_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.sd:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .sd
call dpos_bc
call attr_addr_cr
ld a, (hl)
ld (under_draught + 8), a
ret
restore_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.rd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .rd
call dpos_bc
call attr_addr_cr
ld a, (under_draught + 8)
ld (hl), a
ret
draw_draught:
call dpos_bc
call attr_addr_cr
ld (hl), DRAUGHT_ATTR
call dpos_bc
call scr_addr_cr
ld de, draught_glyph
ld b, 8
.dd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dd
ret
; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------
game_state:
defb STATE_TITLE
input_lock:
defb 0
wall_ramp:
defb %00001111
defb %00001111
defb %01001111
defb %01001111
defb %00011111
defb %00011111
defb %01011111
defb %01011111
defb %01011111
lamp_data:
defb 4, 3
defb 27, 3
defb 9, 7
defb 22, 7
defb 6, 15
defb 25, 15
defb 13, 20
defb 18, 20
defb $FF
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
lit_count:
defb 0
lives:
defb LIVES
draught_col:
defb DRAUGHT_COL0
draught_row:
defb DRAUGHT_ROW0
draught_dx:
defb 1
draught_dy:
defb 1
draught_timer:
defb DRAUGHT_SPEED
dtcol:
defb 0
dtrow:
defb 0
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
under_draught:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
lantern:
defb %00011000
defb %00100100
defb %01111110
defb %01111110
defb %01011010
defb %01111110
defb %01111110
defb %00111100
draught_glyph:
defb %00000000
defb %00111100
defb %01111110
defb %11111111
defb %11111111
defb %01111110
defb %00111100
defb %00000000
title_text:
defb "GLOAMING"
defb $FF
prompt_text:
defb "PRESS SPACE"
defb $FF
win_text:
defb "THE NIGHT IS HELD"
defb $FF
lose_text:
defb "NIGHT FALLS"
defb $FF
end start
When it's wrong, see why
Real hardware has its own short list of failure modes:
R Tape loading erroron a real cassette — almost always volume or azimuth on the replay side; on a Next/SD or a digital tape device it loads clean. Rebuild the.tapand try again.- Loads, but does not start. The BASIC loader's
RANDOMIZE USRaddress must match the code's origin.--tapbastakes it fromend start, so keeporg 32768andend startin step. - Runs, but feels wrong. If it ever ran differently on hardware than on the emulator, suspect a reliance on host timing — but Gloaming's frame-lock and documented I/O are exactly there to stop that. It should feel identical.
Before and after
You started this unit with a finished game that only an emulator could load and ended it with the same code on a real tape, loading through the same ROM routine a Spectrum used in 1983 — and nothing about the game had to change. That is the whole point: a snapshot was a convenience, a tape is the truth, and a game built on documented behaviour crosses from one to the other without a flinch. The loading stripes are the ROM loader doing its oldest job; the title that follows is yours.
Try this: load it on your own hardware
This is the unit only you can finish. Get gloaming.tap (or the .sna) onto a real Spectrum Next, or a 48K with a tape-replay device, and play it. Confirm every piece: the title waits, QAOP moves, lamps light with a blip, the draught snuffs and chases, the walls warm, the win and loss screens appear, and space starts you over. When it all behaves on the metal exactly as it did on screen — that is the sign-off. That is your hardware truth.
Try this: a loading screen
Real Spectrum games greeted you with a picture while they loaded. Add one: a 6912-byte SCREEN$ (the full bitmap plus attributes) as a tape block before the code, and the artwork appears under the loading stripes. Drawing it is a project in itself — but it is the final flourish that makes a tape feel like a published game.
Try this: make a label
You have made a real, loadable, period-faithful game. Print an inlay. Give it a price in 1983 pounds. It earned the £1.99 budget-classic treatment — and writing the back-of-the-box blurb is a surprisingly good test of whether you can say what your game is.
What you've learnt
- Real hardware loads from tape or storage, not snapshots; build a
.tapwith an auto-running BASIC loader (pasmonext --tapbas). - The loading stripes are the ROM loader — the same code path on the emulator and on the metal.
- A frame-locked game using only documented I/O behaves identically on real hardware — the payoff of building it honestly.
- The final verification is yours to make, on your own iron.
What's next
You have finished Gloaming — a complete, winnable, losable, replayable game, built in Z80 assembly from a single attribute write and verified on real hardware. That is no small thing: most people who set out to write an assembly game never finish one. You have.
Next in the track is Shadowkeep, the first substantial game — and it does not start from scratch. The cell sprite, the save/restore, the collision, the game loop, the state machine, the HUD, the sound: every one of them was born here, in Gloaming, and Shadowkeep picks them up and builds a whole walled keep on top. You did not just make a game. You made the engine your next game runs on.