Cross-platform procedural generation: Why your deterministic seeds break across Unity, Unreal, and JS
The Morning Your Dungeon Didn't Match
You pushed the build at 2 a.m. The Unity client on your laptop generated a tidy village from seed 0x4E1A7C. The web version, written in JavaScript and using the same seed, generated a village too — but the tavern sat where the well should have been, and the road bent left instead of right. By the time your Unreal-based server tried to validate the layout, it flagged the entire map as corrupted.
This is the quiet nightmare of cross-platform procedural generation. The seed is identical. The algorithm is, as far as you can tell, identical. And yet the output drifts. Let's walk through why that happens, and how to fix it without rewriting your entire pipeline.
The Hidden Assumption Behind "Deterministic" Seeds
When we say a seed is deterministic, we're making a promise: the same seed, fed into the same algorithm, will always produce the same sequence of numbers. That promise is true — but only inside the boundary of a single runtime. The moment you cross from Unity's Mono/.NET runtime into Unreal's C++ compiler, or into V8 inside a browser, you've left the boundary. The promise no longer applies automatically.
The mistake is assuming that determinism is a property of the seed. It isn't. Determinism is a property of the whole stack: the algorithm, the integer widths, the floating-point rounding mode, the order of operations, and even the compiler's optimization choices. Get any of those wrong, and seed 0x4E1A7C will happily produce three different villages on three different platforms.
Where Unity, Unreal, and JavaScript Diverge
Let's look at the specific fault lines. There are four, and each one is enough to break a cross-platform procedural generation pipeline on its own.
Floating-Point Math and IEEE 754 Drift
Most procedural generation algorithms — Perlin noise, simplex noise, Worley noise — lean heavily on floating-point arithmetic. The IEEE 754 standard defines how floats should behave, but it permits intermediate precision to vary. On x86 hardware, the FPU may use 80-bit extended precision internally. On ARM, it won't. Unity's IL2CPP backend and Unreal's C++ compiler may emit different instructions for the same source expression. JavaScript, running through V8's TurboFan, may reorder floating-point operations during JIT compilation.
A difference of one ULP (unit in the last place) at the millionths place sounds trivial. But feed that difference into a fractal noise function sampled across eight octaves, and the error compounds. A drift of 1e-7 per octave, summed across eight octaves with a persistence of 0.5, can shift a threshold crossing by enough to flip a biome boundary.
Integer Overflow and Modulo Behavior
Many PRNG implementations — xorshift, PCG, Mersenne Twister — rely on unsigned integer overflow and bitwise operations. In C# (Unity), integers don't overflow silently in checked contexts. In C++ (Unreal), signed integer overflow is undefined behavior, and compilers like Clang and MSVC will optimize it in surprising ways. In JavaScript, there are no integers at all by default — every number is a 64-bit float, and bitwise operations coerce to 32-bit signed integers.
This means the line x ^= x << 13 in a xorshift32 implementation will behave differently in each environment. In JavaScript, the shift operates on a 32-bit value. In C#, it operates on a 32-bit signed integer unless you explicitly use uint. In C++, the compiler may assume the shift never overflows and elide checks you relied on.
Built-in RNG Implementations
Unity's UnityEngine.Random uses a xorshift128 variant. Unreal's FRandomStream uses a different algorithm entirely — a linear congruential generator with platform-specific constants. JavaScript's Math.random() is implementation-defined; V8 uses xorshift128+ but doesn't expose the seed at all. If your procedural generation code calls any of these built-in APIs, you are not using the same algorithm across platforms. You are using three different algorithms that happen to share the word "random."
Operation Order and Compiler Reordering
Even when the source code is identical, compilers reorder operations. a + b + c is not guaranteed to produce the same float as a + (b + c). The C# specification permits reassociation under some flags. C++ compilers with -ffast-math will aggressively reassociate. JavaScript engines may or may not, depending on whether the function gets tier-compiled. If your noise function sums octaves in a loop, the order of summation matters, and it may differ per platform even when the source is byte-for-byte identical.
Why "Just Use the Same Algorithm" Isn't Enough
Here's the trap that catches almost every developer the first time they attempt cross-platform procedural generation: they port their PRNG faithfully, they write unit tests, and it still breaks. The reason is that "the same algorithm" is a statement about source code, not about runtime behavior.
Consider a concrete example. Suppose you implement PCG32 in all three environments. You seed it with 0x4E1A7C. You expect the first output to be 0x9E3779B97F4A7C15 (the result of multiplying the seed by the PCG32 multiplier constant, 6364136223846793005). In JavaScript, that multiplication happens in double-precision float. The constant 6364136223846793005 exceeds 2^53, so it cannot be represented exactly as a JavaScript number. The low bits are lost. The output is wrong from the very first step.
In Unity, if you declared the multiplier as a long instead of a ulong, the high bit is interpreted as a sign, and the multiplication wraps differently. In Unreal, if you compiled with /O2 and the compiler assumed no unsigned overflow, the modulo step may be optimized away.
The algorithm is the same. The behavior is not.
Building a Platform-Agnostic RNG Layer
The fix is to stop trusting each platform's native number semantics and to define your own. Here's how to do it, step by step.
Step 1: Choose a Reference Implementation
Pick one PRNG and make it the canonical source of truth for your project. PCG32 is a strong choice — it's small, fast, statistically excellent, and its reference C implementation is easy to port. SplitMix64 is another good option if you only need 64-bit output. Avoid Mersenne Twister; its 2.5 KB state makes it awkward to serialize, and its poor recovery from bad seeds causes subtle cross-platform bugs.
Whatever you choose, write it down. The reference implementation is the spec. Every platform port must match it bit-for-bit.
Step 2: Force Fixed-Width Integers Everywhere
Do not use int, long, or uint in your PRNG code. Use explicitly sized types: uint32_t in C++, uint with a runtime check in C# (which is always 32-bit), and a typed array like Uint32Array in JavaScript. For 64-bit values in JavaScript, use BigInt — yes, it's slower, but correctness matters more than speed in procedural generation seeding. You can optimize later.
Step 3: Implement 64-Bit Multiplication Manually in JavaScript
Because JavaScript cannot multiply 64-bit integers natively, you must implement it. The standard approach is to split each 64-bit value into two 32-bit halves, perform four 32-bit multiplications, and combine the results with carries. This is the same technique used in imul polyfills. It's tedious but it's the only way to guarantee that PCG32's state update matches the C reference.
Step 4: Test Against a Known Output Vector
Generate the first 1000 outputs of your reference implementation on the canonical platform. Hard-code those values as a test fixture. On every other platform, run the same seed and assert that all 1000 outputs match. If even one differs, you have a porting bug — and now you know exactly where to look.
A Worked Example: Matching a Noise Sample
Let's make this concrete. Suppose you're sampling 2D value noise at coordinate (3.7, 2.1) using PCG32 to hash grid points. The hash function is pcg32(ix * 73856093 ^ iy * 19349663).
On Unity, ix is 3 and iy is 2. The multiplication 3 * 73856093 yields 221568279, which fits in 32 bits. So far, so good.
But suppose your coordinate is (37.7, 21.1). Now ix is 37, and 37 * 73856093 = 2732575441, which exceeds 2^31 - 1. In C# with a signed int, this overflows to a negative number. In JavaScript, the bitwise XOR coerces to 32-bit signed — also negative, but only after coercion. In C++ with int32_t, it's undefined behavior. Three platforms, three different hash inputs, three different noise values.
The fix: declare ix and iy as unsigned 32-bit everywhere, and use explicit masking (& 0xFFFFFFFF) in JavaScript before the XOR. Now all three platforms agree: 2732575441, treated as unsigned, hashed identically, producing the same noise sample.
Practical Checklist for Cross-Platform Seeds
Before you ship, run through this list:
1. One reference implementation. Written down, version-controlled, treated as the spec. 2. Fixed-width integers only. No native int or long in PRNG or hashing code. 3. Manual 64-bit math in JavaScript. BigInt or split-multiply. No native multiplication of values above 2^53. 4. Float operations pinned. Use Math.fround in JS, float (not double) consistently in C# and C++, and disable -ffast-math in Unreal build settings for noise modules. 5. Operation order fixed. Write summation loops with an explicit accumulator variable and never rely on compiler reassociation. 6. Test vector fixture. At least 1000 known outputs, asserted on every platform in CI. 7. No built-in RNG calls. Audit your codebase for UnityEngine.Random, FRandomStream, and Math.random(). Replace every one with your portable layer.
Cross-platform procedural generation isn't impossible. It's just unforgiving. The moment you treat the seed as a contract rather than a suggestion — and the moment you stop trusting each runtime to interpret numbers the same way — the drift stops, and seed 0x4E1A7C finally means the same village everywhere.