Engine Core

Coordinate system, main loop order, scene loading sequence, angle math, interrupts, and game time. The engine is written in C (compiled with SDCC for the Game Boy's SM83 CPU) with performance-critical interrupt handlers marked NONBANKED.

Coordinate System

GB Studio uses a 12.5 fixed-point coordinate system internally called subpixels. One pixel equals 32 subpixels. All actor positions (actor->pos.x, actor->pos.y) and camera coordinates (camera_x, camera_y) are stored as UINT16 values in subpixels.

Conversion Macros

Defined in the engine headers, these macros convert between the three coordinate spaces:

// Pixel ↔ Subpixel
#define PX_TO_SUBPX(a)    ((a) << 5)    // pixel × 32
#define SUBPX_TO_PX(a)    ((a) >> 5)    // pixel ÷ 32

// Tile ↔ Subpixel
#define TILE_TO_SUBPX(a)  ((a) << 8)    // tile × 256  (= tile × 8px × 32)
#define SUBPX_TO_TILE(a)  ((a) >> 8)    // tile ÷ 256

// Tile ↔ Pixel
#define TILE_TO_PX(a)     ((a) << 3)    // tile × 8
#define PX_TO_TILE(a)     ((a) >> 3)    // tile ÷ 8

Screen Constants

#define SCREEN_WIDTH       160    // pixels
#define SCREEN_HEIGHT      144    // pixels
#define SCREEN_WIDTH_HALF   80
#define SCREEN_HEIGHT_HALF  72

The visible screen is 20 × 18 tiles. The hardware background map is 32 × 32 tiles (256 × 256 pixels), which the scroll system wraps around.

Common Value Conversions

TilesPixelsSubpixelsNotes
18256One tile
216512Actor default height (8×16 mode)
5401280
10802560Half screen width
181444608Full screen height
201605120Full screen width
322568192Hardware BG map width/height

Usage Example

// Place actor at tile (5, 3) = pixel (40, 24) = subpixel (1280, 768)
actors[1].pos.x = TILE_TO_SUBPX(5);   // 1280
actors[1].pos.y = TILE_TO_SUBPX(3);   //  768

// Read actor pixel position
UINT8 px = SUBPX_TO_PX(actors[1].pos.x);  // 40

// Camera center in subpixels
camera_x = PX_TO_SUBPX(SCREEN_WIDTH_HALF);  // 2560

Main Loop Order

The main loop lives in core.c function process_VM. Every frame, the following functions execute in this exact order:

script_runner_update()          // GBVM bytecode interpreter
input_update()                  // read joypad state

// --- guarded by if (!VM_ISLOCKED()) ---
  events_update()               // joypad events (attach-script-to-input)
  state_update()                // scene-type update (e.g. elevator_update)
  timers_update()               // every 16th frame (game_time & 0x0F == 0)
  music_events_update()         // music event callbacks
// ---

toggle_shadow_OAM()             // swap double-buffered OAM
camera_update()                 // update camera position
scroll_update()                 // compute draw_scroll_x/y from camera
actors_update()                 // offscreen activation/deactivation
actors_render()                 // write active actors to shadow OAM
projectiles_update()            // move projectiles
projectiles_render()            // render projectiles to OAM
ui_update()                     // overlay window / text rendering
actors_handle_player_collision() // player collision checks
game_time++                     // increment frame counter
activate_shadow_OAM()           // apply shadow OAM to hardware
wait_vbl_done()                 // wait for next VBlank
VM_LOCK blocks state_update

state_update() is guarded by VM_ISLOCKED(). On Init scripts auto-generate VM_LOCK at start and VM_UNLOCK at end. An infinite loop in On Init prevents VM_UNLOCK from ever executing, permanently blocking state_update().

However, camera_update, scroll_update, actors_update, and actors_render run unconditionally every frame, regardless of lock state.

script_runner_update runs first

script_runner_update() and input_update() run unconditionally every frame, even when VM is locked. Actor updateScripts execute via script_runner_update() and are therefore never blocked by VM_LOCK.

Scene Loading Sequence

When a scene change occurs, the engine's exception handler runs the following sequence outside the main loop:

 1.  remove_LCD_ISRs()              // remove all LCD interrupt handlers
 2.  script_runner_init()           // reset all script contexts
 3.  load_scene()                   // load BG, actors, triggers; queue On Init
 4.  CRITICAL { add_LCD(...) }      // install ISR based on scene_LCD_type
 5.  if (!hide_sprites) SHOW_SPRITES
 6.  player_init()                  // reset player state
 7.  state_init()                   // call scene-type init (e.g. elevator_init)
 8.  toggle_shadow_OAM()
 9.  camera_update()
10.  scroll_repaint()               // full-screen tile repaint
11.  actors_update()                // activate/deactivate by position
12.  actors_render()                // write actors to shadow OAM
13.  activate_shadow_OAM()          // push to hardware
14.  if (fade_in) fade_in_modal()   // blocking fade-in
On Init timing

The On Init script only executes when the main loop resumes via script_runner_update(). This means state_init() (step 7) runs with default engine field values, not script-configured values. Engine field defaults from engine.json are available in state_init() — they are set during script_engine_init.s which runs before scene loading. Only values overridden by the On Init script require deferral to state_update().

Actor flash on scene load

actors_render() (step 12) runs before the On Init script can deactivate actors. Pinned/persistent actors are auto-activated during load_scene(). If your scene type needs to hide or reposition actors before they appear, set hide_sprites = TRUE in state_init() and clear it on the first state_update() call.

Game Time

game_time is a UINT16 that increments by 1 every frame at the end of the main loop. It is used for animation timing and periodic events throughout the engine.

Angle System (math.h)

The engine provides lookup-table-based trigonometry functions for angle calculations:

int8_t SIN(uint8_t angle) BANKED;
int8_t COS(uint8_t angle) BANKED;

Angle Convention

Angle Constants

#define ANGLE_0DEG    0     // up
#define ANGLE_45DEG  32     // up-right
#define ANGLE_90DEG  64     // right
#define ANGLE_180DEG 128    // down

Direction Mapping

AngleDegreesDirectionSIN()COS()
0Up0127
3245°Up-Right9090
6490°Right1270
96135°Down-Right90−90
128180°Down0−128
160225°Down-Left−90−90
192270°Left−1280
224315°Up-Left−9090

Example: Quarter-Sine Easing Curve

Sweep angle from 0 to 64 for a quarter-sine curve — gentle ease-in, strong ease-out:

// angle goes from 0 to 64 over the duration of the animation
UINT8 angle = (progress * 64) / total_frames;

// SIN(0)=0, SIN(64)=127 -- normalized progress with easing
INT8 eased = SIN(angle);

// Scale to your target range
UINT16 scroll_pos = (target_distance * (UINT16)eased) / 127;
Easing curves

SIN(0..64) gives a quarter-sine (gentle ease-in, strong ease-out). For a symmetric S-curve, use COS(0..128) mapped from 1.0 to 0.0 (inverted).

VBlank ISR

The VBlank interrupt service routine runs every frame during the vertical blanking period:

void VBL_isr(void) NONBANKED {
    if ((WY_REG = win_pos_y) < MENU_CLOSED_Y)
        ... SHOW_WIN;
    else
        ... HIDE_WIN;

    if (hide_sprites)
        HIDE_SPRITES;
    else
        SHOW_SPRITES;

    scroll_shadow_update();
}

Key Behaviors

Never use HIDE_SPRITES macro alone

The HIDE_SPRITES macro writes directly to LCDC, but VBL_isr overwrites LCDC every VBlank based on the hide_sprites variable. Always set hide_sprites = TRUE instead of (or in addition to) calling the macro.

LCD ISR System

The LCD interrupt is triggered at specific scanlines (via the LYC register) to enable mid-frame effects like parallax scrolling and split-screen rendering.

Standard ISRs

ISR FunctionEnum ValuePurpose
simple_LCD_isrLCD_simpleBasic scanline handling (overlay cut)
parallax_LCD_isrLCD_parallaxMulti-layer parallax scrolling (up to 3 layers)
fullscreen_LCD_isrLCD_fullscreenFull-screen rendering mode

The active ISR is selected by scene_LCD_type during scene loading (step 4 of the loading sequence). The engine installs the corresponding ISR via add_LCD() inside a CRITICAL section.

ISR Management Functions

// Remove all three standard ISRs (declared in interrupts.h, BANKED)
void remove_LCD_ISRs(void) BANKED;

// GBDK intrinsics for individual ISR management
remove_LCD(fn);    // remove a specific LCD ISR
add_LCD(fn);       // install a specific LCD ISR

Key Details

Installing a Custom ISR in state_init()

#include "interrupts.h"

extern UBYTE overlay_cut_scanline;

void my_scene_init(void) BANKED {
    // Remove standard ISRs installed by core.c during scene load
    remove_LCD_ISRs();

    // Disable parallax to prevent conflicts
    parallax_rows[0].scx = 0;
    parallax_rows[1].scx = 0;
    parallax_rows[2].scx = 0;
    scene_LCD_type = LCD_simple;

    // Install custom ISR
    CRITICAL {
        add_LCD(my_custom_LCD_isr);
    }

    // Hide sprites on first frame to prevent flash
    hide_sprites = TRUE;
}
Core.c installs ISR after load_scene

The scene loading sequence installs a standard ISR at step 4, before state_init() at step 7. If your plugin uses a custom ISR, you must call remove_LCD_ISRs() followed by add_LCD() in state_init() to replace it.

Hooking VBL_isr for Per-Frame Plugin Code

Plugin code in state_update() is blocked by VM_LOCK during dialog, menus, and On Init scripts. For effects that must run every single frame (sprite VRAM modifications, custom animations, audio sync), you need a VBL hook.

Why add_VBL() Does Not Work

GB Studio installs its own VBlank handler that bypasses GBDK's ISR dispatch chain. Calling add_VBL() from GBDK causes a kernel panic. This applies regardless of whether your callback uses WRAM or banked data — the issue is add_VBL() itself being incompatible with GB Studio's interrupt setup.

Solution: Override interrupts.c

Create a plugin that overrides engine/src/core/interrupts.c. Add your hook call at the end of VBL_isr, after all stock GB Studio VBlank work:

// Plugin's engine/src/core/interrupts.c override
// Copy the stock interrupts.c, then add your hook at the end of VBL_isr

#include "my_plugin.h"

extern UBYTE my_plugin_enabled;
extern void my_vbl_hook(void) BANKED;

void VBL_isr(void) NONBANKED {
    // ... all stock GB Studio VBL code (unchanged) ...

    // Plugin hook — runs every frame, even during VM_LOCK / dialog
    if (my_plugin_enabled) {
        my_vbl_hook();
    }
}

BANKED Calls from ISR Are Safe

The GBDK banked call trampoline (___sdcc_bcall_ehl) is stack-based and fully reentrant:

ldh a,(__current_bank) → push af → switch bank → rst 0x20 → pop af → restore bank

The SM83 CPU auto-disables interrupts on ISR entry (clears IME), so there is no race window. Calling BANKED functions from VBL_isr is safe.

FAR_CALL and SWITCH_ROM_MBC5 are NOT ISR-safe

FAR_CALL() macro and SWITCH_ROM_MBC5 are not ISR-safe. FAR_CALL() uses a non-reentrant global variable for bank tracking. SWITCH_ROM_MBC5 doesn't update the bank shadow variable, causing corruption when the trampoline restores the previous bank. Always use the standard BANKED calling convention from ISR context.

VRAM Access Inside VBL_isr

Code running inside VBL_isr executes during VBlank, so VRAM is always accessible — no wait_vram() or STAT checks needed. This makes VBL hooks ideal for:

// Inside your VBL hook — VRAM is guaranteed accessible
void my_vbl_hook(void) BANKED {
    if (animation_active) {
        // Direct VRAM write — safe during VBlank
        VRAM[tile_addr]     = new_low_byte;
        VRAM[tile_addr + 1] = new_high_byte;
    }
}
Keep VBL hooks short

You have approximately 1140 M-cycles (DMG) or 2280 M-cycles (CGB double-speed) during VBlank. Heavy processing risks overrunning into the next frame's rendering period.

const Data in Bank 255 Is Inaccessible from ISR

If your VBL hook function is BANKED (in bank 255, auto-assigned to a real bank), the correct ROM bank is active when your function executes. However, const arrays declared in a different bank 255 compilation unit may be in a different physical bank. During ISR execution, only one ROM bank is active at a time.

Solution: Store runtime data in static WRAM variables (always accessible from any bank context), not in const ROM arrays. Compute values at setup time and cache them in WRAM.

// WRONG — const data may be in a different ROM bank during ISR
const UINT8 lut[16] = { ... };  // in bank 255 → auto-assigned bank

void my_vbl_hook(void) BANKED {
    val = lut[idx];  // may read garbage if lut is in a different bank
}

// CORRECT — WRAM is always accessible
static UINT8 cached_values[16];  // in WRAM

void my_setup(void) BANKED {
    // Pre-compute and cache at setup time
    for (UINT8 i = 0; i < 16; i++) {
        cached_values[i] = compute(i);
    }
}

void my_vbl_hook(void) BANKED {
    val = cached_values[idx];  // always works
}