SorcerAI
2D puzzle roguelike AI training game— downloadable on itch.io
My bachelor’s thesis “SorcerAI” revolves around resource managment and the training of a blackbox-like artificial intelligence. The game is designed to challenge a player’s mathematics, risk assessment, and cognitive ability to present data in sets that are useful to a neural network.
Demonstrates:
AI Design, System Design
Role:
Solo Dev
System Design
Challenge
In my Bachelor’s Thesis, I ponder the question how AI can be used to dynamically influence a game system during play. In the draft for designing my system, there are several requirements:
- Technical feasability
- Innovation
- AI has direct influence on the game state
- AI is handcrafted, not just an API
- The main interaction involved the AI
Workflow
Over a short period of time, I gathered five ideas together with fellow students, other local game design enthusiasts, and my
instructors. During meetings, anonymous stars were distributed to evaluate the 5 gathered concepts in regards to my above mentioned criteria.






Idea 1 was heavily favored, but before a final decision was made, a strong programmatic corelation between idea 1 and 3 was found. I called this overlap “First Core”. It involved feeding Gems to an AI, which would in turn try to predict these Gems’ values.

After testing the first core in engine, I was able to start playtesting. As time went on, it became clear that idea 1 perfectly fulfilled all my criteria and sparked joy, inspiration and motivation.
The game went through four distinct prototypical states.

Stage 0. The First Core. I used the Unity inspector and context menus to train my AI and let it make predictions.
Stage 1.The game idea was decided on. The first playable UI interface had the following features:
- There are 4 rounds of 4 Gems each. The player decides which Gems get submitted.
- The AI is trained each time, and predicts the Gems’ total values. The error between the actual values is displayed.


Stage 2. The game now had typical game elements, including a win and lose condition. The visuals were slightly reworked, and a final title was found. Basically, the stage 1 prototype was reworked and repeated 6 times. Each repetition, the player had to score an increasingly difficult goal or they would lose. Beating all 6 would lead to the win condition.
Stage 3. The final stage included a narrative, a tutorial, final assets, a visual representation of the AI in the form of a Spell. Additionally the UI and layout had strongly been improved upon player feedback.

