Skip to content

Optional Timed Turns

Let commands advance a game, add an optional NMI-clock deadline, and keep time moving during slow work.

Code status: assembled and emulator-executed. This is the named NTSC NROM/DMC-off caller with a continuously running NMI frame counter, not a count of foreground updates.

Press A to spend a turn. Hold it and you still spend only one. Select enables a deadline; let it run out and one turn expires. Select disables it again, leaving the game free to wait for your next action. The NMI clock keeps running in both modes.

Build with make, load build/demo.nes as NTSC NROM-256, and reset to leave. Asm198x 0.0.72 and Emu198x 0.44.2 are the selected tools. A is an action, Select toggles timing, held B adds diagnostic CPU work, and held Start freezes only the displayed row. Start does not pause the clock or game. The caller uses the maintained controller reader/press detector, frame helper, shared elapsed countdown and existing expiry-first turn dispatcher.

Read the result

Eight hexadecimal bytes show turn low/high, expiry count, active flag, remaining low/high and sampled-clock low/high. Word fields are little endian: 06 00 means six turns. The sampled clock is the clock value used by that update, not a claim that the display is current while Start is held. Expiry count is an eight-bit diagnostic and wraps at 256; turns wrap at 65536.

The normal budget is 180 NTSC frame ticks, roughly three seconds. custom.asm changes only DURATION to 60 ticks. It produces a shorter deadline without changing code/state/cartridge size. Frame ticks are not milliseconds: choose a budget for the actual region and qualify that region. This caller is not a PAL qualification.

The complete cartridge: includes and one chosen duration

demo.asm
.segment "HEADER"
    .byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
DURATION = 180
.include "caller.inc"
.include "../game-loop/frame.inc"
.include "clock.inc"
.include "../input-to-sound/input.inc"
.include "../input-to-sound/presses.inc"
.include "../../../../../bbc-micro/patterns/assembly/framework/turn-deadline/dispatch.inc"
dispatch_code_end:
.include "../../../../../cross-platform/patterns/framework/elapsed-deadline/6502.inc"
deadline_code_end:
POLL_ROUTINE:
    jmp deadline_poll
code_end:
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
    .res 8192-272,0

Reuse the countdown

Include the shared cross-platform/patterns/framework/elapsed-deadline/6502.inc once, with DEADLINE naming 12 owned writable bytes. Keep decimal mode clear. For init/poll, A is clock low and X clock high; the interface permits A/X/flags to change and preserves Y. The normal CALL stack and interrupt frames need their own room. One compiled context is not re-entrant or a general multiple-timer manager.

Offset Meaning
0–1 Configured duration, input word; zero disables on start
2–3 Remaining ticks
4 Active, 0/1
5 Expired this poll, 0/1
6–7 Elapsed ticks for the last poll
8–9 Previous sample/baseline
10–11 Current sample scratch

Set duration, supply a current sample to deadline_init, then call deadline_start when the countdown should begin. Init leaves it inactive and does not change duration. Start copies duration to remaining and clears expiry. It uses the latest baseline: after a long wait, refresh the sample first, or initialise from the fresh clock before starting. Do not reset the global frame clock to start a timer.

Each service reads the clock then calls deadline_poll. Consume the expiry result immediately; another poll clears that pulse. Inactive polling still updates the baseline. An elapsed interval at or beyond the remaining time saturates to zero and emits one expiry. A late service does not manufacture a queue of catch-up turns. Poll before 65536 clock ticks elapse, since a word cannot distinguish an entire unseen wrap. After a longer pause, choose an explicit resume/reinitialisation policy.

The arithmetic can consume another appropriate clock source without this display or controller. The NES adapter is one concrete source, not a requirement that every turn-based game run this particular loop.

The maintained 12-byte elapsed countdown

6502.inc
; DEADLINE owns 12 bytes. A=clock low, X=clock high; decimal mode clear.
; Poll at least once per 65536 clock ticks. A/X/status clobbered; Y preserved.
deadline_init:
    sta DEADLINE+8
    stx DEADLINE+9
    lda #0
    sta DEADLINE+2
    sta DEADLINE+3
    sta DEADLINE+4
    sta DEADLINE+5
    sta DEADLINE+6
    sta DEADLINE+7
    rts
deadline_start:
    lda DEADLINE
    sta DEADLINE+2
    lda DEADLINE+1
    sta DEADLINE+3
    ora DEADLINE
    beq deadline_off
    lda #1
