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