Actor System

Actor System Overview

GB Studio manages up to 21 actor slots (MAX_ACTORS), of which 12 can be active simultaneously (MAX_ACTORS_ACTIVE). The actor system is implemented in C — the actor_t struct, flag definitions, and linked-list management are all C code. Active actors are rendered, updated, and participate in collisions; inactive actors exist in memory but are dormant.

  • actors[] — global array of all actor slots.
  • actors[0] is always the player (accessed via the PLAYER macro).
  • Active actors are linked on a doubly-linked list headed by actors_active_head / actors_active_tail.
  • Inactive actors sit on a singly-linked list headed by actors_inactive_head.
Capacity: 21 total slots, 12 active at once. The hardware OAM holds 40 sprite entries, but each actor may consume multiple 8×16 hardware sprites (metasprites), so the practical limit is lower.

Actor Data Structure (actor_t)

Each actor slot is an actor_t struct. Key fields:

FieldTypeDescription
posupoint16_tPosition in subpixels (12.5 fixed-point: pixel × 32)
dirdirection_eCurrent facing direction (DIR_DOWN, DIR_UP, DIR_LEFT, DIR_RIGHT)
boundsrect16_tCollision bounding box (left, right, top, bottom offsets)
base_tileUINT8First tile index in VRAM for this actor's sprite sheet
frameUINT8Current animation frame index
frame_startUINT8First frame of the current animation range
frame_endUINT8Last frame of the current animation range
anim_tickUINT8Animation timer; decrements each frame. 255 = ANIM_PAUSED
animations[8]UINT8[8]Animation state table — maps state index to frame range
spritefar_ptr_tFar pointer to the sprite sheet data (bank + address)
move_speedUINT8Movement speed in subpixels per frame
collision_groupUINT8Collision group bitmask for filtering collisions
next / prevactor_t*Linked list pointers (active or inactive list)
scriptfar_ptr_tInteract script (runs when player interacts with this actor)
script_updatefar_ptr_tUpdate script (runs every frame via script_runner_update)

Actor Flags

Actor behavior is controlled by a flags bitmask on each actor_t:

FlagValuePurpose
ACTOR_FLAG_PINNED0x01 Fixed to screen coordinates — does not scroll with the camera. Pinned actors are auto-activated during load_scene.
ACTOR_FLAG_HIDDEN0x02 Not rendered by actors_render, but remains on the active list. Still receives update scripts and participates in collisions.
ACTOR_FLAG_ANIM_NOLOOP0x04 Animation plays once and stops on the last frame instead of looping.
ACTOR_FLAG_COLLISION0x08 Actor participates in collision detection with the player.
ACTOR_FLAG_PERSISTENT0x10 Survives scene transitions — not removed when a new scene loads. Also auto-activated during load_scene.
ACTOR_FLAG_ACTIVE0x20 Currently on the active linked list. Set/cleared by activation/deactivation routines.
ACTOR_FLAG_DISABLED0x40 Prevents re-activation by the streaming system. Set by VM_ACTOR_DEACTIVATE. Only VM_ACTOR_ACTIVATE clears it.
ACTOR_FLAG_INTERRUPT0x80 Scripts running on this actor can be interrupted by new interactions.

Actor Indexing

Actor indices in C code are off by one from scene file indices:

  • actors[0] = player (always).
  • Scene actors with _index N in .gbsres files load into actors[N + 1].
  • load_scene copies scene actors into actors + 1 via memcpy.
Always add 1 to the scene _index when accessing actors from C code.
// Scene file defines actor with _index 2
// In C code, access it as:
actor_t *npc = &actors[2 + 1];  // actors[3]

// Player is always actors[0]
actor_t *player = &actors[0];   // or use PLAYER macro
UINT16 px = PLAYER.pos.x;

Animation System

Each actor has 8 animation states (down, up, left, right × idle/moving). The engine auto-advances frames based on anim_tick.

