Home Games About Us Contact Us

Risk of Rain 2: Spawning and Scaling - C#

About this project:

I initially started working on this project because I was interested in learning how the mechanics of my favorite game were made, and because there is a clear documentation on how the systems work and how the value calculations are done. After a while I decided to attempt to remake the systems in the way that I interpreted the documentation and got somewhat close to what I think is how the systems work.


Pages/Papers use for research:

- Risk of Rain Wiki - Difficulity

- Risk of Rain Wiki - Directors


The most important part of this project is the Difficulity Scaling Manager, this script calculates a coefficient every Update() loop. This coefficient is used in most scripts to calculate costs, enemy scalling and lots of other things. On start the Scaling Manger calculates the initial coefficient so the SceneManager has the right information when loading the map.

C#(C-Sharp) +
                
    public class DifficulityScalingManager : MonoBehaviour
    {
        public int playerCount;
        public int difficultyValue;
        public int stagesCompleted;
        public int timeInMinutes;

        public float playerFactor;
        public float timeFactor;
        public float stageFactor;
        public float difficulityCoeff;
        public float timeInSeconds;

        public SceneDirector sceneDirector;

        public void Start()
        {
            CalculateCoeff();
            sceneDirector.enabled = true;
        }
    
        // Update is called once per frame
        void Update()
        {
            CalculateCoeff();
        }
    
        public void CalculateCoeff()
        {
            timeInSeconds += (1.0f * Time.deltaTime);
      
            if(timeInSeconds >= 60)
            {
                timeInSeconds = 0;
                timeInMinutes++;
            }
        
            playerFactor = (1 + 0.3f * (playerCount - 1));
            timeFactor = (0.0506f * difficultyValue * Mathf.Pow(playerCount, .2f));
            stageFactor = Mathf.Pow(1.15f, stagesCompleted);
        
            difficulityCoeff = ((playerFactor + timeInMinutes * timeFactor) * stageFactor);
        }
    }
                
              

The GameManager holds some data that is used troughout the whole run and has lists with enemies based upon which map you are currently using. On start it sets the speed of the game which you can edit for debuggin back to 1, and then it assigns the right enemies to the spawn list. Then every Update() loop it recalculates how many enemies there are so there are never more than 40 enemies present. This limit exludes boss events to eliminate the possibility of a boss not spawining and the player getting a free win. I only included 2 enemy lists in this example because there is no reason to include more than 2.

C#(C-Sharp) +
                
    public class GameManager : MonoBehaviour
    {
        public int mapIndex;
        public int ammountOfMonstersAllowed;

        [Header("Debug")]
        [Range(1.0f, 100.0f)]
        public float gameSpeed;

        [Header("Scripts")]
        public SceneDirector sceneDirector;
        public DifficulityScalingManager difficulityScalingManager;

        [Header("Lists")]
        public List combatDirectors;
        public List spawnableEnemies;
        public List spawnPoints;
        public List enemiesAlive;
      
        [Header("Enemy List per Map")]
        public List enemiesDistantRoost;
        public List enemiesTitanicPlains;
      
        public static int initialEnemyCount;
      
        private void Start()
        {
            gameSpeed = 1.0f;
      
            AssignMonstersToList();
        }
    
        private void Update()
        {
            Time.timeScale = gameSpeed;
      
            CalculateEnemyCount();
        }
    
        public void CalculateEnemyCount()
        {
            foreach (GameObject enemy in GameObject.FindGameObjectsWithTag("Enemy"))
            {
                if (!enemiesAlive.Contains(enemy))
                {
                    enemiesAlive.Add(enemy);
                }
            }
        
            for (int i = 0; i < enemiesAlive.Count; i++)
            {
                if (enemiesAlive[i] == null)
                {
                    enemiesAlive.RemoveAt(i);
                    initialEnemyCount--;
                }
            }
        }
    
        public void AssignMonstersToList()
        {
            switch (mapIndex)
            {
                case 1:
                    for (int i = 0; i < enemiesDistantRoost.Count; i++)
                    {
                        if (!spawnableEnemies.Contains(enemiesDistantRoost[i]))
                        {
                            spawnableEnemies.Add(enemiesDistantRoost[i]);
                        }
                    }
                    break;
                
                case 2:
                    for (int i = 0; i < enemiesTitanicPlains.Count; i++)
                    {
                        if (!spawnableEnemies.Contains(enemiesTitanicPlains[i]))
                        {
                            spawnableEnemies.Add(enemiesTitanicPlains[i]);
                        }
                    }
                    break;
            }
        
            for (int i = 0; i < combatDirectors.Count; i++)
            {
                combatDirectors[i].ReassignEnemies();
            }
        }
    }
                
              

