2022 · Handheld console · Panic

Panic Playdate

The crank as a hardware-neutral relative axis. The fifth Pocket Vapor target.

Pocket Vapor targetPocket Vapor · AOTvapor/runtime/playdate/

Panic's Playdate is a small yellow handheld with a 400×240 one-bit Sharp Memory LCD, a 168 MHz Cortex-M7, 16 MB of RAM and a fold-out crank. It shipped in April 2022 with a season of games and an SDK for Lua and C.

Pocket Vapor maps a 50×30 logical grid byte-for-cell into the SDK's 52-byte-stride framebuffer and packages .pdx bundles for the Simulator and for the device. The crank arrives through the RelativeAxis.Primary contract as signed millidegrees: the runtime never picks a detent, the todo app chooses 45° itself — so the app code names no Playdate API.

Processor
Cortex-M7 · 168 MHz
Memory
16 MB
Display
400 × 240 · 1-bit
A yellow Panic Playdate, three-quarter view with the crank extended
A yellow Panic Playdate, three-quarter view with the crank extended. Photo: Louie Mantia (Louiemantia) · CC BY-SA 4.0
01

Hardware

curated here · sources below

Compute

CPU
ARM Cortex-M7, 168 MHz (STM32F746; STM32H7B0 on later revisions)
RAM
16 MB SDRAM, 8 KB L1 cache
Storage
4 GB flash

Display & input

Display
2.7″ Sharp Memory LCD, 400 × 240, 1-bit monochrome, 173 ppi, no backlight
Pocket grid
50 × 30 cells, 1 byte per cell into the 52-byte-stride framebuffer
Input
D-pad, A, B, Menu, Lock; the crank; 3-axis accelerometer
Audio
Mono speaker, stereo headphone jack, condenser mic + TRRS mic in

Connectivity & body

Wireless
Wi-Fi 802.11b/g/n 2.4 GHz; Bluetooth (hardware present, unused)
Ports
USB-C
Battery
About 8 hours active, 14 days standby
Dimensions
76 × 74 × 9 mm
Released
18 April 2022
02

PocketJS on this machine

Pocket Vapor · AOT

What runs

bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode simulator|device resolves the Playdate SDK, links gen_app.c and vapor_core.c into a Playdate C application and emits independent Simulator and device .pdx packages. The runtime writes the raw 1-bpp framebuffer directly, samples getCrankChange() into signed millidegrees with sub-millidegree carry, and drains docked or lifecycle-reset motion so it cannot reappear as a ghost event. Receipts (PVREADY, PVFRAME, PVINPUT, PVERROR) go to the console.

What is proven

Native-boundary tests and Simulator/device package smoke; a checked-in fake-framebuffer test verifies byte layout. Physical display polarity and crank feel remain a manual acceptance checklist.

Record

  1. v0.8.0

    Playdate is the fifth Pocket Vapor target: 400×240 as a 50×30 grid, .pdx packaging, the crank through RelativeAxis.

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

Example code

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

The crank-driven input variant: onAxisDelta with a 45° detent chosen by the app.

// PLAYDATE VAPOR TODO — the native Playdate input variant.
//
// The application remains hardware-neutral at the event boundary: list
// movement consumes RelativeAxis.Primary signed millidegrees. The Playdate
// runtime preserves physical crank motion; this app, not the host, chooses a
// 45-degree list detent. A future ESP32 encoder host can provide the same
// capability without changing this business logic.
//
// Controls — list mode: crank cursor, A toggle done, B delete, Right cycle
// filter, Up new todo, Down clear completed. Edit mode: Left/Right scrub
// glyph, A put glyph, B backspace, Up save, Down cancel.

import { computed, ref } from "vue";
import {
  Button,
  onAxisDelta,
  onButton,
  RelativeAxis,
  RelativeAxisUnits,
} 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 LIST_CRANK_DEGREES = 45;
const LIST_CRANK_THRESHOLD =
  LIST_CRANK_DEGREES * RelativeAxisUnits.PerDegree;

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

