Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
"site:build": "bun tools/site-build.ts",
"gba:imagegen": "bun imagegen",
"vapor": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx",
"vapor:rpg": "bun vapor/compiler/cli.ts vapor/examples/rpg/rpg.tsx",
"vapor:rpg:play": "bun vapor/scripts/play.ts vapor/examples/rpg/rpg.tsx",
"vapor:rpg:assets:generate": "bun vapor/scripts/rpg-assets.ts generate",
"vapor:rpg:assets:build": "bun vapor/scripts/rpg-assets.ts build",
"vapor:rpg:assets:check": "bun vapor/scripts/rpg-assets.ts check",
"vapor:play": "bun vapor/scripts/play.ts",
"vapor:test": "bun test vapor/tests/",
"serve": "bun hosts/web/serve.ts",
Expand Down
62 changes: 56 additions & 6 deletions vapor/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,9 @@ oxlint plugin would give red squiggles without booting the compiler. The
Each target presents a fixed logical cell screen: 30×20 on GBA, 20×18 on
GB and ESP32, 22×18 on NES, and 50×30 on Playdate. The ESP32 MeowBit profile rasterizes its
20×18 grid as 8×7 cells into a 160×126 content area on the 160×128 ST7735
panel. The JSX vocabulary is deliberately one intrinsic with two
interpreters — the C cell grid on device, and a ~60-line tree walker over
the oracle's micro-DOM:
panel. The portable, cross-target cell vocabulary is deliberately one
intrinsic with two interpreters — the C cell grid on device, and a ~60-line
tree walker over the oracle's micro-DOM:

- `<row y={n} x={n} pal={p}>` — paints its text children at (x, y) in
palette `p`, padded with spaces to the right edge; later rows overwrite
Expand All @@ -210,15 +210,65 @@ the oracle's micro-DOM:
- Looks come from the class DSL (§4.5); the painter and every runtime
agree on pair ids, and the oracle asserts them as a per-cell grid.

Input is not DOM events. The host module exposes two explicit capabilities:
There is one deliberately target-specific exception to that portable cell
vocabulary: the experimental GBA RPG host. It is a narrow vertical slice,
not a new general-purpose JSX renderer, and non-GBA target admission rejects
it. Its responsibilities are split four ways:

1. **Static assets — `defineRpgMap(...)`.** Map rows, solid characters,
event-character ids, and dialogue records are build-time declarations.
The compiler validates the 30×20 bound, equal row widths and byte-oriented
ids, then emits flat tile/collision data plus event and dialogue tables in
ROM. None of this is mutable gameplay state.
2. **Pure world queries — `rpgBlocked(...)` / `rpgEventAt(...)`.** The host
module provides real, deterministic JS lookup semantics; AOT recognizes
the same calls and emits calls to fixed C helpers over the generated map.
Out-of-bounds movement fails closed.
3. **Reactive gameplay — ordinary Vue Vapor.** Mode, integer player position,
facing, in-flight pixel progress, quest state, current dialogue/choice, HP
and battle selection are ordinary `ref` slots; gates are `computed` and
screen offsets are derived expressions. `onButton` keymaps, the standard
Vue Vapor `onFrame(buttons)` lifecycle, and setup functions implement movement,
interaction, dialogue and battle. A step advances by a deterministic 2px
per 60 Hz tick; the destination cell and its event commit only on arrival.
They use the same dirty-bit graph and direct C lowering as Todo. There is
no residual script bytecode, stack machine, hidden controller state, or
second gameplay VM.
4. **Fixed presentation — `<RpgScreen .../>`.** The component is stateless:
every dynamic input, including the signed player pixel offsets and walk
frame, arrives as a prop and therefore participates in the normal
dependency mask. The GBA runtime derives a fractional camera from those
props, renders a 16×11 overscan tile window on BG1 and characters through
OBJ, then commits tile/OAM/scroll/window shadows during VBlank. It does not
own a hidden motion timeline or expose GBA registers or SDK concepts to
application code.

The RPG movement contract consumes the standard hardware-neutral button mask
through `@pocketjs/framework/vue-vapor/lifecycle`'s `onFrame`. The GBA adapter
maps its physical held keys to the shared `BTN` ABI; app code never reads GBA
key bits. Direction priority is deterministic, a current step finishes after
release, and turning, collision and arrival events remain gameplay state that
the compiler/debug tape can observe. Dialogue and battle still use
`onButton` press edges, so choices advance once per physical press.
The JS `RpgScreen` currently returns no pixels, so collision/event behavior
can run in the JS/oracle path but pixel-frame acceptance is mGBA-only for
this POC. A browser pixel renderer, saves, audio and CJK text are follow-ups,
not implied capabilities.

Input is not DOM events. Pocket Vapor exposes four explicit capabilities:

- `onButton((b: Button) => void)` for frame-latched press edges;
- `onButtonRepeat((b: Button) => void)` for normalized held-D-pad repeats
(currently supplied by the GBA target);
- `onFrame((buttons) => void)` from the public Vue Vapor lifecycle for the
shared held `BTN` mask (currently lowered by the GBA target);
- `onAxisDelta(RelativeAxis.Primary, (delta) => void)` for signed,
hardware-neutral incremental movement in canonical units.

