Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/pocket-stack/pocketjs/llms.txt

Use this file to discover all available pages before exploring further.

The AOT compiler turns TypeScript/JSX source files into a PJGB cartridge blob and a linked GBA ROM. Each stage keeps the boundary between build-time JavaScript and runtime game data explicit. The compiler should fail early when author code crosses the supported AOT surface — that keeps the ROM deterministic and the native runtime small.

Pipeline overview

Source TS/TSX (@pocketjs/aot DSL)
  -> evaluate      static JSX/declaration zone executed at build time   (compiler/evaluate.ts)
  -> bake          tilesets/sprites/font -> GBA 4bpp tiles + BGR555 pals (compiler/bake.ts)
  -> residualize   script(function*(){...}) ASTs -> stack-VM bytecode   (compiler/script.ts)
  -> model         JSX scene trees -> concrete maps/actors/warps        (compiler/model.ts)
  -> lower         validate + emit PJGB chunks                          (compiler/lower.ts)
  -> pack          chunks -> PJGB cartridge blob                        (compiler/pack.ts)
  -> rom           link fixed C runtime with arm-none-eabi-gcc -> .gba  (compiler/rom.ts)
The entry point compiler/index.ts orchestrates these stages and exposes the compile(entry) async function, plus helpers for generating the IR snapshot (irJson) and the debug symbol map (debugInfo) consumed by the mGBA test harness.

Stage 1 — Evaluate (compiler/evaluate.ts)

The evaluation stage executes the static declaration zone at build time using Bun’s module loader. Before execution, the source file is transformed:
  1. All script(function* () { ... }) calls are located by parsing the TypeScript AST.
  2. Each generator body is replaced with its sequential id: script(0), script(1), etc. The original AST nodes are stored for the residualizer.
  3. The @pocketjs/aot import specifier is rewritten to the concrete DSL module path so the executed module shares the same REGISTRY singleton as the compiler.
  4. The file is transpiled to ESM using the TypeScript compiler API (with jsx: ReactJSX and jsxImportSource pointing at the DSL’s JSX factory).
  5. The transpiled module is written to a sibling temp file and imported. The DSL builders (defineTileset, defineSprite, defineMap, defineGame, JSX host elements) run, filling REGISTRY.
After execution the compiler reads REGISTRY (maps, tilesets, sprites, game declaration) and the array of script AST sites.
const ev = await evaluateGame(entry);
// ev.registry  — filled by defineGame/defineMap/etc.
// ev.scripts   — ScriptSite[] with AST bodies for each script(function*(){...})
// ev.checker   — TypeScript checker for the residualizer

Stage 2 — Bake (compiler/bake.ts)

The bake stage converts all image assets to GBA-native formats and fills the compiler context (Ctx).

BG tiles

Starting with tile id 0 (a blank tile), the tileset tiles are packed in declaration order. Each tile’s px field — 8 rows of 8 hex-nibble palette indices — is packed into GBA 4bpp format: 32 bytes per tile, 4 bytes per row, low nibble is the left pixel. After tileset tiles, font glyphs are appended. The compiler/font.ts module rasterizes Inter Bold characters (ASCII 0x20–0x7E) using the Inter outline data with horizontally biased 9×3 supersampling, then quantizes coverage into five palette levels. Each glyph occupies one 8×8 tile slot. A final solid-fill textbox tile is appended.
BG VRAM tile layout (charblock 0):
  0         — blank tile
  1..N      — tileset tiles (in defineTileset order)
  N+1..M    — font glyphs (ASCII 0x20..0x7E = 95 glyphs)
  M+1       — textbox fill tile (opaque background)

BG palettes

BG palette bank 0 holds the tileset palette (up to 16 BGR555 colors). BG palette bank 15 holds the textbox/font palette: five subpixel coverage ink shades (indices 1–5) plus an opaque background (index 6), all as BGR555 values.

OBJ tiles and palettes

Each sprite’s facings (down, up, left, right) × walk frames are packed as 8×8 OBJ tile quads (TL, TR, BL, BR) in GBA 1D OAM mapping order. Each sprite gets its own OBJ palette bank (v1 cap: 16 sprites).

