Graphics & Sprites
VRAM layout, spritesheets, backgrounds, tiles, metasprites, shadow OAM, and CGB attribute maps. The graphics system is implemented in C.
VRAM Layout
- Game Boy has 384 tiles in VRAM (3 banks of 128)
- CGB adds a second VRAM bank for attribute maps
- Tile data: 8×8 pixels, 2bpp (2 bits per pixel), 16 bytes per tile
- Background map: 32×32 tiles (256×256 pixels), wrapping
- Window map: separate 32×32 tile map, overlaid on background
- OAM: 40 sprite entries, each 4 bytes (Y, X, tile, attributes)
Sprite Mode
- GB Studio 4.2 uses 8×16 sprite mode (tall sprites)
- Each hardware sprite is 8×16 pixels
- Larger actors are composed of multiple hardware sprites (metasprites)
- 40 hardware sprites max across all actors
Spritesheet Structure
typedef struct spritesheet_t {
UINT8 num_frames;
UINT8 num_tiles; // tiles per frame
// followed by metasprite data and tile data
} spritesheet_t;
- Spritesheets are banked (
far_ptr_treferences) - Each frame contains a metasprite definition + tile data
- Metasprite defines relative positions of hardware sprites
Background System
- Backgrounds are composed of tilemap + tileset
image_tile_width,image_tile_height— scene dimensions in tilesimage_width_subpx,image_height_subpx— scene dimensions in subpixels- Tilemap: array of tile indices
- CGB attribute map: palette + bank selection per tile
- Tilesets are deduplicated (shared tiles across the map)
Background Loading
load_scene()loads background tileset into VRAM- Tilemap loaded via scroll system (incremental row/column loading)
scroll_repaint()— full screen repaint (loads all visible rows/columns)scroll_load_row(x, y)— load a single tile row (NONBANKED, not declared in scroll.h)- Must add manual declaration:
void scroll_load_row(UBYTE x, UBYTE y);
Tile Replacement
- Individual tiles can be replaced at runtime
- GBVM:
VM_REPLACE_TILE_XYreplaces a tile at position VM_LOAD_TILESETloads additional tiles into VRAM- Used for dynamic background changes (animations, state changes)
Direct Tile-Data Replacement (Fast Animation)
The standard approach for background animation uses submapping — copying rectangular tilemap regions into VRAM each frame. A significantly faster technique is direct tile-data replacement: instead of rewriting the tilemap, you overwrite the tile graphics (CHR data) while leaving the tilemap untouched. This is roughly 60% faster than submapping because it skips all tilemap and attribute writes.
How It Works
- At init: Read the background tilemap to find which VRAM tile indices occupy the target region.
- Each frame: Overwrite those tiles' pixel data with the current animation frame's tile graphics using
SetBankedBkgData().
The hardware tilemap stays unchanged — only the graphical content of existing tiles is swapped. Multiple independent animations can coexist on different tile regions without conflicts.
Example: Animated Water Tiles
This example animates a 7×6 tile region (e.g. ocean shoreline) through 8 frames of pre-drawn tile data stored in a tileset:
#pragma bank 255
#include <gbdk/platform.h>
#include "bankdata.h"
#include "data_manager.h"
// Animation tile data (8 frames of 42 tiles each, stored in a banked tileset)
extern const unsigned char anim_tileset[];
extern const unsigned char anim_tilemap[];
BANKREF_EXTERN(ANIM_DATA)
#define ANIM_W 7 // tiles wide per frame
#define ANIM_H 6 // tiles tall per frame
#define ANIM_FRAMES 8
#define TILES_PER_FRAME (ANIM_W * ANIM_H) // 42
#define BYTES_PER_TILE 16
#define UI_OFFSET 2 // tiles reserved by UI (192 - actual tileset size)
static UBYTE dest_x, dest_y;
static UBYTE frame_delay, delay_counter;
static UBYTE current_frame;
static UBYTE active;
// One-time init: store destination and start animation
void anim_init(UBYTE dx, UBYTE dy, UBYTE delay) BANKED {
dest_x = dx;
dest_y = dy;
frame_delay = delay;
delay_counter = delay;
current_frame = 0;
active = TRUE;
}
// Call every frame from state_update or On Update event
void anim_update(void) BANKED {
if (!active) return;
if (--delay_counter) return;
delay_counter = frame_delay;
// Compute source offset into the tileset for current frame
// Frames are laid out in a grid: 4 columns x 2 rows of (ANIM_W x ANIM_H) blocks
UBYTE frame_col = current_frame & 3; // 0-3
UBYTE frame_row = current_frame >> 2; // 0-1
UBYTE src_x = frame_col * ANIM_W;
UBYTE src_y = frame_row * ANIM_H;
UBYTE src_map_w = ANIM_W * 4; // full tilemap width (4 frames across)
// For each tile position in the destination region:
for (UBYTE ty = 0; ty < ANIM_H; ty++) {
for (UBYTE tx = 0; tx < ANIM_W; tx++) {
// 1. Read the DESTINATION tilemap to find which VRAM tile index is there
UINT16 dest_offset = (UINT16)(dest_y + ty) * image_tile_width + (dest_x + tx);
UBYTE dest_tile = ReadBankedUBYTE(
anim_tilemap + dest_offset, // wrong: this is the ANIM tilemap
_current_bank // wrong bank
);
// Actually: read from the SCENE's background tilemap
// The scene tilemap pointer is set during load_scene()
// Use the banked tilemap data from the scene
dest_tile = ReadBankedUBYTE(
scene_tilemap_ptr + dest_offset,
scene_tilemap_bank
);
// Adjust for UI tile reservation (tiles >= 128 shift down)
if (dest_tile >= 128) dest_tile -= UI_OFFSET;
// 2. Read the SOURCE tile index from the animation tilemap
UINT16 src_offset = (UINT16)(src_y + ty) * src_map_w + (src_x + tx);
UBYTE src_tile = ReadBankedUBYTE(
anim_tilemap + src_offset,
BANK(ANIM_DATA)
);
if (src_tile >= 128) src_tile -= UI_OFFSET;
// 3. Overwrite the VRAM tile graphics (no tilemap write!)
SetBankedBkgData(
dest_tile, 1,
anim_tileset + (UINT16)src_tile * BYTES_PER_TILE,
BANK(ANIM_DATA)
);
}
}
current_frame = (current_frame + 1) & 7; // wrap 0-7
}
SetBankedBkgData() writes tile CHR data directly into VRAM tile slots. No tilemap rows need updating, no CGB attribute writes, and no scroll-system interaction. The hardware continues displaying the same tile indices — only their pixel content changes. This makes it ideal for water animations, lava flows, or any repeating background effect.
GB Studio reserves UI tile slots in VRAM. If your tileset has fewer than 192 tiles, tile indices ≥ 128 need adjustment: subtract 192 - actual_tile_count. For a 190-tile tileset, the offset is 2. Without this correction, you will overwrite the wrong tiles.
Simplified Version (Hardcoded Tile Data)
For maximum performance, you can embed the animation tile data directly in the plugin as const arrays, eliminating all tilemap reads at runtime. Pre-compute the destination VRAM tile indices once at init time:
// Pre-computed at init: which VRAM tile index sits at each destination position
static UBYTE vram_indices[ANIM_W * ANIM_H];
void anim_init(UBYTE dx, UBYTE dy, UBYTE delay) BANKED {
// ... setup code ...
// Cache destination tile indices (one-time cost)
for (UBYTE ty = 0; ty < ANIM_H; ty++) {
for (UBYTE tx = 0; tx < ANIM_W; tx++) {
UINT16 offset = (UINT16)(dy + ty) * image_tile_width + (dx + tx);
UBYTE tile = ReadBankedUBYTE(scene_tilemap_ptr + offset, scene_tilemap_bank);
if (tile >= 128) tile -= UI_OFFSET;
vram_indices[ty * ANIM_W + tx] = tile;
}
}
}
// Runtime: just blast tile data, no reads needed
void anim_update(void) BANKED {
if (!active || --delay_counter) return;
delay_counter = frame_delay;
const unsigned char *frame_data = &hardcoded_frames[current_frame][0];
for (UBYTE i = 0; i < ANIM_W * ANIM_H; i++) {
SetBankedBkgData(vram_indices[i], 1, frame_data + i * 16, BANK(ANIM_DATA));
}
current_frame = (current_frame + 1) & 7;
}
2bpp Tile Rotation
Rotating 2bpp tile data is useful when you need rotated variants of existing graphics without storing separate assets. The Game Boy’s 2bpp format stores each 8×8 tile as 16 bytes: 2 bytes per row, where each pixel’s color is encoded across the corresponding bits of the low and high plane bytes.
2bpp Pixel Layout
// Each tile row = 2 bytes (low plane, high plane)
// Pixel X color = bit (7-X) of low_byte | bit (7-X) of high_byte
//
// Byte layout for one tile (16 bytes):
// Byte 0: row 0 low plane (pixels 7..0, bit 0 of color)
// Byte 1: row 0 high plane (pixels 7..0, bit 1 of color)
// Byte 2: row 1 low plane
// Byte 3: row 1 high plane
// ...
// Byte 14: row 7 low plane
// Byte 15: row 7 high plane
90° Counter-Clockwise Rotation
The correct formula for rotating a pixel grid 90° CCW is:
new_pixel[new_y][new_x] = old_pixel[new_x][7 - new_y]
Using old[7-ny][nx] instead of old[nx][7-ny] produces a vertical flip, not a rotation. This is an easy mistake to make and hard to spot visually with symmetric test patterns. Always verify with an asymmetric “L” or “F” shape.
Implementation
// Rotate an 8x8 2bpp tile 90 degrees counter-clockwise
// old_tile: 16 bytes input, new_tile: 16 bytes output (must be zeroed first)
void rotate_tile_ccw(const UINT8 *old_tile, UINT8 *new_tile) {
for (UINT8 ny = 0; ny < 8; ny++) {
for (UINT8 nx = 0; nx < 8; nx++) {
// Source pixel: column = (7 - ny), row = nx
UINT8 src_row = nx;
UINT8 src_col = 7 - ny;
UINT8 src_bit = 7 - src_col; // bit position within byte
// Extract 2-bit color from source
UINT8 lo = (old_tile[src_row * 2] >> src_bit) & 1;
UINT8 hi = (old_tile[src_row * 2 + 1] >> src_bit) & 1;
// Place into destination
UINT8 dst_bit = 7 - nx; // bit position within byte
new_tile[ny * 2] |= (lo << dst_bit);
new_tile[ny * 2 + 1] |= (hi << dst_bit);
}
}
}
// Usage: rotate all tiles in a tileset
void rotate_tileset(const UINT8 *src, UINT8 *dst, UINT8 num_tiles) {
for (UINT8 t = 0; t < num_tiles; t++) {
memset(dst + t * 16, 0, 16); // zero destination tile
rotate_tile_ccw(src + t * 16, dst + t * 16);
}
}
For 90° clockwise: new[ny][nx] = old[7-nx][ny]. For 180°: new[ny][nx] = old[7-ny][7-nx]. These follow the same bit-extraction pattern — only the source coordinate mapping changes.
Shadow OAM (Double Buffering)
shadow_OAM2[40]— secondary OAM buffertoggle_shadow_OAM()— swap buffers before renderingactivate_shadow_OAM()— apply shadow OAM to hardware, hide unused sprite slots- Prevents flickering during OAM updates
- OAM DMA transfers shadow buffer to hardware during VBlank
Metasprite Rendering
actors_render()iterates active actor list- For each actor: reads metasprite data, writes hardware sprites to shadow OAM
- Sprite priority: first in OAM = highest priority (drawn on top when overlapping)
- Actor render order determined by position in active linked list
- Use EditActorActiveIndex plugin to control render order
CGB Attributes
Each background tile has an attribute byte:
- Bits 0–2: palette number (0–7)
- Bit 3: VRAM bank (0 or 1)
- Bit 4: unused
- Bit 5: horizontal flip
- Bit 6: vertical flip
- Bit 7: BG-to-OAM priority
Each sprite has similar attributes in OAM entry byte 3.
Common Graphics Operations
Examples of common graphics tasks in plugin and engine code:
- Loading a tileset from C
- Replacing background tiles
- Managing sprite priorities
- Working with CGB attributes
Submap System
- Copy rectangular regions from background tilemap to overlay window
- SubmappingExPlugin: row-by-row copy using
tmp_tile_buffer[32] - Supports CGB attribute maps and tile offset
- Used for menus, HUDs, inventory screens