Under the oracle the module executes and the test tape feeds it; under the
compiler registrations become `app_on_button()` and
`app_on_axis_delta(axis, delta)`. Physical hosts own normalization:
compiler registrations become `app_on_button()`, `app_on_button_repeat()`,
`app_on_frame(buttons)`, and `app_on_axis_delta(axis, delta)`. Physical hosts
own normalization:
rotary adapters preserve signed motion as millidegrees, while applications
own detents, acceleration, and sensitivity. Playdate forwards crank motion
to Primary; a future ESP32 board can adapt an encoder without exposing pins
Expand Down
61 changes: 60 additions & 1 deletion vapor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,64 @@ Vapor in your browser — inspectable DOM rows, keyboard as the pad,
`?target=esp32` to preview the MeowBit viewport, or `?target=playdate` for
the 50×30 one-bit contract.

## GBA RPG POC

[`examples/rpg/rpg.tsx`](examples/rpg/rpg.tsx) is an experimental,
GBA-only RPG vertical slice. It adds a small native game host without adding
a second gameplay language or VM:

- `defineRpgMap(...)` declares static map rows, solid tile characters, event
characters, and dialogue records. The compiler validates the declaration
and emits fixed ROM tables.
- `rpgBlocked(...)` and `rpgEventAt(...)` are pure map queries with real JS
implementations for behavior/oracle use and equivalent native C helpers.
- The player, mode, facing, quest, dialogue choice, HP, battle cursor, and
in-flight walking progress are ordinary `ref` state; the quest gate is an
ordinary `computed` and presentation offsets are derived expressions.
Button/frame handlers and setup functions contain the actual gameplay and
compile directly to C.
- One stateless `<RpgScreen .../>` receives that complete reactive state. On
GBA the compiler lowers it to the fixed tile/sprite RPG renderer rather
than shipping Vue or JavaScript in the ROM.
- The world uses 16×16 logical metatiles with a pixel-scrolled, clamped 15×10
camera, detailed 32×32 world actors, four-frame directional walk cycles,
and 64×64 battle portraits. A 16×11 overscan buffer keeps fractional camera
movement covered while HUD, dialogue, font, and command UI remain fixed on
the native 8×8 screen grid.

The demo's eleven reactive refs occupy 44 bytes. The fixed GBA host adds 3,136
bytes of BG1 tilemap, OAM, UI/camera-cache, and register-shadow state so VRAM,
OAM, scroll, and window commits stay inside VBlank; map, collision, event,
dialogue, tile and sprite assets remain in ROM. The fixed four-bank art payload
is 17,216 bytes, including sixteen generated directional walk frames.

The demo is one complete loop: start at `(2,2)`, walk next to the solid
Elder, face them and press A, choose whether to accept the quest, walk onto
the Slime event, use ATTACK or HEAL in a turn battle, defeat the Slime with
three attacks, then return to the Elder to complete the quest.

Controls:

- World: D-pad starts a cardinal step and advances it by 2 pixels per fixed
60 Hz frame. Holding continues seamlessly; releasing finishes the accepted
step without stopping between cells. The integer map coordinate and any
destination event commit only after all 16 pixels arrive. A talks to the
event cell directly in front of an idle player.
- Dialogue: Up/Down selects YES or NO; A confirms or advances.
- Battle: Up/Down selects ATTACK or HEAL; A performs the action.

```sh
bun run vapor:rpg # build dist/vapor/rpg.gba
bun run vapor:rpg:play # build and open the ROM in mGBA
```

The POC deliberately keeps a narrow evidence and product boundary. It is
GBA-only, tile-based, and uses printable English ASCII; there is no save
data, audio, or CJK text yet. The JS collision/event helpers are real, but
the browser `RpgScreen` is currently a stateless placeholder rather than a
pixel oracle renderer. Validation is scoped to mGBA; this section does not
claim physical-GBA or flash-cart acceptance.

## Commands

The ESP32 `flash` and default `verify` commands below write the connected
Expand Down Expand Up @@ -219,7 +277,8 @@ arithmetic and bit masks come from a ROM table.
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)
examples/rpg/ experimental GBA-only reactive RPG vertical slice
host/ input/screen contracts + the experimental RPG host
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)
Expand Down
7 changes: 6 additions & 1 deletion vapor/compiler/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,15 @@ if (target !== "playdate" && playdateModeIdx >= 0) {

const source = await Bun.file(entry).text();
const name = basename(entry).replace(/\.tsx$/, "");
const title = name.startsWith("todo")
? "VAPOR TODO"
: name === "rpg"
? "VAPOR QUEST"
: name.toUpperCase();
const app = compileVaporApp(
entry,
source,
name.startsWith("todo") ? "VAPOR TODO" : name.toUpperCase(),
title,
target,
);

Expand Down
Loading