2001 · Handheld console · Nintendo

Nintendo Game Boy Advance

A Vue component compiled to a 9.1 KB cartridge. No JavaScript engine on board.

Pocket Vapor targetPocket Vapor · AOTvapor/runtime/gba/

The Game Boy Advance put a 16.78 MHz ARM7TDMI, 32 KB of fast internal RAM plus 256 KB of external work RAM, and a 240×160 reflective TFT with 32,768 colours into a landscape shell. It shipped in March 2001 and sold over 81 million units.

Pocket Vapor lowers a strict Vue Vapor TodoMVC — real ref/computed, real JSX — into C for arm-none-eabi-gcc: every ref is a dirty bit, every dependency edge a bitmask baked into ROM, every byte of RAM planned at compile time. The whole cartridge is 9.1 KB, and the parity suite checks the emulated screen grid cell-for-cell against real Vue after every button press.

Processor
ARM7TDMI · 16.78 MHz
Memory
32 KB + 256 KB
Display
240 × 160
An indigo Game Boy Advance, front-left view
An indigo Game Boy Advance, front-left view. Photo: Evan-Amos · Public domain
01

Hardware

curated here · sources below

Compute

CPU
ARM7TDMI, 32-bit RISC, 16.78 MHz plus a Sharp SM83-compatible 8-bit core for Game Boy / Color cartridges
RAM
32 KB internal work RAM (IWRAM) + 256 KB external work RAM (EWRAM)
Video RAM
96 KB VRAM, 1 KB OAM, 1 KB palette RAM
Cartridge
ROM up to 32 MB, optional SRAM / Flash / EEPROM saves

Display & input

Display
2.9″ reflective TFT, 240 × 160, 15-bit colour (32,768), 512 on screen in tile modes
Input
D-pad, A, B, L, R, Start, Select
Audio
Two 8-bit PCM channels + the four Game Boy channels; mono speaker

Body

Power
2 × AA, about 15 hours
Dimensions
144.5 × 82 × 24.5 mm
Weight
140 g
Released
21 March 2001 (Japan) · 11 June 2001 (North America)
02

PocketJS on this machine

Pocket Vapor · AOT

What runs

bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx compiles the component to C and links it against vapor/runtime/gba with arm-none-eabi-gcc into dist/vapor/todo.gba. The logical screen is a 30×20 character grid; Tailwind class names lower into real palette banks (rgb555). The console shadow grid is the debug block at a fixed WRAM address, so the harness reads the logical screen from the emulator.

What is proven

Per-interaction emulator parity against the Vue oracle (headless libmgba), comparing logical characters and styles after every press.

Record

  1. v0.7.0

    Pocket Vapor ships: one TodoMVC component becomes .gba, .gb and .nes carts, proven cell-identical against vue@3.6.

  2. v0.10.1

    Sparse conditional constant propagation and overlay slots shrink the reactive graph and permanent RAM.

Profile · as declared by the demo manifestsource ↗
Profile
gba (Pocket Vapor)

Acquisition reports

04

Example code

upstream source · highlighted at build time
vapor/examples/todo/todo.tsxtsx · 222 lines · @6c43f49

The portable TodoMVC component: refs, computeds, keymaps, semantic components.

// VAPOR TODO — TodoMVC for Pocket hardware, written as real Vue Vapor.
//
// This file has two execution paths. Under the oracle it runs unmodified on
// @vue/runtime-vapor (vue 3.6) — ref/computed are the real thing and the
// UI components below are genuine vapor functional components. Under the
// Pocket Vapor compiler it is lowered to C: refs become state-struct slots,
// computeds become cached recompute functions, JSX bindings become paint
// effects with compile-time dependency masks, keymaps become ROM function-
// pointer tables, components inline to zero-cost paint code, and the todo
// list becomes a fixed-capacity arena pool. Same semantics, no JavaScript
// engine.
//
// Controls — list mode: Up/Down cursor, A toggle done, B delete, R cycle
// filter, Select clear completed, Start new todo. Edit mode: Left/Right
// scrub glyph, A put glyph, B backspace, Start save, Select cancel.

import { computed, ref } from "vue";
import { Button, onButton } from "../../host/input.ts";
import { SCREEN } from "../../host/screen.ts";

interface Todo {
  text: string;
  done: boolean;
}

type Keymap = Record<number, () => void>;

