Skip to content
Techniques & Technology

Fixed-Point Maths

Smooth movement without floating point

Fixed-point arithmetic gives 8-bit systems sub-pixel precision for smooth movement, physics, and animation—all with fast integer operations.

commodore-64sinclair-zx-spectrumcommodore-amiganintendo-entertainment-system programmingmathsperformance 1960–present

Overview

8-bit processors don't have floating-point hardware. Moving a sprite by 0.5 pixels per frame seems impossible when positions are whole numbers. Fixed-point maths solves this by treating integers as fractions—the upper bits hold the whole part, lower bits hold the fractional part.

The concept

Split a 16-bit value into whole and fractional parts:

16-bit fixed point (8.8 format):
WWWWWWWW.FFFFFFFF

W = whole number (0-255)
F = fraction (0/256 to 255/256)

The value $0180 represents 1.5:

  • High byte: $01 = 1
  • Low byte: $80 = 128/256 = 0.5

Common formats

Range columns assume unsigned values; signed two's-complement halves the range and adds negative values.

FormatRange (unsigned)PrecisionUse case
8.80 to 255.9961/256 ≈ 0.0039Positions, velocities
4.40 to 15.941/16 ≈ 0.0625Compact, less precision
12.40 to 4095.941/16 ≈ 0.0625Large coordinates
1.70 to ~1.9921/128 ≈ 0.0078Interpolation, percentages
16.160 to ~65,5361/65,536High-precision physics (16/32-bit CPUs)

Basic operations

Addition and subtraction

Same as regular integer operations:

; Add velocity to position (16-bit)
    clc
    lda pos_lo
    adc vel_lo
    sta pos_lo
    lda pos_hi
    adc vel_hi
    sta pos_hi

Getting the whole part

Just read the high byte:

    lda pos_hi      ; whole number for screen position
    sta sprite_x

Setting a value

; Set position to 100.5
    lda #100
    sta pos_hi
    lda #128        ; 0.5 = 128/256
    sta pos_lo

Multiplication

More complex—typically done with shifts and adds.

Multiply by constant (shift)

; Multiply by 2
    asl pos_lo
    rol pos_hi

; Multiply by 4
    asl pos_lo
    rol pos_hi
    asl pos_lo
    rol pos_hi

General multiplication

For 8.8 × 8.8, the result is 16.16—keep the middle 16 bits:

; Simplified: multiply A by fixed-point B
; Result = (A × B) >> 8
multiply_8x8:
    ; Uses 16-bit intermediate
    ; ... (platform-specific implementation)

Division

Divide by power of 2 (shift right)

; Divide by 2
    lsr pos_hi
    ror pos_lo

; Divide by 4
    lsr pos_hi
    ror pos_lo
    lsr pos_hi
    ror pos_lo

General division

More expensive—often avoided by multiplying by reciprocal.

Practical example: smooth movement

Moving 1.5 pixels per frame:

; Velocity = 1.5 = $0180
velocity_hi:  .byte $01
velocity_lo:  .byte $80

; Position starts at 50.0 = $3200
position_hi:  .byte $32
position_lo:  .byte $00

update_position:
    clc
    lda position_lo
    adc velocity_lo
    sta position_lo
    lda position_hi
    adc velocity_hi
    sta position_hi

    ; Use whole part for sprite
    lda position_hi
    sta sprite_x
    rts

After 2 frames: 50.0 → 51.5 → 53.0

Gravity simulation

; Gravity adds to velocity each frame
gravity_lo:    .byte $40     ; 0.25 pixels/frame²
gravity_hi:    .byte $00

apply_gravity:
    ; velocity += gravity
    clc
    lda vel_lo
    adc gravity_lo
    sta vel_lo
    lda vel_hi
    adc gravity_hi
    sta vel_hi

    ; position += velocity
    clc
    lda pos_lo
    adc vel_lo
    sta pos_lo
    lda pos_hi
    adc vel_hi
    sta pos_hi
    rts

This creates smooth, realistic falling motion.

Negative numbers

For signed fixed-point, use two's complement:

; -1.5 in 8.8 signed = $FE80
; $FE = -2, $80 = +0.5, total = -1.5

Subtraction and signed comparison require care.

Lookup tables alternative

For complex functions (sine, square root), use pre-calculated tables. The conventional 8-bit signed-sine encoding is (sin(angle) + 1) × 127, mapping the full ±1 range into unsigned bytes 0-254 (with 127 representing sin = 0):

; 256-entry sine table indexed by angle 0-255 = one full circle
; entry = round((sin(angle * 2π / 256) + 1) * 127)
sine_table:
    .byte 127, 130, 133, 136, 139, 143, 146, 149   ; angles 0-7
    .byte 152, 155, 158, 161, 164, 167, 170, 173   ; angles 8-15
    ; ... continue for 256 entries (entire circle)

Tables trade memory for speed — essential on 8-bit systems. See Lookup Tables for build-time generation, page alignment, and quarter-squares multiplication.

Platform notes

NES

Limited RAM (2 KB main + 8 KB optional cart RAM) makes 16-bit variables expensive. Use 8.8 sparingly; for many gameplay quantities (sub-pixel velocities, fractional health) the 8 fractional bits are overkill — 4.4 fits in a byte.

ZX Spectrum

The Z80 has 16-bit register pairs (BC, DE, HL plus shadow set BC'/DE'/HL') that handle 16-bit add/subtract. ADD HL,DE is 11 T-states for a full 16-bit add — fast for fixed-point work. The IX/IY index registers are useful for indexed addressing into struct-like records but are roughly 2-3× slower than HL for the same indexing operation; reserve them for one variable at a time.

C64

The 6510 has no native 16-bit registers, but zero page behaves as a 256-byte register file: LDA zp is 3 cycles vs 4 for absolute, and operations chain cleanly (CLC; LDA pos_lo; ADC vel_lo; STA pos_lo; LDA pos_hi; ADC vel_hi; STA pos_hi). Allocate fixed-point variables in zero page wherever possible — the difference adds up across a frame.

Amiga

The 68000 is a true 32-bit-internal CPU; 16.16 fixed-point fits naturally into a single D register and ADD.L / SUB.L are one instruction. Demos and games use 16.16 freely for camera transforms, physics, and any sub-pixel work.

See also