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