Common Pitfalls

A catalog of proven mistakes and their solutions, collected from real plugin development on GB Studio 4.2, spanning C engine code, JavaScript event plugins, and JSON scene data. Each entry explains the problem, why it happens, and how to fix it.

1. Subpixel Math Errors

Problem

Positions are wrong by a factor of 2 — actors appear at half the expected coordinates or move at half speed.

Why It Happens

GB Studio uses a 12.5 fixed-point coordinate system (5 fractional bits), but it is easy to assume 12.4 fixed-point (4 fractional bits) and use << 4 instead of << 5.

Solution

Always use << 5 for pixel-to-subpixel and >> 5 for subpixel-to-pixel. Use the engine macros when available.

// WRONG - off by factor of 2
UINT16 subpx = pixels << 4;

// CORRECT - 12.5 fixed-point
UINT16 subpx = pixels << 5;       // PX_TO_SUBPX
UINT16 px    = subpx >> 5;        // SUBPX_TO_PX

// Full conversion reference:
// PX_TO_SUBPX(a)   = (a) << 5
// SUBPX_TO_PX(a)   = (a) >> 5
// TILE_TO_SUBPX(a) = (a) << 8
// SUBPX_TO_TILE(a) = (a) >> 8
// PX_TO_TILE(a)    = (a) >> 3
// TILE_TO_PX(a)    = (a) << 3

2. hide_sprites Variable vs HIDE_SPRITES Macro

Problem

Sprites reappear immediately after calling HIDE_SPRITES, even though you just hid them.

Why It Happens

The VBL_isr interrupt handler checks the hide_sprites variable every VBlank and writes to the LCDC register accordingly. Any direct LCDC write via the HIDE_SPRITES macro is overridden on the very next VBlank.

// Inside VBL_isr (runs every frame):
void VBL_isr(void) NONBANKED {
    // ...
    if (hide_sprites) HIDE_SPRITES; else SHOW_SPRITES;
    // ...
}

Solution

Set the hide_sprites variable instead of calling the macro directly.

// WRONG - overridden next VBlank!
HIDE_SPRITES;

// CORRECT - persists across frames
hide_sprites = TRUE;

// Later, to show sprites again:
hide_sprites = FALSE;

3. Actor Flash on Scene Load

Problem

Actors briefly appear at wrong positions (typically 0,0) for one frame when a scene loads, then snap to their correct positions.

Why It Happens

During scene loading, actors_render() runs in step 12 of the exception handler — before the On Init script has a chance to reposition or deactivate actors. Pinned and persistent actors are auto-activated during load_scene(), so they are immediately visible.

Solution

Set hide_sprites = TRUE in state_init() to suppress rendering during the first frame, then clear it on the first state_update() call.

void my_scene_init(void) BANKED {
    hide_sprites = TRUE;  // Prevent actor flash
}

static UBYTE first_frame = 1;

void my_scene_update(void) BANKED {
    if (first_frame) {
        first_frame = 0;
        hide_sprites = FALSE;  // Now safe to show
    }
    // ... rest of update logic
}

4. ISR Installation Conflict

Problem

Your custom LCD ISR does not work, produces glitches, or is ignored entirely.

Why It Happens

After load_scene(), core.c installs a standard LCD ISR based on the scene_LCD_type value. If your plugin installs a custom ISR before this point, it gets overwritten. If parallax rows are still configured, the parallax ISR may also interfere.

Solution

In state_init(), remove the standard ISRs, clear parallax rows, and install your custom ISR inside a CRITICAL block.

#include "interrupts.h"
#include <string.h>

void my_LCD_isr(void) NONBANKED;  // Forward declare

void my_scene_init(void) BANKED {
    // 1. Remove standard ISR installed by core.c
    remove_LCD_ISRs();

    // 2. Clear parallax to prevent conflicts
    memset(parallax_rows, 0, sizeof(parallax_rows));
    scene_LCD_type = LCD_simple;

    // 3. Install custom ISR
    CRITICAL {
        add_LCD(my_LCD_isr);
    }
}
Timing Matters

state_init() runs at step 7 of the scene loading sequence, after core.c installs the standard ISR at step 4. This is why removing it in state_init() works — your removal happens later.

5. Engine Fields Not Available When Expected

Problem

Engine field values read in state_init() are 0 or have their default values instead of the values set by the On Init script.

Why It Happens

Engine field defaults from engine.json are set during script_engine_init.s, which runs before scene loading. However, the On Init script — which may override these defaults with ENGINE_FIELD_SET events — does not execute until the main loop resumes via script_runner_update(). That happens after state_init() has already run.

Solution

If you only use engine.json default values, reading in state_init() is safe. If the On Init script overrides values, defer reading to the first state_update() call.