How Animation Works

  1. actor->animations[state] maps a state index to a frame range (frame_start / frame_end).
  2. Each frame, anim_tick decrements. When it reaches zero, frame advances.
  3. If frame > frame_end, it wraps to frame_start (unless ANIM_NOLOOP is set).

Manual Frame Control

To control frames directly from C code (e.g., in a scene type plugin), disable auto-animation:

// Stop the engine from auto-advancing frames
actor_t *actor = &actors[1];
actor->anim_tick = 255;  // ANIM_PAUSED

// Now set frames directly
actor->frame = 3;  // show frame 3
ANIM_PAUSED pattern: Set anim_tick = 255 to freeze animation, then write to actor->frame directly. This is the standard approach for scene type plugins that need precise frame control (used by Flight, DrinkingGame, AirHockey, etc.).

Animation from GBVM

; Set specific frame (pauses auto-animation)
VM_ACTOR_SET_ANIM_FRAME  .ARG0   ; struct: [actorId, frame]
VM_ACTOR_SET_ANIM_TICK   .ARG0, 255  ; prevent auto-advance

; Change animation set/state
VM_ACTOR_SET_ANIM_SET    .ARG0   ; switch to different animation set

Streaming Activation

The engine dynamically activates and deactivates actors based on their distance from the camera to stay within the 12-actor active limit.

Lifecycle

  1. Scene load (load_scene): Pinned and persistent actors are auto-activated. All others start on the inactive list.
  2. Each frame (actors_update): Inactive actors within the activation zone (on-screen + padding) are moved to the active list. Active actors that move outside the zone are deactivated.
  3. Script deactivation (VM_ACTOR_DEACTIVATE): Sets ACTOR_FLAG_DISABLED and moves the actor to the inactive list. The streaming system will not re-activate a disabled actor.
  4. Script re-activation (VM_ACTOR_ACTIVATE): Clears ACTOR_FLAG_DISABLED and moves the actor back to the active list. Required after moving an unpinned actor off-screen.
Moving actors off-screen: If you move an unpinned actor off-screen (e.g., via VM_ACTOR_SET_POS), the streaming system will deactivate it. Use VM_ACTOR_ACTIVATE after repositioning to force it back onto the active list if needed.

Actor Rendering

actors_render() iterates the active linked list and writes each actor's metasprite data to shadow OAM.

  • Only actors on the active list are rendered.
  • Actors with ACTOR_FLAG_HIDDEN or ACTOR_FLAG_DISABLED are skipped.
  • Output goes to shadow OAM (double-buffered: shadow_OAM2[40]).
  • The hardware OAM holds 40 sprite entries (8×16 mode). Large actors consume multiple entries.
  • The metasprite system splits actor sprite sheets into individual 8×16 hardware sprites.
Actor flash on scene load: actors_render() runs during the scene loading exception handler — before the On Init script executes. Pinned actors are auto-activated during load_scene and will be visible for one frame. To prevent this, set hide_sprites = TRUE in state_init() and clear it on the first state_update() call.

Common Actor Operations from C

Getting / Setting Position

actor_t *actor = &actors[1];

// Read position (subpixels)
UINT16 x_subpx = actor->pos.x;
UINT16 y_subpx = actor->pos.y;

// Convert to pixels
UINT16 x_px = SUBPX_TO_PX(actor->pos.x);  // pos.x >> 5
UINT16 y_px = SUBPX_TO_PX(actor->pos.y);

// Set position (convert pixels to subpixels)
actor->pos.x = PX_TO_SUBPX(80);  // 80 << 5 = 2560
actor->pos.y = PX_TO_SUBPX(64);

Changing Animation State

// Let the engine handle animation (auto-advance)
actor_set_anim(actor, ANIM_STATE_MOVING);

// Manual frame control
actor->anim_tick = 255;  // pause auto-animation
actor->frame = desired_frame;

