A Maze board in Neon Snake is drawn from a generator written into the page, not from Math.random. That was not a vote of confidence in the generator. It was a response to one specific death: a wall landing diagonally beside a pellet in a corner, which turned up perhaps once in twenty runs and could not be provoked on demand. The recording had the death in it. It did not have the board.
MDN states the gap plainly. Math.random takes no argument, and the seed underneath is chosen by the implementation and, in the page's own words, cannot be chosen or reset by the user. The generator V8 puts there is a good one: xorshift128+, 128 bits of state, a period of 2^128 - 1, in place from v4.9.41.0 after MWC1616 was retired for a cycle that could fall below 40 million entries from a poor start. Quality was never the difficulty. A handle was.
Snake carries six lines of arithmetic that supply the handle and give up nothing a game needs. The same rand and seedRng pair sits on fourteen of the sixteen game pages here, which a grep for either name across games/ will confirm.
The whole generator is three shift-xors and a divide
Stripped of its plumbing, the state transition is this:
var x = rngState; x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0; rngState = x; return x / 4294967296;
The shifts are 13, 17 and 5. That triple comes from Marsaglia's 2003 paper on xorshift generators, which listed all 648 full-period 32-bit variants and named this one as his own preference. Full period means the state visits every one of the 4,294,967,295 non-zero 32-bit values before it repeats. Zero is excluded because it is a fixed point — shifting and xoring zero returns zero forever — so the only way into a dead generator is for a caller to hand it that value, and seedRng refuses it and substitutes 0x9e3779b9 instead.
A period is only useful measured in draws. One draw per frame at 60 frames a second is a generous ceiling for a single game, and at that rate the sequence runs dry after 71,582,788 seconds, which is 828 days, or 2.27 years. A four-minute run of Snake cannot distinguish that from forever.
The comparison that does matter is against the generator the browser already has, because that one cannot be set at all.
| Generator | State | Caller sets the seed | Period | Drained at 60 draws a second |
|---|---|---|---|---|
| xorshift32, in Neon Snake | One 32-bit word | Yes | 4,294,967,295 | 2.27 years |
| mulberry32, in Drift Circuit | One 32-bit counter | Yes | 4,294,967,296 on the counter | 2.27 years |
xorshift128+, V8's Math.random | 128 bits | No | 2^128 - 1 | about 1.8 x 10^29 years |
Both of those periods are computed the same way, by dividing the cycle length by 60 draws a second and then by 31,557,600 seconds in a year. What the last column says is that the browser's generator is astronomically better and still useless for this one job, because the sequence it produces is chosen somewhere inside the engine and never reported back.
Seeding from the clock is fine; seeding from a small number is not
At boot, Snake seeds itself with Date.now() & 0x7fffffff, so two people who press Play inside the same millisecond get the same Maze. That is a feature rather than a flaw, and it is the reason the boards feel varied without any extra work.
The trap opens when the seed comes from something countable instead. A xorshift32 started from a small value stays small for its first few draws, and the amount by which it stays small can be worked out on paper. The 13-shift-step multiplies the state by 8,193 and the 5-shift-step multiplies it by 33, so a seed small enough that neither left shift wraps is multiplied by 270,369 before the divide. That is 6.295 x 10^-5 per unit of seed, and the generator obliges exactly: seed 1 opens on 0.000063, seed 2 on 0.000126, seed 7 on 0.000441. The prediction matches the measured output to six decimal places.
| Seed | xorshift32, first draw | mulberry32, first draw |
|---|---|---|
| 1 | 0.000063 | 0.627074 |
| 2 | 0.000126 | 0.734251 |
| 3 | 0.000189 | 0.720227 |
| 7 | 0.000441 | 0.011705 |
| 4242 | 0.251349 | 0.546706 |
The left column is not a broken test. It is the generator being honest about a seed that carries almost no information. Handing it a number in the billions, as the clock does, hides the effect completely; handing it a level index, a puzzle number, or the row number of a table of fixtures puts a visible diagonal stripe through the opening draws of every case.
The fix is to scramble the seed before it is used, and the generator on Drift Circuit shows why. It is mulberry32: a 32-bit counter stepped by 0x6D2B79F5, then run through two Math.imul multiplies and a pair of shifts before the divide. Because the counter is always incremented by an odd constant, its cycle is the full 4,294,967,296, and because the first thing that happens to a seed is a multiply-and-xor rather than a shift, a seed of 1 opens on 0.627074 instead of 0.000063. Reference notes collected by bryc credit Tommy Ettinger's 2017 C version, put the output period at around four billion, and record Ettinger's later caution that it appears to skip about a third of the 32-bit output values — worth knowing before the same function is asked to deal a shuffled deck.
What the replay is actually worth
The seed hook does not just set a number. window.gamerigTest.seed(n) calls the same resetRun the Play button calls, so the snake is rebuilt, the Maze field is laid again from the reseeded generator, the first pellet goes to its fixed opening position, and the bonus schedule resets. Two generators seeded with 4242 in Node produced 0.251349, then 0.8003, 0.7813, 0.3117 and 0.1028, identically both times. Same input, same board, same death.
That turns a random board into something a script can assert on. The smoke harness here already drives games through gamerigTest.state(); a seed is what lets it say something stronger than "the page did not throw", which is the difference between a check that catches regressions and one that catches outages. The same reasoning applies to writing those checks in the first place, which is the subject of the piece on testing a game in a fake browser.
One sequence per consumer, or the replay leaks
Snake still calls Math.random in one place: the screen shake. It is cosmetic, and it is deliberately kept off the game's own sequence, because a shake drawing from the same well would shift every later pellet by one and quietly invalidate every seed written down before the shake was added. Drift Circuit does the opposite and gets away with it — its tyre smoke draws from the seeded generator, but the car's physics contains no random term at all, so the particles change the sequence without changing the outcome.
The rule that falls out of both is to count the consumers before choosing a generator, then decide which of them may touch it. The other half of the discipline is keeping the seed: if a run is worth a best score, it is worth the number that produced it, and both belong under the same storage key, whose limits are the subject of what localStorage can and cannot do.
Pros
- A failing run becomes a number. Writing that number into a message is the entire bug report.
- The same seed re-lays the Maze field, the pellets and the bonus schedule, so a script can assert on a board rather than on a crash.
- The state is one 32-bit integer, which costs a variable and no measurable frame time on any of these pages.
- The generator is small enough to read in full, so its behaviour is not a matter of trust.
Cons
- A lone xorshift32 seeded with a small integer opens on outputs proportional to that integer, so seeds from a counter need hashing first.
- Every consumer of the sequence becomes part of the contract; a cosmetic draw added later changes every stored seed.
- Plain xorshift fails some of the BigCrush tests, and no version of it belongs anywhere near anything a player could profit from predicting.
- The boot seed is a masked millisecond count, so two runs started in the same millisecond are the same run.
Sources
- There's Math.random(), and then there's Math.random() — the V8 team's account of the move from MWC1616 to xorshift128+, the 2^128 - 1 period, the 40-million-cycle warning and the version numbers.
- MDN: Math.random() — the statement that the seed is chosen by the implementation and cannot be reset by the caller, plus the 0-to-1 return range.
- Wikipedia: Xorshift — the 13, 17, 5 routine, the period of 2^32 - 1 for the one-word version, the note that the state must start non-zero, and the citation to Marsaglia's 2003 paper.
- bryc: PRNGs in JavaScript — the mulberry32 listing, its 32-bit state and roughly four-billion period, the credit to Ettinger's 2017 C version, and the later note about skipped output values.