1989 · Handheld console · Nintendo

Nintendo Game Boy

Reactive Vue on an 8-bit SM83 at 4 MHz, in a 32 KB cart.

Pocket Vapor targetPocket Vapor · AOTvapor/runtime/gb/

The original Game Boy ran a Sharp LR35902 — an 8-bit SM83 core at 4.19 MHz — with 8 KB of work RAM and 8 KB of video RAM behind a 160×144 four-shade STN screen. It launched in April 1989 and, with the Color, sold 118 million units.

The Pocket Vapor Game Boy build compiles the same Vue component with sdcc into a 32 KB cartridge. The DMG has one palette, so logical palettes lower to two glyph styles by luminance; sdcc 4.6's SM83 port miscompiles some 8-bit multiplies, so generated indexing is 16-bit pointer arithmetic and bit masks come from a ROM table. A 1 MHz-class CPU trickling VRAM through vblank still answers every button press in lockstep with the oracle.

Processor
Sharp SM83 · 4.19 MHz
Memory
8 KB + 8 KB VRAM
Display
160 × 144
An original Nintendo Game Boy DMG-01, front-left view
An original Nintendo Game Boy DMG-01, front-left view. Photo: Evan-Amos · Public domain
01

Hardware

curated here · sources below

Compute

CPU
Sharp LR35902 (SM83 core, 8-bit), 4.194 MHz
RAM
8 KB work RAM
Video RAM
8 KB VRAM, 160 B OAM
Cartridge
32 KB ROM without a mapper; up to 8 MB with MBC bank switching

Display & input

Display
2.6″ STN LCD, 160 × 144, 4 shades of grey-green
Input
D-pad, A, B, Start, Select
Audio
Two pulse channels, one wave channel, one noise channel; mono speaker, stereo on the jack

Body

Power
4 × AA, 15–30 hours
Dimensions
148 × 90 × 32 mm
Weight
220 g without batteries
Released
21 April 1989 (Japan) · 31 July 1989 (North America)
02

PocketJS on this machine

Pocket Vapor · AOT

What runs

bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb emits C for sdcc and fixes the header with rgbfix into a 32 KB todo.gb. The logical screen is 20×18 cells; the shadow grid lives at a fixed WRAM address so the harness can read the screen even while the SM83 trickles VRAM through vblank.

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 with a 32 KB Game Boy cart among its first three targets.

  2. v0.10.1

    Overlay slots reduce permanent RAM and stack pressure on the Game Boy and NES.

Profile · as declared by the demo manifestsource ↗
Profile
gb (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.

// 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 · Game-Boy-FL.jpg — Evan-Amos, Public domain.