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.
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.
-- 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) -> successReserve capacity slots for sprites acquired with this
typeTag string. Must be called once before the first
sprite_acquire for that tag.
sprite_acquire(typeTag) -> handleGet a free handle from the pool registered for typeTag.
Returns 0 if the pool is exhausted (every slot currently
in use).
sprite_release(handle) -> successReturn 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?) -> successLoad 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) -> successSwitch 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) -> successChange 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) -> sequenceIdsprite_set_visible(handle, visible)
sprite_get_visible(handle) -> visible
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 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
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)
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
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