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);
}
}
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.
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;
}
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.
| Plugin | Overrides |
|---|---|
| SmoothFade | fade_manager.c, vm_palette.c |
| Flight | trigger.c |
| EditActorActiveIndex | actor.c |
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