For an enemy that feels so relentless on the battlefield, Cemetery Shade's AI looks quite simple when you chart it out:
But the code itself actually has a lot going on that gets elided by my chart-making process. This chunk of code at the beginning of its idle logic, for example:
local random = nil if actor:GetNumber(5) >= 60 then random = actor:GetRandam_Int(10, 120) elseif actor:GetNumber(5) >= 40 then random = actor:GetRandam_Int(10, 100) elseif actor:GetNumber(5) >= 30 then random = actor:GetRandam_Int(10, 80) elseif actor:GetNumber(5) >= 20 then random = actor:GetRandam_Int(10, 45) elseif actor:GetNumber(5) >= 12 then random = actor:GetRandam_Int(10, 30) else random = actor:GetRandam_Int(1, 10) end if random >= 20 then probabilities[20] = 100 else -- Setting probabilities for other attacks end
The whole AI virtual machine seems to be cleared out between frames, so the actor:GetNumber() function (and its dual, actor:SetNumber()) is a way for AI code to store state that's accessible over time. One of the first parts of learning how an AI works is looking at its numbers and figuring out what they represent in terms of the actual battle. In this case, the number I refer to as "n5" in my notes gets incremented by 4 every time the shade does one of its opener attacks. The higher this counter gets, the more likely the shade is to forego an attack and run its Act20 function:
function TombShadow_366400_Act20(actor, goals, _) actor:SetNumber(5, 4) goals:AddSubGoal(GOAL_COMMON_SpinStep, 5, 6001, TARGET_ENE_0, 0, AI_DIR_TYPE_B, 3) local random0 = actor:GetRandam_Int(1, 100) local random1 = actor:GetRandam_Float(4, 6) if random0 > 85 then goals:AddSubGoal(GOAL_COMMON_ApproachAround, random1, TARGET_ENE_0, 0, TARGET_SELF, true, -1, AI_DIR_TYPE_ToBL, actor:GetRandam_Int(6, 10)) elseif random0 > 70 then goals:AddSubGoal(GOAL_COMMON_ApproachAround, random1, TARGET_ENE_0, 0, TARGET_SELF, true, -1, AI_DIR_TYPE_ToBR, actor:GetRandam_Int(6, 10)) elseif random0 > 35 then goals:AddSubGoal(GOAL_COMMON_ApproachAround, random1, TARGET_ENE_0, 0, TARGET_SELF, true, -1, AI_DIR_TYPE_ToL, actor:GetRandam_Int(8, 12)) else goals:AddSubGoal(GOAL_COMMON_ApproachAround, random1, TARGET_ENE_0, 0, TARGET_SELF, true, -1, AI_DIR_TYPE_ToR, actor:GetRandam_Int(8, 12)) end end
This action resets n5 (interestingly to 4 rather than all the way back to 0) and queues up a couple actions. The first one teleports backwards (animation 6001) and the second make it pace a bit in a randomly-determined direction.
Put this all together and you start to see the meaning behind the code: Cemetery Shade's ability to teleport quickly and unpredictably combined with its heavy damage output risk putting too much pressure on the player, so n5 acts as a release valve. The more intensely the Shade attacks, the more likely it'll be to back off and give the player space once its current combo finishes.
This enemy is full of logic like this, with six actively used state numbers and two timers, almost exclusively controlling the details of when and how it teleports. It would have been easy to have the boss's movement be as straightforward as its attacks, but the extra care that's put into making sure it feels scary without feeling unfair is what makes these games great.
