Everything here comes out of one project: a management simulation where a campaign runs about 1,100 in-game days, every day rolls dozens of outcomes, and the whole thing has to reload from a save file and continue as if nothing happened. 1,624 automated tests sit on top of it, and a good number of them assert exact numbers.
I am writing it because the search results for this problem are mostly people being told to call Random.InitState, which is advice for a different problem. Reseeding controls where a stream starts. Almost every determinism bug I have had was about what happens to the stream afterwards.
Three generators, three different promises
A Unity project has three obvious generators within reach, and they do not promise the same things. Picking one by habit is how this starts.
| Generator | What it actually is | Where it breaks |
|---|---|---|
UnityEngine.Random |
Static class, Xorshift 128, seeded once from the OS at process start. State is readable and writable. | The state is globally shared. Anything in the process that rolls a number moves your stream. |
System.Random |
Instance based, so no sharing problem. Seedable. | The algorithm is not a contract. Microsoft's own docs note the same seed may give a different sequence on a different .NET version. |
Unity.Mathematics.Random |
A 32-bit xorshift struct, designed to be embedded in components and used inside jobs. | Nothing, for its purpose. It is the right tool for Burst and jobs, and it is a package dependency with a seed that must never be zero. |
That third row is worth a note: Unity.Mathematics.Random is a perfectly good
answer, and it even ships a CreateFromIndex helper, which is the second
pattern in this article with official support. If you are already on the package, use it.
The rest of this guide is about the reasoning, which is the same either way.
The problem is not saving. It is sharing.
The received wisdom is that UnityEngine.Random cannot be made deterministic.
That is not quite right, and getting it right matters, because it points at the real fix.
Unity's documentation says plainly that the state can be saved and restored, and it
describes the generator as statically initialized with a high-entropy seed from the
operating system, after which it is “left entirely under script control”.
So you can persist it. What you cannot do is own it. The same documentation is equally plain about why: it is a static class, and so its state is globally shared. Your simulation is one of many readers. A particle system, a plugin, an editor tool, a piece of UI picking a flavour of idle animation — each one draws from the same sequence, and every draw shifts everything that comes after.
Why this bug is so hard to see
A shared stream does not fail loudly. It fails as a campaign that reloads and plays almost the same, as a test that passes alone and fails in the suite, and as a bug report that says “the numbers were different the second time” with no reproduction steps, because the reporter cannot see what else rolled.
The giveaway is order sensitivity: if adding a feature somewhere unrelated changes results somewhere else, you have one stream, not several.
The seed that quietly stops meaning the same thing
The instinct after reading the above is to swap in System.Random, which is
instance based and therefore not shared. That removes one problem and introduces a quieter
one, and this is the part I would not have guessed: a seeded System.Random
is not promised to give the same sequence forever.
Microsoft's documentation, in the section that demonstrates how to retrieve the same sequence of random values, carries this note: the example “may produce different sequences of random numbers if run on different versions of .NET”. The underlying algorithm is described as a modified subtractive generator, and that description is documentation of an implementation, not a guarantee of one.
For gameplay noise this is irrelevant. It becomes expensive the moment a number has to survive something:
- a save file written in one engine version and opened in the next;
- a daily or weekly seeded run that two players must experience identically;
- a regression test that asserts an exact figure and is supposed to fail only when you broke something.
None of these announce themselves. A runtime upgrade that changes a sequence produces a build that compiles, launches, logs nothing, and is subtly a different game.
The forty lines worth owning
Which leads to the unexciting conclusion: if the output must be stable across versions and machines, the generator belongs in your repository, where nothing can change it without a commit. Mine is xorshift32 with a single state word, and this is the whole of it:
Three points matter more than the algorithm. The state is one uint, so
persisting it is a single field rather than a serialisation exercise. Zero is guarded in
two places, the constructor and the setter, because xorshift is absorbing at zero: feed
it a zero and it returns zeros forever. That is not my cleverness — Unity's own
mathematics package documents the same rule for its struct, that the seed must be non-zero.
And the algorithm is now yours, so a Unity upgrade cannot renegotiate it.
On top of that sit the conveniences the simulation actually calls: a NextDouble,
an integer range, a NextChance(p), and a Box–Muller
NextGaussian so that a training run lands near its projection rather
than exactly on it. All of them are just readings of the same state word, which is the
point: one thing to save.
Two kinds of randomness, and they are not interchangeable
Owning the generator does not tell you how many of them to have. This is where I spent the most time, and the answer turned out not to be “one per system”. It is that simulations contain two different kinds of randomness, and each has its own correct shape.
The first kind is sequential. It advances with time and the order of draws is part of the meaning. A queue of job applicants arriving over weeks is sequential: each roll depends on how far the campaign has got. This kind needs a stream with a saved state word, so that a reload resumes mid-sequence rather than starting the sequence again.
The second kind is coordinate addressed, and it is the one most projects miss. Some values are not a sequence at all. They are a property of a thing: the staff roster at a rival company, the investor who shows up on a specific date, the personality of lab number four. These do not need to be remembered, because they can be recomputed. Build a generator from the coordinate itself and the same coordinate rebuilds the same values forever:
The comment above that method in my repository states the contract it is keeping: “Deterministic in the lab and the campaign seed, so the same company has the same people every time it is opened, and two campaigns on the same seed agree.”
The mixing function matters here, because a coordinate like seed ^ labId is a
terrible seed: neighbouring labs would produce visibly related rosters. What is needed is an
avalanche, so that one changed bit changes everything:
Note the salt argument. It is what lets two unrelated questions about the same
lab on the same day — does it ship today, does it poach someone — get independent
answers without either of them knowing the other exists.
The practical test for which kind you have
Ask whether the value could be recomputed from scratch rather than remembered. If yes, it is coordinate addressed and belongs to no stream: it stores nothing, it cannot drift, it cannot desync, and it survives a save format change for free because it was never in the save.
In my project this turned out to cover most of it. Exactly two sequential streams have a saved state word in the whole game. Everything about rivals, alliances, investors and rival staff is coordinate addressed and stores nothing at all.
The method I wrote, documented, and never called
When a subsystem needs its own stream, the obvious move is to fork one from the parent. Draw a number, use it as the child's seed, hand the child over. It is a nice API and I wrote it:
Read the implementation against its own doc comment. It promises to let a subsystem roll
without shifting the parent stream, and the first thing it does is call
NextUInt(), which shifts the parent stream.
That is not a typo, it is the shape of the idea. There is no way to derive a child seed from a parent generator without advancing the parent, because advancing is how a generator produces anything. So the act of isolating a subsystem is itself a draw, and the isolation costs exactly the thing it was buying.
It gets worse in the case you actually care about. If the fork is conditional — a subsystem that only initialises once the player unlocks it, or on the day a feature first runs — then whether you forked at all depends on how the campaign went, and now the parent stream is different for two players who did different things. Which is precisely the class of bug the fork existed to prevent.
I found this while giving the hiring system its own stream, and what the code says there is the clearest sentence in the whole repository on this subject:
So Fork() is still in the file, compiled into every build, and called exactly
zero times. Both problems it was written for had better answers: a constant seed with its
own saved state word for the sequential case, and a mixed coordinate for everything else.
If you want to keep a fork anyway
There is a version that works, and it is worth knowing because it is a one-line change in thinking. Do not derive the child from the parent's stream; derive it from the parent's identity. Mix the campaign seed with a constant that names the subsystem, and the child is independent of anything the parent did or did not roll.
That is the same mixing function as above with a per-system salt, which is why the fork disappeared rather than being fixed: once you write that, there is nothing left for it to do.
I have a habit of finding features like this — written, tested, reachable by no one. By my own count this is the sixteenth. The difference here is that the method is not unreachable because I forgot to wire it up. It is unreachable because wiring it up would have been wrong, and the comment three files away explains why. That is the kind of dead code worth keeping, as long as the reason is written down next to it.
The four tests that make this real
None of the above is worth anything as an intention. Determinism is a property that decays silently, so it has to be asserted. These four are the ones that have actually caught regressions, and they are cheap enough that there is no excuse for not having them.
One: the same seed produces the same campaign. The shortest possible statement of the whole idea, and the first thing to break when someone adds a roll in the wrong place.
Two: the stream survives a round trip through the save file. Advance it, save, reload, and assert both the state and the next value out of it. Asserting only the state misses a restore that writes the field but never reaches the generator:
Three: a whole campaign replays under a scripted player. This is the one that earns its runtime. A bot plays 1,100 in-game days twice on the same seed, and the two runs must agree on cash, capability and models shipped. It catches everything the unit tests cannot see, because the failure mode here is accumulated drift rather than a wrong value:
Four: the coordinate addressed systems agree with themselves. Ask for the same rival field, the same roster, the same investor schedule twice, from two separately constructed campaigns on one seed, and compare. This is what guards the pattern from the previous section, where there is no saved state to catch a mistake.
The one that pays for itself
If you only write one, write the third. A two-run comparison over a long campaign needs no knowledge of which system misbehaved and no maintenance when the numbers change, because it never asserts a value — only that the game agreed with itself.
What this does not fix
A guide on determinism that does not state its boundaries is selling something. Everything above gives you a reproducible sequence of numbers. That is not the same as a reproducible game, and the gap matters:
- Floating point across platforms. The same double arithmetic can differ between CPUs, compilers and build targets. If your simulation must match bit for bit across machines, that is a separate and much harder problem than the generator.
- Physics. Unity's default physics is not a deterministic lockstep simulation, and seeding a generator does nothing about it.
- Collection ordering. Iterating a dictionary or hash set and rolling per item will desync even on a perfect generator, because the order is not promised. This bites far more often than floating point.
- Time and frame rate. Anything that reads
Time.deltaTimeand then rolls has made frame rate an input to the simulation.
In a turn based, day stepped simulation like mine the first two mostly do not arise, which is why this guide is about the generator. The third is the one I would watch: it is easy to write by accident and it fails in exactly the same way, so if a run desyncs after you have done everything above, look at what you iterated before you look at what you rolled.
The shape of the answer, in four lines
If you take nothing else:
- Own the generator. Forty lines in your repository beats a dependency whose algorithm is an implementation detail.
- Ask whether a value is sequential or coordinate addressed before giving it a stream. Most of what feels random in a simulation can be recomputed, and recomputed data cannot drift.
- Never derive a child stream from a parent stream. Derive it from identity — a constant, a coordinate, a salt — so it is independent of what the parent did.
- Assert it in a test that replays a long run twice. Determinism you do not test is determinism you used to have.
Questions people ask about this
Why does Unity give different results from the same seed?
Usually because the generator is shared. UnityEngine.Random is a static class,
so its state is global: a particle effect, a plugin, an editor script or any unrelated
MonoBehaviour that rolls a number moves the same stream your simulation is
reading. Reseeding fixes the first frame and nothing after it. The second common cause is
your own code adding a roll earlier in the tick, which shifts every value drawn after it.
Neither is solved by saving the seed, because the seed was never what changed.
Can you save and restore UnityEngine.Random?
Yes — this is the part the internet usually gets wrong. Unity exposes a
state property that can be read and written, and documents the generator as an
Xorshift 128 seeded once from the operating system at process start and then left under
script control. Persistence is not the limitation. Ownership is: the state you save belongs
to the whole process, so restoring it only reproduces a run if nothing else drew from the
stream in between.
Is a seeded System.Random the same on every platform and .NET version?
No, and Microsoft documents it directly. Its own example for retrieving the same sequence of random values carries a note that the code may produce different sequences on different versions of .NET. For gameplay noise that is fine. For a save file, a shared daily seed or a test asserting an exact number, it means a runtime upgrade can change your results in a build that compiles cleanly and reports nothing.
Should every system get its own random generator?
Every system that must stay stable while other systems change, yes — but there are two ways to give it one and they are not interchangeable. A sequential stream has its own saved state word and suits anything that advances over time, like a queue of applicants. A coordinate addressed generator is built on demand from a seed mixed with an identity, such as a campaign seed and a company id, and stores nothing at all. Reach for the second whenever the data can be recomputed rather than remembered; in my project that covers all but two streams.
Is forking a child generator from a parent safe?
Not for isolation, which is usually the reason people reach for it. Drawing a child seed from the parent advances the parent, so isolating a subsystem moves the very stream the isolation was protecting — and if the fork is conditional, two players who played differently now have different streams. Seed the child from a constant or a coordinate instead, and give it its own saved state word if it must survive a reload.
Does this make my game deterministic across platforms?
No. It makes the number sequence reproducible. Floating point arithmetic can still differ between CPUs and build targets, Unity's default physics is not a deterministic lockstep simulation, and iterating an unordered collection will desync a perfect generator. For a turn based or day stepped simulation that is usually enough. For lockstep multiplayer it is the first step of several.
Fork() and all four tests are in the public repository for
Scaling Laws, linked from this article's sources.
If I have got something wrong, the code is there to prove it with.