// Safe: reading engine.json defaults only
void my_scene_init(void) BANKED {
    speed = my_engine_field;  // OK if using default value
}

// Needed for script-overridden values:
static UBYTE initialized = 0;

void my_scene_update(void) BANKED {
    if (!initialized) {
        initialized = 1;
        speed = my_engine_field;  // Now has script-set value
    }
}

6. VM_LOCK Blocks state_update Forever

Problem

state_update() never runs. The scene loads, the On Init script starts, but the plugin's update function is never called.

Why It Happens

The main loop guards state_update() with if (!VM_ISLOCKED()). On Init scripts automatically generate VM_LOCK at the start and VM_UNLOCK at the end. If the On Init script contains an infinite loop (LOOP_WHILE with a condition that never becomes false), VM_UNLOCK never executes, and state_update() is permanently blocked.

// Main loop (simplified from core.c):
if (!VM_ISLOCKED()) {
    events_update();
    state_update();      // <-- YOUR plugin update
    timers_update();
}

Solution

Never use infinite loops in the On Init script if the scene type needs state_update() to run. Let On Init end normally so VM_UNLOCK fires. If you need per-frame script logic, use actor updateScripts instead — they run via script_runner_update(), which executes unconditionally.

Actor updateScripts Are NOT Blocked

While state_update() is blocked by VM_LOCK, actor updateScripts continue running normally. They execute through script_runner_update(), which is outside the VM_ISLOCKED() guard. Use this as an alternative for per-frame script logic.

7. Input Edge Detection

Problem

An action fires every frame while a button is held, instead of only once on the first press.

Why It Happens

Using joy & J_A checks if the button is currently held (true every frame). This is the “held” state, not edge detection.

Solution

Use the INPUT_*_PRESSED macros for single-frame edge detection. These use joy_pressed internally.

// WRONG - fires every frame while held
if (joy & J_A) {
    fire_projectile();
}

// CORRECT - fires only on first frame of press
if (INPUT_A_PRESSED) {
    fire_projectile();
}

// For continuous held input (movement), use INPUT_LEFT, INPUT_RIGHT, etc.
if (INPUT_LEFT) {
    player->pos.x -= speed;
}
Do Not Roll Your Own Edge Detection

Never write (joy & J_UP) && !(last_joy & J_UP). The symbol joy is a macro that expands to frame_joy, not a simple variable. Manual edge detection against it will not behave as expected. Always use the provided INPUT_*_PRESSED macros.

8. Actor Index Off-by-One

Problem

Accessing the wrong actor from C code — the player moves when an NPC should, or the wrong NPC is affected.

Why It Happens

actors[0] is always the player. When load_scene() loads scene actors, it copies them into actors + 1 via memcpy. A scene actor with _index N in the .gbsres file ends up at actors[N + 1].

Solution

Always add 1 to the scene index when accessing actors from C code.

// Player is always actors[0]
actor_t *player = &actors[0];  // or use PLAYER macro

// Scene actor with _index 0 is at actors[1]
actor_t *npc = &actors[1];

// Scene actor with _index 2 is at actors[3]
actor_t *other = &actors[3];

// General rule:
// Scene _index N  ->  actors[N + 1]

9. overlay_cut_scanline Not Declared

Problem

Compiler error: undeclared identifier 'overlay_cut_scanline'.

Why It Happens

The variable overlay_cut_scanline is defined in interrupts.c but is not declared extern in interrupts.h. Including the header does not make the variable visible.

Solution

Add a manual extern declaration in your source file.

// Add this at the top of your .c file
extern UBYTE overlay_cut_scanline;

10. scroll_load_row Not Declared

Problem

Compiler error or linker warning when calling scroll_load_row().

Why It Happens

The function is defined as NONBANKED in scroll.c but is not declared in scroll.h. It is an internal function that the engine uses but does not expose through headers.

Solution

Add a manual forward declaration in your source file.

// Add this at the top of your .c file
void scroll_load_row(UBYTE x, UBYTE y);

11. Sprite OAM Timing Mismatch

Problem

Sprites appear offset from the background by one frame, creating a “jitter” or “lag” effect during scrolling.

Why It Happens

At the start of state_update(), draw_scroll_y still holds the previous frame's scroll value. This matches the sprite OAM that was DMA'd during the last VBlank. When scroll_update() runs later in the frame, it overwrites draw_scroll_y with the new value. If your custom ISR reads draw_scroll_y after this point, it uses the new scroll position while sprites are still rendered at the old position.

Solution

Buffer draw_scroll_y at the start of state_update(), before scroll_update() overwrites it. Use the buffered value in your ISR.

