Sprites

Implemented in src/lua/luaspriteapi.cpp.

Backed by Scarab::Engine::SpritePool — a fixed-capacity pool of sprite slots, not a dynamically-growing list. Every sprite in the game (enemies, bullets, the player ship) is acquired from this same pool.

Handles

sprite_acquire returns an opaque integer handle, not an object. 0 always means "invalid" (INVALID_SPRITE_HANDLE) — check for it after sprite_acquire in case the pool for that type is exhausted. A handle from a released slot doesn't silently alias a new sprite that reuses the same slot — every operation on a stale handle simply fails (returns false/nil) instead of touching the wrong sprite, since each slot's internal generation counter is bumped on release.

The full lifecycle

-- 1. Register a pool for this sprite type, once, with a fixed capacity
pool_register_type("enemy_satellite", 50)

-- 2. Acquire a handle
local handle = sprite_acquire("enemy_satellite")
if handle == 0 then
  return  -- pool exhausted
end

-- 3. Configure at least one texture sequence (sequence 0 is the convention
--    for "main" appearance; sequence 1 is commonly "explosion", but any
--    sequence id is valid - it's just an index you choose)
sprite_configure_texture(handle, 0, BASE_PATH .. "sprites/satellite-ship.png",
                          3, 0, TEXTURE_ANIMATION_MODE_AUTOMATIC_CIRCULAR, 150)
sprite_set_active_sequence(handle, 0)

-- 4. Position it and make it visible on a Tiled layer
sprite_set_pos(handle, 100, 0)
sprite_add_to_layer(handle, LAYER_ENEMIES_SHIPS)

-- ... later, on death/despawn ...

-- 5. Remove from the layer BEFORE releasing - the pool has no idea which
--    layer(s) a handle was added to, so skipping this step leaves a
--    dangling collider that keeps firing collision callbacks against a
--    handle that no longer resolves to anything.
sprite_remove_from_layer(handle, LAYER_ENEMIES_SHIPS)
sprite_release(handle)

pool_register_type(typeTag, capacity) -> success

Reserve capacity slots for sprites acquired with this typeTag string. Must be called once before the first sprite_acquire for that tag.

sprite_acquire(typeTag) -> handle

Get a free handle from the pool registered for typeTag. Returns 0 if the pool is exhausted (every slot currently in use).

sprite_release(handle) -> success

Return a handle to its pool, making the slot available for a future sprite_acquire. Call sprite_remove_from_layer first if the sprite was ever added to a layer (see the gotcha above).

sprite_configure_texture(handle, sequenceId, path, framesByTexture, activeTileIndex, animationMode, delayMilli?) -> success

Load a texture file and configure it as animation sequenceId on handle. path points at a texture sheet containing framesByTexture equal-width frames laid out horizontally; activeTileIndex is which frame to start on. delayMilli (optional, defaults to -1) is the time, in milliseconds, between animation steps of this sequence. -1 means "no animation": the frame is held forever, whatever animationMode says — a sprite configured without a delay shows its activeTileIndex frame and never steps. Pass a real delay (say 100) for any sprite whose frames should cycle: AUTOMATIC_CIRCULAR/AUTOMATIC_RIGHT_LEFT step about once per delayMilli. Configuring the same sequence again (a recycled pool slot) reloads the texture and applies the new delayMilli; the sequence keeps holding exactly one texture.

Animation mode constants:

Constant Behavior
TEXTURE_ANIMATION_MODE_MANUAL No automatic frame advance — you control the active tile yourself
TEXTURE_ANIMATION_MODE_AUTOMATIC_CIRCULAR Cycles through frames automatically, wrapping around
TEXTURE_ANIMATION_MODE_AUTOMATIC_RIGHT_LEFT Cycles automatically, bouncing back and forth
TEXTURE_ANIMATION_MODE_ANIMATE_LEFT / _RIGHT / _CENTER Runtime-switchable directional animation — the player ship uses these to bank left/right/center in response to input (see sprite_set_animation_mode below)
-- a 5-frame strip, starting on frame 2, auto-animating
sprite_configure_texture(handle, 0, path, 5, 2, TEXTURE_ANIMATION_MODE_ANIMATE_CENTER, 100)

sprite_set_active_sequence(handle, sequenceId) -> success

Switch which configured sequence is currently displayed/collided against (also syncs the sprite's bounding box to that sequence's size — important since a "main" and an "explosion" sequence are often different sizes).

Gotcha: this unconditionally resets the target sequence's own animation state, even if it's already the active sequence — confirmed via sunlight's own source (Sprite::SetActiveTextureSequence, src/sprite/sprite.cpp) unconditionally calling Reset() on the newly-selected sequence's TextureCanvas, which snaps it back to it's own starting tile every time. Calling this every frame regardless of whether the sequence actually changed (found live building Options/satellites' pose-sync, 2026-08-19 — see caravellius/src/core/options.lua's own header comment) silently freezes any AUTOMATIC_CIRCULAR/AUTOMATIC_RIGHT_LEFT animation on that sequence at it's first frame forever, since the reset undoes each frame's own advance before it's ever drawn. Always track the last sequence you actually set and only call this again when it genuinely changes:

if sat.lastLeanIndex ~= leanIndex then
  sprite_set_active_sequence(sat.handle, leanIndex)
  sat.lastLeanIndex = leanIndex
end

Pattern: pose-following without a new engine primitive. Nothing exposes a sequence's own currently animating tile back to Lua (sprite_get_active_sequence returns which sequence is active, not the current tile within it) — so a sprite that needs to track another sprite's real-time animated pose (e.g. Options' satellites echoing the player ship's own left/center/right lean) can't just read that pose back from C++. The working pattern, built for exactly this case: give the follower one texture sequence per pose the leader can be in (sprite_configure_texture called once per pose, sequence id == pose index), then have Lua independently recompute which pose the leader is currently in (mirroring whatever discrete input/state drives the leader's own animation, ticked at the leader's own real animation_delay) and drive the follower's sprite_set_active_sequence from that — see player.lua's update_lean_index/Player.get_lean_index() and options.lua's pose-sync loop for a full worked example. Cheaper and simpler than adding a C++ read-back primitive for a purely cosmetic need.

sprite_set_active_sequence(handle, 1)  -- switch to the explosion sequence

sprite_set_animation_mode(handle, sequenceId, mode) -> success

Change a sequence's animation mode after it's already configured — for sprites that switch modes at runtime (e.g. the player ship banking left/right based on input). Enemies that only ever set their mode once typically just pass the final mode to sprite_configure_texture and never call this.

sprite_set_animation_mode(handle, 0, TEXTURE_ANIMATION_MODE_ANIMATE_LEFT)

sprite_get_active_sequence(handle) -> sequenceId

Visibility

sprite_set_visible(handle, visible)
sprite_get_visible(handle) -> visible

World space

Choose what the sprite's position (sprite_set_pos) means. Off by default: the position is relative to the view that draws it — measured from the view's own top-left, scaled by its zoom, and ignoring its camera — which is what a HUD or a single-view game wants. With world_space on, the position is a map position in pixels: every view draws the sprite where it draws the map at that position, so one sprite is correct in the main view, in a minimap and in a close-up, whatever their zoom and camera. It applies to every texture sequence of the sprite, including ones configured later; sprite_release turns it back off, so a recycled handle never inherits it.

Collisions: a collider uses its sprite's position as it is, so a world-space sprite collides in map coordinates and a screen-relative one does not — do not put both kinds in one collision rule. Games that never call this are unchanged. sprite_get_world_space reads it back (false for an invalid handle).

local enemy = sprite_acquire( "enemy" )
sprite_configure_texture( enemy, 0, ... )
sprite_set_world_space( enemy, true )
sprite_set_pos( enemy, 640, 320 )   -- a MAP position: shown right in every view

Position and size

Position is top-left anchored, not center-anchored — x, y is the sprite's top-left corner, matching both the render clip rect and the AABB collision math. pos - height/2 means "above the sprite's own top edge," not "above center."

sprite_get_pos(handle) -> x, y
sprite_set_pos(handle, x, y)
sprite_get_size(handle) -> width, height

Collision inset

Shrinks the rectangle used for this sprite's own side of a collision test, relative to it's full render size — useful for a non-rectangular sprite (a ship hull tapered at bow/stern, a round enemy) whose actual art doesn't fill it's whole bounding box, so the default full-size hitbox reads as unfair ("that clearly missed"). Every argument is a fraction (0.0-1.0) of the sprite's own current width/height, e.g. sprite_set_collision_inset(handle, 0.15, 0.10, 0.15, 0.10) shrinks 15% off each side and 10% off top/bottom. Recomputed from the sprite's current size on every collision check rather than cached as pixels, so it stays correct even if the active sequence later changes size. Defaults to 0 on every side (today's full-size behavior) until called — only affects this sprite's own side of the test, not how other sprites collide against each other. Only shrinks the collision rectangle, never the drawn sprite or it's tracked position.

sprite_set_collision_inset(handle, leftPct, topPct, rightPct, bottomPct)

Layers

Adding a sprite to a layer is also what registers it for collision detection on that layer (see collision.md) — a sprite never added to any layer never collides with anything, regardless of collision_add_rule.

sprite_add_to_layer(handle, layerId) -> success
sprite_remove_from_layer(handle, layerId) -> success

Animation modes

The animationMode argument sprite_configure_texture/ sprite_set_animation_mode take — see either primitive's own doc for the full behavior of each mode.

TEXTURE_ANIMATION_MODE_MANUAL, TEXTURE_ANIMATION_MODE_AUTOMATIC_CIRCULAR, TEXTURE_ANIMATION_MODE_AUTOMATIC_RIGHT_LEFT, TEXTURE_ANIMATION_MODE_ANIMATE_LEFT, TEXTURE_ANIMATION_MODE_ANIMATE_RIGHT, TEXTURE_ANIMATION_MODE_ANIMATE_CENTER