Dev Diary #1: Fixed-point numbers and deterministic dice
In short: In MKA Engine game calculations use fixed-point numbers (Q40.24) instead of floating point, so every computer gives the same result. Dice come from a stateless generator: the same seed and the same question always give the same roll, and the order of rolls does not change the result. The calendar has 365 days and no leap year.
In a strategy game everything is made of three things: numbers (population, gold, stability), dice (the outcome of a battle, whether an event fires) and the calendar (what day it is, whether the month has started). The bottom layer of MKA Engine provides these three tools, and every system above is built on them. In this diary we cover all three in turn: how they work, why we built them this way and what they cost.
The three share one rule: the same input always gives the same result. Load a save and the game continues down the same path, a bug can be reproduced exactly, a game can be replayed from start to finish. How multiplayer relies on this rule is covered in its own diary, Multiplayer and consistency.
The first stone: numbers
A computer does not know 0.1
The usual way computers store numbers is called floating point. It stores decimal numbers approximately, in binary; the best-known result is that 0.1 + 0.2 does not give 0.3 but 0.30000000000000004. That in itself is not the problem. The real problem is that the same formula can give a different result on a different machine or with different build settings. Some processors combine a multiply and an add into one instruction, others do them separately; because the intermediate result is rounded differently, the two paths can give different bits (Fiedler, Floating Point Determinism (opens in a new tab)).
| Operation | Floating point | MKA Engine number (Fx) |
|---|---|---|
| Is 0.1 + 0.2 equal to 0.3? | No, it gives 0.30000000000000004 | Yes, exactly equal |
| 0.1 × 10 − 1 (separate multiply and add) | 0 | 0.00000024 |
| 0.1 × 10 − 1 (with the combined instruction) | 0.0000000000000000555 | 0.00000024 |
| Same result on every machine? | May change with the build and the processor | Always the same |
The point of the table is not that our number is more "correct". Ours cannot hold 0.1 exactly either; it holds the nearest value, 0.10000002384, which is why the second row gives 0.00000024. The difference is that this small error is always the same error.
The fix: store numbers like cents
A bank account stores money not as fractions of a dollar but as whole cents: $12.50 is 1,250 cents. There is no half of a cent; the arithmetic is done with whole numbers, and whole numbers come out the same on every computer. We do the same, only our unit is not the cent but 1 / 16,777,216 (the inverse of 2 to the power of 24). The engine's number type, Fx, is a 64-bit whole number that counts how many of these units there are. For example the number 1.0 is stored inside as 16,777,216.
- 64 bit size of one number in memory
- 40 bit whole part (how large the number can be)
- 24 bit fractional part (how fine the number is)
- ±549.76 billion largest number it can hold
- 0.00000006 smallest step
Why exactly 40 and 24 bits? A ruler analogy
A ruler cannot be both very long and very finely marked; you have to split a budget of 64 marks. Give the whole part many bits and large numbers fit but the steps get coarse; give the fraction many bits and it gets finer but large numbers do not fit. We calculated three splits:
| Format | Largest number | Smallest step | Error on a daily rate of 1% per year | City of 1 million after 100 years |
|---|---|---|---|---|
| 32 bit whole, 32 bit fraction | 2.1 billion | 0.00000000023 | 0.0004% | 2,704,803 |
| 40 bit whole, 24 bit fraction (our choice) | 549.76 billion | 0.00000006 | 0.08% | 2,702,620 |
| 48 bit whole, 16 bit fraction | 140.7 trillion | 0.000015 | 11.9% | 3,046,138 |
| True value (exact calculation) | — | — | — | 2,704,814 |
The first row is the most precise but carries numbers only up to 2.1 billion; world and country money totals can exceed that. The third row carries huge numbers but its step is so coarse that it cannot hold a small daily rate like "1% growth a year": it gets the rate wrong by 11.9%. The second row is the middle ground: world totals fit comfortably and daily rates are held with a 0.08% error. That is the decision.
Let us see what that means in a chart. Imagine a city of 1 million people growing 1% a year, and the engine computing that once a day for 100 years. At the end of every day the result is rounded to our step; each day's rounding leaves a small trace.
A city growing by a daily calculation for 100 years (thousand people)
| Label | True value | Q40.24 | Q48.16 |
|---|---|---|---|
| 0 | 1,000.0 | 1,000.0 | 1,000.0 |
| 10 | 1,104.6 | 1,104.5 | 1,117.8 |
| 20 | 1,220.2 | 1,220.0 | 1,249.5 |
| 30 | 1,347.8 | 1,347.5 | 1,396.8 |
| 40 | 1,488.9 | 1,488.4 | 1,561.3 |
| 50 | 1,644.6 | 1,644.0 | 1,745.3 |
| 60 | 1,816.7 | 1,815.8 | 1,951.0 |
| 70 | 2,006.8 | 2,005.6 | 2,180.8 |
| 80 | 2,216.7 | 2,215.3 | 2,437.8 |
| 90 | 2,448.6 | 2,446.8 | 2,725.1 |
| 100 | 2,704.8 | 2,702.6 | 3,046.1 |
- True value
- Q40.24
- Q48.16
Q40.24 stays right next to the true value; after 100 years the gap is about 2,200 people. The coarse format drifts upward in the first 10 years and ends up 340 thousand people too high after 100. In strategy-game terms: a coarse number format quietly turns a "slowly growing" country into a "fast-growing" one.
The four operations and rounding
Addition and subtraction are plain whole-number operations. Multiplication and division are harder: multiplying two Fx values gives a result with 48 fraction bits instead of 24, and it has to be brought back to 24. We do this by putting a wider (128-bit) number in between so nothing overflows. Then we round to the nearest value in 24 bits; exactly halfway, away from zero. So 2.5 → 3 and −2.5 → −3. The reason is accumulation: if halves were always rounded up, tiny errors would pile up in the same direction over thousands of days. Rounding away from zero makes the pluses and minuses balance out.
There is also a mul_div operation: it computes "a × b / c" with no overflow in the middle step and a single rounding. For example multiplying a country's income by a rate and dividing by a number is a pattern a strategy game needs a lot; one operation instead of two avoids both overflow and a second rounding error.
The cost: Fx is slower than floating point
Let us be honest: this path is slower than floating point. We measured how much.
How many times slower is Fx than floating point? (times)
| Label | Times slower |
|---|---|
| Addition | 1.4 |
| Multiplication | 1.9 |
| Division by a whole number | 4.6 |
| Division | 10.5 |
| Square root | 7.5 |
| Exponential (e to the x) | 28.6 |
| Operation | Fx | Floating point | Ratio |
|---|---|---|---|
| Addition | 1.23 ns | 0.91 ns | 1.4 times |
| Multiplication | 1.88 ns | 0.99 ns | 1.9 times |
| Division by a whole number | 5.02 ns | 1.08 ns | 4.6 times |
| Division | 11.56 ns | 1.10 ns | 10.5 times |
| Square root | 15.77 ns | 2.09 ns | 7.5 times |
| Exponential (e to the x) | 130.83 ns | 4.58 ns | 28.6 times |
Addition and multiplication are the operations the engine does most often, and there the gap is small. Division, square root and exponential are much more expensive: division uses a 128-bit intermediate result, while square root and exponential are our own whole-number methods that guarantee the same result bit for bit. We also moved division by a whole number onto a cheaper 64-bit path because it sits on the hot path of every system; the code's own comment gives the reason: a 128-bit division is a slow library call. In the table it costs less than half of general division (5.02 ns against 11.56 ns). We sped these expensive operations up later; the new measurements are in Diary #7.
The day times in diary #0 are measurements taken with this number type, so this cost is already inside those figures. We do not trade the same result for speed; we win the speed back elsewhere after paying this cost.
On overflow the game stops
The limit of Fx is ±549.76 billion. What should happen if a total goes past it? There are three ways: carry on silently with a wrong number, stop at the limit, or stop the game. In normal operations we stop the game. Our reasoning: in a game that carries on silently with a wrong number, the error goes unnoticed for years. Stopping makes the error visible at once, and it can be reproduced exactly from the last save and the command log. This behaviour is the same in every build; there is no check that is "off in the fast build".
For places where the code deliberately wants another outcome there are two families of operations: checked_* reports "did not work" on overflow, and saturating_* stays at the limit. Division by zero stops the game under the same rule.
Square root, exponential and logarithm
In a strategy game numbers are not only added and multiplied; there are also formulas like "an effect that depends on the square root of population" or "compound growth". For these we cannot use the processor's or a library's ready-made functions, because their results can change from machine to machine. We wrote the square root, exponential, logarithm and power using only whole-number arithmetic.
Exponential, logarithm and power calculate internally with a fraction of 62 bits instead of 24 (a much finer step) and round to Fx only once at the end. This stops step-by-step rounding from growing the error. How far each function can be off is pinned down by tests:
| Function | What it does | Accuracy limit pinned by tests |
|---|---|---|
| Square root | √x | The nearest result, within half a step |
| Exponential | e to the x | One millionth or 2 steps, whichever is larger |
| Logarithm | natural logarithm of x | At most 4 steps (0.00000024) |
| Power | x to the y | One hundred-thousandth or 1 step, whichever is larger |
| Whole-number power | x to the n | Repeated multiplication, in a fixed order |
A "step" is the 0.00000006 above. The range of the exponential is also limited: below −17.3 the result rounds to zero, and a little above 27 the number does not fit and the operation reports "did not work". There is no trigonometry for now; a grand strategy game needs sine and cosine almost never, so it will be added when needed.
How is "12.5" in a content file read?
The numbers of the game and of mods are written in plain text files, like "12.5". Converting that text first to a floating-point number and then to our type would have broken the rule at the very first step. So the text is read straight from its digits without ever passing through a floating-point number: the whole part, the fraction part and whole-number arithmetic give the nearest Fx. At most 18 digits after the point are read; a letter, a second point or a number out of range gives an error and is never silently corrected.
The second stone: dice
Chance is everywhere in a strategy game: whether a siege falls today, whether an event fires, whether a commander is wounded. A computer cannot really roll a die; it produces numbers that look "random" with a formula. What matters is how that formula is fed.
A deck or a drawer?
The usual method is like a deck of cards: cards are drawn in order and each card drawn decides the next. The problem: if two systems draw from the same deck, which one draws first changes the result. Today the engine's work is split across several threads, and the order in which threads run can differ each time. A game that uses a deck produces two different histories in two runs.
So we dropped the deck. Our dice are like a giant cabinet with billions of drawers. Each drawer holds a die result decided in advance, and the address of a drawer is made of five pieces of information:
| Address part | What it says | Example |
|---|---|---|
| Seed | Which game | The number chosen when the game starts |
| Step | Which day or turn | Day 200 of the year 1444 |
| System | Who rolls | The siege system |
| Entity | For whom | Army number 42 |
| Draw | Which roll of the day | The first roll, the second roll |
The result depends only on this address, not on how many dice were rolled before or in which order. The siege system and the event system can roll at the same time on different cores; whichever finishes first, each opens its own drawer. The technical name is "stateless, counter-based" randomness: the address goes through a mixing formula (the final step of SplitMix64) and the 64-bit number that comes out is the result.
We checked this in our own measurement: we read the same 1,000 addresses first from start to end, then from end to start; the two readings gave exactly the same results. When the seed changes, the whole cabinet changes:
| Day | Day 1 | Day 2 | Day 3 | Day 4 | Day 5 |
|---|---|---|---|---|---|
| Seed 1444 | 3 | 3 | 5 | 4 | 6 |
| Seed 1445 | 2 | 5 | 6 | 3 | 6 |
With the same seed the game gives this sequence every time it is opened. Someone who loads a save and replays the same day with the same decisions sees the same dice; loading a save alone does not change a roll.
Is the die fair?
A die must be fair as well as consistent. We rolled 6 million dice and counted how often each face came up:
How often did each face come up in 6 million rolls? (thousand rolls)
| Label | Rolled |
|---|---|
| 1 | 999.98 |
| 2 | 999.51 |
| 3 | 1,000.15 |
| 4 | 1,001.71 |
| 5 | 998.71 |
| 6 | 999.94 |
Each face came up very close to one million times; the deviations are the kind of small fluctuations you would also see with a real die. We also asked the "30% chance" question 6 million times; the answer was "yes" 30.05% of the time. The chance calculation also uses fixed-point numbers, so "30%" never passes through a floating-point number.
One more detail: producing "a number from 1 to 6" by dividing the 64-bit result by 6 and taking the remainder favours some faces, if only slightly. Instead we take the top half of a 128-bit product; with this method one draw gives exactly one value and the bias is at most 6 / 2⁶⁴, which in practice is zero.
The mixing formula and its constants are frozen. If they changed, the future of every saved game would change and replays would break; so they are among the "untouchable" parts of the engine.
The third stone: the calendar
In the game, time does not follow the clock on the wall but the turn counter of a board game. Your computer may be slow or fast, you may pause the game or set it to the fastest speed; none of this is visible inside the simulation. The simulation only knows "which day and which turn it is now". The real clock, files and the network cannot enter the engine core.
365 days, no leap years
- 365 days a year, every year the same
- 12 months of standard length
- 0 leap years
- ±5.8 million years range a date can cover
A date is stored as a single whole number, "how many days have passed since 1 January of year 0"; years before that are valid too. On screen and in data files it is written like 1444.11.11. Because every year has the same twelve months, date arithmetic is plain whole-number arithmetic: "what date is 90 days from now" is one addition and one division.
Having no leap years has a cost: a real year is about 365.24 days, ours is exactly 365. The game calendar falls about 6 hours behind the real calendar every year; over 400 years that is about 97 days. For a game this is a deliberate choice: a date is a label, not an astronomical calculation. In return every special case that 29 February creates disappears; "the same day every year" really falls on the same day every year.
Turns within a day
A day may not be the smallest step for every game. Some strategy games run battles and movement hour by hour; others are fine with weekly or monthly steps. So the game, not the engine, chooses the length of a step:
| What the game chooses | Options |
|---|---|
| Length of a step | N turns a day (for example 2 or 24), daily, weekly or a calendar month |
| Cadence of each system | Every step, daily, weekly, monthly or yearly |
Time is now the pair "day + turn within the day"; in data files it is written like 1444.11.11.12. The turns of a day add up to exactly one day; rounding never shifts the end of the day. A monthly system can spread the month's work over the days of the month, entity by entity, instead of piling it onto one day, so the game does not stutter at the start of the month. The "step" in a die's address is this step number.
The proof: golden files
Saying "always the same result" is easy; here is the proof. The repository holds "golden files" for these three tools, with results that were computed and recorded beforehand. On every test run the engine recalculates them and compares them with the record bit for bit; a single different bit breaks the test.
For numbers the results of the basic operations and maths functions, for dice every kind of draw, and for the calendar reading dates and adding days are recorded in these files.
The same records must also come out the same on different processors; how that is tested is covered in the Multiplayer and consistency diary.
Limits
- Numbers are slower: The ratios in the table above are real. Using division, square root and exponential on the hot path has to be deliberate and measured.
- Limited fineness: A difference smaller than 0.00000006 is invisible. That is enough for a game but not for scientific computing.
- Limited size: An accumulation above 549.76 billion, for example a very long-term total statistic, does not fit in today's number. If needed a separate, wider accumulator will be written; it has not been written for now.
- No trigonometry: It will be added when needed.
- The dice formula is frozen: Even if we found a better mixing formula we could not change it; changing it would break old saves.
- The calendar drifts from the real one: About 6 hours a year. A game that needs an exact match with real dates has to correct this in its own content.
- Scope of the measurements: The speed table and the dice count are simple measurements; they do not replace the cost of a real game day.
Frequently asked questions
Why don't games use floating point here?
They can, but floating-point numbers can give a different last digit on different processors and compilers. In a multiplayer lockstep game that small difference builds up and splits the two players' worlds. Fixed-point numbers give the same result everywhere.
How slow are fixed-point numbers?
In our simple measurement addition came out 1.4 times slower than floating point, multiplication 1.9 times, division 10.5 times and square root 7.5 times. Most of a game day is addition and multiplication, so we accepted this cost.
How do random numbers stay the same?
A roll is computed from a seed, the step number, the system, the entity and the draw number. There is no changing state, so the order or the thread in which dice are rolled does not change the result.
Does the calendar have leap years?
No. The calendar has 365 days. The cost is that the game year runs about 6 hours a year short of the real one, a deliberate simplicity for a game.
Sources and records
External sources:
- Glenn Fiedler, Floating Point Determinism (opens in a new tab)
- Wikipedia, Fixed-point arithmetic (opens in a new tab)
- Wikipedia, Multiply–accumulate operation (fused multiply–add) (opens in a new tab)
- Wikipedia, Counter-based random number generator (opens in a new tab))
- Wikipedia, Tropical year (the length of the real year) (opens in a new tab)
Our internal records (the engine repository is closed, so we cannot link them):
- Number model decision: number format, rounding, overflow and division-by-zero rules; stateless dice; the 365-day calendar.
- Step length decision: turns within a day, weekly and monthly steps, system cadences.
- The hundred-year city calculation: 1% yearly growth was converted to a daily rate and rounded at the end of every day in three formats; the result was compared with the exact value (15 October 2026). This is a calculation, not a measurement.
- Speed measurement: 15 October 2026, 1 million numbers, a separate loop for each operation, median of 15 rounds and best of 5 runs; taken while the computer was busy.
- Dice count: 15 October 2026, seed 1444, 6 million rolls and 6 million "30%" draws; 1,000 addresses read in both directions.
- Golden files: recorded results for numbers, dice and the calendar; compared bit for bit on every test run.
In the next diary we will see why these three stones are so strict: how two players' computers calculate the same world, and how a difference is caught if one appears.
If you have questions or ideas about this diary, write to us from the contact page. For an overview of the engine see the MKA Engine page, and for all diaries the Developer Diaries list.