Seedable/Deterministic RNG (Slay the Spire, Balatro, Spelunky, etc.)

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:

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

  2. 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 take 2479 % 9 to 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.

  3. 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 to 1005. In another run (RUN #2), the player draws 20 cards, advancing the state to 1020. 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 from 1005 whereas, in RUN #2, the attack generated would be based on a random-generated outcome from 1020, resulting in enemy attacks that differ between runs, even though the seed is the same, and this is something that should be avoided.

  4. 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 at 1000) 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 still 1000, so the attack remains #6 (intended outcome).
    This ensures that player decisions or actions don’t influence unrelated RNG outcomes.
  1. 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:

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

  2. 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?

I was aware of this. Random.state is equivalent to std::mt19937 in Unity, but it doesn’t address my questions, which are mainly about game design:

  • Is my assumption of dividing the RNG streams by “events” correct?
  • Is my assumption of adding 1 to the original seed correct or close to correct for each “event”?
int RetrieveRandom_FromYourRandomAlgorithm(initialSeed + 1)
{
     // your logic
}
  • How to reset and synchronize all of the RNG event sequences by the end of the encounter to start the next one fresh?
  • Are there any resources explaining in detail these concepts and giving examples of how to design the RNG sequences and so on?

There isn’t a standardized answer. Games vary so much in gameplay that what is appropriate cannot yield a standard.

For example, I have a RunSeed, which is randomized at the beginning of the run and only changes for the next run, then a RoomSeed, which is a function of all the previous rooms you’ve chosen (door 1 vs door 2). I can also obtain seeds based on your save file or whatever. It’s really just an integer.

The only ones that I mutate each access are stuff like ActionSeed. Every time the player performs an action, it mutates. I can send an ActionSeed over the network once and it’ll be reliable for quite some time because server/client are unlikely to disagree and it resets by the next room if somehow things diverged. If there’s disagreement crit/not may display incorrectly on the client but I haven’t ever seen this in practice.

In implementation, I add/mult some integers and xorshift it all together. I have several method overloads MathUtil.Xorshift(x, y, z) or however many inputs I need. Example: I am going to select a reward to give the player. I combine RunSeed, RoomSeed and an arbitrary offset. This means that the reward for that room will always be the same, but if you were to rewind the save several rooms and select different doors then it changes.

If you place the following script on two gameObjects then they’ll produce their own unique sequence of numbers without interfering with each other’s random sequence.

using UnityEngine;
public class RandomDemo : MonoBehaviour
{
    Random.State myState;

    void Start()
    {
        InvokeRepeating("MyEvent", Random.Range(0, 5), 1);
        Random.InitState(gameObject.GetInstanceID());
        myState = Random.state;
    }
    
    void MyEvent()
    {
        Random.state = myState;
        Debug.Log(gameObject.name + ": " + Random.Range(0, 10));
        myState = Random.state;
    }
}

I think a generalized way to think about it is to ask yourself:

“I want this random number to vary with x, y, z”

Once you’ve identified x, y, and z, then you make sure you have seeds for each of those. You can obtain your random by combining those seeds.

So, the room reward example again: I want to vary the random number by 3 factors:

  1. each run the rewards should be different
  2. each room the rewards should be different
  3. each reward should be different. If there are 3 options, don’t present the same reward 3x

So, MathUtil.Xorshift(x,y,z,w) where it’s those 3 factors plus an arbitrary offset for good measure. Under the hood, there are several Xorshifts and I get my consistent, reproducible random number, given the same run, room and reward #.