Press A and the background changes from white to black. Holding A does not spend another turn. Release and press again to bring white back. The display keeps running while the world waits for you.
Here the shade shows whether a byte is odd or even. It deliberately does not pretend to show the exact count: that belongs to the reusable rule state.

Put it in your project
Build from the repository root:
python3 scripts/build-msx-gameboy-loops.py --assembler /path/to/asm198x --system nintendo-game-boyLoad generated build/working/demo.gb as a cartridge. Asm198x finalises the 32 KiB ROM-only header/checksums. Qualified reset is DMG default state without a boot ROM. It is not a CGB/double-speed or real-boot-ROM qualification.
Origin, settings and includes
; SPDX-License-Identifier: MIT
DEF INITIAL EQU 0
DEF STEP EQU 1
DEF RESET_EACH EQU 0
DEF NO_EDGE EQU 0
DEF NO_WAIT EQU 0
DEF CTX EQU $C000
SECTION "Entry", ROM0[$100]
nop
jp start
ds $134-@,0
db "TURNS"
ds $150-@,0
SECTION "Code", ROM0[$150]
INCLUDE "caller.inc"
INCLUDE "routines.inc"
INCLUDE "presentation.inc"
ds $8000-@,0Startup and the machine-specific outer loop
; DMG default reset, 32 KiB ROM-only cartridge, no boot ROM.
; Takes over LCD/VRAM, JOYP, four WRAM bytes and descending stack from $FFFE.
start:
di
ld sp,$FFFE
ld a,[$FF40]
bit 7,a
jr z,lcd_off
wait_to_disable:
ld a,[$FF44]
cp 144
jr c,wait_to_disable
lcd_off:
xor a
ld [$FF40],a
ld [$FF42],a
ld [$FF43],a
ld hl,$8000
ld bc,$2000
clear_vram:
xor a
ld [hl+],a
dec bc
ld a,b
or c
jr nz,clear_vram
ld a,INITIAL
call turn_init
xor a
ld [CTX+1],a
ld [CTX+2],a
ld [CTX+3],a
call show_turn
ld a,$91
ld [$FF40],a
main:
call wait_frame
ld a,[CTX+2]
inc a
ld [CTX+2],a
jr nz,service_done
ld a,[CTX+3]
inc a
ld [CTX+3],a
service_done:
ld a,RESET_EACH
or a
jr z,keep_state
xor a
call turn_init
keep_state:
call sample_action
or a
jr z,no_action
call turn_advance
no_action:
call show_turn
jp mainReuse the rule
Keep demo.asm, caller.inc, routines.inc and presentation.inc together; include each implementation once. turn_init stores A into CTX, preserving A and flags. turn_advance adds compile-time STEP modulo 256, changing A, flags and count. Neither routine reads buttons, draws or waits.
CTX names four private WRAM bytes. This caller uses $C000–$C003 for count, previous A sample and a little-endian 16-bit display-service counter. Initialising count does not initialise the other bytes; the caller clears those once too. Previous input begins released, so a key held at entry is accepted on the first sample. Reserve these bytes in your own game’s memory plan.
Rule state and the input/wait adapters
; A -> count. A and flags preserved. No display/input/clock access.
turn_init:
ld [CTX],a
ret
; Add STEP modulo 256. A/flags and count changed.
turn_advance:
ld a,[CTX]
add a,STEP
ld [CTX],a
ret
; A=1 for a new A-button press, otherwise zero. AF/BC changed.
; Six reads allow selected action-button lines to settle, then release JOYP.
sample_action:
ld a,$10
ld [$FF00],a
ld a,[$FF00]
ld a,[$FF00]
ld a,[$FF00]
ld a,[$FF00]
ld a,[$FF00]
ld a,[$FF00]
cpl
and 1
ld b,a
ld a,$30
ld [$FF00],a
ld a,NO_EDGE
or a
ld a,b
jr nz,accept_sample
ld a,[CTX+1]
cpl
and b
accept_sample:
ld c,a
ld a,b
ld [CTX+1],a
ld a,c
ret
; AF changed. LCD must remain enabled; interrupts stay disabled.
; Leave any current VBlank before accepting the next one. No missed-frame count.
wait_frame:
ld a,NO_WAIT
or a
ret nz
leave_vblank:
ld a,[$FF44]
cp 144
jr nc,leave_vblank
wait_vblank:
ld a,[$FF44]
cp 144
jr c,wait_vblank
retThe native output adapter
; Blank tile zero fills the cleared map. BGP changes colour zero by parity.
; AF changed. Owns background palette; no game-rule changes or VRAM writes.
show_turn:
ld a,[CTX]
and 1
ld a,$E4
jr z,show_palette
ld a,$1B
show_palette:
ld [$FF47],a
retMachine ownership
The caller disables maskable interrupts, sets a descending stack at $FFFE and owns LCD/VRAM, scrolling, BGP and JOYP. It does not return to firmware. The two-byte return addresses of internal CALLs still need stack space; plan your own call depth and avoid collision with other HRAM use.
It checks whether LCD is enabled, waits for VBlank before disabling it, then clears $8000–$9FFF with LCD off. It clears SCY/SCX, selects the palette and enables LCD/background with LCDC=$91. Replacing this setup inside a running game requires the same LCD shutdown constraint; it is not a per-frame clear.
wait_frame reads LY ($FF44): leave any current LY>=144 interval, then accept the next one. AF changes; LCD must remain enabled. It polls with interrupts disabled and does not count missed fields or guarantee the update fits. It spends CPU while waiting. A low-power HALT/interrupt caller is another ownership agreement, not a drop-in RET replacement.
sample_action selects action buttons with JOYP ($FF00)=$10, performs six settling reads, converts active-low bit 0 and resets the selection to $30. It stores the previous sample on every call, including holds/rejections. Return A=1 for a new press, else zero; AF/BC change. It claims the A button only, not a full directional or remappable controller API.
show_turn selects BGP=$E4 or $1B from count parity. AF changes. The cleared tile/map show colour zero across the background. This is a palette adapter, not a moving sprite or a numeric display. A step of three still flips parity once per action; read the count byte to check magnitude.
The service counter increments once per accepted display field and wraps after 65535. It is diagnostic service state, separate from the byte rule state. A real game needs its own explicit simulation/deadline policy and measured CPU/rendering budget.
| Routine | Contract |
|---|---|
turn_init |
Store A in CTX; preserve A and flags. |
turn_advance |
Add STEP modulo 256; change A, flags and count. |
sample_action |
Return A=1 only for a new press; update previous sample; change AF/BC. |
wait_frame |
Accept a fresh VBlank with LCD enabled; change AF. |
show_turn |
Change BGP from count parity; change AF. |
Customise and diagnose
customise.asm uses STEP=3: two actions give 0 → 3 → 6. One action still invokes the rule once. The byte contract needs a whole-number starting value and step from 0 through 255. Adding them gives at most 510, so one subtraction of 256 suffices in BASIC; the CPU’s byte addition wraps naturally in assembly. The nonzero check starts at 100 with step 255 and reaches 99 then 98.
Three progress units for one accepted action
; SPDX-License-Identifier: MIT
DEF INITIAL EQU 0
DEF STEP EQU 3
DEF RESET_EACH EQU 0
DEF NO_EDGE EQU 0
DEF NO_WAIT EQU 0
DEF CTX EQU $C000
SECTION "Entry", ROM0[$100]
nop
jp start
ds $134-@,0
db "TURNS"
ds $150-@,0
SECTION "Code", ROM0[$150]
INCLUDE "caller.inc"
INCLUDE "routines.inc"
INCLUDE "presentation.inc"
ds $8000-@,0
mistakes/reset-each-command.asm is deliberately broken: initialisation runs inside the repeated path, so the next iteration erases progress. Keep initialisation at startup and jump back to the command/update path to repair it. The repaired caller is checked in a separate cold start.
Deliberately broken: reset inside the repeated path
; Deliberately broken: erase rule state inside the iteration.
; SPDX-License-Identifier: MIT
DEF INITIAL EQU 0
DEF STEP EQU 1
DEF RESET_EACH EQU 1
DEF NO_EDGE EQU 0
DEF NO_WAIT EQU 0
DEF CTX EQU $C000
SECTION "Entry", ROM0[$100]
nop
jp start
ds $134-@,0
db "TURNS"
ds $150-@,0
SECTION "Code", ROM0[$150]
INCLUDE "caller.inc"
INCLUDE "routines.inc"
INCLUDE "presentation.inc"
ds $8000-@,0
--variant no-edge treats a held A as another action each field. --variant no-wait removes display pacing: its service counter must fail the normal one-per-native-field guard. These are distinct deliberate faults, not advanced alternatives; their normal counterparts and the reset repair are executed separately.
An untimed world waits for an accepted action. A timed turn needs its own clock, expiry policy and ordering when a command arrives at the deadline. Display refresh or firmware interrupt activity does not automatically advance game rules. Blocking input is useful when nothing else needs main-loop service; continuing animation, music or deadlines require a different input/service policy.
Related choices
An interrupt-driven caller could reduce the CPU time spent polling; it needs its own interrupt and clock ownership contract.
The loop choices explain untimed actions, timed turns and display servicing as separate decisions.
Verification
Native-emulator executed: Emu198x 0.44.1, Asm198x 0.0.71, on the configuration above. The source README gives the reproduction command; the retained record identifies the exact inputs and tools.
The six common cold starts check normal use, step three, reset fault/repair, wraparound and nonzero initialisation. Idle, first accepted action, waiting/holding, ignored input and a second accepted action are retained as native captures and actual observations. Assembly adds no-edge and no-wait controls for eight cold starts. The complete 32 KiB CPU-visible ROM is compared with the finalised cartridge. The checker compares actual palette framebuffers for both parity states and checks the service counter against 120 native frames. This does not establish calibrated real-world speed or a catch-up policy.
scripts/check-msx-gameboy-loops.py independently decodes the records; falsification tests reject missing cases/stages, altered source/capture/payload/counts and false repairs. The additive three-route inventory does not expand the broad input/drawing/sound probe count or qualify new audio. No original-hardware, cassette-transfer, other model/firmware or whole-emulator claim follows.
The maintained source includes the complete caller, settings, customisation and deliberate fault. The retained record binds the executed source, observations, native images and released-tool identities.
Primary references
Nintendo, Game Boy Programming Manual, version 1.1: P1 input/settling example; LCDC/LCD shutdown caution; LY values 0–153 and VBlank lines 144–153. This implementation follows the six-read action-button example; the retained older probe remains a separate observation.