• she/her

Still gay, avatar and banner by Skippy Lynn


nex3
@nex3

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.


You must log in to comment.