Hiding / Showing Actors

// Hide (still active, still runs scripts)
actor->flags |= ACTOR_FLAG_HIDDEN;

// Show
actor->flags &= ~ACTOR_FLAG_HIDDEN;

Checking Flags

if (actor->flags & ACTOR_FLAG_PINNED) {
    // Actor is pinned to screen
}

if (actor->flags & ACTOR_FLAG_ACTIVE) {
    // Actor is on the active list
}

Moving Actors

// Direct position set (teleport)
actor->pos.x = PX_TO_SUBPX(100);
actor->pos.y = PX_TO_SUBPX(50);

// Incremental movement (per-frame in state_update)
actor->pos.x += actor->move_speed;

// Direction-based movement
switch (actor->dir) {
    case DIR_UP:    actor->pos.y -= actor->move_speed; break;
    case DIR_DOWN:  actor->pos.y += actor->move_speed; break;
    case DIR_LEFT:  actor->pos.x -= actor->move_speed; break;
    case DIR_RIGHT: actor->pos.x += actor->move_speed; break;
}

GBVM Actor Commands

These are the GBVM opcodes for manipulating actors from scripts. Many use a pseudo-struct pattern where arguments are packed into consecutive stack slots.

OpcodeStack LayoutDescription
VM_ACTOR_SET_POS [actorId, X, Y] (3 words) Set actor position. X and Y are in subpixels.
VM_ACTOR_GET_POS [actorId, X, Y] (3 words) Read actor position into the struct slots.
VM_ACTOR_MOVE_TO Varies (INIT + XY steps) Start a scripted move. Uses VM_ACTOR_MOVE_TO_INIT then VM_ACTOR_MOVE_TO_XY.
VM_ACTOR_SET_ANIM_FRAME [actorId, frame] (2 words) Set the current animation frame. Pair with VM_ACTOR_SET_ANIM_TICK to prevent auto-advance.
VM_ACTOR_GET_ANIM_FRAME [actorId, frame] (2 words) Read the current animation frame into the struct.
VM_ACTOR_SET_ANIM_TICK actorVar, tick Set animation tick speed. Use 255 to pause animation.
VM_ACTOR_SET_ANIM_SET actorVar, animSet Switch to a different animation set (sprite state).
VM_ACTOR_ACTIVATE actorVar Re-activate a deactivated actor. Clears ACTOR_FLAG_DISABLED.
VM_ACTOR_DEACTIVATE actorVar Deactivate actor and set ACTOR_FLAG_DISABLED to prevent streaming re-activation.
VM_ACTOR_SET_FLAGS actorVar, flags, mask Set or clear specific actor flags using a bitmask.

Stack Struct Pattern

Several GBVM actor commands use a pseudo-struct — consecutive stack words that the opcode reads/writes as a group. The base variable points to the first word.

; Position struct: 3 words at consecutive stack slots
;   offset +0 = actorId
;   offset +1 = X (subpixels)
;   offset +2 = Y (subpixels)

; In event plugin JS:
const posStruct = helper._declareLocal("pos", 3, true);
helper.setActorId("actorId", "pos");        // writes actorId at +0
helper._addCmd("VM_ACTOR_GET_POS", posStruct);  // fills +1 (X) and +2 (Y)

; Access individual fields in appendRaw:
;   ^/(${posStruct} + 0)/  = actorId
;   ^/(${posStruct} + 1)/  = X
;   ^/(${posStruct} + 2)/  = Y
; Frame struct: 2 words
;   offset +0 = actorId
;   offset +1 = frame

const frameStruct = helper._declareLocal("frame", 2, true);
helper.setActorId("actorId", "frame");
helper._addCmd("VM_ACTOR_SET_ANIM_FRAME", frameStruct);

; After setting frame, pause auto-animation:
helper._addCmd("VM_ACTOR_SET_ANIM_TICK", frameStruct, "255");