deadline_off:
    sta DEADLINE+4
    lda #0
    sta DEADLINE+5
    rts
deadline_poll:
    sta DEADLINE+10
    stx DEADLINE+11
    sec
    sbc DEADLINE+8
    sta DEADLINE+6
    txa
    sbc DEADLINE+9
    sta DEADLINE+7
    lda DEADLINE+10
    sta DEADLINE+8
    lda DEADLINE+11
    sta DEADLINE+9
    lda #0
    sta DEADLINE+5
    lda DEADLINE+4
    beq deadline_done
    sec
    lda DEADLINE+2
    sbc DEADLINE+6
    sta DEADLINE+2
    lda DEADLINE+3
    sbc DEADLINE+7
    sta DEADLINE+3
    bcc deadline_expired
    ora DEADLINE+2
    bne deadline_done
deadline_expired:
    lda #0
    sta DEADLINE+2
    sta DEADLINE+3
    sta DEADLINE+4
    lda #1
    sta DEADLINE+5
deadline_done:
    rts

Own the clock and interrupts

clock.inc extends the existing game-loop frame_clock low byte with frame_high. Zero all clock storage before enabling NMI and call initialise_clock for the maintained frame waiter. The NMI handler calls clock_tick exactly once per vblank; it must preserve everything else the interrupted code needs. The supplied tick leaf changes flags only, and the interrupt return restores the interrupted status. A surrounding NMI worker can call the tick leaf as part of its own work; do not install competing NMI owners or reset the shared count.

read_clock returns a coherent A/X word by reading high, low and high again and retrying across a high-byte change. It preserves Y and changes A/X/flags plus its private high-copy byte. That scratch makes one reader instance non-re-entrant. The foreground never writes the running count.

The existing wait_frame has a separate fixed-step scheduling job and a less-than-256-frame sampling contract. The deadline uses the full 16-bit clock, not the waiter’s one-byte delta or a count of game updates. The deliberately slow workload remains within that scheduling limit. A longer stall needs its own scheduling policy even if a deadline’s arithmetic could measure it.

Extend the maintained frame counter coherently

clock.inc
; Existing game-loop frame_clock is low byte. NMI calls clock_tick once.
; Zero clock bytes before NMI; one owner must not reset them during use.
.segment "BSS"
frame_high: .res 1
clock_high_copy: .res 1
.segment "CODE"
clock_tick:
    inc frame_clock
    bne clock_tick_done
    inc frame_high
clock_tick_done:
    rts
; Coherent word: A=low X=high. A/X/flags/scratch change; Y survives.
; Retry if an NMI carry split the read. One private scratch, not re-entrant.
read_clock:
    lda frame_high
    sta clock_high_copy
    lda frame_clock
    ldx frame_high
    cpx clock_high_copy
    bne read_clock
    rts
read_clock_end:

Include and call from a project

The complete demo shows storage, include order and the two call boundaries. An existing engine can keep its own input, display and NMI worker and reuse only the countdown/clock interface it needs. The dispatcher requires STATE’s ten bytes and a commit_turn callback; the standalone caller uses STATE+0/1 for an update count, +4/5 for turns, +6 for expiries, +7 as the per-service changed flag and +8 as command bits (1 action, 2 toggle).

The maintained dispatcher consumes an expiry before input. If A arrives on the same sample as an expiry, the timeout wins and the action is not queued. With no expiry, a simultaneous toggle wins over the action. These are this caller’s chosen policies, not inevitable rules for an adventure or board game. The independent caller tests every combination of expiry, active state and both input bits. A committed timed action restarts the current budget; an untimed action advances without enabling a timer.

The standalone cartridge owns reset/stack, RAM, PPU/NMI setup, APU/DMC disable state and controller-1 strobing. It clears RAM and does not restore another engine. The simple serial reader requires DMC sample DMA off and a single shared strobe owner. It is not made DMC-safe by adding a timer. The short status transfer waits for a fresh vblank even after slow work, then restores scroll and keeps NMI enabled. It is not a general VRAM scheduler.

Standalone caller with an independent display latch

caller.inc
.segment "BSS"
DEADLINE: .res 12
STATE: .res 10
ready: .res 1
status_row: .res 8
display_row: .res 8
caller_state_end:
.segment "CODE"
reset:
    sei
    cld
    ldx #$ff
    txs
    inx
    stx $2000
    stx $2001
    stx $4010
    stx $4015
    lda #$40
    sta $4017
    lda #0
