This feature was a magic moment for me. 🪄
Procrastination comes from fear, and I’ve been procrastinating AI for a long time. I knew it was the critical feature stopping the game from being playable. I’ve implemented pathfollowing, navmeshes, worldgen - so many difficult features.
But none of that mattered until there was something on the other side. Something that fought back.
The last thing standing between me and an a playable game.
When I saw the enemy mages man their sigils for the first time, I teared up. Skymagi came alive.
Castle battles with events, sigils, travel, and AI. That’s Skymagi. That’s what I’ve been building towards.
I should’ve done this at the start. We’ll call it a lesson learned.
Utility AI
I’ve spent days reading through approaches to Game AI. Everything from behavior trees to GOAP. Skymagi AI needed to be creative, tactical, and imperfect. Optimal play isn’t always ideal, it needs unique strategies with variety.
And utility AI is designed for that kind of emergent gameplay.
The idea comes from utilitarianism. A unit considers all possible actions. Each action has a utility value for how useful it is at the current moment. A high utility means urgent and impactful, low means unimportant. The unit then chooses the best action.
It’s just making up numbers.
Game AI is an entire field, so there’s a lot of complexity within, but I struggled to find basic examples. Here’s what helped me.
Though the concepts seemed simple, I struggled to grasp joint decision making. Mages could choose actions individually, but how could the cabal make decisions together? Deciding who mans each sigil, distribution of castle mana, repairing, or healing.
Castle AI
I decided to split the problem into two systems.
The CastleAiSystem
determines the strategy, mana allocation, and sigil assignments for the whole cabal.
Then the UnitAiSystem
makes individual tactical decisions for each mage based on their assignments.
I started with sigil assignment. So I need a score function for utility of a given assignment.
// returns a 0-1 based on how good the assignment is.
float scoreSigilAssign(Sigil sigil, Entity unit) {
// let's start simple. If a sigil is full, don't use it!
if (!sigil.hasOpenSlot())
return 0;
// otherwise, score by how important the sigil is overall
return switch(sigil.sigilType) {
case Abjuration -> 0.3f;
case Evocation -> 0.2f;
case Teleport -> 0.1f;
case Scrying -> 0.05f;
case Anchor -> 0.05f;
// only use alchemy if the unit is hurt
// this is a power function exponential based on missing HP
case Alchemy -> scoreUnitHurt(unit);
}
}
And then, for each unit, I score an assignment for sigils. After the best has been
found, the UseSigilCommand
forces assignment.
void assignSigilToCabal(Array<Entity> sigils, Array<Entity> cabal) {
Map<Entity, Entity> assignments = new ObjectMap<>();
// for each unit, greedily determine ideal assignment (first come, first serve)
for (Entity unit : cabal) {
float highest = -1;
Entity assignedSigil = null;
for (Entity sigil : sigils) {
float score = scoreSigilAssign(sigil, unit);
// Any actions with 0 are disqualified, unassigned if nothing is viable
if (score > 0 && score > highest) {
highest = score;
assignedSigil = sigil;
}
}
// Currently, explicitly command entity to use assignment until we write Unit AI
if (!isPursuingSigil(unit, sigil)) {
CommandUtils.ClearUnitCommands(unit);
CommandUtils.UseSigilCommand(unit, sigil);
}
}
}
This approach worked, but had a lot of issues.
- Units swapped sigils way too often.
- Units didn’t take walking distance into account.
- Units only looked at current state, not planned state.
- Units don’t do anything on most sigils.
Stop switching so much!
I added inertia. An additive bonus to the score if a unit is already using it.
float score += isUsingOrAssigned(unit, sigil) ? 0.15 : 0;
Units left the alchemy sigil when only at ~60% health beforehand. Inertia solved that issue, but in some cases, they would stick around even after reaching full health.
So I made inertia conditional for alchemy.
Check the distance
Just add a penalty to far distances within the castle. The distance score function is linear with a cap around max castle sizes.
// squared distance messed with utility calculations
float dist = MathUtilsEx.Distance(unit, sigil);
// distance is a good tie-breaker, but 5% weight seemed right
// higher weights caused rigidness combined with inertia
score -= scoreDistancePenalty(dist) * 0.05;
Planning assignments
Rather than using current open slots solely for score, the CastleAi
tracked
curAssignments
to avoid stepping on other’s toes.
Similarly, I added weight to keeping one mage in each sigil ideally, so better coverage overall.
// assignments: map that tracks current planned assignments
float scoreSigilAssign(Sigil sigil, Entity unit, Map assignments) {
// ...
// 20% vacancy bonus. Try not to leave any sigil totally vacant
int users = assignments.get(sigil).size;
if (users == 0 || (users == 1 && isUsing(unit))) {
// I later customized this per sigil for Abjuration++
score += 0.2;
}
// ...
}
Playing around with utility math and seeing behavior changes is quite fun.
I wrote distributeSigilMana
in a similar vein with scoreSigilMana
and added
mana as a consideration for assignment scores.
All my dogmatic discipline for adding Command components for everything (e.g. SigilManaCommand
)
made executing decisions simple.
Unit AI
Rather than a unit obeying CastleAiSystem
directly via commands, units should make their own choices
and execute them.
I added an assignedSigil
field to the UnitAi
component and scaffolded a UnitAiSystem
.
// CastleAiSystem updated to set assignments instead of commands
unitAi.assignedSigil = highestSigil;
// UnitAiSystem update
for (Entity unit : unitAis) {
UnitAi unitAi = Comps.unitAi.get(unit);
// for now, just always do the assignment
if (!isPursuingSigil(unit, sigil)) {
CommandUtils.ClearUnitCommands(unit);
CommandUtils.UseSigilCommand(unit, sigil);
}
}
Identical behavior, but now units can carve their own path.
What actions will a unit consider at first?
useAssignedSigil
: Do your assigned job.repairSigil
: Fix a broken sigil.idle
: Do nothing.
I wrote scoreUseAssignedSigil
, scoreRepairSigil
, and idle is the default at 0. Unlike CastleAi,
an array drives the list of available actions since I suspect this list might get quite long.
This enum is stored as the unit’s current action and used as a consideration similarly to assignments.
// UnitAiSystem
void update(float deltaTime) {
for (Entity unit : unitAis) {
UnitAi ai = Comps.unitAi.get(unit);
// determine utility through scoring actions, pick highest
UnitAction nextAction = findHighestScoreAction(unit, ai.curAction);
UnitAction prevAction = ai.curAction;
ai.curAction = nextAction;
// execute action (through issuing varying commands)
executeAction(unit, ai.curAction, prevAction);
}
}
However, units still weren’t attacking using the Evocation Sigil.
Attacking requires the unit to be using their assigned sigil, so it is a dependent action. I thought about making it
its own action with its own score, but instead I added it to execUseAssignedSigil
.
void execUseAssignedSigil(Entity unit) {
// ... follow orders
if (evocation) {
// pick best attack spell via utility
Spell spell = pickBestSpell(unit, validTargets);
// pick best target with highest utility
Sigil attackTarget = findBestTarget(spell, validTargets);
// cast spell at target
CommandUtils.CastCommand(spell, attackTarget);
}
}
Units now repair sigils broken near them, rush to heal, attack when using evocation, and more.
And with that, Skymagi is finally playable.
I spent thirty minutes hopping between castles, fighting the AI. It felt good.
Still more to do, like more spells, more decisions, alternate strategies like boarding.