📰 newsreader

hackernews score 0.88 好み 0.00 en

Slay the Spire 2における相関的なランダム性

原題: Correlated randomness in Slay the Spire 2

slay the spire 2correlated randomnessrngpseudorandom number generatorsc#seedingsystem.randomgame mechanics
原文 ↗

日本語訳

# 『Slay the Spire 2』における相関のある乱数

『Slay the Spire 2』(シングルプレイ)に関して、以下の3つの記述はすべて真実です。

- アンダードックス(Underdocks)で「ニオの骨(Neow's Bones)」を選んだ場合、ランダムな呪いが「借金(Debt)」になる確率は約54%である。

- 「ゴミ捨て場(Trash Heap)」イベントで「リバウンド(Rebound)」を受け取ることは不可能である。

- 最初の戦闘でのポーションドロップ率は、アンダードックスでは76%、オーバーグロース(Overgrowth)では4%である。

(* 「ニオの骨」から得られるレリックが「ニューリーフ(New Leaf)」または「カレイドスコープ(Kaleidoscope)」ではないと仮定した場合)

(** 「ニオ」のレリックがカードや他のレリックを与えないと仮定した場合)

(*** すべて現在のベータパッチ v0.107.0 における数値)

な、なんだって?!

なぜか? その原因は、異なる乱数生成器(RNG)の間に予期せぬ相関があることです。ゲーム内のあるRNGの最初の出力値を知ることで、他のすべてのRNGの最初の出力を予測する手がかりが得られてしまうのです。

### 『Slay the Spire 2』の乱数生成器

ここでは、この相関について極めて簡略化した説明を行います。詳細を知りたい場合は、この記事の最後でより深く掘り下げます。興味がない方は、このセクションを飛ばして、以下に挙げる面白い例を見てください。

「相関のあるRNG(CRNG)」という現象は、すでに『Slay the Spire』コミュニティでは知られています。なぜなら、前作の『Slay the Spire』にも同様の問題があり、Forgotten Arbiter氏のブログ記事で詳細に記述されていたからです。

簡単に言えば、前作では、例えば戦闘内のランダム性が将来のカード報酬に影響を与えないように、複数の異なる擬似乱数生成器を使用していました。しかし、それらすべてが同じ初期状態(シード)で初期化されていたため、生成される数値のシーケンスがすべて同じになってしまっていたのです。そのため、巧妙なプレイヤーは過去のランダムなイベントの結果に注目することで、将来のランダムなイベントに関する情報を得ることができました。

この問題の再発を防ぐため、『Slay the Spire 2』では、擬似乱数生成器を異なる状態に初期化しています。コードは(教育的な目的のために大幅に簡略化していますが)以下のようになっています。

```csharp

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 Relic(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"));

// ...

```

簡潔にするために記載していませんが、ゲーム内には他にも多くの乱数生成器が存在します。特筆すべきは、すべてのイベントが独自のRNGを持っている点です。

ハッシュ関数は、入力文字列から本質的に「ランダムに見える」数値を生成しますが、同じ入力に対しては常に同じ数値になります。したがって、RNGの状態をシャッフルしていても、同じシード値からは常に同じラン・(run)が生成されるという仕組みです。

問題は、これらのシード値がC#の標準的な `System.Random` クラスに渡されるときに発生します。あいにく、C#で使用されている擬似乱数生成アルゴリズムは、初期シードに対してほぼ完全に「線形」なのです。

これが具体的に何を意味するかは少し複雑です。これについても後ほど詳しく説明します。しかし、その結果として、シード値が既知の固定量だけ異なる2つのRNGは、その出力値も(不鮮明ではあるものの)依然として利用可能な範囲で差が生じてしまうのです。

「どの程度利用可能なのか?」と聞かれたら……。

以下に、CRNGがもたらす結果を、単なる面白い現象から、ゲームプレイに実質的な影響を与えるもの(カジュアルなプレイヤーですら気づかないものさえ!)まで、大量に挙げていきます。

#### ニオの骨(Neow's Bones)

まず、導入部で挙げた最初の例から始めましょう。アンダードックスで「ニオの骨」を選んだ場合、受け取る「ランダムな」呪いの分布は、実際には以下のようになります。

(※ここにアンダードックスの分布図がある想定)

しかし、オーバーグロースでは、代わりに次のような分布になります。

(※ここにオーバーグロースの分布図がある想定)

これは私にとって非常に面白い現象です。RedditやDiscordの至る所で、人々が「ニオの骨で『借金』ばかり引いてしまう、なんて運の悪さだ」と嘆いていました。CRNGの存在を知る前から、私は「これ、ランダムというより頻度が高い気がする」と主張する投稿をいくつか目にしました。私の脳がいかに瞬時に、それらを典型的な確証バイアスとして片付けてしまったか、言葉では言い尽くせません。ところが……。

これを理解するには、3つのランダムなソースを相関させる必要があります。

- ニオから得られる「呪いレリック」は、ニオのイベント専用RNGの呼び出しによって決まり、そのシードは `seed + 1 + hash("NEOW")` です。Wikiに記載されている通り、ニオの選択肢には常に「呪いプール」からちょうど1つのレリックが含まれます。8種類の呪いレリックのうちどれを提示するかという選択が、ニオのRNGへの最初の呼び出しとなります。

- 「ニエの骨」によるランダムな呪いは、`RunState.Rng.Niche` の呼び出しによって決まり、そのシードは `seed + hash("niche")` です。「ニューリーフ」や「カレイドスコープ」もランダム性のために `Niche` を呼び出すため、これらのレリックを「ニオの骨」で引いてしまうと、この相関は破壊されます。しかし、それ以外の場合、これが `Niche` への最初の呼び出しとなります。

- 第1幕のバリエーション(アンダードックスかオーバーグロースか)は、`StartRunLobby#BeginRunLocally` で作成される名前のないRNGの呼び出しによって決まり、そのシードはベースとなるシード値です。

「ニオの骨」はニオの「呪いプール」から選ばれるため、ニオのRNGへの最初の呼び出しが特定の範囲に収まったときにのみ出現します。これが、`Niche` への最初の呼び出しの可能な範囲に対して強い制約を与えます(どの第1幕にいるかと組み合わせることで、その制約はさらに強まります)。

この相関は、たとえ気づいていないプレイヤーにとっても、ゲームプレイに非常に大きな影響を与えていることは明らかです。この相関のせいで「ニオの骨」は、本来よりもはるかに悪いレリックになっています。「不器用」「有罪」「負傷」といった害の少ない呪いが出ることは極めて稀で、「借金」のような致命的な呪いが出る頻度が大幅に高まっているのです。

この時点で、「待てよ、それってニオのすべてのレリックのランダム性を予測できるってことか?」と思っているかもしれません。その通りです! 他の例も見てみましょう。

#### ラージカプセル(Large Capsule)

ラージカプセルから得られる最初のレリックが「コモン」になることはありません。

なんて強化だ!

より具体的には、オーバーグロースでは、約70%がアンコモン、約30%がレアになります。アンダードックスでは、約37%がアンコモン、約63%がレアになります。ただし、一つ注意点があります。

すべてが互いに相関しているため、アンダードックスの幕においてラージカプセルが出現するのは、わずか約1.65%に過ぎません。(これには誰も気づいていないようでした。私が調査を始めた際、この情報に対する誰かの非常に面白い反応がありました。)

以下は、アンダードックスにおけるニオの「呪いプール」の選択肢の具体的な分布です。

(※分布図)

そして、オーバーグロースでは以下の通りです。

(※分布図)

特にラージカプセルについて言えば、「ニオの骨」と同様に、この相関はゲームプレイに実質的な影響を与えています。このレリックは、平均すると「本来あるべき姿」よりも強力なのです。

では、スモールカプセル(Small Capsule)はどうでしょうか?

#### スモールカプリセル(Small Capsule)

スモールカプセルは「呪いプール」のレリックではないため、「ニオの骨」や「

原文(英語)を表示

Correlated randomness in Slay the Spire 2

2026-06-13Here 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.

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:

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

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 withseed + 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 toNiche

. -

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:

And in Overgrowth:

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:

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

Overgrowth:

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:

Overgrowth:

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:

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:

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]

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:

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:

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

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:

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:

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.

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.

