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
| Tiles | Pixels | Subpixels | Notes |
|---|---|---|---|
| 1 | 8 | 256 | One tile |
| 2 | 16 | 512 | Actor default height (8×16 mode) |
| 5 | 40 | 1280 | — |
| 10 | 80 | 2560 | Half screen width |
| 18 | 144 | 4608 | Full screen height |
| 20 | 160 | 5120 | Full screen width |
| 32 | 256 | 8192 | Hardware 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
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() 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
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().
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.
- Timer tick rate: Timers fire every 16th frame, checked via
game_time & 0x0F == 0 - Overflow: Wraps at 65535 back to 0 (approximately 18 minutes at 60fps)
- Animation: Actor animation ticks are driven by
game_timevia theactors_update()system
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
- Range: 0–255 = full 360° circle
- Direction: 0 = up, increases clockwise
- Return value:
INT8range −128 to 127 (scaled sine/cosine)
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
| Angle | Degrees | Direction | SIN() | COS() |
|---|---|---|---|---|
| 0 | 0° | Up | 0 | 127 |
| 32 | 45° | Up-Right | 90 | 90 |
| 64 | 90° | Right | 127 | 0 |
| 96 | 135° | Down-Right | 90 | −90 |
| 128 | 180° | Down | 0 | −128 |
| 160 | 225° | Down-Left | −90 | −90 |
| 192 | 270° | Left | −128 | 0 |
| 224 | 315° | Up-Left | −90 | 90 |
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;
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
hide_spritesvariable: VBL_isr checks this variable every VBlank and writes to the LCDC register accordingly. Calling theHIDE_SPRITESmacro directly (which writes LCDC) is immediately overridden on the next VBlank. You must sethide_sprites = TRUEto persist sprite hiding across frames.- Window position:
win_pos_ycontrols the overlay window.MENU_CLOSED_Y=MAXWNDPOSY + 1(fromui.h) — setting the window position below the screen effectively disables it. scroll_shadow_update(): Applies the double-buffered scroll values (draw_scroll_x,draw_scroll_y) to the hardware scroll registers.
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 Function | Enum Value | Purpose |
|---|---|---|
simple_LCD_isr | LCD_simple | Basic scanline handling (overlay cut) |
parallax_LCD_isr | LCD_parallax | Multi-layer parallax scrolling (up to 3 layers) |
fullscreen_LCD_isr | LCD_fullscreen | Full-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
- LYC sync value: 150 (during VBlank period, safe for register changes)
overlay_cut_scanline: Defined ininterrupts.cbut not declaredexternininterrupts.h. You must add a manual declaration:extern UBYTE overlay_cut_scanline;- Parallax conflicts: Custom ISR plugins must clear
parallax_rowsand setscene_LCD_type = LCD_simpleto prevent the parallax ISR from conflicting
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;
}
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() 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:
- Writing pre-computed tile data to sprite VRAM
- Modifying background tiles for per-frame effects
- Reading VRAM safely (returns correct data, not 0xFF)
// 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;
}
}
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
}