Hi, I’ve been curious about how seedable/deterministic RNG works in games like, say, Slay the Spire, where you can input the seed, and the random-generated events will produce the same outcome for that seed.
So far, I’ve come to the following conclusions:
-
All random-generated events (such as encounters, enemy attacks, card draws, etc.) originate from the same seed. This seed is most likely converted into an integer, and then you’d probably apply a deterministic algorithm (such as
std::mt19937), that uses complex bitwise operations and permutations, to yield a seemingly random number from your seed.
Since this algorithm is deterministic, using the same seed will always produce the same output or sequence of numbers.
To sum up, the key idea here is to avoid basic arithmetic transformations on the seed, such as offsetting or multiplying the seed by X amount (e.g., if seed is 1000: “1000 + X” or “1000 * X”), as the random outcome could become predictable. By using a proper algorithm that simulates randomness, you could get a seemingly random result from the seed (for instance, seed = 1000, outcome = 73467923) -
Another important concept would be to understand how to get random results from the same seed. Let’s assume that each time we use the RNG algorithm from our seed, we’re advancing the “state” of the seed.
Let’s focus on a single event for simplicity (enemy attacks). Suppose our starting seed is “1000”, and we need to generate the first random attack. We run the algorithm from our seed (1000) and we get this result “2479”. Assuming the enemy has a pool of 10 possible attacks (indexed from 0 to 9), we take2479 % 9to select a random attack. Let’s say it gives us attack #4. Then we move into the second enemy attack. In order to get a different random number, we advance the RNG state of our original seed (1000 + 1), and now the RNG state is at state 1. We run the random generator from our seed state 1 (1001) and we get a seemingly random, yet consistent and deterministic, number (8587). We again get the indexed attack (8587 % 9) and let’s say this time it’s “attack #8”. In this way, the sequence of attacks appears random, but using the same seed always produces the same sequence. -
Potential problems:
We want to ensure that different types of events (such as “Card Draw”) don’t interfere with one another (such as “Enemy Attacks”). For instance, if the player draws cards before the enemy attacks, drawing cards would advance the RNG state. This could cause the enemy attack pattern to change between runs.
Suppose, in one run (RUN #1), the player draws 5 cards before the enemy attack, advancing the RNG state to1005. In another run (RUN #2), the player draws 20 cards, advancing the state to1020. If the enemy attack uses this “advanced state” seed to generate the first random attack, in RUN #1, the attack generated would be based on a random-generated outcome from1005whereas, in RUN #2, the attack generated would be based on a random-generated outcome from1020, resulting in enemy attacks that differ between runs, even though the seed is the same, and this is something that should be avoided. -
Potential solutions:
To avoid this, one potential solution would be to separate the RNG streams for different event types. This is, separating the RNG states for all events. For instance, the “Card Draw” event would have its own seed advancement, the “Enemy Attack” event would also have its own seed advancement, and so on. This way, no matter how many cards a player draws, no matter the actions or decisions they make, those wouldn’t affect other event RNG outcomes, and therefore it would yield consistent results within the same seed.
For instance:
- In RUN #1, the player draws 5 cards (“Draw Card RNG State” is at
1005), then enemy attacks come (“Enemy Attack RNG State” is at1000) and it yields attack #6. - In RUN#2, the player draws 20 cards (“Draw Card RNG State” is at
1020), but the enemy attack is still1000, so the attack remains #6 (intended outcome).
This ensures that player decisions or actions don’t influence unrelated RNG outcomes.
- One last consideration:
After each encounter, the game must ensure that all RNG streams are synchronized across different runs. In other words, by the end of each encounter, all players must be synchronized, because the cards they’ll draw and the enemies they’ll face (and their attack sequence) should be the same, regardless of what they did (and how many “state advancements” took place) in the previous encounter.
To do this, there must be a way to “reset” all the states for each game event in a way that:
- (1) Is not predictable
- (2) Is independent of player actions and choices or events that might have occurred
- And (3), Is still seedable and consistent for the same seed.
----------------------- TL;DR -------------------------
So, the questions are:
-
Are there any good resources explaining key concepts and standard practices for implementing seedable/deterministic RNG? I’ve googled it but I haven’t found anything useful so far.
-
Do you know how these kinds of games manage the “RNG resetting” in practice? I’d like to know the best/standard code practices. For instance, which sort of operation developers use to reset the seed state for every “event” (e.g., enemy encounters, enemy attacks, potions, card draw, etc.) by the end and the start of a new encounter?