My above-linked bachelor’s thesis on “SorcerAI” describes the process and decisions in more detail and depth. However, it is only available in German.
Solution
My final game system of “SorcerAI” works in the following way:
- The game consists of 6 Antes
- Succeeding all Antes wins the game
- Failing an Ante loses the game
- An Ante consists of 4 Cauldrons
- The player adds 1-4 Gems from the Gem Grid to a Cauldron
- A Gem has 4 Gem Properties (and their corresponding Property Values)
- Size: Small (2), Medium (3), Large (4)
- Polish: Fractured (1), Uncut (1.25), Refined (1.5), Lucent (1.75), Brilliant (2)
- Shape: Nol (0), Hecat (0.25), Lilite (0.5), Tivyst (0.75)
- Value = Size (X) ^ Polish (Y) + Shape (Z). This is the Gem Formula.
- A Cauldron’s Total Value is the sum of its Gems’ Values.
- The Gem Grid is a 4 by 4 grid, which is randomly populated with Gems at the start of the game. The Gems inside can be sorted by their 4 Gem Properties.
- A Gem has 4 Gem Properties (and their corresponding Property Values)
- The player adds a Cauldron the the Spell
- The Spell is the in-game representation of the AI
- The Spell can hold up to 4 Cauldrons
- Each Cauldron’s Gems inform the Spell, acting as training data.
- The Spell can ascertain the Cauldron’s Total Value, as well as the individual Gem’s Property Values, but not the universal Gem Formula.
- The Spell approximates the Gem Formula from the training data the player has given it. With that, it infers a guess of the Cauldron’s Total Value. This called a Spell Prediction.
- Each Cauldron’s Gems inform the Spell, acting as training data.
- After submitting a Cauldron to the Spell, the game proceeds to the next Cauldron.
- There are 2 kinds of alternating Cauldrons:
- Glass Cauldrons score points equal to the Cauldron’s Total Value minus the Spell Error. The Spell Error is the difference between that value and the Spell Prediction of what that value is.
- Stone Cauldrons don’t score points. When the Spell has to remove 1 of its 4 Cauldrons due to receiving a new one, and that Cauldron is a Stone Cauldron, its Gems get returned to the Gem Grid.
- The player adds 1-4 Gems from the Gem Grid to a Cauldron
- At the end of an Ante,
- The Antes threshold has to be beat to succeed.
- Upon succeeding
- The player’s score gets reduced to that threshold.
- Extra points are scored, according a randomly predetermined scoring criterium. Possible criteria include:
- Points per specific Gem Property in a specific Cauldron type.
- Points per Cauldron only containing Gems of a specific Gem Property.
- The Gem grid gets randomly refilled
To more easily understand the game system, I recommend watching the tutorial of the walkthrough below:
AI Design
Challenge
In SorcerAI, players train an artificial intelligence. The better the AI is, the less points are being deducted from the player’s score. More on that, in the System Design section.
The AI has to learn the following Gem Logic:
Value = Size ^ Polish + Shape.
This yields the following criteria for the AI for this game
- It must be performant. The play experience should not be slowed due to loading times.
- The AI only has to be able to good enough not have an error of close to 0, but also has to be able to fail in doing so. The deciding factor will have to be the player’s skill.
Workflow
My criteria made 1 thing clear from the start: I would have to write this AI myself. I used Unity due to my affinity with C# and because an early test in UE5 using blueprints displayed performance issues.
At first, the neural network (NN) worked like this:
Iteration 1:
Size * Weight1 + Polish * Weight2 + Shape * Weight3 = Value.
The NN could never determine the true relationship from this, since there was no provision for exponentiation. To address this, a polynomial function was introduced for each property:
Iteration 2:
Per property:
[Property^2 * Weight1 + Property * Weight2 + Weight3] PropertyFunction(Size) + PropertyFunction(Polish) + PropertyFunction(Shape) = Value.
The results improved with the now nine weighs, but never came close to the actual formula, since the relationship between the properties still could not be represented other than through addition/subtraction.
Iteration 3:
The latest version only weights the various relationships of the properties (y… Value, sh… Shape, p… Polish, s… Size, wX… ValueX):
y = (sh^p)w1 + (sh^s)w2 + (p^s)w3 + (shp)w4 + (shs)w5 + (ps)w6 + (sh+p)w7 + (sh+s)w8 + (p+s)w9
Thus, the AI does not learn the values of the attributes but rather their relationships to one another.
Momentum, gradient clipping, learning rate decay, early stopping, weight regularization, learning rate types, standardization, normalization, etc., are all ways to make AI learning more efficient and sustainable. All have been tested, and the settings that had a positive impact have been retained.
Here’s a gallery of tests I made throughout the process. Here, “TDS” stands for the number of training data sets. “TDS I” refers to training data sets in which the AI was trained using individual Gems rather than as a set. The value displayed in the cells is the average error:




