Enemy AI Examples¶
Enemy scripts can be attached once to the entity in logic.json, so the same AI runs regardless
of which animation or state the enemy is currently in:
"aiScript": {
"packName": "My Chronicle",
"scriptName": "My Enemy AI"
}
The file lives at Editor/<Pack Name>/Scripts/My Enemy AI.lua. The script's update function runs
once per frame before the enemy's native controller and receives the normal game global; the
current enemy is available as game.this_enemy.
This setting belongs to the enemy entity. When a custom level spawns that entity, its instances
automatically use the declared AI; level spawn data does not need a script field. aiScript is not
inherited from referenceEntityName, so put it in the custom entity's own logic.json when the
custom entity should use it.
- Return
falsewhen the script handled the frame. The native enemy controller is skipped. - Return
trueto let the native controller continue, which lets a script augment existing AI.
Animation scripts remain useful for behavior belonging to one particular move. Use aiScript for
decisions that must follow the enemy across standing, walking, wind-up, and attack states.
Complete Entity-Level Example¶
The following can be used as the entity's aiScript. It handles hook-drop setup, hit
interruptions, facing, delay before action, distance gating, and grounded versus aerial attack
selection. Because it runs in every state, it first leaves non-standing animations to their normal
controller.
local distance_to_attack = 12000
return function()
local enemy = game.this_enemy
if enemy == nil then
return true
end
-- Restrict this decision block to the entity's standing state. Replace 0 with the
-- appropriate mode if this enemy uses a different standing mode.
if enemy.object_mode ~= 0 then
return true
end
if enemy.check_being_hit() then
-- The hit check already changed state. Stop standing AI for this frame.
return false
end
if enemy.initial_ninja_hook_heigh ~= 0 then
-- Some spawn paths put this enemy on a ceiling hook. Convert that setup
-- value into real height once, then clear it so it is not applied again.
enemy.position_offset_y = -enemy.initial_ninja_hook_heigh
enemy.initial_ninja_hook_heigh = 0
end
-- Arguments are:
-- in_same_line: false means any depth line is allowed.
-- forward_check: true allows targets in front.
-- backward_check: true allows targets behind.
local target = enemy.get_nearest_opponent(false, true, true)
if target == nil then
return true
end
enemy.is_facing_right = target.position_x > enemy.position_x
-- Wait before attacking. This gives the spawn or idle animation time to read.
if enemy.current_animation_frame < 120 then
return true
end
local distance = enemy.position_x - target.position_x
if distance < -distance_to_attack then
-- Too far to the left of the target: return to roaming behaviour.
enemy.object_mode = 0
end
if distance < 0 then
distance = -distance
end
if distance < distance_to_attack then
if enemy.position_offset_y == 0 then
enemy.set_animation(1)
else
enemy.set_animation(2)
enemy.impulse_y = -250
end
return false
end
return true
end
Same-Line Melee Gate¶
Use this for enemies that should only attack when they are aligned on the belt-scroller depth axis.
return function()
local enemy = game.this_enemy
if enemy == nil then
return true
end
if enemy.check_being_hit() then
return false
end
local target = enemy.get_nearest_opponent(true, true, true)
if target == nil then
return true
end
enemy.is_facing_right = target.position_x > enemy.position_x
local dx = enemy.position_x - target.position_x
if dx < 0 then
dx = -dx
end
if dx < 5000 then
enemy.set_animation(3)
return false
end
return true
end
Retreat, Then Attack¶
This pattern uses a custom value as an AI phase. Phase 0 retreats from the nearest target; phase 1 turns around and attacks.
return function()
local enemy = game.this_enemy
if enemy == nil then
return true
end
if enemy.check_being_hit() then
enemy.custom_value_1 = 0
return false
end
local target = enemy.get_nearest_opponent(false, true, true)
if target == nil then
return true
end
if enemy.custom_value_1 == 0 then
enemy.is_facing_right = target.position_x < enemy.position_x
enemy.velocity_x = 120 * enemy.direction_signal
enemy.custom_value_2 = enemy.custom_value_2 + 1
if enemy.custom_value_2 > 35 then
enemy.custom_value_1 = 1
enemy.custom_value_2 = 0
end
return false
end
enemy.is_facing_right = target.position_x > enemy.position_x
enemy.set_animation(4)
enemy.custom_value_1 = 0
return false
end
Enemy Projectile Volley¶
This example fires a three-shot spread from an enemy attack animation. It uses try_spawn_item because the enemy chooses the volley dynamically.
-- Each row is projectile animation id, velocity X, velocity Y/depth. Built once.
local shots = {
{0, 420, -80},
{0, 480, 0},
{0, 420, 80}
}
return function()
local enemy = game.this_enemy
if enemy == nil then
return true
end
if enemy.check_being_hit() then
return false
end
if enemy.current_animation_frame == 10 then
for i = 1, #shots do
local shot = shots[i]
enemy.try_spawn_item(
"My Pack",
"Enemy Projectile",
shot[1],
-1,
-900,
0,
shot[2],
shot[3],
80,
6,
3,
"projectile-hit",
"My Pack",
0
)
end
end
if enemy.is_animation_over then
enemy.set_animation(0)
return false
end
return true
end
Falling Ambush¶
Use this when an enemy starts above the ground and should attack after dropping.
return function()
local enemy = game.this_enemy
if enemy == nil then
return true
end
if enemy.check_being_hit() then
return false
end
if enemy.current_animation_frame == 0 and enemy.position_offset_y == 0 then
-- Start above the floor. Negative height means airborne.
enemy.position_offset_y = -4000
enemy.impulse_y = 0
end
enemy.position_offset_y = enemy.position_offset_y + enemy.impulse_y
enemy.impulse_y = enemy.impulse_y + 24
if enemy.position_offset_y >= 0 then
enemy.position_offset_y = 0
enemy.impulse_y = 0
enemy.set_animation(5)
return false
end
return false
end
AI Authoring Notes¶
- Put cross-state decisions in the entity-level
aiScript; keep the mechanics of one move in that animation's script. get_nearest_opponent(true, true, true)is good for melee because it requires depth-line compatibility.get_nearest_opponent(false, true, true)is good for facing or projectile decisions.- Always call
check_being_hit()in custom enemy controllers that returnfalse. - If the script changes to a new animation, return
falseso the old AI does not continue acting for the same frame. - Store AI phase and cooldowns in
custom_value_*. Locals inside the update function start over on every call, and locals outside it are shared by every enemy using the script.