This is the correct conclusion - game developers should consider gameplay-relevant random generators part of their gameplay code rather than platform code.
Another solution is to switch to a cryptographic hash function. For example, using sha256(seed || event type || counter) only requires storing seeds and counters in the save game.
This has several benefits:
- You can find efficient implementations on all platforms without having to roll your own.
- Gives the same output on all platforms by design.
- Output is practically indistinguishable from randomness by design.
The main downside is that sha256 is significantly slower than any non-cryptographic PRNG, but considering how few random numbers you need during a typical game, this doesn't really matter.Why is this important? Feels like fixing what seems to be a non-issue lead to a bunch of real issues.
With a good RNG it should not be possible to predict future numbers based on past numbers so players cannot manipulate card rewards in their favour based on combat actions, right?
I hope the StS team is made aware of this and is able to make the earlier outcomes a bit more evenly spread, so that the distribution matches more closely with what people would intuit them to be.
I assumed that was just deterministic. Didn't realize the game permitted such a challenge on floor 2 :(
- Hades 1 is a series of "chambers", or enemy encounters, where some layouts are faster than others [0]
- chambers (and other things like enemy spawns, boons, etc.) are "randomly" picked by an RNG with its seed normally unknown to the player (well that, and other factors [1])
- you can see the per-chamber RNG seed using mods [2], and manipulate it with seemingly meaningless actions [3] — e.g. breaking a pot (a mundane, cosmetic environmental item) increments the RNG seed by 1
- this leads to the existence of "routed runs" [4] — very fast speedruns enabled by very deliberate actions that can be replicated by a skilled player [5].
- anecdotally, with enough practice, skilled players can also recognize chamber patterns in unseeded speedruns and give themselves better odds at more favorable chambers by manipulating the RNG (although tbh the ability to recognize this on the fly is a little dubious)
So the invisible correlated RNG seeding adds in a higher skill ceiling for experienced players, while not really taking anything away from casual players.
Another game with this kind of RNG mechanic is Super Mario Bros. 3 — there's an excellent (86-minute, fyi) Summoning Salt video about the history of speedrunning this game and dealing with the "random" Hammer Bros movement (@27:15 to skip to that part).
[0] https://docs.google.com/document/d/e/2PACX-1vR6NaU9v1-raeibk...
[1] https://docs.google.com/document/d/e/2PACX-1vSl9RGGyPbNqCnTL...
[2] https://www.youtube.com/watch?v=AHdt35TDvNY
[3] https://www.speedrun.com/hades/guides/jxpkj
[4] https://www.youtube.com/watch?v=CBRTQkoOZ4k
[5] https://docs.google.com/spreadsheets/d/1fNlBhBOsCz6092GUnsIt...
On the other hand, it's STUPENDOUSLY useful to have "default" random functionality in your core library, for the "just give me a random number" or "shuffle this array, I don't care how" users, who don't really care about the details. But if you do that: always seed it with some external entropy (current time or /dev/random or whatever), don't even allow users to seed it. That means you can improve it in the future, because users already can't ever rely on the sequence. If the users do want to rely on the sequence, they should have to specify the exact engine they want.
TL;DR: System.Random in C# should not ever have been seedable, big mistake.
They did not address it in StS1, exactly the same bugs were reported there. I would not be very hopeful. They did not even change their RNG to something better, like MT.
This is a pretty funny abbreviation since CRNG is sometimes "cryptographic random number generator", which would not be susceptible to this correlation. Albeit I think CSRNG is more common.
Slay the Spyre is a rogue deck builder, the PRNG of Windows Solitaire (3.11) would be good enough for it.
Standard library invocations - including random number generation - often break entirely when targeting wasm freestanding for instance, as in that case there is really very little "platform" to speak of.
It seems really the problem is twofold: the reference is from 1992 and cites a 1981 publication's reference to an unpublished 1958 generator. Not to say that being old makes the algorithm bad, but it's a bad implementation of an algorithm that already is questionable given more recent research.
I'll go section by section: > //Apparently the range [1..55] is special (Knuth) and so we're wasting the 0'th position.
This is a silly comment. Knuth explicitly states that "24 and 55 in this definition were not chosen at random; they are special values that happen to define a sequence whose least significant bits, {Xn mod 2), will have a period of length 2^55 - 1. Therefore the sequence (Xn) must have a period at least this long."
Then you have the initial seeding of the LCG with with a = 21 and m = 55, which is interesting. Numerical Recipes uses those values, but Knuth whom they got the algorithm from does not suggest them. The closest Knuth suggests is 24 and 55. This suggestion is from 1981, so the viability is questionable (and Knuth clearly states that this is an unpublished algorithm from 1958 - Numerical Recipes itself questions the quality).
Then they use 21 for inextp - this is wrong. Numerical Recipes uses 31, and that is significant per the period length quote above. The use of 21 should measurable lower the period.
Instead if it were a simple LCG using values found in L'Ecuyer's 1999 publication on the topic (https://www.ams.org/journals/mcom/1999-68-225/S0025-5718-99-...) I assume it would have a better distribution.
So the implementation is a questionable algorithm from 1958, and it's done incorrectly. Numerical Recipes opens the chapter on randomness almost immediately with: "Now our first ... lesson in this chapter is: be very, very suspicious of a system-supplied rand()," and then the authors of the .NET random package show exactly why that is.
If the stdLib changes and you need to use the same, then you're unfortunately going to be suck with porting the previous version into your own library. It's pretty forward thinking from the devs here, I would love to see my boss' face if I told him we need time to port some of the stdLib incase they update it in the future.
I had to check for my own curiosity, but it looks like the Random class has not been updated in 12 or so years. At least in the inital subset of framework to core.
https://github.com/microsoft/referencesource/commits/main/ms...
To avoid regression I have some simple code examples I compile and execute, and I compare their output to "known good" versions.
I reached a point where I wanted to write a "sort array" routine and my immediate thought was to generate an array of 50 random numbers, sort them, and print them. But of course that wouldn't give me predictable output for my test-driver.
In the end I decided I'd do that when run interactively, but for testing purposes I'd just sort the characters in a string "The quick brown fox .." and while it isn't super-convincing it's enough to let me see regressions in my sorting function and/or array indexing runtime code.
I think you're overlooking the distinction between seeded and unseeded runs. An unseeded run is a run in which the player enters the game not knowing what the seed of the RNG is and not being able to pick it (this is the default mode). A seeded run is where the player provides the RNG seed. Generally, things like unlocks and steam achievements can only be earned on unseeded runs, but players want to be able to play seeded runs anyway. Of course all runs have an RNG seed: an unseeded run is when the seed is itself chosen at random, a seeded run is when the player specifies the seed.
Imagine a game with a standard deck of cards played over several rounds, where your opponent performs actions in response to your actions. The deck of cards is shuffled pseudo-randomly between every round. Every time you make a move, your opponent has N valid moves, and picks between them pseudo-randomly.
Players play a seeded run because they want to retry the same set of challenges, because they are asking themselves "if I had done this, would I have won" or "if I had done this, what would have happened".
So in this example, given a known seed: in round 1, my cards are shuffled this way, and in round 2, my cards a shuffled this other way, regardless of which moves I make in round 1.
If the opponent picks its response using the same RNG that shuffles the deck, the players actions in round 1 would change the shuffling of the deck in round 2. This would change the design parameters of what a seeded run means: it's no longer giving the guarantee "the deck is shuffled in the same way in round 2 regardless of what you do in round 1", which is the experience the designer wants to create and what the players want to play. Players might, e.g., say "who can get the highest score on this seed", they might search for the easiest or most difficult seeds, or they might search for seeds where particularly unlikely sequences of events are guaranteed to occur, because perhaps this sequence of events is so unlikely that a human would have to play a hundred million games to witness that sequence organically, and people want to see that sequence of events because it's interesting in some way. It's designed this way so that if you play the same seed, certain random events play out the same way, i.e., non-randomly.
You can be safe from RNG manipulation while still suffering from RNG prediction. Players can't modify the events that are going to happen, but if they can predict them, it's still a problem.
The situation is like there's a bug in the blackjack table where, instead of shuffling the whole shoe together, each deck in it was shuffled on its own in the same way and then the identical decks are stacked together. Once you've seen 52 cards, you know the repeating pattern and can play with perfect or near-perfect knowledge of what's about to be dealt.
Since they are using the built-in RNG, it is trivial to predict if you know (or can guess) the seed: just run the same RNG a few steps ahead.
For something like a tool-assisted speed run, this is very exploitable to setup optimal runs
Minecraft does this too with world generation for example.
(Generally, when you just press 'start game', you'll get a truly random seed, but then you can also put that seed in again to get the same RNG).
Imagine the game of two players having the same state X. While combat, one player would trigger a random action, the other doesn't. After the combat, both should still get the same randomized reward options. This wouldn't work with just a singular RNG.
>The way Slay the Spire allows you to save and resume runs is by storing the total number of times each RNG has been called, and then calling each RNG that many times (throwing away the result) whenever a save file is loaded.
Depending on what the game is like (I know nothing about it), that could make sense, even if it is inelegant.
However, one of their design goals is that people playing on the same seed should have roughly the same game, it should feel "fair". Some things you probably want to be fairly random, for instance your card choices can depend on what cards you chose before. But it's also important that people choosing the exact same cards (and taking the same path, maybe?) should be offered the same options.
In STS1, the order of relics was fixed from the start as I recall. So if you skipped a shop, you'd get exactly the same relics in the next shop as you would have in the one you skipped. Good for seed fairness, but a little odd.
The game needs a RNG that's stable when seeded, for reproducible runs. I look for the same kind of qualities when doing generative art.
In comparison, a CSPRNG should be safe from oracle attacks, which is essentially the opposite goal.
That requirement is what made this problem difficult for the devs to solve.
(Note this is roughly what slay the spire did, but if they were to use a 'master' RNG output as the source of these sub-seeds then these correlations would also not be a problem. With a custom implementation they could save the RNG state directly as opposed to hacking around calling the RNG X times on loading a save)
But ideally sort is something you want to test with something like quickcheck/hypothesis, not gold tests (and I say that as probably the world's number 1 proponent of gold tests).
This way, you can see how e.g. players of different skill level navigate the "same" run (same seed), without everything diverging completely on the very first (meaninglessly small) combat choice.
The only difference is that if you don't know the seed it is computationally difficult to predict the next value given the previous ones. But that's not something any game dev is ever going to want to do (or waste time trying to do)
This doesn't really have any impact on the gameplay, and isn't related to the correlation problem, it's just a constraint on the class of RNG algorithms in use, they need to be deterministic with recoverable state.
Although I can definitely respect Go's decision to always iterate over maps in a random order.
The issue is that knowing the offset of seeds helps predict outputs.
Instead of calling RNG(seed+hash(string)) 10x, make one RNG(seed) and call that 10 times to get random seeds for your 10 rngs. Now you have perfect determinism and no correlation.
It's also more robust than calling RNG 10 times since if you use the same algorithm to seed as for the RNG proper then you will get the same sequences in each instance, just offset.
Point being, the current problematic state of the game is trivially fixable in multiple ways that require half a second's thought (once being aware of the problem).
2026-06-13
Here are three true statements about the game of Slay the Spire 2 (in single player):
If you pick Neow's Bones in the Underdocks, the random curse is ~54% likely to be Debt.*
It is impossible to receive Rebound from the Trash Heap event.
Your first fight is 76% likely to drop a potion in Underdocks, and 4% likely to drop a potion in Overgrowth.**
(* assuming neither of the relics from Neow's Bones is New Leaf or Kaleidoscope)
(** assuming your Neow relic doesn't give cards or other relics)
(*** all on the current beta patch, v0.107.0)
What?!
Why? The culprit is unexpected correlation between different random number generators -- knowing the first output of one of the game's RNGs gives information that helps predict the first output of all of the others.
For now, I will give an extremely simplified explanation of this correlation. If you want more details, I will go into much greater depth at the end of this post. If you don't care, you can skip this section to see all the funny examples below.
The phenomenon of "correlated RNG" (or "CRNG") is already known in the Slay the Spire community, because Slay the Spire 1 had a similar issue, described in detail in Forgotten Arbiter's blog post.[1]
Briefly, in Spire 1, the game used several distinct pseudorandom number generators, to prevent e.g. randomness within a combat from influencing future card rewards. However, they were all initialized to the same starting state, which meant they produced the same sequence of numbers. A crafty player could therefore pay attention to the results of past random events and gain information about future random events.
In an attempt to avoid the same problem, Spire 2 initializes its pseudorandom number generators to different states. The code looks something like this (highly simplified for didactic purposes):
Rng UpFront = new Rng(seed + hash("up_front"));
Rng Shuffle = new Rng(seed + hash("shuffle"));
Rng UnknownMapPoint = new Rng(seed + hash("unknown_map_point"));
Rng CombatCardGeneration = new Rng(seed + hash("combat_card_generation"));
Rng CombatPotionGeneration = new Rng(seed + hash("combat_potion_generation"));
Rng CombatCardSelection = new Rng(seed + hash("combat_card_selection"));
Rng CombatEnergyCosts = new Rng(seed + hash("combat_energy_costs"));
Rng CombatTargets = new Rng(seed + hash("combat_targets"));
Rng MonsterAi = new Rng(seed + hash("monster_ai"));
Rng Niche = new Rng(seed + hash("niche"));
Rng CombatOrbGeneration = new Rng(seed + hash("combat_orbs"));
Rng TreasureRoomRelics = new Rng(seed + hash("treasure_room_relics"));
// ...
There are many more random number generators in the game that I have not listed for brevity; notably, every event has its own RNG.
The hash function essentially produces a "random-looking" number from the input string, but the number is always the same for the same input. So the idea is that the RNG states are shuffled around, but the same seed still always results in the same run.
The problem comes when these seeds are passed to the stock System.Random class in C#. Unfortunately, the pseudorandom number generation algorithm used in C# is almost entirely "linear" in the starting seed.
What this means exactly is a bit complicated -- again, I will go into greater detail later in this post. But the consequence is that two RNGs whose seeds differ by a known fixed amount have their outputs differ by a fuzzier but still-exploitable amount.
How exploitable, you might ask? Well...
Here is a big pile of consequences of CRNG, ranging from amusing-but-unimportant to legitimately impactful on gameplay (some of them even to casual players unaware of it!).
I'll start with the first example from the intro. If you pick Neow's Bones in Underdocks, the "random" curse you receive actually has the following approximate distribution:
Clumsy
0.10%
Debt
54.25%
Decay
40.32%
Doubt
1.50%
Guilty
0%
Injury
0%
Normality
0%
Regret
0%
Shame
0%
Writhe
3.82%
However, in Overgrowth, you instead get a curse from this distribution:
Clumsy
0.51%
Debt
0%
Decay
0%
Doubt
0%
Guilty
0.19%
Injury
5.53%
Normality
1.18%
Regret
0%
Shame
18.85%
Writhe
73.74%
This one is quite funny to me -- people all over Reddit and Discord have been lamenting their terrible luck that they keep rolling Debt from Neow's Bones.[2] Even before discovering CRNG, I saw some of these posts insisting it seemed more frequent than random. It is hard to express how instantaneously my brain automatically dismissed them as textbook confirmation bias. And yet...
To understand this one, we need to correlate three sources of randomness:
The "curse relic" available from Neow comes from a call to Neow's event-specific RNG, which is seeded with seed + 1 + hash("NEOW").
The Neow options always have exactly one relic from the "curse pool", as described on the wiki. The choice of which of the 8 curse relics to offer is the first call to Neow's RNG.
The random curse from Neow's Bones comes from a call to RunState.Rng.Niche, which is seeded with seed + hash("niche").
Since New Leaf and Kaleidoscope also call Niche for their randomness, rolling either of these relics from Neow's Bones will destroy the correlation. But otherwise, this will be the first call to Niche.
The Act 1 variant (Underdocks or Overgrowth) comes from a call to an unnamed RNG created in StartRunLobby#BeginRunLocally, which is seeded with the base seed.
Since Neow's Bones comes from Neow's "curse pool", you will only ever see it when the first call to the Neow RNG rolls in a particular range, which imposes a strong constraint on the possible range for the first call to Niche (stronger when combined with which Act 1 you are in).
It is clear that this correlation is very impactful on gameplay, even for players unaware of it. It makes Neow's Bones a much worse relic, giving less harmful curses like Clumsy, Guilty, and Injury extremely rarely and more crippling ones like Debt much more often.
At this point, you might be thinking "wait, doesn't that mean we can predict the randomness of every Neow relic?" Indeed we can! Let's do some more of them.
The first relic from Large Capsule is never common.
What a buff!
More specifically, in Overgrowth, it's about 70% to be uncommon and 30% to be rare. In Underdocks, it's about 37% to be uncommon and 63% to be rare -- but there's a caveat:
Large Capsule will only appear about 1.65% of the time in an Underdocks act, because everything is correlated with everything. (Nobody seems to have noticed this one; here was someone's very funny reaction to this information as I was first investigating all of this.)
Here is the specific distribution of the "curse pool" option at Neow in Underdocks:
CursedPearl
11.95%
HeftyTablet
1.32%
LargeCapsule
1.65%
LeafyPoultice
12.72%
NeowsBones
13.01%
PrecariousShears
23.75%
SilkenTress
23.22%
SilverCrucible
12.37%
And in Overgrowth:
CursedPearl
12.99%
HeftyTablet
23.79%
LargeCapsule
23.24%
LeafyPoultice
12.39%
NeowsBones
11.90%
PrecariousShears
1.35%
SilkenTress
1.65%
SilverCrucible
12.70%
Getting back to Large Capsule in particular, much like Neow's Bones, the correlation has a legitimate gameplay impact. The relic is better than it "should be" on average.
What about Small Capsule?
Since Small Capsule is not a curse pool relic, it does not have an intrinsic bias the way Neow's Bones and Large Capsule do.
However, this means we can use the presence of another curse pool relic to predict the rarity of the Small Capsule relic:
common
uncommon
rare
CursedPearl[U]
4.58%
0.92%
1.33%
HeftyTablet[U]
0.56%
0.20%
LeafyPoultice[U]
5.81%
0.48%
0.95%
NeowsBones[U]
7.42%
PrecariousShears[U]
13.56%
SilkenTress[U]
13.28%
SilverCrucible[U]
7.08%
CursedPearl[O]
5.70%
1.72%
HeftyTablet[O]
10.96%
2.62%
LeafyPoultice[O]
4.61%
2.48%
NeowsBones[O]
2.23%
1.14%
3.45%
PrecariousShears[O]
0.77%
SilkenTress[O]
0.94%
SilverCrucible[O]
1.42%
3.65%
2.14%
(Here, [U] means Underdocks and [O] means Overgrowth. Large Capsule is never present because there is a hardcoded restriction that both Capsules can't appear simultaneously.)
I've kept the scaling on the bars the same as the two charts in the previous section -- the total width of each row is proportional to how often that curse pool relic actually appears in the act. This is to demonstrate a concise heuristic: Small Capsule will usually give a common relic in Underdocks, and usually give an uncommon or rare relic in Overgrowth.
Okay, there's a lot more Neows with randomness, so I'll sort of speed through a few more and then get to some different stuff.
(These are "transform 2" and "choose a rare".)
Since these are both curse pool relics, they have an intrinsic bias. But they both generate multiple cards, so we can only predict the first one.
It turns out that the first transform from Leafy Poultice only has 22 possibilities (out of each character's 80-card pool), with some significantly more likely than others.
(These charts are pretty big, so I've hidden them away here. You can click through each character to see the available options, and have fun deciding which act is better.)
Leafy Poultice
Underdocks:
Aggression
3.78%
Anger
7.84%
Armaments
7.82%
AshenStrike
7.82%
Barricade
7.84%
BattleTrance
7.82%
BloodWall
7.83%
Bloodletting
7.82%
Bludgeon
7.83%
BodySlam
7.85%
Brand
4.02%
FiendFire
1.59%
FightMe
2.15%
FlameBarrier
2.17%
ForgottenRitual
2.17%
Havoc
2.17%
Headbutt
2.17%
Hellraiser
2.17%
Hemokinesis
2.18%
HowlFromBeyond
2.17%
Impervious
2.17%
InfernalBlade
0.60%
HeirloomHammer
Overgrowth:
MoltenFist
0.64%
NotYet
2.20%
Offering
2.20%
OneTwoPunch
2.20%
PactsEnd
2.21%
PerfectedStrike
2.21%
Pillage
2.21%
PommelStrike
2.21%
PrimalForce
2.21%
Pyre
2.20%
Rage
1.58%
SecondWind
3.99%
SetupStrike
7.79%
ShrugItOff
7.81%
Spite
7.80%
Stampede
7.80%
Stoke
7.78%
Stomp
7.78%
StoneArmor
7.83%
SwordBoomerang
7.76%
Taunt
7.79%
TearAsunder
3.78%
HeirloomHammer
Similarly, the first option from Hefty Tablet only has 11 possibilities in Overgrowth, and 3 possibilities in Underdocks! This is because as shown above, Hefty Tablet only appears in about 1.3% of Underdocks in the first place, so seeing it is very strong information.
Underdocks:
Juggernaut
72.52%
TearAsunder
16.77%
Thrash
10.71%
HeirloomHammer
Overgrowth:
Juggernaut
4.52%
Mangle
10.17%
NotYet
10.17%
Offering
7.65%
OneTwoPunch
6.12%
PactsEnd
15.99%
PrimalForce
12.99%
Pyre
17.29%
Stoke
9.04%
TearAsunder
5.61%
Thrash
0.45%
HeirloomHammer
(These are "transform 1" and "random rare".)
As with Small Capsule, both the act and the curse pool option influence these relics.
Including a full card list for all 14 combinations of act and curse pool relic would take way too much space, so I'll just say: you can narrow the possible transforms from New Leaf down to anywhere from 4 to 39 options (out of 80), and the possible cards from Arcane Scroll down to anywhere from 3 to 12 options (out of 25), depending on your act and Neow.
Here's one fun tidbit, though: if you see Precarious Shears on Overgrowth (which is quite rare), then New Leaf is ~70% likely to give your character's alphabetically first card, and Arcane Scroll is ~65% likely to give your character's alphabetically first rare card.
Okay, but let's be real, at this point most of this isn't actually going to change the way you play. How about something else that does?
The Underdocks easy pool has two multi-enemy fights: the Corpse Slugs and the Toadpoles. If you are the Defect, you might want to know where your first lightning orb will hit, especially if you drew Dualcast turn 1.
In the first fight of Underdocks specifically, your first orb is 75% to hit the enemy on the left. (This applies to the evoke if you play Dualcast, or the passive if you don't.) If you remember what curse pool you saw, you can do better:
left
right
CursedPearl
11.91%
HeftyTablet
1.34%
LargeCapsule
1.67%
LeafyPoultice
12.63%
NeowsBones
12.58%
0.52%
PrecariousShears
8.42%
15.25%
SilkenTress
11.02%
12.26%
SilverCrucible
12.40%
You can do even better in the Corpse Slugs fight, which has a randomized starting attack pattern. I'm not going to list the whole table here, but for example: if you saw Precarious Shears and the Corpse Slug on the right is debuffing, then your orb is actually >95% to hit the one on the right.
(By the way, floor 2 Corpse Slugs will both be attacking on turn 1 less than 3% of the time. How nice of them!)
This applies to the first random combat target of the entire run -- for example, you might predict your first Countdown proc on Necrobinder, or your first Parrying Shield proc on anyone.
Speaking of early Act 1, let's finally get to the two other examples from the intro.
Since the Trash Heap is Underdocks-exclusive, it is intrinsically biased. Here is the output of the Trash Heap RNG conditioned on the act RNG rolling Underdocks:
Caltrops
10.14%
Clash
14.85%
Distraction
19.97%
DualWield
13.78%
Entrench
9.84%
HelloWorld
9.89%
Outmaneuver
5.17%
Rebound
0%
RipAndTear
6.19%
Stack
10.17%
As you can see, it is literally impossible to obtain the card Rebound in a single player game.[3]
In case you care about predicting the relic, the pairs of consecutive cards correspond to Darkstone Periapt, Dream Catcher, Hand Drill, Maw Bank, and The Boot respectively (e.g. if the card is Entrench or Hello World, the relic is Hand Drill).
In case you want to predict the Trash Heap more precisely, here is the output further conditioned on the curse pool relic you saw (bars are hoverable):[4]
Caltrops
Clash
Distraction
DualWield
Entrench
HelloWorld
Outmaneuver
Rebound
RipAndTear
Stack
CursedPearl
HelloWorld (66.63%)
Outmaneuver (32.26%)
RipAndTear (0.43%)
Stack (0.69%)
HeftyTablet
Outmaneuver (98.82%)
RipAndTear (1.18%)
LargeCapsule
HelloWorld (0.04%)
Outmaneuver (0.26%)
RipAndTear (99.70%)
LeafyPoultice
Entrench (0.08%)
HelloWorld (0.26%)
RipAndTear (35.29%)
Stack (64.36%)
NeowsBones
Caltrops (76.61%)
Clash (8.24%)
DualWield (0.14%)
Entrench (0.18%)
Stack (14.82%)
PrecariousShears
Clash (57.61%)
Distraction (42.16%)
DualWield (0.23%)
SilkenTress
Clash (0.56%)
Distraction (42.90%)
DualWield (56.54%)
SilverCrucible
Caltrops (0.83%)
Clash (0.03%)
DualWield (4.40%)
Entrench (79.10%)
HelloWorld (15.46%)
Stack (0.18%)
Incidentally, after finding this one, I searched for discussion about it on the internet, and indeed people have noticed they can't seem to complete their Compendiums. But I also discovered that user @hoge posted a spot-on description of the issue on Discord about a month ago. Props to them!
Finally, here is the third point mentioned in the intro -- how often does your first fight drop a potion? You know the drill by now:
CursedPearl[U]
16.85%
HeftyTablet[U]
0%
LargeCapsule[U]
0%
LeafyPoultice[U]
51.23%
NeowsBones[U]
96.02%
PrecariousShears[U]
97.21%
SilkenTress[U]
84.30%
SilverCrucible[U]
99.49%
CursedPearl[O]
0%
HeftyTablet[O]
0%
LargeCapsule[O]
0%
LeafyPoultice[O]
0%
NeowsBones[O]
13.08%
PrecariousShears[O]
72.90%
SilkenTress[O]
37.08%
SilverCrucible[O]
6.61%
Again, recall that Tablet and Capsule are extremely rare in Underdocks, and Shears and Tress are extremely rare in Overgrowth. Accounting for this, overall, the chance that your first fight drops a potion is 76% in Underdocks and just 4% in Overgrowth!
However, note that picking any Neow that generates a card reward or random relic breaks this correlation, since it steals the first call to rewards RNG. So Lost Coffer might look more appealing than average on bad Overgrowth maps.
As a bonus, the chance that the first ? room is a combat is also quite unevenly distributed:
CursedPearl[U]
10.58%
HeftyTablet[U]
0%
LargeCapsule[U]
0%
LeafyPoultice[U]
5.72%
NeowsBones[U]
19.09%
PrecariousShears[U]
0%
SilkenTress[U]
0%
SilverCrucible[U]
41.44%
CursedPearl[O]
23.22%
HeftyTablet[O]
16.09%
LargeCapsule[O]
15.24%
LeafyPoultice[O]
0%
NeowsBones[O]
0%
PrecariousShears[O]
0%
SilkenTress[O]
0%
SilverCrucible[O]
0%
(It mostly evens out by act, at ~9.6% in Underdocks and ~10.4% in Overgrowth.)
So far, everything here has only applied to Act 1. But -- you guessed it -- we can go further...
The Doll Room is an event that appears in Act 2. But as with most events, it uses its own RNG, so we can correlate it with the first call to every other RNG.
By this point in the game, you have seen a very large number of first-RNG-calls, and it's probably possible to predict the Doll Room with very high accuracy. But even just the Neow options are pretty good:
Daughter
Struggles
BingBong
CursedPearl
60.77%
7.66%
31.57%
HeftyTablet
91.00%
9.00%
LargeCapsule
55.07%
44.35%
0.58%
LeafyPoultice
23.14%
70.81%
6.05%
NeowsBones
4.54%
70.38%
25.08%
PrecariousShears
46.09%
53.91%
SilkenTress
8.50%
91.50%
SilverCrucible
32.22%
9.89%
57.88%
This shows which doll you will get if you click the "one doll" option. The "two dolls" option can be determined from the "one doll" option as follows:
| 1 doll | 2 dolls | |
| Daughter | → | Daughter + Struggles |
| Struggles | → | Struggles + BingBong |
| BingBong | → | BingBong + Daughter |
So if you rolled Hefty Tablet and want to guarantee Mr. Struggles, or if you rolled Precarious Shears or Silken Tress and want to guarantee Bing Bong, you only need to pay 5HP, and it will always be one of the options.
You might notice that the doll distribution looks pretty similar to the Underdocks/Overgrowth distribution. And in fact, there's a simpler "rule": in Underdocks runs, the "one doll" button is ~62% to be Bing Bong and ~4% to be Daughter, and vice versa for Overgrowth.
The Crystal Sphere also only appears in Act 2 or 3.
Again, it uses its own RNG, but this time the first interesting RNG call is the second one, which determines where to place the relic box.[5]
What's the easiest second roll to correlate with? There are some that are very high-signal (e.g. the top left card in the first shop), but it's kind of obnoxious to track because it depends on which rarity was rolled first.
It turns out that the amount of gold your first combat drops is the second roll of the "rewards" RNG (the first is whether you get a potion, as seen above).
So here's a little widget where you can see the distribution conditioned on gold number, assuming Ascension 3+:
1st fight gold:
But okay, this opens up a whole new world of possibilities. What else can we do by correlating 2nd rolls?
Being able to predict Ancients would be extremely powerful. But unfortunately (or fortunately, depending on your perspective?), combats, elites, bosses, and Ancients are all rolled by RunState.Rng.UpFront, which first rolls about 100 times to shuffle the relic lists.
What you can do is predict what Ancient options you will get if that Ancient shows up. For example, here's Pael's option 2 based on first combat gold:
PaelsWing
PaelsClaw
PaelsTooth
PaelsGrowth
7
47.17%
50.23%
2.60%
8
75.03%
16.04%
8.93%
9
3.63%
12.60%
46.84%
36.93%
10
4.31%
25.40%
68.59%
1.71%
11
33.98%
52.76%
13.25%
12
56.83%
40.97%
2.20%
13
33.39%
9.47%
16.47%
40.68%
14
84.14%
15.86%
15
2.63%
49.67%
28.03%
19.67%
While this information is surprisingly strong, it's not immediately clear how useful it is, because you don't know whether the Act 2 Ancient will be Pael in the first place. But I suppose it means if you roll 11 gold, you should immediately give up on your Clone dreams. (Or maybe I'm dreaming too small, and 13 gold means the Perfected Strike immediately gets in the deck...)
You can do the same for Tezcatara's option 2, but those are mostly not particularly actionable in Act 1. On the other hand, Tezcatara's option 1 contains Nutritious Soup, which very well might influence how much you prioritize Strike removes:
VeryHotCocoa
YummyCookie
NutritiousSoup
CursedPearl[U]
19.37%
67.89%
12.74%
HeftyTablet[U]
36.64%
63.36%
LargeCapsule[U]
69.33%
30.67%
LeafyPoultice[U]
39.00%
32.20%
28.80%
NeowsBones[U]
29.19%
70.81%
PrecariousShears[U]
9.54%
90.46%
SilkenTress[U]
33.77%
66.23%
SilverCrucible[U]
69.44%
30.56%
CursedPearl[O]
36.78%
63.22%
HeftyTablet[O]
90.85%
9.15%
LargeCapsule[O]
89.56%
10.44%
LeafyPoultice[O]
71.42%
28.58%
NeowsBones[O]
1.34%
32.13%
66.52%
PrecariousShears[O]
36.29%
63.71%
SilkenTress[O]
68.04%
31.96%
SilverCrucible[O]
12.98%
67.89%
19.13%
Particularly noteworthy is that if Precarious Shears is offered -- which you might have used to remove two Strikes -- then Tez option 1 is 88.75% to be Soup. This was especially funny because as I was actively dumping CRNG discoveries into Discord, two different people posted sad screenshots of them seeing Soup with 2+ Strikes removed. And what do you know, both of them had Shears in the relic bar. I only felt a little bad breaking the news.
What about Orobas? It turns out that that one rolls a color for Sea Glass and a choice between Prismatic Gem and Sea Glass before picking option 1, so we actually need the third roll of some RNG. The easiest one to reach for is the first combat reward.
ElectricShrymp
GlassEye
SandCastle
GemOrGlass
common potion
15.65%
23.77%
37.93%
22.65%
uncommon potion
39.99%
28.81%
1.63%
29.58%
rare potion
46.57%
23.14%
30.29%
common card
16.52%
23.65%
37.47%
22.36%
uncommon card
42.19%
27.85%
29.95%
If you got a potion, that was the third RNG roll; otherwise it was the first card. Also note that picking any Neow that gives you a card or relic breaks this correlation and introduces a new one, which I won't bother trying to elaborate on here.
I suppose the actionable information here is the uneven distribution of Electric Shrymp, which might influence how much you want to pick a good Imbue card.
As for Darv and the Act 3 Ancients, all of them shuffle longish lists, which calls RNG too many times to be cleanly predictable.
In Slay the Spire 1, to choose between some number of things, most of the RNGs rolled an integer from 0 to something very large, then took the remainder when divided by that number. This meant that you could only take advantage of correlations when the numbers of things being chosen from shared a lot of factors, which was not that common.
In Slay the Spire 2, to choose between some number of things, most of the RNGs roll a decimal number from 0 to 1, then scale by that number. This means that basically every RNG output gives information about every other RNG output.
I have already described many specific instances of correlation. But really, every first roll can be correlated against every other first roll, and second roll against second roll, and so on.
To that end, here is a very long, yet still incomplete, list of first rolls. Remember, all of these give some information about all the others.
Here is a shorter list of second rolls.
I could go on, but hopefully, I have made my point.
This section title is mostly just a reference to Forgotten Arbiter's post about Spire 1 CRNG. Of course, I do think that CRNG in Spire 2 is a bug and ought to be fixed, and I think it would be pretty bad for the game if it wasn't.
However, I am confident that Mega Crit will address this issue. For one thing, Spire 2 is still in Early Access, much earlier in its development cycle than when CRNG was discovered in Spire 1.
But also, compared to Spire 1, the influence of CRNG is much more directly impactful to players who don't know or care about it. It would be pretty unreasonable, for example, if it was impossible to complete the in-game Compendium (due to being unable to ever see the card Rebound). And other correlations, such as the curse distribution of Neow's Bones, have a significant balance impact which would not make sense to allow to exist in a very intentionally-designed strategy game.
Luckily, this problem is very simple to fix. For example, replacing System.Random with this drop-in 50-line replacement I threw together would be a 3-line change in the Spire 2 code and immediately eliminate all correlation. (I don't expect Mega Crit to literally use this code, although I would be perfectly fine with them copying it wholesale; the point is just to demonstrate how easy it is.)
If you are curious about the nitty-gritty details of what causes the issue and other options for fixing it, feel free to read the appendices below.
Otherwise, some closing remarks: I spent a lot of effort writing this post, basically entirely because I thought it was fun. The length of the post is wildly disproportionate to the seriousness and magnitude of the bug. But I hope you enjoyed reading it too! :)
You might be wondering how I realized that Spire 2 has CRNG, given that the code appears explicitly written to prevent it. In fact, with some very reasonable assumptions on how the System.Random class is implemented in C#, the randomness in Spire would be totally fine.
I wish I could say that I read the code and thought of this possible flaw from first principles, that'd be really cool. Alas, I am not that clever. It was actually a complete accident: during jmac's recent overnight Royalties + Spectrum Shift stall for 2 million gold[8], I was inspired to write a seed-search program to find a seed where you could transform into The Scythe + Call of the Void at Neow, and stall the first fight of the game to Transfigure your Scythe arbitrarily many times to scale its damage arbitrarily high.[9]
I got the seed search working, and I started adding conditions one by one. It successfully found many seeds where Neow offered a Leafy Poultice, and the transforms were The Scythe and Call of the Void in some order. However, I also wanted the act to be Overgrowth, both because it has more stallable easy pools and because of the Overgrowth-exclusive transform 2 event, which would allow obtaining 2 more Scythes on floor 3.
But as soon as I added the Overgrowth condition, suddenly there were no seeds to be found. I was baffled and thought my code somehow had a bug, but it was still generating tons of Underdocks seeds perfectly fine.
Finally, I just had it check the other conditions and print out the raw value of the RNG output used to determine the act (which is Underdocks when it's less than 0.5, and Overgrowth otherwise). To my befuddlement, not only was the value always less than 0.5, it was always very close to 0.1.
This made absolutely no sense to me unless there was in fact correlation somehow. So to actually determine whether a correlation somehow existed, I made a scatterplot with the transform roll on the X-axis and the act roll on the Y-axis. And the results were, uh, rather shocking.

Thus began the unexpected dive into correlating every single other roll in the game. For posterity, I saved the video of this whole adventure (link is to somewhere around the point where I noticed something was up).
The reason my Call of the Void + The Scythe seed was impossible, by the way, is because neither card can be the first transform in an Overgrowth act with Leafy Poultice offered (as can be seen in the Leafy Poultice table).
As promised, I will now actually show you why the C# implementation causes all of this.
The one-sentence summary is "the output is linear in abs(seed)", if you understand what those words mean. If not, or you want more specific details, here's a more complete explanation.
The actual code of System.Random, copied directly from the .NET reference source, is:
// ==++==
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
// ==--==
[...]
private int inext;
private int inextp;
private int[] SeedArray = new int[56];
[...]
public Random(int Seed) {
int ii;
int mj, mk;
//Initialize our Seed array.
//This algorithm comes from Numerical Recipes in C (2nd Ed.)
int subtraction = (Seed == Int32.MinValue) ? Int32.MaxValue : Math.Abs(Seed);
mj = MSEED - subtraction;
SeedArray[55]=mj;
mk=1;
for (int i=1; i<55; i++) { //Apparently the range [1..55] is special (Knuth) and so we're wasting the 0'th position.
ii = (21*i)%55;
SeedArray[ii]=mk;
mk = mj - mk;
if (mk<0) mk+=MBIG;
mj=SeedArray[ii];
}
for (int k=1; k<5; k++) {
for (int i=1; i<56; i++) {
SeedArray[i] -= SeedArray[1+(i+30)%55];
if (SeedArray[i]<0) SeedArray[i]+=MBIG;
}
}
inext=0;
inextp = 21;
Seed = 1;
}
[...]
private int InternalSample() {
int retVal;
int locINext = inext;
int locINextp = inextp;
if (++locINext >=56) locINext=1;
if (++locINextp>= 56) locINextp = 1;
retVal = SeedArray[locINext]-SeedArray[locINextp];
if (retVal == MBIG) retVal--;
if (retVal<0) retVal+=MBIG;
SeedArray[locINext]=retVal;
inext = locINext;
inextp = locINextp;
return retVal;
}
There are two parts -- the constructor (public Random) and the function ultimately called to generate random numbers (int InternalSample).
First, most of the work of the constructor is initializing the internal SeedArray state, which will ultimately be used to produce the outputs. The last entry is set to some constant minus the absolute value of the seed, and then we jump around setting the other entries in a random-looking order (that's what the times 21 mod 55 stuff is about). To determine the value for the next entry, we subtract the previous two entries.
After that, we do 4 more rounds of subtracting random-looking entries from each other. All of this is being done mod 2^31-1, which is what the MBIG lines are doing (MBIG is set to Int32.MaxValue).
Finally, when we actually ask for a random number, we see that the value we get is SeedArray[1] - SeedArray[22]. Every time we ask for a new number, those numbers are incremented (so the next one is SeedArray[2] - SeedArray[23]), wrapping around as necessary. The output is also inserted into SeedArray, replacing some previous value to give a new value the next time the indices come back around.
The root of the problem is that the only input to this whole process is the absolute value of the seed -- let's call it S -- and every entry of SeedArray is linear in S. What this means is that you can express them as x*S + y, for some integers x and y.[10]
Why is this true? Well, the first thing we put into SeedArray is a constant minus S, which is linear. Then everything else in the constructor sets entries of SeedArray to the difference between two of its existing entries. But the difference between two linear things is itself linear -- (x1*S + y1) - (x2*S + y2) = (x1-x2)*S + (y1-y2). So this property remains true no matter how much random-looking subtraction we mess around doing.
The InternalSample function only contains subtractions too. So if we make an RNG with some S, then its first output will be exactly x*S + y for some known constants x and y. But imagine we make a new RNG with S+1. Now the first output will be exactly x greater than the first output of the other one! In general, RNGs whose S differ by some amount d will have their first outputs differ by exactly x*d.
Since the game's RNGs differ by a known fixed value, this immediately gives the desired correlations. There is one tiny wrinkle, which is that S is the absolute value of the input seed. If the fixed offset between RNGs crosses 0, one of them will have an extra negation. This is why the image of the graph above has lines with both positive and negative slope.
Incidentally, there is some further discussion on the internet of this exact property of the default C# random generator and how it produces exactly this kind of correlation.
What exactly would fix the problem? I'll start from the simplest option and go from there.
The naive first-order fix is to generate the seeds for different RNGs by a nonlinear operation, like multiplication. If you multiply the seed by a fixed constant for each RNG, instead of adding, then the extremely easy predictive power of linearity goes away. (Alternatively, you could hash the values produced after whichever operation you choose.)
However, this is still not a very good solution. While it does address the blatant problems like Rebound and Neow's Bones, it still leaves in subtle bits of exploitability. Knowing that the outputs of two RNG streams are related by a constant offset can still be taken advantage of given many samples of both, even if the exact offset is not known up front.
The easiest "real" fix is to simply implement a nonlinear psuedorandom number generator. The topic of PRNGs with desirable apparent-randomness properties is very well-studied, and many suitable options are available with extremely simple algorithms. The one I chose for my sample implementation from the main post was PCG32, but this was pretty arbitrary and basically any modern algorithm will do.
Implementing a PRNG within the codebase instead of calling the C# standard library has an additional advantage: seeds are guaranteed to be the same on all platforms. In Spire 1, seeds on the desktop version of the game were different from seeds on the mobile version of the game, because the standard library implementation of PRNG differed between platforms. It is also worth mentioning that the standard library implementation might change over time, which would break all past seeds.
As a bonus, I will also mention a slightly more complex option. The way Slay the Spire allows you to save and resume runs is by storing the total number of times each RNG has been called, and then calling each RNG that many times (throwing away the result) whenever a save file is loaded. This works totally fine, but feels a little silly. The alternative[11] is a class of PRNGs known as counter-based random number generators, which store no internal state. Instead, to request the _n_th random number, you pass the parameter n (you could also think of this as the internal state being an integer that is incremented by 1 each call). So using any PRNG of this style instead and slightly modifying Slay the Spire's internal Rng class would eliminate the need for the "advancing" process.
The title and several section headings of this post are an intentional homage to that one. Also, hi Arbiter, I am sure you will be reading this at some point :)
Actually, when Neow's Bones was first added, everyone kept getting Guilty -- I'm guessing that the correlation was different in that patch and has since changed due to more Neow options being added, though I haven't dug through old patch source code to check.
Also, the author of the Reddit post with 9 screenshots (all Underdocks, by the way) has no idea that their "joke" about Doubt being "close enough letter wise" to Debt was actually totally correct, since the curses are sorted alphabetically internally.
Why is this different in multiplayer? It's because the event RNGs are offset not only by a constant corresponding to the event, but also your Steam ID, if playing multiplayer (so that people don't all get the same results). This defaults to 1 in single player so that everyone's single player seeds are the same. This also means that if you have an unlucky Steam ID, you might be permanently locked out of ever seeing the card Rebound, even in multiplayer. Too bad.
I scaled all the bars to full width for visibility, but note that this distorts the apparent overall probability -- Hefty Tablet and Large Capsule are both extremely rare in Underdocks, so e.g. Outmaneuver is much less common than it looks.
The first call determines the randomly generated gold price of the first event option.
There are two different egg skins, for some reason! All of the eggs in an individual fight are set to the same skin, but across multiple runs you will see different ones.

In case you were wondering, the Silent is the richest character (average of 13.4 gold first fight), and the Necrobinder is the poorest character (average of 8.3 gold first fight).
And I do mean "during" -- the entire discovery process happened while he was still "playing" the run. (I started while he was still asleep, in the middle of the 8-hour Royalties animation.)
It scales the full number of times even if the enemy dies first, which is also probably a bug. Hopefully CRNG is fixed before that one so I still get to do it. (Or hopefully not, because it would probably be terrible to actually execute.)
Again, all taken mod 2^31-1, but I'll just assume as a given that everything is mod 2^31-1 from now on.
There is actually another option, which is to implement a PRNG which supports an efficient "advance" function. In fact, PCG32 can do this, but I didn't bother implementing it in my example code, because this would significantly increase its complexity for very little benefit.
There are no comments yet.