Graphics & Sprites

VRAM layout, spritesheets, backgrounds, tiles, metasprites, shadow OAM, and CGB attribute maps. The graphics system is implemented in C.

VRAM Layout

Sprite Mode

Spritesheet Structure

typedef struct spritesheet_t {
    UINT8 num_frames;
    UINT8 num_tiles;        // tiles per frame
    // followed by metasprite data and tile data
} spritesheet_t;

Background System

Background Loading

Tile Replacement

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

  1. At init: Read the background tilemap to find which VRAM tile indices occupy the target region.
  2. 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
}
Why This Is Faster

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.

UI Tile Offset

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]
Common Mistake

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);
    }
}
Clockwise and 180° Variants

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)

Metasprite Rendering

CGB Attributes

Each background tile has an attribute byte:

Each sprite has similar attributes in OAM entry byte 3.

Common Graphics Operations

Examples of common graphics tasks in plugin and engine code:

Submap System