Correlated randomness in Slay the Spire 2

50 min read Original article ↗

Here are three true statements about the game of Slay the Spire 2 (in single player):

  1. If you pick Neow's Bones in the Underdocks, the random curse is ~54% likely to be Debt.*

  2. It is impossible to receive Rebound from the Trash Heap event.

  3. 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.

The random number generators of Slay the Spire 2

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!).

Neow's Bones

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

Debt

Decay

Doubt

Guilty

Injury

Normality

Regret

Shame

Writhe

However, in Overgrowth, you instead get a curse from this distribution:

Clumsy

Debt

Decay

Doubt

Guilty

Injury

Normality

Regret

Shame

Writhe

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.

Large Capsule

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

HeftyTablet

LargeCapsule

LeafyPoultice

NeowsBones

PrecariousShears

SilkenTress

SilverCrucible

And in Overgrowth:

CursedPearl

HeftyTablet

LargeCapsule

LeafyPoultice

NeowsBones

PrecariousShears

SilkenTress

SilverCrucible

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?

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:

CursedPearl[U]

HeftyTablet[U]

LeafyPoultice[U]

NeowsBones[U]

PrecariousShears[U]

SilkenTress[U]

SilverCrucible[U]

CursedPearl[O]

HeftyTablet[O]

LeafyPoultice[O]

NeowsBones[O]

PrecariousShears[O]

SilkenTress[O]

SilverCrucible[O]

(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.

Leafy Poultice and Hefty Tablet

(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

Anger

Armaments

AshenStrike

Barricade

BattleTrance

BloodWall

Bloodletting

Bludgeon

BodySlam

Brand

FiendFire

FightMe

FlameBarrier

ForgottenRitual

Havoc

Headbutt

Hellraiser

Hemokinesis

HowlFromBeyond

Impervious

InfernalBlade

HeirloomHammer

Overgrowth:

MoltenFist

NotYet

Offering

OneTwoPunch

PactsEnd

PerfectedStrike

Pillage

PommelStrike

PrimalForce

Pyre

Rage

SecondWind

SetupStrike

ShrugItOff

Spite

Stampede

Stoke

Stomp

StoneArmor

SwordBoomerang

Taunt

TearAsunder

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

TearAsunder

Thrash

HeirloomHammer

Overgrowth:

Juggernaut

Mangle

NotYet

Offering

OneTwoPunch

PactsEnd

PrimalForce

Pyre

Stoke

TearAsunder

Thrash

HeirloomHammer

New Leaf and Arcane Scroll

(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?

Lightning orbs and random targeting

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:

CursedPearl

HeftyTablet

LargeCapsule

LeafyPoultice

NeowsBones

PrecariousShears

SilkenTress

SilverCrucible

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.

Trash Heap

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

Clash

Distraction

DualWield

Entrench

HelloWorld

Outmaneuver

Rebound

RipAndTear

Stack

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]

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!

Potion drops and question mark combats

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]

HeftyTablet[U]

LargeCapsule[U]

LeafyPoultice[U]

NeowsBones[U]

PrecariousShears[U]

SilkenTress[U]

SilverCrucible[U]

CursedPearl[O]

HeftyTablet[O]

LargeCapsule[O]

LeafyPoultice[O]

NeowsBones[O]

PrecariousShears[O]

SilkenTress[O]

SilverCrucible[O]

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]

HeftyTablet[U]

LargeCapsule[U]

LeafyPoultice[U]

NeowsBones[U]

PrecariousShears[U]

SilkenTress[U]

SilverCrucible[U]

CursedPearl[O]

HeftyTablet[O]

LargeCapsule[O]

LeafyPoultice[O]

NeowsBones[O]

PrecariousShears[O]

SilkenTress[O]

SilverCrucible[O]

