Skip to content

State Machine Loop

Organise game flow with distinct states (title, playing, game over). Clean separation of game phases.

Code status: complete maintained caller, executed through the 48K ROM. The automated check drives real Q and Space events through title, play, pause, resume and game over. It checks held-key behaviour and the session clock; it does not establish an entire game’s frame budget.

Audit execution record · Scope and remaining checks

Four states, one update loop

A title screen should not keep moving the player behind the menu. Pausing should freeze the current game, then resume it rather than start another one. This small caller makes those choices visible with the border: blue for title, green for play, yellow for pause and red for game over.

Build demo.tap with the adjacent Makefile and load it on a 48K Spectrum. Space starts, pauses and resumes; Q ends a running game; Space returns from game over to the title. Reset the machine to leave. The caller starts at $8000, owns its stack at $FF00, keeps the 48K ROM visible for IM 1 interrupts, and uses the maintained QAOP/Space reader and frame waiter.

A complete state-machine caller

demo.asm
; 48K PAL, entered through BASIC USR32768. ROM IM1 remains visible.
; Space: title->play, play<->pause, game over->title. Q: lose while playing.
; Blue=title, green=playing, yellow=paused, red=game over. Reset to leave.
; This caller owns the stack, border/MIC/beeper port and the declared state.
    org $8000
start:
    di
    ld sp,$ff00
    ld iy,$5c3a
    im 1
    xor a
    ld (game_state),a
    ld hl,0
    ld (game_ticks),hl
    call read_controls
    ld (previous),a             ; held controls at entry are not new presses
    ld hl,$cafe
    ld (ready),hl
    ei
main_loop:
    call wait_frame
    call read_controls
    ld b,a
    ld a,(previous)
    cpl
    and b
    ld (pressed),a
    ld a,b
    ld (previous),a
    call update_state
    call draw_state
    jr main_loop

; One state consumes one sampled update. AF/BC/HL change; DE/IX/IY survive.
; State is0..3. Entry/reset actions happen only on a transition.
update_state:
    ld a,(game_state)
    or a
    jr z,state_title
    cp 1
    jr z,state_playing
    cp 2
    jr z,state_game_over
    cp 3
    jr z,state_paused
invalid_state:
    di                         ; expose corrupted state instead of masking it
    halt
    jr invalid_state
state_title:
    ld a,(pressed)
    and 16
    ret z
    ld hl,0                    ; a fresh session, not a resume
    ld (game_ticks),hl
    ld a,1
    ld (game_state),a
    ret
state_playing:
    ld hl,(game_ticks)
    inc hl
    ld (game_ticks),hl
    ld a,(pressed)
    bit 0,a
    jr nz,state_lose           ; loss takes priority over pause
    and 16
    ret z
    ld a,3
    ld (game_state),a
    ret
state_lose:
    ld a,2
    ld (game_state),a
    ret
state_game_over:
    ld a,(pressed)
    and 16
    ret z
    xor a
    ld (game_state),a
    ret
state_paused:
    ld a,(pressed)
    and 16
    ret z
    ld a,1                     ; retain the current session's counter
    ld (game_state),a
    ret

draw_state:
    ld a,(game_state)
    ld l,a
    ld h,0
    ld de,state_colours
    add hl,de
    ld a,(hl)
    out ($fe),a
    ret
signature: defm "198XSTATES!"
ready: defw 0
game_state: defb 0
previous: defb 0
pressed: defb 0
game_ticks: defw 0
state_colours: defb 1,4,2,6
    include "../game-loop/frame.inc"
    include "../input-to-sound/input.inc"
    end start

Fit it into a game

Sample controls once per update. Compute new presses from that sample and the previous sample, then dispatch the current state. The history survives transitions: holding Space must not start a game and immediately pause it on the next update. Seeding history on entry also prevents a key held during loading from becoming a new press.

update_state changes AF, BC and HL, preserves DE, IX and IY, and consumes writable game_state, pressed and game_ticks. States are 0–3; an invalid value deliberately halts for diagnosis. Q takes priority if loss and pause arrive together. Replace those demo actions with your game’s rules.

game_ticks increments only in play. Starting a fresh session clears it; resuming preserves it. It is a wrapping 16-bit demonstration counter, not a permanent score or wall clock. The transition update counts as a playing update before entering pause or game over. draw_state also changes DE and writes the border/MIC/beeper port; replace it if another routine owns that output.

Keep drawing separate from state updates. Menus, play and pause can share drawing without sharing movement. Add work incrementally and measure the complete update before assuming it fits the frame.

Related: Game loop, Keyboard reading.

For a component you can call from another loop, game states and restart separate transition decisions, entry pulses and fresh-session resets, with a verified example of accidentally resetting on resume.