const FILTERS = ["ALL", "ACTIVE", "DONE"];
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789";
const LIST_Y = 3;
const WINDOW = SCREEN.height - 8;
const EDIT_Y = SCREEN.height - 3;
const HELP_Y = SCREEN.height - 1;
const TEXT_MAX = 20;
const NARROW = SCREEN.width < 30;

// ---- UI components ----------------------------------------------------------
// Presentational, pure functions of props: they own their palette and
// indentation; the app owns state and layout (which row each one lives on).

function TitleBar(props: { line: number; text: string }) {
  return (
    <row y={props.line} class="bg-emerald-500 text-slate-950 align-center">
      {props.text}
    </row>
  );
}

function StatusBar(props: { line: number; count: number; label: string }) {
  return (
    <row y={props.line} x={1} class="text-emerald-400">
      {props.count}
      {" LEFT / "}
      {props.label}
    </row>
  );
}

function TodoRow(props: { line: number; todo: Todo; selected: boolean }) {
  return (
    <row
      y={props.line}
      x={1}
      class={props.selected ? "bg-slate-100 text-slate-950" : props.todo.done ? "text-slate-500" : ""}
    >
      {props.selected ? ">" : " "}
      {"["}
      {props.todo.done ? "X" : " "}
      {"] "}
      {props.todo.text}
    </row>
  );
}

function Notice(props: { line: number; text: string }) {
  return (
    <row y={props.line} x={1} class="text-slate-500">
      {props.text}
    </row>
  );
}

function EditorBar(props: { line: number; draft: string; glyph: string }) {
  return (
    <row y={props.line} x={1} class="bg-amber-300 text-slate-950">
      {"NEW: "}
      {props.draft}
      {"["}
      {props.glyph}
      {"]"}
    </row>
  );
}

function HelpBar(props: { line: number; text: string }) {
  return (
    <row y={props.line} x={1} class="text-slate-500">
      {props.text}
    </row>
  );
}

// ---- app --------------------------------------------------------------------

export default () => {
  const todos = ref<Todo[]>([
    { text: "SHIP POCKET VAPOR", done: false },
    { text: "WRITE THE COMPILER", done: true },
    { text: "RUN ON DEVICE", done: false },
  ]);
  const cursor = ref(0);
  const filter = ref(0);
  const editing = ref(false);
  const draft = ref("");
  const glyph = ref(0);

  const filtered = computed(() =>
    filter.value === 0
      ? todos.value
      : filter.value === 1
        ? todos.value.filter((t) => !t.done)
        : todos.value.filter((t) => t.done),
  );
  const remaining = computed(() => todos.value.filter((t) => !t.done).length);
  const current = computed(() => filtered.value[cursor.value]);
  const scroll = computed(() =>
    Math.max(0, Math.min(cursor.value - WINDOW + 1, filtered.value.length - WINDOW)),
  );
  const visible = computed(() => filtered.value.slice(scroll.value, scroll.value + WINDOW));

  function moveCursor(d: number) {
    cursor.value = Math.max(0, Math.min(cursor.value + d, filtered.value.length - 1));
  }
  function scrubGlyph(d: number) {
    glyph.value = (glyph.value + d + GLYPHS.length) % GLYPHS.length;
  }
  function toggleDone() {
    const t = current.value;
    if (t) t.done = !t.done;
    moveCursor(0);
  }
  function deleteCurrent() {
    const t = current.value;
    if (t) todos.value = todos.value.filter((x) => x !== t);
    moveCursor(0);
  }
  function clearDone() {
    todos.value = todos.value.filter((t) => !t.done);
    moveCursor(0);
  }
  function cycleFilter() {
    filter.value = (filter.value + 1) % FILTERS.length;
    moveCursor(0);
  }
  function openEditor() {
    editing.value = true;
    glyph.value = 0;
  }
  function closeEditor() {
    draft.value = "";
    editing.value = false;
  }
  function putGlyph() {
    if (draft.value.length < TEXT_MAX) draft.value += GLYPHS[glyph.value];
  }
  function saveDraft() {
    if (draft.value.length > 0) {
      todos.value.push({ text: draft.value, done: false });
      closeEditor();
    }
  }

  const listKeys: Keymap = {
    [Button.Up]: () => moveCursor(-1),
    [Button.Down]: () => moveCursor(1),
    [Button.A]: toggleDone,
    [Button.B]: deleteCurrent,
    [Button.R]: cycleFilter,
    [Button.Right]: cycleFilter,
    [Button.Select]: clearDone,
    [Button.Start]: openEditor,
  };

  const editKeys: Keymap = {
    [Button.Left]: () => scrubGlyph(-1),
    [Button.Right]: () => scrubGlyph(1),
    [Button.A]: putGlyph,
    [Button.B]: () => {
      draft.value = draft.value.slice(0, -1);
    },
    [Button.Start]: saveDraft,
    [Button.Select]: closeEditor,
  };

  onButton((b) => (editing.value ? editKeys : listKeys)[b]?.());

  return (
    <>
      <TitleBar line={0} text="POCKET VAPOR TODO" />
      <StatusBar line={1} count={remaining.value} label={FILTERS[filter.value]} />
      {visible.value.map((t, i) => (
        <TodoRow line={LIST_Y + i} todo={t} selected={t === current.value} />
      ))}
      {filtered.value.length === 0 ? <Notice line={LIST_Y} text="NOTHING HERE" /> : null}
      {editing.value ? (
        <EditorBar line={EDIT_Y} draft={draft.value} glyph={GLYPHS[glyph.value]} />
      ) : null}
      <HelpBar
        line={HELP_Y}
        text={
          editing.value
            ? NARROW
              ? "A:+ B:- ST:OK SE:Q"
              : "A:PUT B:DEL ST:SAVE SE:QUIT"
            : NARROW
              ? "A:OK B:X >:F ST:NEW"
              : "A:DONE B:DEL R:FILT ST:NEW"
        }
      />
    </>
  );
};
05