(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...

Doll Room

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:

CursedPearl

HeftyTablet

LargeCapsule

LeafyPoultice

NeowsBones

PrecariousShears

SilkenTress

SilverCrucible

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 doll2 dolls
Daughter Daughter + Struggles
StrugglesStruggles + 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.

Divination

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+:

But okay, this opens up a whole new world of possibilities. What else can we do by correlating 2nd rolls?

Ancient rewards

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:

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:

CursedPearl[U]

HeftyTablet[U]

LargeCapsule[U]

LeafyPoultice[U]

NeowsBones[U]

PrecariousShears[U]

SilkenTress[U]

SilverCrucible[U]

CursedPearl[O]

HeftyTablet[O]

LargeCapsule[O]

LeafyPoultice[O]

NeowsBones[O]

PrecariousShears[O]

SilkenTress[O]

SilverCrucible[O]

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.

common potion

uncommon potion

rare potion

common card

uncommon card

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.

And more...

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.

Plea to the developers

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! :)


Appendix: How?

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).

Appendix: Why?

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.

Appendix: What?

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 nth 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.


anon 2026-06-16 00:29

cool!

Freya 2026-06-16 04:11

So a simple solution would be to hash the seed and the text string together?

tckmn 2026-06-16 23:50

that would solve it to some extent! but there would still be an exploitable correlation -- i mention all of this in the last appendix if you want more details

Destroything 2026-06-16 06:38

wow, super interesting stuff. do hope it gets fixed somewhat soon

Verbante 2026-06-16 09:20

what a cool write up

Zing 2026-06-16 11:09

I wish there was an RSS feed for your blog :)

humanoid_human 2026-06-16 11:38

would it solve the problem if they generated seed offsets for the other randomizers with calls to the first one? this would require knowing every rng you need in advance, and storing that, but it doesn't seem completely impractical.

tckmn 2026-06-16 23:50

that would only partially solve it, for similar reasons as the ones i mention in response to Freya's comment above

poshpotato 2026-06-16 12:12

Absolutely fascinating, thank you

malinus_keshar 2026-06-16 12:54

It was a great job.

Now i hope that they'll change it to make random independent. Because i don't really want meta of checking all these things for consistent gameplay (streaks).

P.s. Sorry for my English.

? 2026-06-16 13:23

Thanks for the deep-dive, this was very interesting!

rex337 2026-06-16 13:39

holy cow!

Starling 2026-06-16 13:42

Hi! I really enjoyed this article. May I translate it into Chinese and repost the translation on Chinese website? I will clearly credit you as the original author and include a link to the original article. Thank you!

tckmn 2026-06-16 23:50

yes, absolutely!

Josh 2026-06-16 14:46

The correct solution for seeded games is probably to use a Random Function (i.e., a good hash function) rather than Random Number Generators to begin with. For example, if you want to determine card rewards, compute:
X = hash(seed, "card reward", floor, reward_index)
and use X to pick the card (X is a uniformly distributed large integer, you can get a smaller integer relatively easily). Couple notes:

  • comma (,) above is concatenation. You want to keep feeding the hash function more data. Never use +.
  • You might need more arguments to fully decorrelate events. For example, you need reward_index due to Prayer Wheel, White Star, etc. Enemy intents would use Turn number, or a counter (need to be careful around Vault, Stun, etc.).
  • You need to use a good hash function. xxHash, MurmurHash3, sha3, etc. Don't roll your own.
  • This approach is strictly better than the recommendation of counter-based RNGs. It is cheaper, easier to implement, and easier to reason about. You don't need to maintain 40 counters, you don't need to save/load any data you weren't saving before. And you can reason about what is and isn't correlated.
  • The fact that STS2 has like 30 RNGs is a code-smell. To me, it indicates the developers are trying to achieve what I described here, but using the wrong tools.

tckmn 2026-06-16 23:50