clear_ram:
    sta $0000,x
    sta $0100,x
    sta $0200,x
    sta $0300,x
    sta $0400,x
    sta $0500,x
    sta $0600,x
    sta $0700,x
    inx
    bne clear_ram
    jsr wait_vblank
    jsr wait_vblank
    bit $2002
    lda #$20
    sta $2006
    lda #0
    sta $2006
    ldy #4
    ldx #0
clear_names:
    sta $2007
    inx
    bne clear_names
    dey
    bne clear_names
    lda #$3f
    sta $2006
    lda #0
    sta $2006
    lda #$0f
    sta $2007
    lda #$30
    sta $2007
    lda #<DURATION
    sta DEADLINE
    lda #>DURATION
    sta DEADLINE+1
    jsr read_clock
    jsr deadline_init
    jsr initialise_clock
    lda #165
    sta ready
    lda #$0a
    sta $2001
    lda #$80
    sta $2000
main_loop:
    jsr wait_frame
    jsr read_clock
    jsr POLL_ROUTINE
    jsr read_controls
    jsr detect_presses
    lda #0
    sta STATE+7
    sta STATE+8
    lda new_presses
    and #$80
    beq no_action
    inc STATE+8
no_action:
    lda new_presses
    and #$20
    beq no_toggle
    lda STATE+8
    ora #2
    sta STATE+8
no_toggle:
    jsr dispatch
    inc STATE
    bne update_done
    inc STATE+1
update_done:
    lda STATE+4
    sta status_row
    lda STATE+5
    sta status_row+1
    lda STATE+6
    sta status_row+2
    lda DEADLINE+4
    sta status_row+3
    lda DEADLINE+2
    sta status_row+4
    lda DEADLINE+3
    sta status_row+5
    lda DEADLINE+10
    sta status_row+6
    lda DEADLINE+11
    sta status_row+7
; Fresh vblank protects the transfer even after slow work.
    jsr wait_vblank
    lda controls
    and #$10
    bne work_start
    bit $2002
    lda #$20
    sta $2006
    lda #$22
    sta $2006
    ldy #0
show_status:
    lda status_row,y
    sta display_row,y
    jsr show_byte
    iny
    cpy #8
    bne show_status
    lda #$80
    sta $2000
    lda #0
    sta $2005
    sta $2005
work_start:
; Held Start freezes only the displayed row; timing and turns continue.
; Held B is diagnostic work after the transfer. NMI still owns the clock.
    lda controls
    and #$40
    beq frame_done
    ldy #64
work_outer:
    ldx #0
work_inner:
    dex
    bne work_inner
    dey
    bne work_outer
frame_done:
    jmp main_loop
commit_turn:
    inc STATE+4
    bne turn_committed
    inc STATE+5
turn_committed:
    inc STATE+7
    rts
show_byte:
    pha
    lsr
    lsr
    lsr
    lsr
    clc
    adc #1
    sta $2007
    pla
    and #15
    clc
    adc #1
    sta $2007
    lda #0
    sta $2007
    rts
wait_vblank:
    bit $2002
    bpl wait_vblank
    rts
nmi:
    jsr clock_tick
irq:
    rti

The unchanged expiry-first turn policy

dispatch.inc
; Consume expiry before input; one overdue event, no catch-up turns.
dispatch:
    lda DEADLINE+5
    beq consider_input
    inc STATE+6
    jsr commit_turn
    jmp deadline_start
consider_input:
    lda STATE+8
    and #2
    beq consider_action
    lda DEADLINE+4
    beq enable_timer
    lda #0
    sta DEADLINE+4
    sta DEADLINE+2
    sta DEADLINE+3
    inc STATE+7
    rts
enable_timer:
    inc STATE+7
    jmp deadline_start
consider_action:
    lda STATE+8
    and #1
    beq dispatch_done
    jsr commit_turn
    lda DEADLINE+4
    beq dispatch_done
    jsr deadline_start
dispatch_done:
    rts

Common error: renew the baseline before every poll

The labelled wrong caller stores the current clock into the previous-sample fields immediately before deadline_poll. Every elapsed interval becomes zero, so its active timer stays at B4 (180) even after more than a full budget of real NMI ticks. The clock itself keeps moving. The six extra instruction bytes are the problem, not a slower clock.