The combat director script is used more than once per scene, there are 2 types of directors. A slow director and a fast director. The difference between the 2 being the retargeting time and the spawnrate. Combat directors have a spawn credit generator assigned and when deactivated they give there remaining credits to the other combat directors. They lock to a certain enemy to spawn with a specified tier until a new enemy gets selected. On Awake() the combat directors get the right list of enemies to spawn out of the GameManager and adds all the different weights to a list. After assigning the enemies and their weights the director gets the max allowed number of enemies, adds all players to a list and assigns a retarget time based upon a fast or slow director.

C#(C-Sharp) +
                
    public enum ActiveState { Activated, Deactivated };
    public ActiveState activeState;

    public enum DirectorType { Slow, Fast}
    public DirectorType directorType;

    [Header("Scripts")]
    public DifficulityScalingManager difficulityScalingManager;
    public GameManager gameManager;

    [Header("Combat Directors")]
    public List combatDirectors

    [Header("Target Spawning")]
    public List players;

    public float minTime, maxTime;

    private float[] monsterWeights;
      
    private void Awake()
    {
        gameManager = GameObject.Find("Managers").GetComponent();
      
        for (int i = 0; i < gameManager.spawnableEnemies.Count; i++)
        {
            if (!spawnableEnemies.Contains(gameManager.spawnableEnemies[i]))
            {
                spawnableEnemies.Add(gameManager.spawnableEnemies[i]);
          
                spawnableEnemies[i].monsterTier = Enemy.MonsterTier.Tier1;
            }
        }
    
        monsterWeights = new float[spawnableEnemies.Count];
    }

    // Start is called before the first frame update
    void Start()
    {
        maxMonsters = gameManager.ammountOfMonstersAllowed;

        foreach (GameObject player in GameObject.FindGameObjectsWithTag("Player"))
        {
            players.Add(player.transform);
        }
    
        if(directorType == DirectorType.Slow)
        {
            minTime = 10;
            maxTime = 20;
        }else if(directorType == DirectorType.Fast)
        {
            minTime = 0;
            maxTime = 10;
        }
    }
                
              

In the Update() loop the director first checks if it's state is still active, if it is deactivated it directs 40% of it's remaining credits to one of the other directors. If it is active it adds new credits, then if it hasn't already chosen an enemy and tier to spawn it fetches a new monster and resets the weights to their original value. After that it checks if it is time to select a new enemy and tier. If it found a valid enemy than it will start spawning these until the credits are either lower than the spawn cost or it is time to select a new enemy.

