Skip to content

Jump Buffering and Coyote Time on C64

Press Jump just before landing and it is frustrating when the press disappears.

Press Jump just before landing and it is frustrating when the press disappears. Walk a tiny distance off a ledge and a short forgiveness window can still let the jump happen. These two timers give the game those choices without hiding the rules inside the input reader.

C64 shows the working diagnostic row 00 04 00 01 00 00 00 00.
A native completed caller shows its state as hexadecimal bytes. The field order below explains the row; the full packet retains every call.

Include, initialise and call

Reserve JUMPED, COYOTE, BUFFER, GROUNDED, PRESSED as 5 contiguous private bytes in that order. Include the helper once. Call jump_windows_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call jump_windows_step for each intended service.

Initialise at the session/reset boundary. Update grounded state from collision, supply the new-press bit, then service once per simulation update. On JUMPED, the caller applies the chosen vertical velocity and updates its movement state. The helper grants a jump; it does not move the player or decide whether a floor contact is real.

GROUNDED and PRESSED are caller inputs; any nonzero value is true, and PRESSED must mean a new press. Each step first decrements positive COYOTE and BUFFER timers, then reloads COYOTE from COYOTE_STEPS when grounded and BUFFER from BUFFER_STEPS on a press. If both timers are positive, it emits JUMPED = 1 and consumes both timers; otherwise JUMPED is zero. Both configured windows are 1–255 service calls. The maintained builds use four and two.

Reusable initialisation and service

6502.inc
; SPDX-License-Identifier: MIT
; Jump Buffering and Coyote Time. Contiguous private bytes: JUMPED, COYOTE, BUFFER, GROUNDED, PRESSED.
; init clears all fields; set inputs afterwards. No I/O, waits or IRQ ownership.
; A/X/flags change; Y and decimal mode survive. D=0 for binary arithmetic.
jump_windows_init:
    lda #0
    sta JUMPED
    sta COYOTE
    sta BUFFER
    sta GROUNDED
    sta PRESSED
    rts
jump_windows_step:
    lda #0
    sta JUMPED
    lda COYOTE
    beq jump_buffer
    dec COYOTE
jump_buffer:
    lda BUFFER
    beq jump_ground
    dec BUFFER
jump_ground:
    lda GROUNDED
    beq jump_press
    lda #COYOTE_STEPS
    sta COYOTE
jump_press:
    lda PRESSED
    beq jump_test
    lda #BUFFER_STEPS
    sta BUFFER
jump_test:
    lda COYOTE
    beq jump_done
    lda BUFFER
    beq jump_done
    lda #1
    sta JUMPED
    lda #0
    sta COYOTE
    sta BUFFER
jump_done:
    rts
component_end:

State, registers and ownership

Five bytes: JUMPED is a replaced pulse, COYOTE and BUFFER persist, GROUNDED and PRESSED are inputs. A timer of one expires at the start of this call unless its corresponding current input reloads it. A stationary grounded player with no buffered or new press does not jump.

A/X/flags may change; Y and decimal mode survive. Clear decimal mode before binary arithmetic; the caller uses CLD. A direct call needs two return-address stack bytes.

The implementation binds fixed symbols and has one mutation owner. Independent users need separate state blocks and scoped/renamed labels, including routine labels; sharing the same bindings means sharing the state. Do not let an interrupt and the main loop mutate the same block without an explicit ownership policy.

The standalone PRG owns screen/font/colour RAM, disables interrupt sources and does not restore BASIC. Its private scratch uses $70–$76 and the state binding starts at $80; its diagnostic output starts at $C000.

A caller you can adapt

The buildable program supplies bindings and include order. Its separate client retains state between calls. checked_step calls the public jump_windows_step entry and checks register sentinels; emit_state records diagnostics. Your game can call the public entry directly, remove the diagnostics and consume the documented outputs.

Bindings, buildable caller and include order

demo.asm
JUMPED=$80
COYOTE=$81
BUFFER=$82
GROUNDED=$83
PRESSED=$84
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=0
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
!if CUSTOM {
    lda #1
    sta PRESSED
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
    lda #0
    sta PRESSED
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
    lda #1
    sta GROUNDED
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
} else {
    lda #1
    sta PRESSED
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
    lda #0
    sta PRESSED
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
    lda #1
    sta GROUNDED
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #1
    sta PRESSED
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
}
    rts

The display fields are JUMPED, COYOTE, BUFFER, GROUNDED, PRESSED; remaining display fields are zero. Each is one hexadecimal byte. The normal row is 00 04 00 01 00 00 00 00. This is a completed state observation, not a recording of an actual character jumping, a camera scrolling or an asset being displayed.

Make it your own

The custom build shortens both windows from four calls to two. In the supplied trace, a press three calls before landing is accepted with the four-call buffer and has expired with the two-call buffer. The final row alone does not count earlier jump pulses; the complete packet retains every call.

Maintained custom configuration or caller

custom.asm
JUMPED=$80
COYOTE=$81
BUFFER=$82
GROUNDED=$83
PRESSED=$84
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=1
BROKEN=0
COYOTE_STEPS=2
BUFFER_STEPS=2
TEXT_CAP=4
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=49
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

The custom row is 00 02 00 01 00 00 00 00. These records qualify the named builds and inputs; another layout, format or input domain needs explicit bounds and fresh execution evidence.

Common mistake and repair

The wrong client supplies a held level as PRESSED on every call. The permission can be reissued on successive grounded updates even though the player never made another press. Feed a new-press edge and retain the timers; also update the caller’s grounded/movement state when it accepts a jump.

Select the deliberately wrong client

wrong.asm
JUMPED=$80
COYOTE=$81
BUFFER=$82
GROUNDED=$83
PRESSED=$84
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=1
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:
C64 shows the deliberate fault row 01 00 00 01 01 00 00 00.
The wrong client completes with 01 00 00 01 01 00 00 00. Its code and the explanation above identify the cause; the capture observes the result.

The BROKEN branch is visible in the client. repaired.asm restores the normal selection; its executable matches demo exactly. Spectrum tape filenames may differ while loaded CODE remains identical. The fault is a labelled experiment, not a suggested alternative.

Cost, limits and verification

The measured layout has 64 code bytes, 0 immutable data bytes and 5 private state bytes. Alignment, stack usage, diagnostic tables and display machinery are additional. The extents come from the loaded program’s symbols, not from counting source lines.

Two saturating decrements, input-dependent reloads and a pair of tests per update. There is no busy wait or keyboard scan. The same call-count settings represent different real durations under different service rates; choose a simulation clock before tuning them.

This is a logical permission service, not a complete platformer jump controller. Variable jump height, multiple air jumps, wall jumps and input consumption across menus need explicit additional policies. Freeze or advance the timers during pause deliberately. Respawn invulnerability is a different grace window and remains covered by damage/recovery.

Four native runs use PAL C64 with stock BASIC/KERNAL PRG startup and RUN; Asm198x 0.0.72 and Emu198x 0.44.2. Each checks 64 boundary/varied/error cases, dirty initialisation and every state of a separate sequential client against an independent integer model. This is not exhaustive coverage of every possible byte combination. Source/tool/firmware hashes, immutable loaded code/data, native startup/completion, preserved-register sentinels, helper/data sizes, screenshot bytes/pixels and exact repair are checked. Deliberately altered observations must fail. Firmware is hashed, never distributed.

Related technique · Maths for Games · The Craft.

Source and native evidence.