Pre-seeding ids

bake also pre-seeds the flag, var, item, and battle id tables from the defineGame declarations so numeric ids are stable and independent of script compilation order.

Stage 3 — Residualize (compiler/script.ts)

The residualizer reads each script’s generator AST (collected in Stage 1) and lowers supported statements to stack VM bytecode. The Emitter class walks the TypeScript AST:
  • yield say("text")TEXT <textId> (the text string is interned into the text bank)
  • yield hasFlag("id")PUSH_FLAG <flagId> (leaves a value on the stack)
  • yield choose([...]) + switch (x) { ... }CHOICE n, t0..t(n-1) with jump dispatch
  • if (yield hasFlag(...)) { ... } else { ... }PUSH_FLAG + JUMP_IF_FALSE / JUMP
  • yield lockPlayer() / yield releasePlayer()LOCK_PLAYER / RELEASE_PLAYER
  • yield facePlayer("actor")FACE_PLAYER 0xFF (the 0xFF operand means “the actor that started this script”)
  • yield battle("id")BATTLE <battleId> (v1 stub: always pushes 1=win)
  • yield giveItem("id", n)GIVE_ITEM <itemId> <count> (v1 stub)
Any statement not in the supported set throws a ScriptError with a source location, which aborts the build. Static op arguments (text strings, flag names, item ids) are folded at compile time. The only runtime values are flag states, choice selections, and battle results — these residualize into branches over the VM stack. Each compiled script’s bytecode is registered in ctx.scripts. Sign entities generate synthetic single-line TEXT <id>; END scripts at model build time and are appended after user scripts.

Stage 4 — Model (compiler/model.ts)

The model stage normalizes the executed JSX scene trees into a concrete GameModel: flat tile grids, collision arrays, actor tables, and warp tables. For each map:
  1. The <Layer> child provides the ASCII rows and legend. Each character maps to a tile name, then to a tile id, and the collision flag is read from tileset.tiles[name].solid.
  2. Scene children are walked:
    • <PlayerSpawn> and <Entrance> register named entrance positions (used by warp resolution and defineGame.start).
    • <Npc> produces an ActorModel with sprite id, position, facing, movement kind, and a script reference.
    • <Sign> generates a synthetic TEXT; END script and a solid ActorModel at that tile.
    • <Warp> records a WarpModel with a "destMap:entrance" target.
    • Any other host element throws an error.
  3. After all maps are processed, warp targets are resolved to concrete destMapIdx, destX, destY, destDir values. A missing destination map or entrance is a build error.
  4. The start field from defineGame is resolved to a { map, x, y, dir } starting position.

Stage 5 — Lower and validate (compiler/lower.ts)

The lower stage validates the complete IR against compile-time budgets and emits PJGB chunks.

Validation

CheckLimit
Maps≤ 32
Sprites≤ 16 (one OBJ palette bank each)
Flags≤ 128 (16 bytes of packed bits)
Vars≤ 16
Texts≤ 512
Scripts≤ 256
BG tiles≤ 512 (charblock 0)
OBJ tiles≤ 1024 (OBJ VRAM)
Actors per map≤ 24
Map dimensions≤ 32×32 tiles (single screenblock)
Exceeding any limit is a hard build error.

Emitted chunks

CHUNK kindContent
GAME44-byte game header: title (24 ASCII bytes), start position, counts for maps/sprites/flags/texts/scripts, and font_base/box_tile BG tile indices.
PAL_BGu16[] BGR555 — full 256-entry BG palette (bank 0 = tileset, bank 15 = textbox).
PAL_OBJu16[] BGR555 — full 256-entry OBJ palette (one bank per sprite).
TILES_BGConcatenated 4bpp BG tiles (32 bytes each): blank + tileset + font + box.
TILES_OBJConcatenated 4bpp OBJ tiles for all sprites.
SPRITE_TABLESpriteProto[] — 8 bytes per sprite: tile_base, dimensions, palbank, frames.
TEXT_BANKString table: u16 count, u16 reserved, u32[count] offsets, then null-terminated ASCII strings.
SCRIPT_CODERaw bytecode for all scripts, concatenated.
SCRIPT_TABLEu32[] — byte offsets into SCRIPT_CODE, indexed by script id.
MAP (×N)One per map (id = map index): 28-byte header with size, actor/warp counts, and offsets, followed by u16[] tile indices, u8[] collision, ActorInstance[], and Warp[] tables.