Bring-up guide

upstream documents · rendered verbatim

Toolchain, build, deploy and acceptance are owned by pocket-stack/pocketjs. The documents below are rendered from the pinned checkout without edits; relative links point back into the repository at the same revision.

Pocket Vapor

7 min read · 1,547 words

The compiler, the oracle, the three cartridges, the commands and toolchains.

rendered verbatim fromvapor/README.md@ 6c43f49raw ↗

Vue Vapor, compiled all the way down. You write a component in a strict TypeScript subset of Vue Vapor — real ref/computed, real JSX — and the Pocket Vapor compiler emits native code for devices that could never host a JavaScript engine: ARM7 on the Game Boy Advance, SM83 on the Game Boy, 6502 on the NES, Xtensa LX6 on the ESP32, and Cortex-M7 on Playdate. No JS engine, no GC, no allocator. Vue Vapor compiles the virtual DOM away; Pocket Vapor compiles the JavaScript engine away.

GBA (arm-none-eabi-gcc)GBA edit mode
bootedit
Game Boy (sdcc, 20x18)NES (cc65, 22x18)
gbnes

The portable Todo component targets the oracle on real vue 3.6, three cartridges, and ESP32 firmware. Its Playdate input variant shares the same business model and rendering vocabulary while replacing list Up/Down with the generic relative-axis input supplied by the crank. Screen geometry is a compile-time constant (SCREEN.width/SCREEN.height from the host module): layout math and width ternaries fold per target, so the narrow help strings on GB/NES/ESP32 cost zero bytes on GBA — compile-time responsive UI.

