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 false when the script handled the frame. The native enemy controller is skipped.
  • Return true to 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 return false.
  • If the script changes to a new animation, return false so 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.