C#(C-Sharp) +
                
    public enum ActiveState { Activated, Deactivated };
    public ActiveState activeState;

    public enum DirectorType { Slow, Fast}
    public DirectorType directorType;

    [Header("Scripts")]
    public DifficulityScalingManager difficulityScalingManager;
    public GameManager gameManager;

    [Header("Credits")]
    public float monsterCredits = 0;
    public int monsterCreditsInt;
    public float creditMultiplier;
    public float creditsPerSecond;

    public float creditsToOtherDirector;

    [Header("Combat Directors")]
    public List combatDirectors;

    [Header("Target Spawning")]
    public List players;
    public Transform targetedPlayer;

    public float minDst, maxDst;
    public float minTime, maxTime;

    [Header("Enemy Spawning")]
    public List spawnableEnemies;

    public float timeTillNextSpawn, retargetTimer;

    public bool foundValidEnemy, firstSpawn;

    public int spawnedMonstersThisWave, maxMonsters;

    Enemy monsterToSpawn;
    Transform spawnMonster;
    GameObject spawnedMonster;

    Enemy.MonsterTier savedMonsterTier;

    [SerializeField]
    private float[] monsterWeights;

    bool triggeredTooManyMonsters = false;

    public void GetCredits()
    {
        creditsPerSecond = creditMultiplier * (1 + .4f * difficulityScalingManager.difficulityCoeff) * (difficulityScalingManager.playerCount + 1) / 2;

        monsterCredits += (creditsPerSecond * Time.deltaTime);
        monsterCredits = Mathf.Round(monsterCredits * 1000f) / 1000f;

        if(monsterCredits >= 1.000f)
        {
            monsterCreditsInt++;
      
            monsterCredits = 0;
        }
    }
    
    void Update()
    {
        if(timeTillNextSpawn > 0 && !foundValidEnemy)
        {
            timeTillNextSpawn -= Time.deltaTime;
        }
    
        if(retargetTimer > 0)
        {
            retargetTimer -= Time.deltaTime;
        }else
        {
            retargetTimer = Random.Range(minTime, maxTime + 1);
      
            targetedPlayer = players[Random.Range(0, players.Count)];
        }
    
        switch (activeState)
        {
            case ActiveState.Activated:
                GetCredits();
      
                if(monsterToSpawn == null)
                {
                    SpawnWeightedMonster();
                    ResetMonsterSpawnWeights();
                }
            
                if (monsterCreditsInt > 0 && timeTillNextSpawn <= 0)
                {
                    SpawnWeightedMonster();
                    ResetMonsterSpawnWeights();
              
                    timeTillNextSpawn = Random.Range(minTime, maxTime + 1);
                }
                else if (monsterCreditsInt <= 0)
                {
                    monsterCreditsInt = 0;
                }
            
                if(foundValidEnemy && monsterToSpawn.creditCost <= monsterCreditsInt)
                {
                    SpawnMonster();
                }else if(foundValidEnemy && monsterToSpawn.creditCost > monsterCreditsInt)
                {
                    foundValidEnemy = false;
                }
                break;
            
            case ActiveState.Deactivated:
                CombatDirector combatDirector = this.gameObject.GetComponent();
              
                creditsToOtherDirector = (monsterCreditsInt / 10) * 4;
              
                for (int i = 0; i < gameManager.combatDirectors.Count; i++)
                {
                    if (!combatDirectors.Contains(gameManager.combatDirectors[i]))
                    {
                        combatDirectors.Add(gameManager.combatDirectors[i]);
                    }
                }
            
                CombatDirector directorToGive = combatDirectors[Random.Range(0, combatDirectors.Count)];
            
                directorToGive.monsterCredits += creditsToOtherDirector;
            
                combatDirector.enabled = false;
                break;
        }
    }

    public void SpawnMonster()
    {
        float spOffset = Random.Range(minDst, maxDst);
        Vector3 offset = new Vector3(spOffset, 0, spOffset);

        if (GameManager.initialEnemyCount < maxMonsters)
        {
            triggeredTooManyMonsters = false;
      
            if (spawnableEnemies.Count != 0)
            {
                if (!foundValidEnemy)
                {
                    monsterToSpawn = spawnableEnemies[Random.Range(0, spawnableEnemies.Count)];
                    spawnMonster = monsterToSpawn.transform;
              
                    if (monsterToSpawn != null)
                    {
                        CheckSpawnCardValid();
                    }
                
                    //Remove after Debugging 
                    creditsNeededDebugInt = monsterToSpawn.creditCost;
                }
                
                if (monsterToSpawn.creditCost <= monsterCreditsInt && foundValidEnemy)
                {
                    if(spawnedMonstersThisWave == 6)
                    {
                        foundValidEnemy = false;
                  
                        spawnedMonstersThisWave = 0;
                    }
                
                    Transform spawnedMonsterT = Instantiate(spawnMonster, targetedPlayer.position + offset, targetedPlayer.rotation);
                    spawnedMonster = spawnedMonsterT.gameObject;
                
                    CalculateMonsterTier();
                
                    spawnedMonstersThisWave++;
                
                    GameManager.initialEnemyCount++;
                }
                else if (monsterToSpawn.creditCost >= monsterCreditsInt && foundValidEnemy)
                {
                    print("Not Enough credit to spawn enemy");
              
                    foundValidEnemy = false;
              
                    spawnedMonstersThisWave = 0;
                }
            }
        }else
        {
            if(!triggeredTooManyMonsters)
            {
                print("Too Many Enemies");
          
                triggeredTooManyMonsters = true;
            }
        }
    }

    private void SpawnWeightedMonster()
    {
        float value = Random.value;

        for (int i = 0; i < monsterWeights.Length; i++)
        {
            if (value < monsterWeights[i])
            {
                SpawnMonster();
                return;
            }
        
            value -= monsterWeights[i];
        }
    }

    private void ResetMonsterSpawnWeights()
    {
        float totalEnemyWeight = 0;

        for (int i = 0; i < spawnableEnemies.Count; i++)
        {
            monsterWeights[i] = spawnableEnemies[i].weight;
            totalEnemyWeight += monsterWeights[i];
        }
    
        for (int i = 0; i < monsterWeights.Length; i++)
        {
            monsterWeights[i] = monsterWeights[i] / totalEnemyWeight;
        }
    }

    private void CalculateMonsterTier()
    {
        int tier2Cost = monsterToSpawn.creditCost * 6;
        int tier3Cost = monsterToSpawn.creditCost * 36;

        if (!firstSpawn)
        {
            spawnedMonster.GetComponent().monsterTier = savedMonsterTier;
        }
    
        if (firstSpawn)
        {
            if (difficulityScalingManager.stagesCompleted <= 4)
            {
                if (monsterCreditsInt >= tier2Cost)
                {
                    spawnedMonster.GetComponent().monsterTier = Enemy.MonsterTier.Tier2;
                    savedMonsterTier = Enemy.MonsterTier.Tier2;
                } else
                {
                    spawnedMonster.GetComponent().monsterTier = Enemy.MonsterTier.Tier1;
                    savedMonsterTier = Enemy.MonsterTier.Tier1;
                }
            } else if (difficulityScalingManager.stagesCompleted > 4)
            {
                if (monsterCreditsInt >= tier3Cost)
                {
                    spawnedMonster.GetComponent().monsterTier = Enemy.MonsterTier.Tier3;
                    savedMonsterTier = Enemy.MonsterTier.Tier3;
                }
                else if (monsterCreditsInt >= tier2Cost)
                {
                    spawnedMonster.GetComponent().monsterTier = Enemy.MonsterTier.Tier2;
                    savedMonsterTier = Enemy.MonsterTier.Tier2;
                }
                else
                {
                    spawnedMonster.GetComponent().monsterTier = Enemy.MonsterTier.Tier1;
                    savedMonsterTier = Enemy.MonsterTier.Tier1;
                }
            }
        
            firstSpawn = false;
        }
    
        switch (monsterToSpawn.monsterTier)
        {
            case Enemy.MonsterTier.Tier1:
                spawnedMonster.GetComponent().creditCost = monsterToSpawn.creditCost * 1;
                spawnedMonster.GetComponent().monsterHP = monsterToSpawn.monsterHP * 1;
                spawnedMonster.GetComponent().monsterDMG = monsterToSpawn.monsterDMG * 1;
                break;
              
            case Enemy.MonsterTier.Tier2:
                spawnedMonster.GetComponent().creditCost = monsterToSpawn.creditCost * 6;
                spawnedMonster.GetComponent().monsterHP = monsterToSpawn.monsterHP * 4;
                spawnedMonster.GetComponent().monsterDMG = monsterToSpawn.monsterDMG * 2;
                break;
              
            case Enemy.MonsterTier.Tier3:
                spawnedMonster.GetComponent().creditCost = monsterToSpawn.creditCost * 36;
                spawnedMonster.GetComponent().monsterHP = monsterToSpawn.monsterHP * 18;
                spawnedMonster.GetComponent().monsterDMG = monsterToSpawn.monsterDMG * 6;
                break;
        }
    
        if(spawnedMonster.GetComponent().creditCost > monsterCreditsInt)
        {
            Destroy(spawnedMonster);
            GameManager.initialEnemyCount--;
      
            spawnedMonstersThisWave = 0;
      
            foundValidEnemy = false;
        }
    
        monsterCreditsInt -= spawnedMonster.GetComponent().creditCost;
      
        print("Spawning " + monsterToSpawn + " of " + spawnedMonster.GetComponent().monsterTier);
    }

    public void CheckSpawnCardValid()
    {
        if(spawnedMonstersThisWave != 6)
        {
            //Add if statement to check if stage is valid
      
            IsMonsterTooCheap();
        }
    }

    public void IsMonsterTooCheap()
    {
        int creditsForTooCheap = ((monsterToSpawn.creditCost * 36) * 6);

        if (creditsForTooCheap > monsterCreditsInt)
        {
            foundValidEnemy = true;
      
            firstSpawn = true;
        }
        else if(creditsForTooCheap < monsterCreditsInt)
        {
            foundValidEnemy = false;
        }
    }
                
              