The proof is examples/todo/todo.tsx, plus the Playdate control mapping in examples/todo/todo.playdate.tsx — TodoMVC with filters, a computed remaining-count, windowed scrolling and a glyph editor. Each component runs two ways:

  • Oracle: unmodified on vue@3.6 runtime-with-vapor (through the repo's vue-jsx-vapor pipeline) over a micro-DOM, in bun.
  • Device: compiled to C by vapor/compiler/compile.ts, linked against a target runtime, and run as native code on a console or ESP32 — still with no JavaScript engine.

The parity suite drives one tape of button presses through the oracle and each console emulator, then compares the rendered logical cell grid — characters and palettes — cell-for-cell after every press. The ESP32 device verifier applies the same contract over UART to the physical board; it is an opt-in hardware check, not a claim made by the emulator-only test suite.

$ bun test vapor/tests/                 # incl. 3-console per-press parity

$ bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx
== reactive graph ==
refs:      todos cursor filter editing draft glyph      (6 dirty bits)
computeds: filtered remaining current scroll visible    (masks inferred)
effects:   eff_0 rows [1,2)   mask {todos, filter}
           eff_1 rows [3,15)  mask {todos, cursor, filter}
           eff_2 rows [17,18) mask {editing, draft, glyph}
           eff_3 rows [19,20) mask {editing}
== memory plan ==
state RAM: 41 B scalars/strings + 833 B pools + 66 B computed views
dist/vapor/todo.gba  (9.1 KB)

The business logic is keymaps, not branch ladders — and the compiler meets the style: each named action becomes one C function, each keymap becomes a 10-slot function-pointer table in ROM, and the dispatch line becomes a bounds-checked indexed call:

const listKeys: Keymap = {
  [Button.Up]: () => moveCursor(-1),
  [Button.Down]: () => moveCursor(1),
  [Button.A]: toggleDone,
  [Button.B]: deleteCurrent,
  [Button.R]: cycleFilter,
  [Button.Select]: clearDone,
  [Button.Start]: openEditor,
};

onButton((b) => (editing.value ? editKeys : listKeys)[b]?.());

Incremental controls are a separate, hardware-neutral input capability:

onAxisDelta(RelativeAxis.Primary, (delta) => {
  if (!editing.value) {
    remainder.value += delta;
    const steps = Math.trunc(
      remainder.value / (45 * RelativeAxisUnits.PerDegree),
    );
    remainder.value %= 45 * RelativeAxisUnits.PerDegree;
    moveCursor(steps);
  }
});

The generated ABI receives signed canonical deltas. Rotary hosts normalize physical movement to millidegrees but do not choose a UI detent. The Playdate Todo chooses 45 degrees itself; a future ESP32 board can map an encoder or wheel to the same axis without exposing GPIO or Playdate APIs to the app.

Deleting is todos.value = todos.value.filter((x) => x !== t) (compiled to in-place pool compaction), and the selected todo is itself a computed — const current = computed(() => filtered.value[cursor.value]) — cached as a nullable record pointer with the same validity-bit laziness as any other computed.

The view is semantic components, not raw rows — real vapor functional components under the oracle, inlined to zero-cost paint code by the compiler (props substitute at the AST level, so const folding, dependency masks and row spans all see through; six components add zero effects and zero RAM):

function TodoRow(props: { line: number; todo: Todo; selected: boolean }) {}

<TitleBar line={0} text="POCKET VAPOR TODO" />
<StatusBar line={1} count={remaining.value} label={FILTERS[filter.value]} />
{visible.value.map((t, i) => (
  <TodoRow line={LIST_Y + i} todo={t} selected={t === current.value} />
))}
{editing.value ? <EditorBar line={17} draft={draft.value} glyph={GLYPHS[glyph.value]} /> : null}

Reactivity survives compilation as data: every ref is a dirty bit, every dependency edge is a bitmask baked into ROM, computeds are lazy cached functions with validity bits, and template bindings are paint effects that run only when their mask intersects the dirty word. Pressing a button that changes nothing costs zero repaints; pressing ↑ repaints only the list block. See DESIGN.md for the whole argument, including where it deliberately over-approximates Vue (static dependency analysis).

The look is declarative now — the same Tailwind names the big framework compiles, lowered through each target's style contract (GBA: real palette banks; ESP32: RGB565 ink/paper pairs; GB/NES: two glyph styles by luminance), with the whole diagnostics matrix one command away:

<row y={0} class="bg-emerald-500 text-slate-950 align-center">
<row class={selected ? "bg-slate-100 text-slate-950" : done ? "text-slate-500" : ""}>
$ bun run vapor:check
gba     OK    30x20, 6 style pairs
gb      OK    20x18, 6 style pairs
        warn  VS104: 3 distinct color pairs render as the same glyph style ...
nes     OK    22x18, 6 style pairs
        warn  VS104: 3 distinct color pairs render as the same glyph style ...
esp32   OK    20x18, 6 style pairs
playdate FAIL
        error VT101: playdate has no physical input for Select, Start, R ...
meowbit OK    board (esp32)
        warn  VB103: "start" is only reachable as the a+b chord on meowbit ...
$ bun vapor/compiler/cli.ts check app.tsx --strict   # lossy lowering = failure
$ bun vapor/compiler/cli.ts check app.tsx --json     # demands + verdicts as data

That failure is intentional for the portable button-only file. Checking todo.playdate.tsx reports Playdate support through its six direct buttons and RelativeAxis.Primary; targets without a relative-axis adapter fail with VT102.

Board rows are the AOT admission rule at work: MCU devices are data files (boards/meowbit.json), the compiler derives what the app demands (buttons used, style pairs, grid), and check judges every registered board against them — see BOARDS.md for how this scales past one store's ability to enumerate devices.

And the oracle is visible: bun run vapor:dev serves the app on real Vue Vapor in your browser — inspectable DOM rows, keyboard as the pad, ?target=gb to see the DMG's two-style world before you burn a cart, ?target=esp32 to preview the MeowBit viewport, or ?target=playdate for the 50×30 one-bit contract.

Commands

The ESP32 flash and default verify commands below write the connected board; make a full-flash backup first as described in runtime/esp32/README.md. The standalone todo.esp32.bin is app-only and, if written manually, belongs at 0x10000—never offset zero. Prefer the segmented flash script.

bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx                 # → dist/vapor/todo.gba
bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb     # → todo.gb  (32 KB)
bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes    # → todo.nes (40 KB)
bun run vapor:esp32                                        # → app-only todo.esp32.bin + gen-esp32/
bun run vapor:playdate                                     # → crank-driven Todo Simulator .pdx
bun run vapor:playdate:device                              # → crank-driven Todo device .pdx
bun run vapor:playdate:both                                # → both independent .pdx packages
bun run vapor:playdate:smoke                               # → six-button regression fixture
bun run vapor:esp32:flash                                  # build + flash the connected ESP32 MeowBit
bun run vapor:esp32:verify                                 # build + flash + replay the Vue-oracle tape
bun vapor/scripts/play.ts                                 # build + open in mGBA
bun vapor/scripts/dev.ts [app.tsx]                        # visible oracle in the browser
bun vapor/compiler/cli.ts check <app.tsx> [--strict]      # cross-target diagnostics matrix
bun vapor/scripts/shot.ts                                 # bake docs screenshots
bun test vapor/tests/                                     # oracle + compiler + 3-console parity + shared device tape

Toolchains: arm-none-eabi-gcc + mgba (GBA/GB), sdcc + rgbfix (GB), cc65 (NES, emulated by the jsnes dev-dependency), ESP-IDF v6.0.2, and the Playdate SDK CMake/pdc toolchain (ESP32; set IDF_PATH / IDF_TOOLS_PATH when auto-discovery does not find the installation). Oracle tests run with bun alone. Notable per-target facts the runtime absorbs: the console shadow grid IS the debug block (fixed WRAM/CPU-RAM addresses), so the harness reads the logical screen even while a 1 MHz SM83 trickles VRAM through vblank; DMG has one palette, so logical palettes map to baked glyph styles; NES fits grid + pool + views into 2 KB of CPU RAM with the font in CHR-ROM; ESP32 rasterizes the same logical 20×18 grid into RGB565 on a 160×128 ST7735; Playdate maps a 50×30 grid byte-for-cell into its 400×240 1bpp framebuffer; and sdcc 4.6's SM83 port miscompiles some u8-by-u8 multiplies, so generated indexing is u16 pointer arithmetic and bit masks come from a ROM table.

Layout

vapor/
  DESIGN.md            the thesis + subset + target/style contracts
  examples/todo/       portable Todo + Playdate relative-axis input variant
  host/                input.ts (buttons + relative axes), screen.ts (SCREEN geometry)
  oracle/              micro-DOM + grid painter + bundle boot (real vue)
  compiler/            compile.ts (TS AST → C), styles.ts (class DSL), rom.ts, cli.ts
  runtime/             vapor.h contract + vapor_core.c (shared grid/strings/line)
  runtime/gba|gb|nes/  per-console halves: crt0, video commit, input, debug block
  runtime/esp32/       ESP-IDF loop, ST7735 RGB565 raster, buttons, UART receipt
  runtime/playdate/    SDK lifecycle, raw 1bpp framebuffer, buttons + crank adapter
  scripts/             dev.ts (visible oracle), play.ts, shot.ts, esp32.ts (device protocol)
  tests/               styles + compiler + oracle + 3-console parity + shared device tape
  tests/harness/       headless libmgba runner (GBA+GB) + jsnes runner (NES)
Photo

Wikimedia Commons · Nintendo-Game-Boy-Advance-Purple-FL.jpg — Evan-Amos, Public domain.