Solution
The AI has to learn a specific correlation between 3 properties of Gems.
It only knows a Gem’s property values, not their relation or order of operations.
On top of that, a training data consists of a cluster of 1−4 Gems. The AI knows each inidividual Gem’s property values, but not the Gem’s values. Instead, it knows the sum those 1−4 Gems.
The below code shows my neural network:
using System.Collections.Generic;using System.Linq;using UnityEngine;//gems[System.Serializable]public enum GemSize{ Small, Medium, Large}[System.Serializable]public enum GemPolish{ Fractured, Uncut, Refined, Lucent, Brilliant}[System.Serializable]public enum GemShape{ Nol, Hecat, Lilite, Tivyst}[System.Serializable]public class Gem{ public GemSize size = GemSize.Medium; public GemPolish polish = GemPolish.Brilliant; public GemShape shape = GemShape.Nol; [HideInInspector] public float value;}[System.Serializable]public class GemSet{ public Gem[] gems; [HideInInspector] public float totalValue; [HideInInspector] public bool evaluated = false; [HideInInspector] public bool startTrainingData = false;}public class NN_SorcerAI : MonoBehaviour{ [HideInInspector] public float[] weights; //training data private enum TrainingDataSetting { Untrained, HandTrainedWithIndividualGems, RandomlyTrainedWithIndividualGems, HandTrainedWithGemSets, RandomlyTrainedWithGemSets } [HideInInspector] public List<GemSet> trainingData; //gem float getters public float GetGemSizeFloat(GemSize size) { return size switch { GemSize.Small => 2f, GemSize.Medium => 3f, GemSize.Large => 4f, _ => 0f }; } public float GetGemPolishFloat(GemPolish polish) { return polish switch { GemPolish.Fractured => 1f, GemPolish.Uncut => 1.25f, GemPolish.Refined => 1.5f, GemPolish.Lucent => 1.75f, GemPolish.Brilliant => 2f, _ => 0f }; } public float GetGemShapeFloat(GemShape shape) { return shape switch { GemShape.Nol => 0f, GemShape.Hecat => 0.25f, GemShape.Lilite => 0.5f, GemShape.Tivyst => 0.75f, _ => 0f }; } // Inspector [SerializeField] private GemSet mySet = new GemSet(); [Header("Training Settings")] [SerializeField] private int trainingCycles = 10; [SerializeField] private float learningRate = 0.001f; [SerializeField] private TrainingDataSetting trainingDataSetting = TrainingDataSetting.HandTrainedWithIndividualGems; [SerializeField] private int randomTrainingDataSamples = 3; public bool startTrainingDataIsPermanent = false; [Header("Random Gem Set Settings")] [SerializeField] private int minSetSize = 1; [SerializeField] private int maxSetSize = 4; [Header("Randomization Settings")] [SerializeField] private bool useFixedSeed = true; [SerializeField] private int randomSeed = 12345; private System.Random sharedRandom; //Context Menus [ContextMenu("Retrain with My Gem Set")] private void RetrainWithMyGemSet() { AddMySetToTrainingData(); RetrainNetwork(); } [ContextMenu("Retrain with Random Gem Set")] private void RetrainWithRandomGemSet() { RandomizeMyGemSet(); RetrainWithMyGemSet(); } [ContextMenu("Retrain with Random Single Gem")] private void RetrainWithRandomGem() { RandomizeMyGemSetAsSingleGem(); RetrainWithMyGemSet(); } [ContextMenu("Add My Gem Set to Training Data")] private void AddMySetToTrainingData() { if (mySet.gems == null || mySet.gems.Length == 0) { Debug.Log("No gems in training set. Training Data won't be modified."); return; } ValueGemSet(mySet); trainingData.Add(mySet); } public GemSet ValueGemSet(GemSet gemSet) { float total = 0f; foreach (Gem gem in gemSet.gems) { gem.value = CalculateValueFromGemProperties(gem.size, gem.polish, gem.shape); total += gem.value; } gemSet.totalValue = total; return gemSet; } [ContextMenu("Randomize My Gem Set")] private void RandomizeMyGemSet() { mySet = CreateRandomGemSet(); } [ContextMenu("Randomize My Gem Set as Single Gem")] private void RandomizeMyGemSetAsSingleGem() { mySet = SingleGemToValuedGemSet(CreateRandomValuedGem()); } [ContextMenu("ResetNetwork")] private void RetrainCleared() { ClearTrainingData(); InitializeWeights(); } private void ClearTrainingData() { trainingData = new List<GemSet>(); Debug.Log("Training Data Cleared"); } // Testing Context Menus [ContextMenu("Test Network with My Gem Set")] private void TestNetworkWithMyGemSet() { ValueGemSet(mySet); float predicted = Forward(mySet.gems); Debug.Log($"Testing set with {mySet.gems.Length} gems:"); Debug.Log($"Predicted total: {predicted:F2}, Actual total: {mySet.totalValue:F2}"); Debug.Log($"Error: {Mathf.Abs(predicted - mySet.totalValue):F2}"); } [ContextMenu("Test Network with Medium Refined Tivyst")] private void TestNetworkWithSingleGem() { GemSize size = GemSize.Medium; GemPolish polish = GemPolish.Refined; GemShape shape = GemShape.Tivyst; float result = Forward(new Gem[] { new Gem() { size = size, polish = polish, shape = shape } }); float value = CalculateValueFromGemProperties(size, polish, shape); Debug.Log($"Test result for {size} {polish} {shape}: {result:F2}, should be {value:F2}"); } [ContextMenu("Test Network with Random Set")] private void TestNetworkWithRandomGemSet() { GemSet testSet = CreateRandomGemSet(); float predicted = Forward(testSet.gems); Debug.Log($"Randomly Testing set with {testSet.gems.Length} gems:"); Debug.Log($"Predicted total: {predicted:F2}, Actual total: {testSet.totalValue:F2}"); Debug.Log($"Error: {Mathf.Abs(predicted - testSet.totalValue):F2}"); } //Helper Methods public Gem CreateRandomValuedGem() { GemSize[] allSizes = (GemSize[])System.Enum.GetValues(typeof(GemSize)); GemSize size = allSizes[sharedRandom.Next(0, allSizes.Length)]; GemPolish[] allPolishes = (GemPolish[])System.Enum.GetValues(typeof(GemPolish)); GemPolish polish = allPolishes[sharedRandom.Next(0, allPolishes.Length)]; GemShape[] allShapes = (GemShape[])System.Enum.GetValues(typeof(GemShape)); GemShape shape = allShapes[sharedRandom.Next(0, allShapes.Length)]; return CreateValuedGem(size, polish, shape); } public Gem CreateValuedGem(GemSize size, GemPolish polish, GemShape shape) { Gem gem = new Gem() { size = size, polish = polish, shape = shape, value = 0f }; return GetValuedGem(gem); } public Gem GetValuedGem(Gem gem) { gem.value = CalculateValueFromGemProperties(gem.size, gem.polish, gem.shape); return gem; } public float CalculateValueFromGemProperties(GemSize size, GemPolish polish, GemShape shape) { return Mathf.Pow(GetGemSizeFloat(size), GetGemPolishFloat(polish)) + GetGemShapeFloat(shape); } private GemSet SingleGemToValuedGemSet(Gem singleGem) { singleGem = GetValuedGem(singleGem); //for safety return new GemSet() { gems = new Gem[] { singleGem }, totalValue = singleGem.value }; } private GemSet CreateRandomGemSet() { int setSize = sharedRandom.Next(minSetSize, maxSetSize); GemSet newSet = new GemSet() { gems = new Gem[setSize] }; float totalValue = 0f; for (int i = 0; i < setSize; i++) { newSet.gems[i] = CreateRandomValuedGem(); totalValue += newSet.gems[i].value; } newSet.totalValue = totalValue; return newSet; } //Public Methods for Gameplay public void AddGemSetToTrainingData(GemSet newSet) { trainingData.Add(newSet); } public void RetrainNetwork() { InitializeWeights(); TrainNetwork(); } public Gem GenerateOptimalSingleGem() { Gem currentOptimalGem = new Gem(); float bestPredictedValue = float.MinValue; List<Gem> allCombinations = new List<Gem>(); GemSize[] allSizes = (GemSize[])System.Enum.GetValues(typeof(GemSize)); GemPolish[] allPolishes = (GemPolish[])System.Enum.GetValues(typeof(GemPolish)); GemShape[] allShapes = (GemShape[])System.Enum.GetValues(typeof(GemShape)); foreach (GemSize size in allSizes) { foreach (GemPolish polish in allPolishes) { foreach (GemShape shape in allShapes) { allCombinations.Add(new Gem { size = size, polish = polish, shape = shape }); } } } foreach (Gem gem in allCombinations) { Gem[] singleGemSet = { gem }; float predictedValue = Forward(singleGemSet); if (predictedValue > bestPredictedValue) { bestPredictedValue = predictedValue; currentOptimalGem = gem; } } Debug.Log($"Optimal Gem Found: {currentOptimalGem.size} {currentOptimalGem.polish} {currentOptimalGem.shape} " + $"(Predicted Value: {bestPredictedValue:F2})"); return currentOptimalGem; } public void RemoveOldestTrainingDataSets(int amount) { int startSets = trainingData.Where(x => x.startTrainingData).Count(); amount = startTrainingDataIsPermanent ? amount : amount + startSets; //not protected well enough for (int i = 0; i < amount; i++) { trainingData.RemoveAt(startTrainingDataIsPermanent ? startSets : 0); } TrainNetwork(); } //Neural Network void Start() { InitializeRandom(); InitializeWeights(); InitializeTrainingData(); TrainNetwork(); TestNetworkWithRandomGemSet(); } private void InitializeRandom() { if (useFixedSeed) { sharedRandom = new System.Random(randomSeed); Random.InitState(randomSeed); } else { sharedRandom = new System.Random(); } } private void InitializeWeights() { weights = new float[12]; for (int i = 0; i < weights.Length; i++) { weights[i] = Random.Range(-1f, 1f); } } private void InitializeTrainingData() { trainingData = new List<GemSet>(); switch (trainingDataSetting) { case TrainingDataSetting.HandTrainedWithIndividualGems: trainingData = new List<GemSet> { // size, polish, shape, value SingleGemToValuedGemSet(new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol, value = 2f }), SingleGemToValuedGemSet(new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Tivyst, value = 2.75f }), SingleGemToValuedGemSet(new Gem() { size = GemSize.Medium, polish = GemPolish.Uncut, shape = GemShape.Nol, value = 3.94822f }), SingleGemToValuedGemSet(new Gem() { size = GemSize.Small, polish = GemPolish.Brilliant, shape = GemShape.Lilite, value = 4.5f }), SingleGemToValuedGemSet(new Gem() { size = GemSize.Medium, polish = GemPolish.Lucent, shape = GemShape.Hecat, value = 7.08852f }), SingleGemToValuedGemSet(new Gem() { size = GemSize.Large, polish = GemPolish.Refined, shape = GemShape.Nol, value = 8.75f }), SingleGemToValuedGemSet(new Gem() { size = GemSize.Large, polish = GemPolish.Brilliant, shape = GemShape.Tivyst, value = 16.75f }), }; break; // extensive code above case TrainingDataSetting.RandomlyTrainedWithIndividualGems: for (int i = 0; i < randomTrainingDataSamples; i++) { trainingData.Add(SingleGemToValuedGemSet(CreateRandomValuedGem())); } break; case TrainingDataSetting.HandTrainedWithGemSets: trainingData = new List<GemSet> { // ======== MINIMUM REQUIREMENTS ======== // 4 small fractured nols (value = 2 each) ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol }, new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol }, new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol }, new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol } } } ), // 4 large brilliant tivysts (value = 16.75 each) ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Large, polish = GemPolish.Brilliant, shape = GemShape.Tivyst }, new Gem() { size = GemSize.Large, polish = GemPolish.Brilliant, shape = GemShape.Tivyst }, new Gem() { size = GemSize.Large, polish = GemPolish.Brilliant, shape = GemShape.Tivyst }, new Gem() { size = GemSize.Large, polish = GemPolish.Brilliant, shape = GemShape.Tivyst } } } ), // ======== ADDITIONAL VALUABLE EXAMPLES ======== // Mixed size, same polish/shape ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Small, polish = GemPolish.Refined, shape = GemShape.Nol }, new Gem() { size = GemSize.Medium, polish = GemPolish.Refined, shape = GemShape.Nol }, new Gem() { size = GemSize.Large, polish = GemPolish.Refined, shape = GemShape.Nol } } } ), // Same size, mixed polish, same shape ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Medium, polish = GemPolish.Fractured, shape = GemShape.Hecat }, new Gem() { size = GemSize.Medium, polish = GemPolish.Uncut, shape = GemShape.Hecat }, new Gem() { size = GemSize.Medium, polish = GemPolish.Brilliant, shape = GemShape.Hecat } } } ), // Mixed everything - most challenging ValueGemSet(new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Tivyst }, new Gem() { size = GemSize.Medium, polish = GemPolish.Lucent, shape = GemShape.Lilite }, new Gem() { size = GemSize.Large, polish = GemPolish.Uncut, shape = GemShape.Nol }, new Gem() { size = GemSize.Small, polish = GemPolish.Brilliant, shape = GemShape.Hecat } } } ), // Two identical gems (tests if network learns 2x) ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Medium, polish = GemPolish.Refined, shape = GemShape.Tivyst }, new Gem() { size = GemSize.Medium, polish = GemPolish.Refined, shape = GemShape.Tivyst } } } ), // Single gem set (important baseline) ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Large, polish = GemPolish.Lucent, shape = GemShape.Nol } } } ), // Empty-ish gem (tests shape contribution) ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol }, new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Tivyst } } } ), // Extreme contrast set ValueGemSet( new GemSet { gems = new Gem[] { new Gem() { size = GemSize.Small, polish = GemPolish.Fractured, shape = GemShape.Nol }, new Gem() { size = GemSize.Large, polish = GemPolish.Brilliant, shape = GemShape.Tivyst } } } ) }; break; // extensive code above case TrainingDataSetting.RandomlyTrainedWithGemSets: for (int i = 0; i < randomTrainingDataSamples; i++) { trainingData.Add(CreateRandomGemSet()); } break; case TrainingDataSetting.Untrained: break; default: Debug.LogError("No TrainingDataSetting"); break; } foreach (GemSet gemSet in trainingData) { gemSet.startTrainingData = true; } } private float[] GetFeatures(float sizeF, float polishF, float shapeF) { return new float[] { Mathf.Pow(sizeF, polishF), Mathf.Pow(polishF, sizeF), Mathf.Pow(sizeF, shapeF), Mathf.Pow(shapeF, sizeF), Mathf.Pow(polishF, shapeF), Mathf.Pow(shapeF, polishF), sizeF * polishF, sizeF * shapeF, polishF * shapeF, sizeF + polishF, sizeF + shapeF, polishF + shapeF }; } public float Forward(Gem[] gems) { float result = 0f; foreach (Gem gem in gems) { float[] features = GetFeatures( GetGemSizeFloat(gem.size), GetGemPolishFloat(gem.polish), GetGemShapeFloat(gem.shape)); for (int i = 0; i < weights.Length; i++) result += features[i] * weights[i]; } return result; } private float[] GetWeightDeltas(float error, Gem[] gems) { float[] deltas = new float[12]; foreach (Gem gem in gems) { float[] features = GetFeatures( GetGemSizeFloat(gem.size), GetGemPolishFloat(gem.polish), GetGemShapeFloat(gem.shape)); for (int i = 0; i < deltas.Length; i++) deltas[i] += error * features[i]; } return deltas; } private void TrainNetwork() { for (int cycle = 0; cycle < trainingCycles; cycle++) { foreach (GemSet sample in trainingData) { Gem[] sampleIn = sample.gems; float predictedResult = Forward(sampleIn); float sampleOut = sample.totalValue; float error = sampleOut - predictedResult; float[] weightDeltas = GetWeightDeltas(error, sampleIn); for (int i = 0; i < weightDeltas.Length; i++) { weights[i] += weightDeltas[i] * learningRate; } } } // Print final weights Debug.Log("Network trained: Final Weights: " + string.Join(", ", weights.Select(w => w.ToString("F2")))); Spell.instance.UpdateSpellParticles(weights); }}
Leave a Reply