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:
- All
script(function* () { ... }) calls are located by parsing the TypeScript AST.
- Each generator body is replaced with its sequential id:
script(0), script(1), etc. The original AST nodes are stored for the residualizer.
- 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.
- The file is transpiled to ESM using the TypeScript compiler API (with
jsx: ReactJSX and jsxImportSource pointing at the DSL’s JSX factory).
- 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:
-
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.
-
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.
-
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.
-
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
| Check | Limit |
|---|
| 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 kind | Content |
|---|
GAME | 44-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_BG | u16[] BGR555 — full 256-entry BG palette (bank 0 = tileset, bank 15 = textbox). |
PAL_OBJ | u16[] BGR555 — full 256-entry OBJ palette (one bank per sprite). |
TILES_BG | Concatenated 4bpp BG tiles (32 bytes each): blank + tileset + font + box. |
TILES_OBJ | Concatenated 4bpp OBJ tiles for all sprites. |
SPRITE_TABLE | SpriteProto[] — 8 bytes per sprite: tile_base, dimensions, palbank, frames. |
TEXT_BANK | String table: u16 count, u16 reserved, u32[count] offsets, then null-terminated ASCII strings. |
SCRIPT_CODE | Raw bytecode for all scripts, concatenated. |
SCRIPT_TABLE | u32[] — 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).
Stage 7 — Link ROM (compiler/rom.ts)
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 0xA0–0xBC) 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]
| Argument | Description |
|---|
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-rom | Skip 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.
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.