Stage 6 — Pack (compiler/pack.ts)

The pack stage assembles chunks into the PJGB container blob:
[16-byte header: magic "PJGB", version, chunk_count, table_offset, total_size]
[chunk directory: chunk_count × 16-byte entries (kind, id, offset, size)]
[chunk payloads, each 4-byte aligned]
The magic bytes are P, J, G, B (bytes 0x50 0x4A 0x47 0x42). All integers are little-endian. The companion function emitCartC renders the blob as a linkable C byte array (gen_cart.c). The ROM stage writes gen_cart.c to the runtime source directory and invokes arm-none-eabi-gcc:
arm-none-eabi-gcc \
  -mcpu=arm7tdmi -marm -ffreestanding -nostdlib -O2 -fno-strict-aliasing -Wall \
  -Iaot/runtime -Taot/runtime/gba.ld \
  aot/runtime/crt0.s \
  aot/runtime/{cart,video,bg,obj,input,map,player,actor,camera,script_vm,textbox,debug,main,gen_cart}.c \
  -lgcc -o dist/game.elf
arm-none-eabi-objcopy -O binary dist/game.elf dist/pocket-town.gba
After objcopy, the GBA header complement checksum (byte 0xBD, covering bytes 0xA00xBC) is patched in-place so the ROM passes the hardware and mGBA header check.

The CLI (compiler/cli.ts)

The pocket-aot CLI wraps the full pipeline:
bun aot/compiler/cli.ts build <entry.tsx> [--out <file.gba>] [--no-rom]
ArgumentDescription
build <entry.tsx>Required. Path to the game entry file (must export defineGame as default).
--out <file.gba>Output ROM path. Defaults to aot/dist/pocket-town.gba. Companion files use the same base name with .pjgb, .ir.json, .debug.json suffixes.
--no-romSkip the ARM cross-compile step. Emits the PJGB blob and metadata but not the .gba ROM.
The CLI also prints a build summary with counts for maps, scripts, texts, flags, BG tiles, OBJ tiles, sprites, cartridge size, and elapsed time.

The intermediate representation

compiler/model.ts defines the GameModel — the normalized, fully-resolved IR before chunk emission:
interface GameModel {
  maps: MapModel[];
  start: { map: number; x: number; y: number; dir: number };
}
interface MapModel {
  name: string; index: number; w: number; h: number;
  tiles: number[];      // u16 BG screen entries
  collision: number[];  // u8 per tile (0=walkable, 1=solid)
  actors: ActorModel[];
  warps: WarpModel[];
  entrances: Map<string, { x: number; y: number; dir: number }>;
}
The Ctx object (compiler context) holds all interned tables: bgTiles, objTiles, bgPalette, objPalette, spriteProtos, scripts, texts, flags, vars, items, battles, and mapIndex. Together, GameModel and Ctx constitute the full IR. The .ir.json output (irJson(built)) is a human-readable snapshot of the IR, useful for debugging and verifying compiler output without inspecting binary data.

Spec generation (spec/gen-c.ts)

spec/pjgb.ts is the single source of truth for the PJGB format, VM ISA, and debug block layout. Running bun aot/spec/gen-c.ts regenerates runtime/pjgb_gen.h with #define constants for every chunk kind, struct size, opcode, budget limit, and debug block offset. This ensures the C runtime and TypeScript compiler always agree on the binary contract.
If you modify aot/spec/pjgb.ts (for example, adding a new opcode or changing a struct layout), you must re-run bun aot/spec/gen-c.ts and rebuild the C runtime. Skipping this step will produce a mismatched runtime/compiler pair that silently produces incorrect cartridge data or runtime crashes.

Build docs developers (and LLMs) love