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-systemprogrammingmathsperformance

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.

Format Range (unsigned) Precision Use case
8.8 0 to 255.996 1/256 ≈ 0.0039 Positions, velocities
4.4 0 to 15.94 1/16 ≈ 0.0625 Compact, less precision
12.4 0 to 4095.94 1/16 ≈ 0.0625 Large coordinates
1.7 0 to ~1.992 1/128 ≈ 0.0078 Interpolation, percentages
16.16 0 to ~65,536 1/65,536 High-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.

The machine did ship with floating point: Commodore BASIC keeps two floating accumulators in zero page ($61–$66 and $69–$6E), and machine-code programs can call the ROM routines. Krill/Plush’s Mathematics in Assembly series, written for GO64! magazine, says why demo coders did not: the 6510’s “command set … supplies all powerful operations - addition, subtraction, shift and rotate, and that’s it”, the Kernal’s floating-point routines “are far from what we call fast and optimized”, and “fixed point arithmetic routines are fast and accurate enough” because the coder chooses “the amount of fraction bits” and the range.

Stephen Judd and George Taylor’s 1994 3D article in C=Hacking chose its scale out loud. Sines and cosines “range between negative one and one”, so “instead of dealing with the number 0.2, we use the number 64*0.2 = 12.8 = 13, and divide the end result by 64”. The factor is a power of two “to make the division at the end simple (just an LSR)”; “128 is too big. 32 doesn’t give us much accuracy.” Their warning is the one every fixed-point coder meets: with a = b = 64, “(a+b)/2 will not give the same answer as a/2 + b/2”, because the sum overflows the signed byte before the halving.

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

Not yet fact-checked. This entry was drafted by an AI and nobody has verified it. The dates, figures and technical details may be wrong. Use it to find your bearings, then confirm anything that matters against a primary source.