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.