Deterministic Floating-Point Emulation in Games

In lockstep multiplayer games and physics-driven simulations, maintaining cross-architecture determinism across varied hardware platforms like x86 and ARM is notoriously difficult due to microarchitectural differences in IEEE-754 floating-point implementations. This article examines how game developers build deterministic floating-point emulation wrappers, detailing the mathematical pitfalls of hardware floats, the choice between software floating-point and fixed-point math, the implementation of cross-platform transcendental functions, and optimization techniques for keeping network simulations tightly synchronized.

The Problem with Native Floating-Point Hardware

While modern CPUs adhere to the IEEE-754 specification, the standard leaves several implementation details ambiguous or optional:

  • Fused Multiply-Add (FMA): Operations like (a * b) + c can execute as a single instruction with a single rounding step on modern architectures, or as two distinct instructions with intermediate rounding on older hardware, producing different low-order bits.
  • Transcendental Functions: Hardware-level trigonometric and logarithmic implementations (sin, cos, sqrt) rely on vendor-specific approximations, microcode, or polynomial expansions that vary widely between Intel, AMD, and ARM processors.
  • Denormals and NaN Payloads: Sign bits and payloads for NaN (Not a Number) values are not standardized. Hardware flags like Flush-to-Zero (FTZ) and Denormals-are-Zero (DAZ) behave differently or may not be supported uniformly across mobile and desktop silicon.
  • Compiler Reordering: Optimizations like -ffast-math or aggressive instruction scheduling can violate the associative law (\(a + (b + c) \neq (a + b) + c\)), leading to diverging results across different compiler versions.

Strategy 1: Fixed-Point Arithmetic Wrappers

The most common approach to deterministic simulation is replacing floating-point operations entirely with fixed-point math implemented over integer types.

Developers construct custom types using integer scalars, commonly employing formats like \(Q16.16\) (32-bit total: 16 bits integer, 16 bits fractional) or \(Q48.16\) / \(Q32.32\) (64-bit total):

struct Fixed64 {
    int64_t raw_value;
    static constexpr int FRACTIONAL_BITS = 32;
    static constexpr int64_t ONE = 1LL << FRACTIONAL_BITS;

    Fixed64() : raw_value(0) {}
    explicit Fixed64(int32_t val) : raw_value(static_cast<int64_t>(val) << FRACTIONAL_BITS) {}

    Fixed64 operator+(const Fixed64& rhs) const {
        Fixed64 result;
        result.raw_value = this->raw_value + rhs.raw_value;
        return result;
    }

    Fixed64 operator*(const Fixed64& rhs) const {
        Fixed64 result;
        // 128-bit intermediate storage prevents overflow during multiplication
        #if defined(__SIZEOF_INT128__)
            __int128 temp = static_cast<__int128>(this->raw_value) * static_cast<__int128>(rhs.raw_value);
            result.raw_value = static_cast<int64_t>(temp >> FRACTIONAL_BITS);
        #else
            // Fallback for platforms lacking 128-bit native types
            result.raw_value = multiply_split(this->raw_value, rhs.raw_value);
        #endif
        return result;
    }
};

Fixed-point math guarantees identical behavior across any architecture because integer addition, subtraction, bit-shifts, and multiplications are strictly specified in hardware logic.

Strategy 2: Pure Software Floating-Point (Soft-Float)

When a wide dynamic range is strictly required (such as in celestial mechanics or high-velocity collisions) and fixed-point scales are too restrictive, developers emulate the IEEE-754 standard entirely in software.

Instead of routing operations to the CPU's Floating-Point Unit (FPU), a soft-float wrapper unpacks the raw sign, exponent, and mantissa fields of a 32-bit or 64-bit integer, executes integer-based math, applies precise rounding rules (typically round-to-nearest, ties-to-even), and packs the fields back into an integer.

Soft-float wrappers enforce uniform behavior by:

  1. Hardcoding the handling of subnormal numbers without relying on CPU hardware flags.
  2. Canonicalizing all NaN results to a single predefined bit pattern (e.g., 0x7FC00000).
  3. Preventing the generation of architecture-dependent FMA instructions by explicitly handling intermediate precision in integer registers.

Implementing Deterministic Transcendental Functions

Standard C Runtime libraries (math.h) cannot be used because their implementations vary by platform. Deterministic wrappers substitute them with explicit mathematical approximations:

  • Look-Up Tables (LUTs): For trigonometric functions, developers precompute high-resolution tables (e.g., 4096 entries for a quarter-sine wave) and use integer linear interpolation for intermediate values.
  • CORDIC Algorithms: Coordinate Rotation Digital Computer (CORDIC) algorithms compute trigonometric, hyperbolic, and square root operations using only addition, subtraction, table lookups, and bit-shifts.
  • Chebyshev Polynomials: Fixed-point or software-float implementations often evaluate mini-max polynomial approximations where the order of operations and rounding modes are completely locked.

Isolating Simulation from Presentation

Because software-emulated floating-point and fixed-point types incur a measurable performance penalty compared to native hardware vector units, developers isolate their execution:

  1. State Partitioning: The deterministic wrapper is used strictly inside the simulation core: gameplay state, rigid-body physics, collision queries, and character logic.
  2. Decoupled Rendering: Graphics, animation blending, audio, and camera tracking consume the simulation state, convert the fixed/emulated values into native float or double representations, and run at native hardware speeds via standard SIMD (AVX, NEON) pipelines.
  3. Data Marshalling: Explicit cast operators are restricted to output-only boundaries to prevent accidental pollution of the simulation loop with native floating-point types.

Vectorization and SIMD Optimizations

To mitigate the overhead of software wrappers, developers implement custom SIMD wrappers that target integer vector instructions. Because integer vector registers (__m128i on x86, int32x4_t on ARM) execute identically across architectures, math routines can process four to eight simulated values simultaneously using integer shifts, masks, and parallel integer multiply operations. This maintains total bit-for-bit synchronization while recovering the performance necessary for high-tick-rate simulation engines.