// Global for ISR access
UBYTE my_isr_scroll_y;

void my_scene_update(void) BANKED {
    // Buffer BEFORE scroll_update overwrites it
    my_isr_scroll_y = draw_scroll_y;

    // ... rest of update logic
    // scroll_update() will change draw_scroll_y, but
    // our ISR uses my_isr_scroll_y instead
}

void my_LCD_isr(void) NONBANKED {
    // Use buffered value to match sprite OAM timing
    SCY_REG = my_isr_scroll_y;
}

12. Controlling Actor Frames from C

Problem

Setting actor->frame has no visible effect, or the frame resets immediately to the animation sequence.

Why It Happens

The engine's animation system auto-advances actor frames based on anim_tick. Every tick, actors_render() increments the frame counter. Your manual frame assignment is overwritten before it is ever displayed.

Solution

Set anim_tick = 255 (ANIM_PAUSED) to stop auto-animation, then set frame directly.

// Stop engine auto-animation
actor->anim_tick = 255;   // ANIM_PAUSED

// Now direct frame control works
actor->frame = 3;         // Stays on frame 3

// To resume auto-animation later:
actor->anim_tick = 0;     // Engine takes over again

13. Parallax Conflicts in Custom ISR

Problem

Visual glitches, wrong scroll positions, or screen tearing when using a custom LCD ISR.

Why It Happens

The standard parallax ISR may still be active alongside your custom ISR. GB Studio supports up to 3 parallax scroll layers via parallax_rows[3], and the parallax LCD ISR can fire on the same scanlines as your custom ISR, causing conflicting register writes.

Solution

Clear all parallax rows and set the LCD type to simple before installing your custom ISR.

#include <string.h>

// Clear all parallax configuration
memset(parallax_rows, 0, sizeof(parallax_rows));
scene_LCD_type = LCD_simple;

14. Core File Override Conflicts

Problem

Installing a new plugin breaks an existing plugin. Features that previously worked stop functioning.

Why It Happens

Multiple plugins can override the same core engine file. For example, if Plugin A overrides actor.c with custom actor management and Plugin B also overrides actor.c with rendering changes, only one version survives. GB Studio does not merge overrides.

Solution

Manually merge conflicting overrides into a single file. Keep track of which plugins override which core files.

PluginOverrides
SmoothFadefade_manager.c, vm_palette.c
Flighttrigger.c
EditActorActiveIndexactor.c
Prevention

Before adding a new plugin that overrides core files, check if any existing plugin already overrides the same files. If so, you must manually merge both sets of changes into a single override file.

15. _declareLocal Returns a String

Problem

Event plugin compile error when trying to use _declareLocal result as an object or calling methods on it.

Why It Happens

_declareLocal returns a string like .LOCAL_TMP0_POS, not an object with properties. It represents a GBVM stack-relative symbol name.

Solution

Use the returned string directly in template literals for assembly references.

// _declareLocal(name, size, isTemporary)
const tmp = _declareLocal("pos", 3, true);
// tmp is ".LOCAL_TMP0_POS" -- a string

// Use in appendRaw with template literals
appendRaw(`VM_SET_CONST ${tmp}, 42`);      // Correct

// For struct field access (multi-word locals):
// Offset +0 = first word, +1 = second word, etc.
appendRaw(`VM_SET_CONST ^/(${tmp} + 0)/, 10`);  // First word
appendRaw(`VM_SET_CONST ^/(${tmp} + 1)/, 20`);  // Second word

16. Mixing Tracked and Untracked Stack Ops

Problem

Event plugin fails with a “stack not neutral” compiler error, even though the assembly logic is correct.

Why It Happens

The GB Studio compiler tracks stack depth internally. The _rpn().stop() helper adjusts the tracked stackPtr, but raw VM_RPN pushes via appendRaw are invisible to the tracker. The compiler sees a mismatch between its expected stack depth and the actual depth.

Solution

Use one approach per section — either all tracked helpers (_rpn, _declareLocal) or all appendRaw. Do not interleave them for stack-affecting operations.

// WRONG - mixing tracked and untracked pushes
_rpn().int8(5).stop();           // Tracked push
appendRaw(`VM_RPN
  .R_INT8 10
  .R_OPERATOR .ADD
  .R_STOP`);                     // Untracked push - tracker is confused!

// CORRECT - all tracked
_rpn().int8(5).int8(10).operator(".ADD").stop();

// ALSO CORRECT - all untracked (when tracked stackPtr == 0)
appendRaw(`VM_RPN
  .R_INT8 5
  .R_INT8 10
  .R_OPERATOR .ADD
  .R_STOP`);
appendRaw(`VM_POP 1`);           // Clean up manually