Projectiles And Item Examples

Projectiles are standalone items. A projectile script usually runs with game.this_item, not game.this_character. If Lua owns the projectile's movement, return false so the fallback item controller does not move or destroy it a second time.

Spawn A Projectile From A Character

Attach this to the attack animation that should emit the projectile.

return function()
    local character = game.this_character

    if character == nil then
        return true
    end

    if character.current_animation_frame == 12 then
        character.try_spawn_item(
            "My Pack",       -- pack containing the projectile entity
            "Fireball",      -- projectile entity name
            0,               -- projectile animation id
            -1,              -- object mode; -1 lets the spawned item keep defaults
            -2750,           -- spawn X offset; helper applies source-facing rules
            -2750,           -- spawn Y/depth offset
            -250,            -- X velocity/impulse argument used by spawn helper
            0,               -- Y/depth velocity argument
            90,              -- lifetime in frames
            8,               -- projectile damage
            4,               -- hit pause when it connects
            "fireball-hit",  -- optional hit sound
            "My Pack",       -- hit sound pack
            0                -- hit sound channel
        )
    end

    -- Spawning is a one-time action. Let the attack controller continue.
    return true
end

Basic Projectile Physics

Attach this to the projectile item's active animation.

return function()
    local item = game.this_item

    if item == nil then
        return true
    end

    -- check_projectile_hits returns true when the projectile hit something.
    -- It also applies projectile damage/hit-pause/sound data configured at spawn.
    local should_destroy = item.check_projectile_hits()

    -- X is horizontal screen/world movement.
    item.velocity_x = item.velocity_x + item.impulse_x

    -- velocity_y is depth movement along the belt-scroller plane.
    item.velocity_y = item.velocity_y + item.impulse_z

    -- inner_timer is the usual projectile lifetime.
    item.inner_timer = item.inner_timer - 1
    if item.inner_timer <= 0 then
        should_destroy = true
    end

    -- position_offset_y is height. Negative means above the ground.
    if item.position_offset_y < 0 or item.impulse_y < 0 then
        item.position_offset_y = item.position_offset_y + item.impulse_y
        if item.position_offset_y >= 0 then
            item.position_offset_y = 0
            should_destroy = true
        end
    end

    -- Acceleration is applied after movement, matching the default item pattern.
    item.impulse_x = item.impulse_x + item.acceleration_x
    item.impulse_y = item.impulse_y + item.acceleration_y
    item.impulse_z = item.impulse_z + item.acceleration_z

    if item.update_position_and_check_world_collisions() then
        should_destroy = true
        item.play_sound("Classic", "SE10", 0)
    end

    if should_destroy then
        -- Switch to a hit/explosion animation and deactivate the projectile body.
        item.set_animation(48)
        item.is_active = false
    end

    return false
end

Projectile That Dies Without A Hit Animation

Use this for effects that simply vanish when lifetime, landing, or wall collision ends them.

return function()
    local item = game.this_item

    if item == nil then
        return true
    end

    local should_destroy = item.check_projectile_hits()

    item.velocity_x = item.velocity_x + item.impulse_x
    item.velocity_y = item.velocity_y + item.impulse_z
    item.inner_timer = item.inner_timer - 1

    if item.inner_timer <= 0 then
        should_destroy = true
    end

    if item.position_offset_y < 0 or item.impulse_y < 0 then
        item.position_offset_y = item.position_offset_y + item.impulse_y
        if item.position_offset_y >= 0 then
            item.position_offset_y = 0
            should_destroy = true
        end
    end

    item.impulse_x = item.impulse_x + item.acceleration_x
    item.impulse_y = item.impulse_y + item.acceleration_y
    item.impulse_z = item.impulse_z + item.acceleration_z

    if item.update_position_and_check_world_collisions() then
        should_destroy = true
    end

    if should_destroy then
        item.is_active = false
        item.object_mode = 0
    end

    return false
end

Stationary Hitbox Or Freeze Field

Use this for an item that should keep its current world position and ignore gravity. It still runs world collision so the object remains synchronized with the engine.

return function()
    local item = game.this_item

    if item == nil then
        return true
    end

    -- This field is intentionally not moved by impulses.
    -- The entity can still have hitboxes in logic.json.
    item.impulse_x = 0
    item.impulse_y = 0
    item.impulse_z = 0
    item.acceleration_x = 0
    item.acceleration_y = 0
    item.acceleration_z = 0
    item.position_offset_y = 0

    item.inner_timer = item.inner_timer - 1
    if item.inner_timer <= 0 then
        item.is_active = false
        item.object_mode = 0
        return false
    end

    item.update_position_and_check_world_collisions()
    return false
end

Projectile Burst

Spawn many projectile angles from one animation frame. This is useful for fan shots, radial magic, or multi-shuriken bursts.

-- Each row is animation id, velocity X, velocity Y/depth. Built once.
local shots = {
    {0, 524, 0},
    {1, 511, -116},
    {2, 472, -227},
    {3, 409, -326},
    {4, 326, -409},
    {5, 227, -472},
    {6, 116, -511},
    {7, 0, -524}
}

return function()
    local character = game.this_character

    if character == nil then
        return true
    end

    if character.current_animation_frame == 20 then
        character.play_sound("My Pack", "burst", 2)

        for i = 1, #shots do
            local shot = shots[i]

            character.try_spawn_item(
                "My Pack",
                "Burst Projectile",
                shot[1],
                -1,
                -850,
                2000,
                shot[2],
                shot[3],
                60,
                4,
                2,
                "projectile-hit",
                "My Pack",
                0
            )
        end
    end

    return true
end

Persistent Area Hitbox

For a repeated-damage area, use an item for the visual/effect and a global or owner script for the repeated hit rule. This avoids relying on undocumented projectile fields.

return function()
    local item = game.this_item

    if item == nil then
        return true
    end

    -- Keep the field alive for custom global logic. The global hook can scan
    -- active enemies and apply damage when their positions overlap this item.
    item.custom_value_1 = 1

    item.inner_timer = item.inner_timer - 1
    if item.inner_timer <= 0 then
        item.is_active = false
        item.object_mode = 0
    end

    return false
end

Pair it with a pack-level post-update script:

return function()
    local character = game.this_character

    if character == nil or not character.is_enemy then
        return true
    end

    -- The actual area scan would loop over known area object IDs stored in
    -- custom values or use a known single spawned item ID. This compact example
    -- shows the damage-side rule only.
    if character.custom_value_8 > 0 then
        character.custom_value_8 = character.custom_value_8 - 1
        return true
    end

    -- A real script should first check distance to the area item.
    -- Use custom_value_8 as a per-enemy cooldown so the same field can hit
    -- repeatedly without damaging every frame.
    character.damage_taken = 2
    character.is_being_hit = true
    character.check_being_hit()
    character.custom_value_8 = 12

    return true
end

Spawn An Enemy Helper

The item layer also exposes try_spawn_enemy. Use it for minions, traps, or helper attacks.

return function()
    local character = game.this_character

    if character == nil then
        return true
    end

    if character.current_animation_frame == 30 then
        local helper_entity_id = 123456789

        character.try_spawn_enemy(
            "My Pack",
            helper_entity_id, -- replace with the helper enemy's entity type/id
            0,
            0,
            -1000,
            0
        )
    end

    return true
end

If your data uses entity names rather than numeric IDs, prefer SpawnEnemyEvent in logic events, which hashes entityName for you. The Lua helper is intentionally lower level and expects the entity type/id value.