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.