Introduction
The upcoming update for Treasure Rogue, “improved level generation algorithm”, has finally been completed, so this article explains the implementation in detail with code.
Before that, I want to briefly explain what this improvement is trying to achieve.
The “Every Run Feels Too Similar” Problem
Treasure Rogue is a roguelike game.
However, one of its current problems is that every run feels too similar.
The game currently works like this: “the first level has thieves, the next level has thieves and wolves, and so on.” In other words, the pattern of enemies is fixed.
Only the number of enemies is random; the enemy lineup itself never changes.
As a result, the roguelike ends up feeling repetitive even though it is supposed to be random.
In this “improved level generation algorithm” update, I added the following features to solve that problem:
- Enemies are selected at random when they are generated
- Enemies that have not appeared recently are more likely to be selected
- Difficulty for each level is also controlled with
AnimationCurve
Code
FieldObject
This component is attached to the object you want to spawn.
using UnityEngine;
public class FieldObject : MonoBehaviour {
[SerializeField]
float m_Strength = 1f;
[SerializeField]
float m_MinDifficulty = 0f;
[SerializeField, Range(0f,1f)]
float m_MaxSpawnRatio = 1f;
public float Strength => m_Strength;
public float MinDifficulty => m_MinDifficulty;
public float MaxSpawnRatio => m_MaxSpawnRatio;
}
| Strength | The object's strength. For example, if the difficulty is 3 and Strength is 1, you can spawn up to three of that object. |
| MinDifficulty | The minimum difficulty at which the object can appear. This prevents strong enemies from spawning early in the game. |
| MaxSpawnRatio | The maximum portion of the difficulty that the object can occupy. For example, if the difficulty is 3 and MaxSpawnRatio is 0.5, that object can occupy only 1.5 difficulty points. |
LevelContents
LevelContents is a class that stores the number of generated FieldObjects in a level, using each spawned FieldObject as the key.
using System.Linq;
using System.Collections.Generic;
public interface IReadOnlyLevelContents {
IReadOnlyDictionary<FieldObject,int> Contents { get; }
float GetDifficulty ();
}
public class LevelContents : IReadOnlyLevelContents {
readonly Dictionary<FieldObject,int> m_Contents;
public IReadOnlyDictionary<FieldObject,int> Contents => m_Contents;
public LevelContents () {
m_Contents = new Dictionary<FieldObject,int>();
}
public void Increment (FieldObject fieldObject) {
m_Contents.SetValue(fieldObject,1);
}
public float GetDifficulty () => m_Contents.Sum(pair => pair.Key.Strength * pair.Value);
}
| Contents | A dictionary of the `FieldObject`s generated in the level and their counts. |
| Increment | Call this when an object spawns. |
| GetDifficulty | Returns the total Strength value of `Contents` (that is, the difficulty). |
LevelContext
This is passed as an argument to the function that actually generates a level.
It contains everything needed for difficulty-aware level generation, such as the required difficulty for the level and the LevelContents from previous levels.
using System.Linq;
using System.Collections.Generic;
public class LevelContext {
readonly LevelContents[] m_EverContentsStack;
public float RequiredDifficulty { get; }
public LevelContents CurrentContents { get; } = new LevelContents();
public IReadOnlyList<IReadOnlyLevelContents> EverContentsStack => m_EverContentsStack;
public LevelContext (float requiredDifficulty,IEnumerable<LevelContents> everContentsStack) {
RequiredDifficulty = requiredDifficulty;
m_EverContentsStack = everContentsStack.ToArray();
}
public float GetRemainingDifficulty () {
return RequiredDifficulty - CurrentContents.GetDifficulty();
}
}
| RequiredDifficulty | The difficulty required for the level. |
| CurrentContents | The current `LevelContents` for the level. |
| EverContentsStack | History of `LevelContents` from previous levels. |
| GetRemainingDifficulty | Returns the required level difficulty minus the difficulty of the current `LevelContents`. |
FieldManager
The manager for Treasure Rogue’s endless vertical map.
The code below is an excerpt related to controlling the difficulty of each level with AnimationCurve.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
public class FieldManager : SingletonMonoBehaviour<FieldManager> {
[SerializeField]
float m_BaseLevelDifficulty;
[SerializeField]
AnimationCurve m_LevelDifficultyCurve = AnimationCurve.Constant(0f,10f,1f);
readonly List<LevelContents> m_EverContentsStack = new List<LevelContents>();
readonly ReactiveProperty<int> m_TotalLevelCount = new ReactiveProperty<int>();
protected override void Awake () {
m_LevelDifficultyCurve.preWrapMode = WrapMode.Loop;
m_LevelDifficultyCurve.postWrapMode = WrapMode.Loop;
}
public IObservable<Unit> GenerateLevel (FieldDataBase fieldData) {
var onCompleted = new AsyncSubject<Unit>();
Observable.FromCoroutine(GenerateLevelCoroutine).Subscribe(onCompleted);
return onCompleted;
IEnumerator GenerateLevelCoroutine () {
// Calculate the difficulty for the level to be generated
float difficulty = m_LevelDifficultyCurve.Evaluate(m_TotalLevelCount.Value) * m_BaseLevelDifficulty;
m_TotalLevelCount.Value++;
Debug.Log($"Time: {m_TotalLevelCount.Value}, Base Difficulty: {m_BaseLevelDifficulty}, Difficulty: {difficulty}");
// Generate the field
yield return GenerateField(fieldData,difficulty);
yield return GenerateField(goalField,0f);
// omitted
}
}
IEnumerator GenerateField (FieldDataBase fieldData,float difficulty) {
// The code that creates a new field is long, so it is omitted here
IField field = ...
// Create the LevelContext needed to generate the level
var context = new LevelContext(difficulty,m_EverContentsStack);
// Add the new LevelContext contents to the LevelContents history
m_EverContentsStack.Add(context.CurrentContents);
return field.Generate(context);
}
}
| BaseLevelDifficulty | The base difficulty for the level. |
| LevelDifficultyCurve | An `AnimationCurve` that returns a multiplier applied to `BaseLevelDifficulty`. For example, if `BaseLevelDifficulty` is 3 and `LevelDifficultyCurve` returns 2, the level difficulty becomes 6. |
| EverContentsStack | History of `LevelContents` from previous levels. |
| TotalLevelCount | The number of levels generated so far. |
WeightedMultiSpawnLevelProcessor
The main event!
This class performs the work of “selecting enemies and spawning them.” Since it is built on top of the map generation algorithm, the article below should make it easier to understand.
Reference: Explaining a Roguelike Map Generation Algorithm
using System;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class WeightedMultiSpawnLevelProcessor : ILevelProcessor {
const float k_DefaultWeight = 1f;
const float k_WeightIncrement = 10f;
[SerializeField]
FieldObject[] m_FieldObjects = Array.Empty<FieldObject>();
public IEnumerator Process (IField field,LevelContext context) {
float difficulty = context.GetRemainingDifficulty();
// Generate the weighted collection of enemies
Dictionary<FieldObject,float> weightedContents = m_FieldObjects
.Where(e => (e != null) && (difficulty >= e.MinDifficulty))
.ToDictionary(e => e,e => k_DefaultWeight);
foreach (FieldObject element in context.EverContentsStack.SelectMany(x => m_FieldObjects.Except(x.Contents.Keys))) {
weightedContents[element] += k_WeightIncrement;
}
Debug.Log("Contents Weight:\n" + string.Join("\n",weightedContents.Select(x => $"{x.Key.name}: {x.Value} weight")));
// Select the enemies to spawn and decide how many of each to generate
Dictionary<FieldObject,int> contents = new Dictionary<FieldObject,int>();
float totalDifficulty = 0f;
while ((weightedContents.Count > 0) && (totalDifficulty < difficulty)) {
float remainingDefficulty = difficulty - totalDifficulty;
// Select an enemy
FieldObject element = weightedContents
.Where(x => x.Key.Strength <= remainingDefficulty)
.WeightedSelect(x => x.Value).Key;
// If element is null, there is no room left in the difficulty budget to spawn more enemies.
if (element == null) {
break;
}
// Determine the number of enemies based on the difficulty
int maxSpawnableQuantity = Mathf.FloorToInt(remainingDefficulty / element.Strength);
int maxQuantity = Mathf.FloorToInt(difficulty / element.Strength * element.MaxSpawnRatio);
int quantity = Random.Range(0,Mathf.Min(maxSpawnableQuantity,maxQuantity) + 1);
if (quantity > 0) {
weightedContents.Remove(element);
contents.Add(element,quantity);
totalDifficulty += element.Strength * quantity;
}
yield return null;
}
// Spawn the enemies
foreach (var content in contents) {
yield return new MultiSpawnLevelProcessor(
prefab: content.Key,
quantity: content.Value,
direction: SpawnDirection.Random,
securePath: true
).Process(field,context);
}
}
}
Related Articles
- Explaining a Roguelike Map Generation Algorithm
- Implementing Weighted Random Selection in C#
- How Contrast Makes Games Fun [Game Design]