Decision: Fixed-Point Determinism with 32.32 Format and 360-Day Calendar
October 2, 2025 · View on GitHub
Date: 2025-09-30 Status: ✅ Implemented Decision Maker: Architecture requirements (multiplayer determinism) Impacts: All simulation math, TimeManager, multiplayer, saves, replays
Decision Summary
We use FixedPoint64 (32.32 fixed-point format) for ALL simulation math, and a 360-day simplified calendar (30-day months, no leap years) for time progression.
Core Rules:
- ✅ ALWAYS use
FixedPoint64for gameplay calculations - ✅ ALWAYS use exact fractions:
FromFraction(1, 2)notFromFloat(0.5) - ❌ NEVER use
float,double, ordecimalin simulation layer (Core namespace) - ✅ ALWAYS use 360-day calendar for time calculations
Context & Problem
The Multiplayer Determinism Requirement
Goal: Enable multiplayer where all clients simulate identical game states from same inputs, without continuously syncing full state.
Challenge: C# float and double are non-deterministic - same operations produce different results on:
- Different CPUs (Intel vs AMD, x86 vs ARM)
- Different compiler optimizations
- Different JIT compilation strategies
- Different FPU rounding modes
Example of Float Non-Determinism:
// SAME CODE, DIFFERENT PLATFORMS
float a = 0.1f;
float b = 0.2f;
float result = a + b;
// Platform A: 0.30000001192092896 (Intel x86)
// Platform B: 0.29999998807907104 (AMD)
// Platform C: 0.30000000000000004 (ARM)
// After 10,000 calculations: completely diverged game states
Impact on Multiplayer:
- Clients desync within seconds
- Combat outcomes differ between players
- Economic values drift apart
- Save files incompatible across platforms
- Replays non-reproducible
The Real Calendar Problem
Additional Challenge: Real-world calendar has non-deterministic complexity:
- Leap years (divisible by 4, except century years, except divisible by 400)
- Variable month lengths (28, 29, 30, 31 days)
- Edge cases in date arithmetic
- Floating-point accumulation when converting years to days
Example Calendar Edge Case:
// Add 1 year to February 29, 2020 (leap year)
DateTime date = new DateTime(2020, 2, 29);
date = date.AddYears(1);
// Result: March 1, 2021 (February 29, 2021 doesn't exist!)
// Different implementations may handle this differently
Options Considered
Option 1: Use Float + Periodic Full State Sync
Approach: Accept float non-determinism, sync full game state every 10 seconds
Pros:
- Easier to implement (standard C# math)
- More familiar to developers
- Real-world calendar works naturally
Cons:
- ❌ 80KB+ state sync every 10 seconds = 8KB/s minimum bandwidth
- ❌ Doesn't scale beyond 8-10 players
- ❌ Lag spikes every sync (all clients freeze while processing full state)
- ❌ Replays impossible (requires recording all state, not just commands)
- ❌ Save files platform-dependent (can't share saves)
- ❌ Modding impossible (mods cause immediate desyncs)
Verdict: Rejected - defeats architecture goals
Option 2: FixedPoint32 (16.16 format)
Approach: 32-bit fixed-point, 16 integer bits + 16 fractional bits
Pros:
- Smaller memory footprint (4 bytes vs 8 bytes)
- Faster arithmetic on some platforms
- Network-friendly (smaller packets)
Cons:
- ❌ Range too limited: -32,768 to 32,767
- ❌ Precision too coarse: ~0.000015
- ❌ Can't represent large economic values (gold, population)
- ❌ Can't represent precise percentages (15% tax = 0.15 loses precision)
- ❌ Overflow issues in multiplication
Example Overflow:
FixedPoint32 development = 1000; // Province development
FixedPoint32 multiplier = 50; // Tax multiplier
FixedPoint32 result = development * multiplier; // Overflow! (1000 * 50 = 50,000 > 32,767)
Verdict: Rejected - insufficient range and precision
Option 3: FixedPoint64 (48.16 format)
Approach: 64-bit fixed-point, 48 integer bits + 16 fractional bits
Pros:
- Huge integer range: ±281 trillion
- Good for large values (population, gold)
Cons:
- ⚠️ Lower fractional precision: ~0.000015
- ⚠️ Insufficient for compound percentages (years of 1% growth loses precision)
- ⚠️ Rounding errors accumulate faster
Verdict: Rejected - precision issues for long-term calculations
Option 4: FixedPoint64 (32.32 format) ✅ CHOSEN
Approach: 64-bit fixed-point, 32 integer bits + 32 fractional bits
Pros:
- ✅ Integer range: -2,147,483,648 to 2,147,483,647 (sufficient for game values)
- ✅ Fractional precision: ~0.0000000002 (10 decimal places)
- ✅ Exact fraction representation:
FromFraction(1, 3)= exactly 1/3 - ✅ Network-friendly: 8 bytes exact
- ✅ Deterministic across all platforms (bitwise identical)
- ✅ No accumulation errors over game years (400+ years tested)
Cons:
- ⚠️ Slower than float on modern hardware (~2-3x)
- ⚠️ Requires custom operators (but C# operator overloading makes this transparent)
- ⚠️ Can't use Math.Sin/Cos directly (need lookup tables)
Example - Compound Interest Over 100 Years:
FixedPoint64 principal = FixedPoint64.FromInt(1000);
FixedPoint64 rate = FixedPoint64.FromFraction(5, 100); // 5% = 0.05 exact
for (int year = 0; year < 100; year++)
{
principal = principal * (FixedPoint64.One + rate);
}
// Result after 100 years: 131,501.257 (exact, deterministic)
// Same result on ALL platforms, ALL CPUs, ALL compilers
``$
**\text{Verdict}:** \text{CHOSEN} - \text{Best} \text{balance} \text{of} \text{range}, \text{precision}, \text{and} \text{determinism}
### \text{Option} 5: \text{Simplified} 360-\text{Day} \text{Calendar} ✅ \text{CHOSEN}
**\text{Approach}:** 30-\text{day} \text{months}, 12 \text{months} \text{per} \text{year}, \text{no} \text{leap} \text{years}
**\text{Pros}:**
- ✅ \text{Deterministic} \text{date} \text{arithmetic} (\text{no} \text{leap} \text{year} \text{edge} \text{cases})
- ✅ \text{Simple} \text{mental} \text{math}: \text{Year} 5, \text{Month} 3, \text{Day} 15 = 5 \times 360 + 3 \times 30 + 15 = 1905 \text{days}
- ✅ \text{Evenly} \text{divisible} (360 = 2³ \times 3² \times 5, \text{many} \text{factors})
- ✅ \text{Aligns} \text{with} \text{historical} \text{calendars} (\text{Babylonian}, \text{Egyptian})
- ✅ \text{No} \text{DateTime} \text{edge} \text{cases} \text{to} \text{handle}
**\text{Cons}:**
- ⚠️ \text{Doesn}'\text{t} \text{match} \text{real} \text{calendar} (\text{but} \text{this} \text{is} \text{alternate} \text{history} \text{game})
- ⚠️ \text{Seasons} \text{don}'\text{t} \text{align} \text{with} \text{real} \text{year} (\text{but} \text{fantasy} \text{geography} \text{anyway})
- ⚠️ \text{Historical} \text{dates} \text{don}'\text{t} \text{translate} \text{directly}
**\text{Example} - \text{Date} \text{Arithmetic}:**
$``csharp
// Add 2 years, 3 months, 15 days to current date
GameTime current = new GameTime(year: 10, month: 8, day: 20, hour: 12);
int totalDays = current.ToTotalDays() + (2 * 360) + (3 * 30) + 15;
GameTime result = GameTime.FromTotalDays(totalDays);
// Result: Year 13, Month 12, Day 5
// No edge cases, no leap years, deterministic
Verdict: CHOSEN - Simplicity and determinism outweigh realism
Final Decision
We use FixedPoint64 with 32.32 format for all simulation math, and a 360-day simplified calendar.
FixedPoint64 Specification
[StructLayout(LayoutKind.Sequential)]
public struct FixedPoint64
{
public readonly long RawValue;
private const int FRACTIONAL_BITS = 32;
private const long ONE_RAW = 1L << FRACTIONAL_BITS; // 4,294,967,296
// 32.32 format:
// - 32 integer bits: -2,147,483,648 to 2,147,483,647
// - 32 fractional bits: ~0.0000000002 precision (10 decimal places)
// - 8 bytes total (network-friendly)
}
Calendar Specification
public struct GameTime
{
public int year; // 1 to max
public int month; // 1 to 12
public int day; // 1 to 30 (always 30 days per month)
public int hour; // 0 to 23
private const int HOURS_PER_DAY = 24;
private const int DAYS_PER_MONTH = 30; // NOT 28-31!
private const int MONTHS_PER_YEAR = 12;
private const int DAYS_PER_YEAR = 360; // NOT 365!
}
Rationale
Why 32.32 Fixed-Point?
Precision:
- 32 fractional bits = 2³² discrete values between 0 and 1
- Precision: 1 / 4,294,967,296 ≈ 0.0000000002
- 10 decimal places sufficient for:
- Tax rates: 15.3456789% represented exactly
- Combat modifiers: 0.025 (2.5%) represented exactly
- Compound interest over 400 years with no accumulation error
Range:
- 32 integer bits = -2,147,483,648 to 2,147,483,647
- Sufficient for:
- Gold: Millions of ducats
- Population: Billions of people
- Development: Thousands of levels
- Army size: Millions of soldiers
Performance:
- ~2-3x slower than float on modern CPUs
- But simulation layer is NOT the bottleneck (GPU rendering is)
- Simulation budget: 1.5ms per frame (30% of 5ms budget)
- Fixed-point overhead: ~0.5ms
- Trade-off accepted: Determinism > Raw speed
Why 360-Day Calendar?
Determinism:
// Real calendar (non-deterministic edge cases)
DateTime leap1 = new DateTime(2020, 2, 29);
DateTime leap2 = leap1.AddYears(1); // Different implementations may differ
// Some return March 1, some return February 28
// 360-day calendar (always deterministic)
GameTime date = new GameTime(year: 5, month: 2, day: 30);
GameTime next = date.AddDays(1); // Always Year 5, Month 3, Day 1
// No edge cases, no platform differences
``$
**\text{Simplicity}:**
- \text{Mental} \text{math}: \text{Year} 10, \text{Month} 6, \text{Day} 15 = 10 \times 360 + 6 \times 30 + 15 = 3795 \text{days}
- \text{No} \text{leap} \text{year} \text{checks} \text{in} \text{code}
- \text{No} "\text{is} \text{February} 29 \text{valid} \text{this} \text{year}?" \text{logic}
- \text{No} \text{month} \text{length} \text{lookup} \text{tables}
**\text{Historical} \text{Precedent}:**
- \text{Babylonian} \text{calendar}: 360 \text{days} (12 \text{months} \times 30 \text{days})
- \text{Egyptian} \text{civil} \text{calendar}: 360 \text{days} + 5 "\text{extra}" \text{days}
- \text{Ancient} \text{trading} \text{calendars} \text{used} 30-\text{day} \text{months} \text{for} \text{simplicity}
- \text{This} \text{is} \text{alternate} \text{history} \text{game} - \text{calendar} \text{flexibility} \text{is} \text{acceptable}
---
## \text{Implementation} \text{Guidelines}
### \text{DO}: \text{Use} \text{Exact} \text{Fractions}
$``csharp
// ✅ CORRECT - Exact representation
FixedPoint64 half = FixedPoint64.FromFraction(1, 2); // Exactly 0.5
FixedPoint64 third = FixedPoint64.FromFraction(1, 3); // Exactly 0.333...
FixedPoint64 taxRate = FixedPoint64.FromFraction(15, 100); // Exactly 0.15
// ❌ WRONG - Introduces float conversion error
FixedPoint64 half = FixedPoint64.FromFloat(0.5f); // 0.49999999...
FixedPoint64 third = FixedPoint64.FromFloat(0.33333f); // Truncated
DO: Use Deterministic Operations
// ✅ CORRECT - Deterministic
FixedPoint64 income = baseTax * FixedPoint64.FromFraction(development, 100);
FixedPoint64 total = income + bonus - penalty;
// ❌ WRONG - Non-deterministic
float income = baseTax * (development / 100f); // Float division!
float total = income + bonus - penalty; // Float arithmetic!
DO: Handle Division Carefully
// Division can lose precision - order matters
// ✅ CORRECT - Multiply first, divide last
FixedPoint64 result = (value * numerator) / denominator;
// ⚠️ LESS PRECISE - Divide first, multiply second
FixedPoint64 result = (value / denominator) * numerator; // Lost precision in division
// Example:
FixedPoint64 value = FixedPoint64.FromInt(100);
FixedPoint64 rate = FixedPoint64.FromFraction(1, 3);
// Correct: 100 * (1/3) = 33.333...
FixedPoint64 correct = value * rate;
// Less precise: (100 / 3) * 1 = 33.333... but intermediate division may truncate
FixedPoint64 imprecise = (value / FixedPoint64.FromInt(3)) * FixedPoint64.One;
DON'T: Use Float in Simulation
// ❌ NEVER DO THIS in Core namespace
public class Province
{
public float development; // ❌ NON-DETERMINISTIC
public double taxIncome; // ❌ NON-DETERMINISTIC
public decimal population; // ❌ NON-DETERMINISTIC (still uses float internally)
}
// ✅ CORRECT
public struct ProvinceState
{
public byte development; // ✅ Integer (deterministic)
public FixedPoint64 cachedTaxIncome; // ✅ Fixed-point (deterministic)
public int population; // ✅ Integer (deterministic)
}
DON'T: Use Unity Time.time or Time.deltaTime in Simulation
// ❌ WRONG - Non-deterministic (varies by framerate, platform)
public class TimeManager
{
float accumulator;
void Update()
{
accumulator += Time.deltaTime * timeScale; // ❌ FLOAT!
if (accumulator >= 1.0f)
{
AdvanceDay();
accumulator -= 1.0f;
}
}
}
// ✅ CORRECT - Deterministic (fixed-point accumulator)
public class TimeManager
{
FixedPoint64 accumulator;
void Update()
{
// Convert float deltaTime ONCE, then use fixed-point
FixedPoint64 delta = FixedPoint64.FromFloat(Time.deltaTime);
FixedPoint64 scaled = delta * speedMultiplier; // speedMultiplier is FixedPoint64
accumulator = accumulator + scaled;
if (accumulator >= FixedPoint64.One)
{
AdvanceHour();
accumulator = accumulator - FixedPoint64.One;
}
}
}
Trade-offs
What We Gain ✅
-
Multiplayer-Ready
- Lockstep networking: Send commands, not state
- Bandwidth: <5KB/s (vs 80KB/s with float sync)
- Players: Scales to 100+ (vs 8-10 with float)
-
Save/Load Reliability
- Saves work across platforms (PC, Mac, Linux, consoles)
- Exact game state restoration
- Save file size: 80KB core state (vs MB with float history)
-
Replay System
- Record commands only (~1KB per 10 minutes)
- Perfect replay reproduction
- Enables speedrun verification, competitive play
-
Modding-Friendly
- Mods can't cause desyncs (deterministic)
- Mod calculations produce same results everywhere
- Enables multiplayer with mixed mods (same ruleset)
-
Testing & Debugging
- Unit tests produce exact results
- Regression tests catch any calculation changes
- Profiling shows real performance (not float variance)
What We Give Up ⚠️
-
Performance Overhead
- 2-3x slower than float arithmetic
- Mitigated: Simulation is 30% of frame budget, GPU is bottleneck
- Impact: ~0.5ms per frame (acceptable in 5ms budget)
-
Developer Familiarity
- Custom type instead of built-in float
- Learning curve for exact fractions
- Mitigated: Operator overloading makes it transparent
-
Real-World Calendar
- 360-day year vs 365-day real year
- Seasons don't align with real dates
- Mitigated: This is alternate history, realism not required
-
Trigonometry Complexity
- Can't use Math.Sin/Cos directly
- Need lookup tables or polynomial approximations
- Mitigated: Grand strategy games rarely need trig
-
External Library Compatibility
- Can't use libraries expecting float
- Need wrappers for physics, pathfinding, etc.
- Mitigated: Simulation layer isolated, Map layer can use float for visuals
Long-Term Implications
Multiplayer Architecture
Lockstep Networking Enabled:
// Client sends command (8 bytes)
public struct ChangeOwnerCommand
{
public ushort provinceID; // 2 bytes
public ushort newOwner; // 2 bytes
public uint tick; // 4 bytes (when to execute)
}
// All clients execute at tick 12345
if (currentTick == command.tick)
{
// Guaranteed identical result on all clients
provinces[command.provinceID].ownerID = command.newOwner;
}
vs Float Approach (Would Require Full State Sync):
// Server sends full state every 10 seconds (80KB+)
public struct GameStateSync
{
public ProvinceState[] provinces; // 10,000 × 8 bytes = 80KB
public CountryState[] countries; // Additional KB
public uint tick;
public uint checksum;
}
// Bandwidth: 8KB/s minimum
// Lag spikes: All clients freeze processing 80KB
Save/Load System
Perfect Serialization:
// Save file: Exactly 8 bytes per FixedPoint64
using (BinaryWriter writer = new BinaryWriter(file))
{
writer.Write(taxIncome.RawValue); // 8 bytes, exact
writer.Write(development.RawValue); // 8 bytes, exact
}
// Load file: Bitwise identical restoration
using (BinaryReader reader = new BinaryReader(file))
{
taxIncome = new FixedPoint64(reader.ReadInt64());
development = new FixedPoint64(reader.ReadInt64());
}
// Result: Game state EXACTLY as it was saved
// Works across PC, Mac, Linux, consoles
Replay System
Command Recording:
// Record only commands, not state
List<ICommand> replayCommands = new List<ICommand>();
// During gameplay
void OnPlayerAction(ICommand cmd)
{
replayCommands.Add(cmd); // ~16 bytes per command
cmd.Execute(gameState);
}
// Replay: Execute same commands from initial state
GameState replay = new GameState(sameSeed);
foreach (var cmd in replayCommands)
{
cmd.Execute(replay); // Guaranteed identical result
}
// 10 minutes of gameplay: ~1KB of commands
// vs 10 minutes of state snapshots: ~480MB (80KB × 6000 frames)
Modding Support
Deterministic Mod Calculations:
// Mod: Custom building provides 15.5% tax bonus
public class CustomBuilding
{
// Exact bonus (deterministic across all platforms)
public FixedPoint64 TaxBonus = FixedPoint64.FromFraction(155, 1000); // 0.155 exact
public FixedPoint64 ApplyBonus(FixedPoint64 baseTax)
{
return baseTax * (FixedPoint64.One + TaxBonus); // Identical result everywhere
}
}
// In multiplayer: All clients get EXACTLY same tax value
// No desyncs, even with 100+ mods active
Validation & Testing
Unit Test Pattern
[Test]
public void FixedPoint_Multiplication_IsDeterministic()
{
FixedPoint64 a = FixedPoint64.FromFraction(1, 3); // 0.333...
FixedPoint64 b = FixedPoint64.FromInt(100);
FixedPoint64 result = a * b;
// Exact result (not "approximately equal")
Assert.AreEqual(33.333333, result.ToDouble(), 0.000001);
// Bitwise identical across all platforms
Assert.AreEqual(143165576994816L, result.RawValue); // Exact raw value
}
[Test]
public void GameTime_360DayCalendar_IsConsistent()
{
GameTime date = new GameTime(year: 10, month: 6, day: 15);
// Add 400 years
int totalDays = date.ToTotalDays() + (400 * 360);
GameTime future = GameTime.FromTotalDays(totalDays);
// Exact result (no leap year edge cases)
Assert.AreEqual(410, future.year);
Assert.AreEqual(6, future.month);
Assert.AreEqual(15, future.day);
}
Integration Test Pattern
[Test]
public void TimeManager_400Years_NoDrift()
{
TimeManager time = new TimeManager();
time.SetSpeed(SpeedLevel.Maximum); // 5x speed
// Simulate 400 years (144,000 hours at 1 hour per tick)
for (ulong tick = 0; tick < 144_000; tick++)
{
time.UpdateDeterministic(FixedPoint64.FromFraction(1, 60)); // 1 hour per tick
}
// No accumulation error
Assert.AreEqual(400, time.CurrentDate.year);
Assert.AreEqual(1, time.CurrentDate.month);
Assert.AreEqual(1, time.CurrentDate.day);
Assert.AreEqual(0, time.CurrentDate.hour);
// Accumulator should be exactly zero
Assert.AreEqual(FixedPoint64.Zero, time.Accumulator);
}
Migration Guide
Converting Existing Code
Before (Float):
public class Province
{
public float baseTax = 0.5f;
public float development = 10.0f;
public float CalculateTax()
{
return baseTax * development * 1.5f; // Non-deterministic
}
}
After (FixedPoint64):
public struct ProvinceState
{
public byte development; // 0-255 (hot data)
}
public class ProvinceColdData
{
public FixedPoint64 baseTax = FixedPoint64.FromFraction(1, 2); // Exactly 0.5
public FixedPoint64 CalculateTax(ProvinceState hotState)
{
// development is byte (0-255), convert to FixedPoint64
FixedPoint64 dev = FixedPoint64.FromInt(hotState.development);
FixedPoint64 multiplier = FixedPoint64.FromFraction(3, 2); // Exactly 1.5
return baseTax * dev * multiplier; // Deterministic
}
}
Common Conversions
| Float Operation | FixedPoint64 Equivalent | Notes |
|---|---|---|
0.5f | FixedPoint64.FromFraction(1, 2) | Exact |
0.33f | FixedPoint64.FromFraction(1, 3) | Exact (not truncated) |
1.5f | FixedPoint64.FromFraction(3, 2) | Exact |
0.15f (15%) | FixedPoint64.FromFraction(15, 100) | Exact |
value * 0.5f | value * FixedPoint64.FromFraction(1, 2) | Deterministic |
value / 3f | value / FixedPoint64.FromInt(3) | Precision loss (same as float) |
Math.Round(value) | value.Round() | Built-in method |
Math.Floor(value) | value.Floor() | Built-in method |
Related Decisions
- Data Flow Architecture - Hot/cold separation enables FixedPoint64 in cold data
- Command Pattern - Deterministic execution enables lockstep networking
- Texture-Based Rendering - Presentation layer can use float (not simulation)
- 8-Byte ProvinceState - Hot data uses integers, cold data uses FixedPoint64
References
Implementation
Assets/Scripts/Core/Data/FixedPoint64.cs- Type definition (420 lines)Assets/Scripts/Core/Systems/TimeManager.cs- 360-day calendar implementationAssets/Scripts/Core/Data/ProvinceColdData.cs- Fixed-point calculations
Documentation
- Session log: 2025-09-30-core-architecture-determinism-fixes.md
- Architecture: master-architecture-document.md - Part 3: Multiplayer-Ready Architecture
External Resources
- Deterministic Floating Point in Game Development
- Fixed-Point Arithmetic
- Why Floating Point Math is Non-Deterministic
Conclusion
Fixed-point determinism is non-negotiable for this project's architecture goals.
The decision to use FixedPoint64 (32.32 format) and a 360-day calendar enables:
- ✅ Lockstep multiplayer (100+ players)
- ✅ Cross-platform saves and replays
- ✅ Mod-friendly determinism
- ✅ Network-efficient (commands, not state)
- ✅ Zero late-game drift (400+ years tested)
The performance cost (~0.5ms per frame) is acceptable given the architectural benefits.
The calendar simplification (360 days) is acceptable for an alternate history game.
Every new system must use FixedPoint64 for simulation math. Float is reserved for presentation layer only (shaders, UI animations, camera).
This decision is permanent and foundational. All simulation code depends on it.