that would also be a perfectly fine solution, i think it's overkill by quite a bit though (we don't need any of the cryptographic properties that hash functions are designed to have). and "cheaper" is definitely not true (unless i'm misunderstanding what you mean), a prng call is like 5 bitwise operations whereas a hash call is comparatively much more computationally intensive. i don't agree that the pile of RNGs is a code smell, it seems like a fine solution to me as long as the underlying PRNG is not flawed

Zhenta 2026-06-16 15:59

I read an article a long time ago that had some visualizations of the correlated randomness - as your examples demonstrate, it's really bad. Looking back at it now, PCG also has some issues with correlation as well, though it at least performs much better.

tckmn 2026-06-16 23:50

oh, ha - that is the same article as the wayback link at the end of the "Why?" section! i didn't notice it was posted elsewhere, nice

i will note that the "PcgHash" mentioned in that article is not the same as the PCG32 pseudorandom number generator mentioned in this post

Anon 2026-06-16 16:12

Good post

sigmaboy67 2026-06-16 20:46

first

Amitri 2026-06-16 21:11

This is so damn cool, and a super interesting read.

Hieu 2026-06-16 23:17

Great write up! Didn't even realize the different egg skins.

s 2026-06-16 23:24

W post

Scevenate 2026-06-17 01:32

Activation function for random iteration? Probabilities are always a headache. You write some super custom function and turns out it's bad in a way you've never thought of.

anongoner 2026-06-17 03:06

is it okay to use this information for my video?

tckmn 2026-06-17 23:20

certainly, go ahead!

compendium-guy 2026-06-17 05:43

Reddit shittalked me for trying to complete the compendium and being unable after dozens of roles yielding the same behaviours. Thank you for making a reference I can point to, to shut them up.

Sowl 2026-06-17 06:49

I wonder if [7] is in any way connected to funny silent clone runs metric

tckmn 2026-06-17 23:20

lol, i didn't think of that, but that is actually extremely funny: if you random into Silent, you are ~36% to see clone at Pael, and if you random into Ironclad or Defect, you will never see Clone at Pael

anon 2026-06-17 07:03

Advancing the rngs by the time they were called when loading a save instead of saving the internals of the rngs is certainly one of the design decisions of all time.

Matthew 2026-06-17 07:39

Just want to throw my 2 cents in to say I really like Josh's suggestion. It would align seeded runs much more, rather than them quickly deviating from each other. Performance obviously doesn't matter for this area of the game, any sensible solution will be fine. There's certainly some gottchas in rolling your own PRNG that I see (including in your implementation, though you do mention it and it probably is fine). With that in mind, a more simple implementation than rolling their own PRNG might be to use C#'s built in CSRNG. Maybe. I'm not a C# coder and haven't looked at it for more than 30 seconds.

I do however think moving to hash functions is likely a massive change to the code base that probably isn't worth it. I just liked the suggestion.

Anon 2026-06-17 08:25

Could you fix this by adding the offset to the starting state of the different rng, instead of changing the seed? Like if the card reward rng is automatically incremented 1000x, would it be completely decoupled from the other rng systems.

tckmn 2026-06-17 23:20

i suppose that would also remove the correlation, but it feels pretty silly to force yourself to call the rng a billion times for no reason lol (especially when there are easier fixes)

Siv 2026-06-17 10:08

I play only random, and have noticed it’s very streaky. Out of the last 16 runs since I started tracking, 8 were the same character as the previous run. I had chalked it up to confirmation bias, but could this be a similar thing? I’d expect them to use unseeded or at least sequential calls on the same channel for the random character selection.

tckmn 2026-06-17 23:20

nope, that one actually is confirmation bias

Dan Yaghsizian 2026-06-17 10:09

Brilliant write-up! Love how you just stumbled upon this because you were seed searching for absurd theory craft combos :D

Also interesting how better PRNG implementation fundamentally changes game architecture and allows for seeds to be consistent across platforms (and future-proofs seed continuity against library updates). Maybe we could eventually see a world where we can start a run on the phone and finish on the PC.

Anyway, can’t wait to see the patch notes where they reference this post! Great job!

nyco 2026-06-17 10:18

good post 2

dk 2026-06-17 10:41

Absolutely bonkers stuff, hope they fix this soon

rowan 2026-06-17 13:07

This was a fascinating read and I imagine it will be incredibly helpful for the devs. Nice one!

S 2026-06-17 13:30

Godot already uses PCG32 for its RandomNumberGenerator class, couldn't they just swap from the C# standard to that?

Crit 2026-06-17 14:06

Spectacular write-up on this, I really loved the structure of plain language introduction, data breakdown, and technical conclusion.

z 2026-06-17 14:37

This is cool! How do we make the devs aware?

tckmn 2026-06-17 23:20

they are already aware and will fix the issue!

Josh 2026-06-17 14:59

I was being sloppy with the "cheaper" claim. You are right that PRNGs are usually going to be cheaper than hash functions in terms of compute, especially if you go overboard and use a cryptographically secure one like SHA3.[1] A random function has the benefit of being stateless though, so you don't need to save/load any extra data, which could maybe possibly matter for some games (ones that use a lot of PRNGs and want to save constantly). That's what I was thinking of when I wrote "cheaper." Doesn't apply to STS though. Besides, this cost debate is purely academic since STS doesn't generate nearly enough randomness for anything to matter.

I would claim we do need some of the cryptographic properties of hash functions though. Not in the sense of "this needs to be unbreakable even using a supercomputer," but in that these properties are what ensure correlated/predictable randomness doesn't exist. For example, the avalanche effect (small input changes lead to large output changes) ensure the minimal modifications we make to the input (floor = 21floor = 22) don't result in linear looking output. And similarly, that hash(seed, CARD_REWARD) and hash(seed, COLORLESS_POTION_RESULT) aren't obviously correlated. It would be a fun exercise to figure out which properties of hash functions one needs for this domain. Or just use a good one and call it a day.

[1] You don't need to. The article linked by @Zhenta references a lot of good functions that are also cheap. Rust's hash tables use SipHash 1-3 by default.

tckmn 2026-06-17 23:20

ah sure, certainly with you re everything in the first paragraph

regarding the second, i also agree that both of those properties are important for a stateless-function-based approach like your proposal. but the PRNG-based approach, with a high quality PRNG, also has both properties, the first one corresponding to the statistical randomness of the PRNG output, and the second one corresponding to the lack of correlation between two different PRNGs. i don't really see a compelling concrete reason that the functional approach is tangibly "better" than the PRNG approach

i will say though that now that you've made me think about it more, i probably agree that your proposal is a bit "nicer" / more elegant, in the same way that, like, a clean mathematical proof is nicer than bashing out some casework. so i would probably be convinced if i was writing my own similar game (but for spire all i really care about is that it gets fixed one way or another :p)

hotwords 2026-06-17 15:34

Hi, thanks for the amazing write-up. I’m trying to reproduce the results from this blog and wanted to ask: were the percentages in the post generated by Monte Carlo sampling or exact calculations?

I wrote an exact probability calculator for the STS2/.NET Random model. The results are very close to the post, but there are a few tiny differences.

Code is here: https://github.com/hotwords123/sts2_rng_predictor

One interesting example: the post/table rounds Decay in Overgrowth to 0.00%, but it is not actually impossible in the exact model. Conditioned on Overgrowth + Neow’s Bones appearing as the Neow curse option, Decay has count 1 out of 257,539,712, i.e. about 3.883e-07%.

A concrete standard seed that hits this case is: 0P2ENNHM

It hashes to the boundary base seed:
StringHelper.GetDeterministicHashCode("0P2ENNHM") == -2147483648

For that seed:
act roll = Overgrowth
Neow curse-option roll = Neow’s Bones
Niche curse roll = Decay

tckmn 2026-06-17 23:20

woah, very cool! yeah, the numbers in the post are all just from random sampling, i thought about doing the exact math but didn't end up getting around to that. (the 0 in my post is because i literally saw no such occurrences of it, but that makes sense given your numbers lol.) nice!

YCross00 2026-06-17 16:49

such a good read loved every sentence of it. hope for a quick fix

LtSMASH324 2026-06-17 16:57

tckmn 2026-06-17 23:20

the correlation with Hefty Tablet only allows you to predict the first card offered -- the other two cards can be any cards from the 80 card pool

LetMeSee 2026-06-17 17:30

It's really cool that you found those correlations so early on. I really hope Mega Crit will implement fix for them as soon as possible. (I knew i wasn't hallucinating with my bad luck! Thanks for proving me right).

