Skip to content

Fractional Position Updates on C64

A quarter-pixel step should eventually move the sprite a whole pixel.

A quarter-pixel step should eventually move the sprite a whole pixel. Keep the fraction between updates and four small steps add up; throw it away each time and the sprite never gets there.

C64 shows the working diagnostic row 00 65 40 00 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 POS_LO, POS_HI, VEL_LO, VEL_HI as 4 contiguous private bytes in that order. Include the helper once. Call fractional_position_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call fractional_position_step for each intended service.

Initialise once, then set both position bytes and both velocity bytes. Call once per simulation update. Use POS_HI as the integer coordinate only where that coordinate range suits the renderer; retain POS_LO in the game state. The normal client adds 0.25 four times to position 100 and reaches exactly 101.

POS_LO/POS_HI hold an unsigned 8.8 position: the low byte counts 1/256 units and the high byte is the integer part. VEL_LO/VEL_HI hold a signed two’s-complement 8.8 displacement. Step adds the whole two-byte displacement to the whole position, preserving both velocity bytes. Position wraps modulo 65,536 raw units by design; this helper does not clamp a world boundary.

Reusable initialisation and service

6502.inc
; SPDX-License-Identifier: MIT
; Fractional Position Updates. Contiguous private bytes: POS_LO, POS_HI, VEL_LO, VEL_HI.
; 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.
fractional_position_init:
    lda #0
    sta POS_LO
    sta POS_HI
    sta VEL_LO
    sta VEL_HI
    rts
fractional_position_step:
    lda POS_LO
    clc
    adc VEL_LO
    sta POS_LO
    lda POS_HI
    adc VEL_HI
    sta POS_HI
    rts
component_end:

State, registers and ownership

Four persistent/input bytes. A displacement of −0.5 is FF80 in hexadecimal, stored low byte 80 then high byte FF. One raw unit is 1/256 of your chosen world unit; these are units per service call, not per second.

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 fractional_position_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
POS_LO=$80
POS_HI=$81
VEL_LO=$82
VEL_HI=$83
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 #0
    sta POS_LO
    lda #100
    sta POS_HI
    lda #128
    sta VEL_LO
    lda #0
    sta VEL_HI
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
} else {
    lda #0
    sta POS_LO
    lda #100
    sta POS_HI
    lda #64
    sta VEL_LO
    lda #0
    sta VEL_HI
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta POS_LO
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
}
    rts

The display fields are POS_LO, POS_HI, VEL_LO, VEL_HI; remaining display fields are zero. Each is one hexadecimal byte. The normal row is 00 65 40 00 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 client uses 0.5 per update instead of 0.25. Four calls reach 102. The representation and helper code are unchanged.

Maintained custom configuration or caller

custom.asm
POS_LO=$80
POS_HI=$81
VEL_LO=$82
VEL_HI=$83
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 66 80 00 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 clears POS_LO before every call. The quarter-step is always restarted from a whole unit, so four calls leave position 100.25 instead of 101. Retain the fractional byte between calls; discarding it only for drawing is harmless, discarding it from state is not.

Select the deliberately wrong client

wrong.asm
POS_LO=$80
POS_HI=$81
VEL_LO=$82
VEL_HI=$83
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 40 64 40 00 00 00 00 00.
The wrong client completes with 40 64 40 00 00 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 25 code bytes, 0 immutable data bytes and 4 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.

One two-byte addition, with no rendering or wait. The 6502 carries from the low byte; Z80 adds a word; 68000 packs the byte binding into a word. Integer movement is smaller when fractional motion is unnecessary, but cannot accumulate these sub-unit steps.

An 8.8 unsigned position covers 0–255.996 units. C64 and Amiga displays can extend beyond 255 horizontal pixels, so do not mistake this for a complete screen-coordinate format. Wider worlds need a wider integer part. The Amiga Flock lessons already use 16.16 positions; that is a useful wider alternative rather than a reason to squeeze those games into this byte-oriented interface. Clamp or wrap at the game’s actual bounds as a separate policy.

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.