Remove those two stores and leave the normal jump to deadline_poll. The repaired cartridge is byte-for-byte the working one. Both the real held-B workload and the independent supplied-clock caller reveal the fault; completion guards alone would not, since the wrong program runs normally. Keep wrong.nes separate from the working download.

Deliberate fault: overwrite the baseline before polling

wrong.asm
.segment "HEADER"
    .byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
DURATION = 180
.include "caller.inc"
.include "../game-loop/frame.inc"
.include "clock.inc"
.include "../input-to-sound/input.inc"
.include "../input-to-sound/presses.inc"
.include "../../../../../bbc-micro/patterns/assembly/framework/turn-deadline/dispatch.inc"
dispatch_code_end:
.include "../../../../../cross-platform/patterns/framework/elapsed-deadline/6502.inc"
deadline_code_end:
POLL_ROUTINE:
    sta DEADLINE+8
    stx DEADLINE+9
    jmp deadline_poll
code_end:
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
    .res 8192-272,0

Make it your own: 60 ticks instead of 180

custom.asm
.segment "HEADER"
    .byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
DURATION = 60
.include "caller.inc"
.include "../game-loop/frame.inc"
.include "clock.inc"
.include "../input-to-sound/input.inc"
.include "../input-to-sound/presses.inc"
.include "../../../../../bbc-micro/patterns/assembly/framework/turn-deadline/dispatch.inc"
dispatch_code_end:
.include "../../../../../cross-platform/patterns/framework/elapsed-deadline/6502.inc"
deadline_code_end:
POLL_ROUTINE:
    jmp deadline_poll
code_end:
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
    .res 8192-272,0

Costs and verification

The shared countdown is 140 code bytes and 12 state bytes in this linked caller. The clock extension is 27 code bytes plus the maintained frame helper’s code; the selected clock/waiter/reader bookkeeping uses five state bytes. Complete caller BSS is 47 bytes: 12 countdown, 10 policy/update, 16 current/display-latch, one ready, five clock/waiter and three controller bytes. These are overlapping component totals, not numbers to add twice.

The complete normal code prefix is 668 bytes plus six vector bytes; the original hexadecimal font is 272 CHR bytes. Padding to 32 KiB PRG and 8 KiB CHR makes each cartridge 40,976 bytes including its header. No CPU-cycle/vblank-budget or complete-game RAM measurement is claimed. In the recorded normal run, the fast interval averages 34 ticks/34 updates and the chosen slow interval 185 ticks/72 updates. The timer still expires during that work. These are measured intervals for this workload, not a promised range for every update. Verification compares the measured fast/slow ratios rather than assuming an arbitrary slowdown threshold.

Six native runs cover working/custom/wrong/repaired cartridges plus two independent guest-owned fixtures. Real buttons verify untimed idle, new/held actions, toggles, renewal, busy-work expiry, disable/re-enable and a frozen display with continuing timer service. The NMI clock crosses a low-byte carry. The fixture supplies 12 elapsed/wrap/zero/exact/late cases and 16 dispatcher combinations; the wrong baseline completes its markers but fails the arithmetic oracle. The observer does not write state or set the PC.

Source/tool/configuration identities, all 32 KiB of native immutable PRG, cartridge/CHR identity, completion stops, state traces and frozen-row PNG glyphs are retained under verification. Run python3 scripts/check-nes-deadlines.py and python3 -m unittest discover -s scripts -p test_nes_deadlines.py -v from the samples root. Re-capture with verify-nes-deadlines.py and explicit assembler/emulator paths. Corrupted observations and picture expectations must fail. This qualifies the named NTSC NROM/NMI/DMC-off configuration, not every mapper, region or original hardware.

Native output

The working NES caller shows turns, expiries, active state, remaining ticks and the sampled NMI clock.
Start holds the last displayed sample still for the capture. The clock and deadline service continue; words are shown low byte first.
The faulty caller has zero timed expiries and its remaining field stays B4 although the NMI clock has advanced.
Resetting the previous-sample field makes elapsed time zero. The native trace and repaired run distinguish this from a stopped clock.

Pinned code, contracts and complete callers · Native configuration and evidence. Maths for Games connects modular elapsed time and saturating subtraction; loop choices keeps an optional clock distinct from turn progression, and the NES NMI loop explains the maintained frame interface.