LiquidLittle 2026-06-17 20:18

Awesome post. Curious about any of this changes in multiplayer (other than the one you mentioned about rebound), like with massive scroll, what pool of multiplayer cards you can get

tckmn 2026-06-17 23:20

all of the same shapes of predictions will still apply, just with different concrete numbers. (it will be different for each person though, since the seeding is done partially with your steam ID, so you would have to do all of the computations specific to your own personal account)

MrFrosty 2026-06-17 20:26

My Neow's bones results don't mesh at all with what you've written unless I'm misunderstanding. I've had multiple of the 0% results from only 12~ runs in my records.

tckmn 2026-06-17 23:20

i mentioned this in a footnote, but i'm pretty sure previous patches had totally different correlations with Neow's Bones (since the neow curse pool was different). anecdotally i clicked bones like 3 times or something on the patch it first came out and they were all Guilty

snickerdoodle 2026-06-17 21:14

I believe the devs using many different RNGs is intentional. Basically, they want to make sure that 2 players playing the same seed see similar outcomes for events, etc. even if they make different choices throughout the run.

For example, suppose I take a path with a ?-room on floor 3 and get trash heap. My friend takes a different path and doesn't enter a ?-room until floor 4. The devs want to make sure we both get the same card from the event, even though we're seeing the event on different floors.