Finally the combat director has a ReassignEnemies() function called somewhere earlier on this page. This function just reassigns the enemies the director uses so the manager and other scripts can be send throughout scenes.

C#(C-Sharp) +
                
    public void ReassignEnemies()
    {
        for (int i = 0; i < gameManager.spawnableEnemies.Count; i++)
        {
            if (!spawnableEnemies.Contains(gameManager.spawnableEnemies[i]))
            {
                spawnableEnemies.Add(gameManager.spawnableEnemies[i]);
          
                spawnableEnemies[i].monsterTier = Enemy.MonsterTier.Tier1;
            }
        }
    
        monsterWeights = new float[spawnableEnemies.Count];
    }
                
              

There is one more script called the SceneDirector, the scene director handles the initially spawned enemies, chests and other interactables that are already on the map when you first enter. It uses almost the exact same code as the combat director with the only difference being that it has calculated credits at the start instead of a credit generator. 2 of the maps have something called a vault, if it is opened the SceneDirector gets an additional 160 interactable credits on the already recieved credits. Meaning it can spawn more Chests, Shrines or other support items. In this example I once again only included 2 of the calculations, however every map does have a different starting ammount because these maps have a chance of getting loaded later in the game.

C#(C-Sharp) +
                
    void Start()
    {
        CalculateStartCredits();
    }

    public void CalculateStartCredits()
    {
        switch (chosenMap)
        {
            case Map.DistantRoost:
                interactableCredit = (180 * (1.0f + (.5f * scalingManager.playerCount)));
                enemyCredit = (100 * (1.0f + (.5f * scalingManager.difficulityCoeff)));
      
                if(vaultDROpened)
                {
                    interactableCredit += 160;
                }
            
                gameManager.mapIndex = 1;
                break;
            
            case Map.TitanicPlains:
                interactableCredit = (220 * (1.0f + (.5f * scalingManager.playerCount)));
                enemyCredit = (100 * (1.0f + (.5f * scalingManager.difficulityCoeff)));
            
                gameManager.mapIndex = 2;
                break;
        }
    
        enemyCredit = Mathf.RoundToInt(enemyCredit);
    }
                
              

Conclusion:

I really enjoyed working on this project, it gave me a better look at what is happening behind the scenes of my favorite game. There are some things missing but that is mostly because it would've just mostly been copies of the existing scripts or they weren't vital parts for what I wanted to learn. Here is the direct link to the scripts folder in my github repository:

- RoR2: Spawning and Scaling Github