function StatusBar(props: { line: number; count: number; label: string }) {
  return (
    <row y={props.line} x={1} class="text-black">
      {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-black text-white" : 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-black text-white">
      {"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>
  );
}

export default () => {
  const todos = ref<Todo[]>([
    { text: "SHIP POCKET VAPOR", done: false },
    { text: "WRITE THE COMPILER", done: true },
    { text: "RUN ON PLAYDATE", done: false },
  ]);
  const cursor = ref(0);
  const filter = ref(0);
  const editing = ref(false);
  const draft = ref("");
  const glyph = ref(0);
  const crankRemainder = 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() {
    crankRemainder.value = 0;
    editing.value = true;
    glyph.value = 0;
  }
  function closeEditor() {
    crankRemainder.value = 0;
    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.A]: toggleDone,
    [Button.B]: deleteCurrent,
    [Button.Right]: cycleFilter,
    [Button.Up]: openEditor,
    [Button.Down]: clearDone,
  };

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

  onButton((button) => (editing.value ? editKeys : listKeys)[button]?.());
  onAxisDelta(RelativeAxis.Primary, (delta) => {
    if (!editing.value) {
      crankRemainder.value += delta;
      const steps = Math.trunc(
        crankRemainder.value / LIST_CRANK_THRESHOLD,
      );
      if (steps !== 0) {
        crankRemainder.value %= LIST_CRANK_THRESHOLD;
        moveCursor(steps);
      }
    }
  });

  return (
    <>
      <TitleBar line={0} text="PLAYDATE VAPOR TODO" />
      <StatusBar line={1} count={remaining.value} label={FILTERS[filter.value]} />
      {visible.value.map((todo, i) => (
        <TodoRow line={LIST_Y + i} todo={todo} selected={todo === 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
            ? "A:PUT B:DEL </>:GLYPH UP:SAVE DOWN:QUIT"
            : "CRANK:MOVE A:DONE B:DEL >:FILT UP:NEW DOWN:CLEAR"
        }
      />
    </>
  );
};
04

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 Playdate runtime

2 min read · 508 words

Prerequisites, build, framebuffer contract, crank axis, manual acceptance checklist.

rendered verbatim fromvapor/runtime/playdate/README.md@ 6c43f49raw ↗

This directory is the native Playdate hardware boundary for Pocket Vapor. It links generated gen_app.c and the shared vapor_core.c directly into a Playdate C application. No JavaScript engine, interpreter, GC, or PocketJS guest host is involved.

Prerequisites

  • Playdate SDK, resolved from an explicit PLAYDATE_SDK_PATH or the first SDKRoot entry in ~/.Playdate/config
  • CMake
  • a platform C compiler for Simulator builds
  • arm-none-eabi-gcc for device builds

Build through the compiler so SDK resolution, build identity, staging, and artifact validation stay observable:

bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx \
  --target playdate --playdate-mode simulator

bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx \
  --target playdate --playdate-mode device

both produces two independent packages. Playdate loads either a native Simulator library or a device binary from a package, so the target never claims that one .pdx contains both:

dist/vapor/todo.playdate.playdate-simulator.pdx
dist/vapor/todo.playdate.playdate-device.pdx

The renderer writes the SDK's 52-byte-stride framebuffer directly. Only the first 50 bytes of each physical row are visible and modified. Invalid character/palette data or framebuffer acquisition failure sets VP_TRIP_PLATFORM_RENDER, logs PVERROR, preserves dirty state, and stops the update loop instead of substituting fallback pixels.

The crank implements the shared RelativeAxis.Primary capability. The runtime samples getCrankChange() and forwards signed millidegrees without choosing an interaction detent. Fractional sub-millidegree motion is retained between frames. Clockwise is positive. The Todo application, rather than the runtime, chooses a 45-degree list detent. Docked and lifecycle-reset motion is drained so it cannot reappear as a ghost event. Buttons and relative-axis input are dispatched before one batched app_flush() per update.

Runtime receipts:

PVREADY target=playdate build=<id> grid=50x30 ...
PVFRAME frame=<n> flush=<n> commit=<n> trips=<mask>
PVINPUT axis=primary delta_mdeg=<n> raw_mdeg=<n> sub_mdeg_x1000=<n> event=<n>
PVERROR stage=<stage> code=<code> ...

The checked-in fake-framebuffer test verifies byte layout. A physical-device smoke is still required before claiming display polarity, lifecycle redraws, or hardware input parity are verified.

Manual acceptance checklist

Build and open the Simulator package:

bun run vapor:playdate

The Simulator supports dragging its crank control or using a mouse/trackpad scroll wheel (see the official Simulator controls). Validate the following:

  1. Boot shows PLAYDATE VAPOR TODO, three seed rows, 2 LEFT / ALL, and no PVERROR.
  2. Extend the crank. Clockwise motion moves the selection down; anti-clockwise moves it up. The Todo moves once per 45 degrees; slower partial turns accumulate instead of being lost.
  3. Stow the crank, rotate/scroll, then extend it again. No delayed cursor jump should occur.
  4. In list mode: A toggles completion, B deletes, Right cycles the filter, Up opens the editor, and Down clears completed todos.
  5. In edit mode: Left/Right select the glyph, A inserts, B backspaces, Up saves, and Down cancels. Crank motion must not move the hidden list cursor.
  6. Pause/resume or lock/unlock. The full screen should redraw without corruption or a synthetic crank step.
  7. Console output should contain PVREADY, PVINPUT with signed millidegrees, and PVFRAME after paints; trips remains zero.

For hardware, build bun run vapor:playdate:device, sideload the resulting device .pdx, and repeat the same sequence. Simulator success is not a substitute for checking physical screen polarity and crank feel.

Photo

Wikimedia Commons · Playdate with crank.png — Louie Mantia (Louiemantia), CC BY-SA 4.0.