The simplest way to do this is to use multiple different RNGs. I think the devs just made the (IMO very reasonable) assumption that RNGs with different seeds would be uncorrelated.

Dknsdsgn 2026-06-17 21:33

Not a coder or anything, just an STS fan. Nevertheless, I've enjoyed reading your post and found it quite informative and comprehensible even for me. Thx and have a good one!

Aphid 2026-06-18 00:53

Alternative solution still using system:

Rather than modifying the 'seed' of the system.random initialization, modify the position.

Each random call becomes a 'wrapper' Spire.random().

Spire.random() wraps system.random(). The first time, it calls it with the seed of the game. It then pre-generates the first 100K random doubles and saves them.

Most of the RNGs in spire are only called a few amount of times (like ancient rewards, transform options, etc.). All of those are done using a fixed chosen one of our 100K random numbers (so say the doll room would be #284).

This does have some programming downsides in that you'd need to take care not to overlap them.

So I do still think that the best solution is to pick a better PRNG. MT19937-64 is often used in games because it's the C++ standard, and it's much more robust to most of our shenanigans.

It's got a large internal state which means its 'period' is bigger than any typical PRNG at effectively infinite numbers, it's nonlinear so much harder to predict, etc.

You could also offset each random PRNG after the first with some fixed N calls you throw away. Note that this does have quadratic complexity, so after some point (mods will notice this) it becomes more efficient to save the state before creating a new RNG (if we take N=5, you need 5, 15, 30, 50, ... calls to de-correlate the state).

Aphid 2026-06-18 04:41

Addendum / Theoretical.

I had a bit of an observation: 'information' is a preserved quantity. A seed has a fixed amount of information. Directly deriving from the seed, in a known way, a quantity of information larger than that inside of the seed (64 bits) is going to force some kind of correlations to exist. They may become quite complicated, but they will exist if the game needs more than 64 bits.

A card transform needs at least 7 bits (there are roughly 2^7 cards rounded up), the doll room event needs 3 bits (there are 6 possible ways of ordering 3 dolls), randomizing a starting curse needs ~4 bits, etc.

Perhaps a much bigger seed could help. You could chop it up into many small seeds for the 30-odd PRNGs used by the game. Instead of a 64-bit seed you have a 2048-bit seed file. That forces independence for sure. The reason to do that is to anticipate the future where some clever users will start seeing patterns even with a better RNG.

Why am I somewhat uncertain about just replacing the RNG alone? Because of a field of study named 'Differential Cryptanalysis'. This correlated randomness post is actually applying some basic differential cryptanalysis on Spire's PRNG. More advanced analysis might find patterns in more advanced PRNGs, and it becomes a question of when to stop 'improving'.

To keep the seed shorter but also make the risk of DC lower, You could combine this with the previous suggested approaches too, by employing techniques used in cryptography. Algorithms like AES that shuffle stuff around do so multiple times to make the key (the small amount of information that's encrypting a larger amount of information) more difficult to guess.

Now StS hands you the key (the seed) but here the assumption is that generating your own results is cheating. Assuming you don't use the seed in any way, can you use what the game outputs (the random selections) to infer the seed in some way? With the Mersenne twister, you need ~300 outputs.

Another way of dealing with the messyness is to use a strong function, either a good PRNG, a CSPRNG, or even just AES(seed,0,0) as the way to determine each other RNG's seed. So RNG2 's seed is the first output of RNG1, RNG3's seed is the second output, etc. You use RNG1's output to seed each other RNG, and you make RNG1 be quite 'strong' in cryptographic terms to minimize correlations etc.

hexy 2026-06-18 03:35

Awesome investigation. I'm investigating how RNG works in a different game (Stone Story RPG) that also uses the C# System.Random RNG, that appendix really blows my mind. I'm going to have to mess around with it myself.

Wug 2026-06-18 12:58

Thanks for the great writeup!

Something curious is that I've gotten Rebound from Trash Heap before in a singleplayer Ironclad run. My compendium says it was on beta v0.106.1, so it's possible that the correlations were different because of changes to the pool, but I wouldn't expect the Trash Heap pool to change between patches in the same way that something like Neow's Bones did.

I play with some non-gameplay altering mods (SlayTheStats, UnifiedSavePath, BetterSpire2 Lite, and BaseLib) but I wouldn't expect those to affect RNG either, especially because I can play multiplayer with no issues while they're loaded.

IMO it can probably be chalked up to version differences but I thought I would mention it in case it was interesting. Have a good one!

tckmn 2026-06-21 10:00

huh, yeah, i don't think i have an explanation purely based on changing content pools, since i agree the trash heap has always been the same. as far as i could tell nobody had ever gotten a Rebound in singleplayer at v0.107.0 or earlier, my best guess is that it must somehow have been mod related

Ole 2026-06-18 15:47

Its kinda wierd but im currently sitting rn with a guilty in my deck from news bones, and i am never experienced getting debt. idk if there is other factors playin in but ive also seen other people getting the guilty curse from neow bones. I can send you a screen shot if you contact me.

tckmn 2026-06-21 10:00

you were probably on the main branch (all of these results only applied to the v0.107.0 beta)

OkamiShiranui 2026-06-18 18:14

Does this apply to the RNG for the rock-paper-scissors for fighting over relic choices in multiplayer? Anecdotally, when playing with the same steam friend there were chains of 10+ rolls in a row where he'd win.

tckmn 2026-06-21 10:00

the multiplayer map selection roll was in fact correlated with the other RNGs, but what you are describing is not a correlation, that's just you getting unlucky lol

Python Enjoyer 2026-06-18 20:44

Very cool blog post. It has it all. Slay the Spire, fun data science and constructive criticism. Thank you for your service. I wonder why the solution is not just a single random number generator? Why the need for many? Is this to keep the map layout independent from player actions (playing a card that involves some randomness)? In that case two rngs would be a lot easier to handle i would imagine. Also random numbers in c# seem to be a nightmare.

Python Enjoyer 2026-06-18 21:22

Another solution would be to use the seed of one rng to produce the next. This would still mean that one seed gives information about another, but this would not matter for average player, correct?

tckmn 2026-06-21 10:00

since the game lets you play specific run seeds, it's desirable for the same seed to always give the same card rewards / relics / etc, so that playing the same seed as someone else actually feels like playing the same run. if there were only one rng then the seeds would immediately become completely different if you shuffle your deck a different number of times, say

yes, that would partially but not totally fix the problem (as mentioned in the "What?" appendix)

SS 2026-06-18 23:05

Agree with Josh's proposed solution. Using a PRF makes more sense, and probably simplifies a lot of things in the code. E.g., there is no need to save the PRNG counter each time. It looks like they fixed it already by just changing the RNG algo though.

Martin 2026-06-18 23:07

And as of v0.107.1, they’ve switched from the C# System.Random to xoshiro256**, all thanks to this great post!

Anonymous 2026-06-18 23:45

This is so cool, thank you for sharing and writing such a detailed article!

Dr. Pin 2026-06-19 00:24

Kind of curious, does it have anything to do with Godot as the engine, or not really?

tckmn 2026-06-21 10:00

nope, the RNG in question comes from the C# runtime and is unrelated to Godot

bottomdeckeverypower 2026-06-19 03:22

You've done the game a real service with this, thank you.

Giocentric 2026-06-19 08:48

Hi! First off, your article is super well written and approachable so thank you! Secondly, I wanted to say congrats on the shoutout inside the Steam update. And lastly, will you be publishing your seed search code that you used for this? I’ve wanted to find fun seeds to play through such as big bang into clone, but haven’t seen anything online that could do this.

tckmn 2026-06-21 10:00

sure, here's the slightly messy but still usable seed search code (for both the old version and the current version with new RNG). unfortunately it doesn't have a remotely friendly user interface or anything, but i am happy to be seed google if you dm me your search queries :p

i also have some gpu code for ultra fast seed searching, but it's even less usable by people who aren't me and i haven't posted it anywhere

Kayiu 2026-06-19 09:11

Congratulations on being officially shouted out by MegaCrit! This is a fascinating analysis, and I think that it could've even stopped a few layers shallower than you went and have still been crazy - shoutout to the level of detail you've put in here while still making it comprehensible for a layman like me!

smilingnavern 2026-06-19 10:20

Thanks! This is a very cool article. I enjoyed a lot while reading it.

Btw it's very funny how you are mention that you are found this by accident. I think this article is very smart and so you are as well.

Randomness is interesting topic, never heard of correlated randomness previously. So it was good to get a grasp of it.

Bigsmiles 2026-06-19 12:02

Please save me from myself. if you leave a fight before dying and you continue it restarts with the same rng so I'm FORCED to keep fighting loosing fight until i optimize them enough to not die to them, I'm hostage to my brain, please spare me

tckmn 2026-06-21 10:00

ok, how about this: i hereby declare that you are allowed to lose to any fight in the game. there, hopefully now you're no longer forced to keep trying and can just start a new run

OutOfNickels 2026-06-19 15:28

Amazing writeup!

Given these statements of yours:

Unfortunately, the pseudorandom number generation algorithm used in C# is almost entirely "linear" in the starting seed.
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 am now curious, was the java implementation equally flawed? The RNG Fix mod for the original game is implemented in the same way that the original code for STS2 is, but no one ever noticed a crng problem there, which could be either a lack of problem, or simply no one ever noticed it.

So was this an issue created specifically by the C# implementation of random?

tckmn 2026-06-21 10:00

i did actually think about this, lol. java's stock RNG (until recent versions) was somehow even worse, but the RNGFix mod for spire 1 uses a different one (xorshift+) which is related to the one now in spire 2 (xoshiro256**) and does not have this issue

DarkenedAuras 2026-06-19 15:37

I kneel to your power and intelligence to find this. I'm glad you found this and ecstatic that you did such a comprehensive writeup for me and my smaller brain to read and appreciate

lbs21 2026-06-21 18:34

What a beautiful write up. Thank you for your service!

Griffen25 2026-06-21 23:39

Very cool, thank you

maximiliano 2026-06-22 19:02

great read! I love this kinda stuff in games