- version of Act 1

- Neow curse pool relic

- the first common relic seen in a shop

- the last card you draw in your first combat

- the first ? room's contents

- the first card you generate during combat (e.g. Attack Potion)

- the first randomly selected card during combat (e.g. Mummified Hand)

- the first random energy cost chosen (e.g. Slither)

- the first random enemy chosen (e.g. lightning orbs)

- the first instance of random monster AI

- the first "niche RNG" result (e.g. Whetstone)

- the first orb made by Trash to Treasure or Chaos

- whether your first fight drops a potion

- which card is on sale in the first shop

- option 1 of Pael or Tezcatara

- the first transform from Leafy Poultice

- the version of music heard in acts with multiple tracks

- cosmetic skin of Byrdpip or Pael's Legion

- the behavior of all of these encounters: Endless Conveyor (initially offered food), Ovicopter (cosmetic skin of eggs[6]), Punch Off (starting HP of Punch Constructs), Three/Four Slimes (order of the two small slimes), any encounter with randomized starting intents (e.g. Two-Tailed Rats), and any encounter with randomized enemies (e.g. Slithering Strangler's companion)

- the randomness from all of these events: Aroma of Chaos (transform), Colorful Philosophers (colors offered), Crystal Sphere (gold cost), Dense Vegetation (gold amount), Doll Room (any button's result), Jungle Maze Adventure (first gold amount), Luminous Choir (gold cost), Morphic Grove (first transform), Ranwid the Elder (requested potion), Reflections snoitcelfeR (first downgraded card), Stone of All Time (requested potion), Slippery Bridge (initially offered card), Sunken Treasury (first gold reward), Symbiote (transform), Tablet of Truth (first upgrade), The Future of Potions? (first card type), The Lost Wisp (gold amount), The Sunken Statue (gold amount), The Trial (which trial), This or That? (gold amount), Tinker Time (missing card type), Trash Heap (card or relic), Welcome to Wongo's (downgraded card), Whispering Hollow (gold cost)

Here is a shorter list of second rolls.

- every bullet that starts with "the first", replacing with "the second"

- your first fight's gold reward

- the top left card in the first shop

- option 2 of Pael or Tezcatara

- the character you are playing, if you chose "random character"[7]

- the randomness from all of these events: Crystal Sphere (placement of relic box), Doll Room (2nd or 3rd button's result), Endless Conveyor (transform / upgrade / next food), Jungle Maze Adventure (second gold amount), Morphic Grove (second transform), Ranwid the Elder (requested relic), Reflections snoitcelfeR (second downgraded card), Slippery Bridge (next card), Sunken Treasury (second gold reward), Tablet of Truth (second upgrade), The Future of Potions? (second card type), The Trial (first transform), Tinker Time (order of offered card types), Whispering Hollow (transform)

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.

comments

There are no comments yet.

← 一覧に戻る