← Blog

Pocket Figma: Figma at 333 MHz

Pocket Figma is out — a Figma viewer for the Sony PSP. What actually lives inside a .fig file (it tells you how to read itself), the two-character instance-expansion bug, baking 14,430 nodes into streamed CLUT8 tile pyramids where whitespace is free, and nub-panning a 26,000-pixel artboard at 60 FPS in 32 MB — deterministic to the byte, at any clock rate.

Written by Yifeng "Evan" Wang

Today we are releasing Pocket Figma — a Figma file viewer that runs on a 2004 Sony PSP. It opens a real Community file, the Paper Wireframe Kit: 14,430 nodes, 2,293 component instances, hand-drawn Patrick Hand lettering, photos, masks, the works. You pan it with the analog nub and zoom it with the shoulder triggers, at 60 FPS, on a machine with 32 MB of RAM and no GPU shaders — and it ships as one EBOOT.PBP you drop on a Memory Stick.

Pocket Figma on a PSP at 59% zoom: the Paper Kit cover — a hand-drawn character asking 'I'm out of paper, got any?' in a speech bubble, the word Wireframe in handwriting, dot-grid paper texture — with the viewer HUD along the bottom

Native 480×272 output of the shipping build at 59% zoom, mid-pan across the kit's cover. This frame — like every PSP screenshot in this post — is the executable's own framebuffer, captured in the deterministic emulator our byte-exact tests run on.

Pocket Figma is open source at pocket-stack/pocket-figma. It is the first of the pocket-<product> apps — self-contained PocketJS applications, each carrying a pocket.json manifest that the upcoming Pocket Studio and Pocket Store will consume. And like OpenStrike before it, it exists to make a point about architecture: the device never parses, it only consumes. A design file is just the most literal possible test of that law, because a design file is nothing but things to parse.

This post is the full story: what is actually inside a .fig file, the two-character bug that turned a smiley into a black hole, how you turn a 26,000-pixel-wide canvas into something a fixed-function GPU can stream, and why the whole thing is a pure function of the button mask.

A .fig file is a database, not a picture

Export any Figma document and you get a .fig — a ZIP with a thumbnail, the referenced images, and one opaque blob called canvas.fig. That blob opens with the magic bytes fig-kiwi, and the name is the first delight of this project: kiwi is Evan Wallace's schema format — Figma's co-founder wrote the serialization layer, published it as open source, and the file format carries his name in its header. More on him at the end.

The blob is a sequence of compressed chunks (deflate historically, zstd in current exports — the header tells you). Chunk one is a kiwi binary schema; chunk two is the document, encoded in that schema. Read that again: the file tells you how to read itself. You do not chase a version-specific spec — you decode the embedded schema with the kiwi library and get back a typed tree of everything Figma knows about the document. For our kit, that is a 3 MB blob inflating to a 9.8 MB message: 14,430 node records and 4,066 binary geometry blobs.

Paper Wireframe Kit (Community).fig      22 MB (zip)
├─ canvas.fig                            fig-kiwi, 2 chunks
│   ├─ chunk 0: kiwi schema              the decoder ring, embedded
│   └─ chunk 1: the document             14,430 nodes / 4,066 blobs
│        ELLIPSE ×3537 · VECTOR ×2410 · INSTANCE ×2293
│        SYMBOL ×1937 · TEXT ×1522 · BOOLEAN_OPERATION ×177 …
├─ images/                               44 bitmaps (the photos)
└─ thumbnail.png

Here is the discovery that made this project feasible in a weekend rather than a quarter: Figma bakes its own render into the file. Every shape node carries fillGeometry and strokeGeometry — flattened path blobs where strokes have already been outlined into fills, boolean operations already evaluated, dashes already cut. Every text node carries derivedTextData: the full text layout, glyph by glyph, each with a vector outline blob in em units. We never wrote a stroker, never evaluated a boolean, never loaded a font — the hand-drawn Patrick Hand lettering renders from outlines the file itself provides. The path encoding is almost quaint: one command byte (move/line/quad/cubic/close), then float32 coordinates.

If you build on the web this pattern should feel familiar from the other direction: it is a lockfile for rendering. Figma's editor spent real CPU deciding exactly what those shapes look like; the file pins the result; any consumer — including, it turns out, a handheld from 2004 — just replays the answer.

The two-character bug

One thing the file does not hand you is component instances. An INSTANCE node stores a pointer to its SYMBOL plus a list of overrides — "this nested text says 9:41", "this icon's fill is red" — each keyed by a guidPath, a path of node GUIDs. Expansion is recursive tree-grafting with two scoping rules that took most of a debugging evening:

First, within one symbol expansion, a descendant is addressed by a single-element path no matter how deep it sits — path segments accumulate only when you cross nested instance boundaries. Get this wrong and every override silently misses everything below the first level.

Second — and this is the one with the picture — nodes carry a field called overrideKey. When a component is copied between files (this kit imported its icon set from a library), every node gets a fresh GUID, but overrides keep referencing the original. The overrideKey is the original identity, preserved. Match overrides against node.overrideKey ?? node.guid and 1,040 remapped nodes snap into place. Match against node.guid alone and you get the left half of this:

Before and after of the Paper Kit logo component: left, a mangled black rounded square with a stray white notch; right, the correct smiling paper-document icon in a dark rounded square

The kit's logo component, before and after one line: overrides matched by guid (left) versus overrideKey ?? guid (right). The derived-geometry overrides that carve the smiling document out of the black plate simply never bound on the left.

The renderer that produces these images — decode, expand, rasterize any region at any scale — is about 600 lines of TypeScript (tools/fig.ts) plus a canvas library. It reproduces every page of the kit pixel-close to Figma's own thumbnails. That was the checkpoint where this stopped being archaeology and became a build pipeline.

The Paper Kit Welcome page rendered by tools/fig.ts: the Wireframe PAPER KIT cover with dozens of wireframe components, and six white document cards with illustrations, text and forms

The kit's Welcome page, rendered entirely by our decoder from the file's own derived geometry — instances expanded, masks applied, glyph outlines filled. No Figma runtime, no fonts, no network.

A bundler for design files

Now the PSP part. The naive plan — ship the vectors, tessellate on device — dies on arithmetic: tens of thousands of antialiased paths per screen against a 333 MHz in-order CPU with no JavaScript JIT. The honest plan is the one every map application converged on: rasterize offline into a tile pyramid, stream tiles on demand. Google Maps, for wireframes, on a PSP.

So Pocket Figma's build step is a cooker, exactly parallel to OpenStrike's pocket3d-cook, and it bakes just as aggressively:

PaperKit.fig fig-kiwi · 22 MB 14,430 nodes carries its own schema tools/fig.ts build time, on your laptop · decode via the embedded schema · expand 2,293 instances (overrideKey remap · masks) · fills + strokes + glyphs from the file's derived geometry · rasterize pages in strips a ladder of halving scales per page tile cooker TILESET pyramids · 'PKTS' · one 256-color CLUT per page · classify: bg / solid / textured · PackBits-RLE the indices · dedupe identical tiles Welcome 0.8 MB · Components 1.9 Stickers 0.2 · Examples 3.1 = 5.9 MB, four pages, committed linked into the EBOOT — tiles decode straight out of read-only memory; nothing is ever “loaded” same ideology as the Tailwind→binary style table, the font atlases, and OpenStrike's .p3d — moved cost is free cost

Three cooker decisions carry most of the weight:

  • CLUT8, one palette per page. Tiles are 8-bit indices into a 256-color palette — 4× smaller than RGBA before compression, and the format the PSP's GPU natively samples through its hardware color-lookup table. A paper wireframe kit is nearly monochrome; 256 colors per page is generous enough to hold the photos too.
  • Whitespace is a directory entry, not pixels. A wireframe page is mostly nothing — paper background and flat card fills. The cooker classifies every tile: background tiles vanish entirely, uniform-color tiles become a 4-byte "solid" marker in the tile directory, and only tiles with actual ink get a pixel stream (run-length encoded, because flat fills compress brutally).
  • The zoom ladder is capped per page. Each page bakes at halving scales from a chosen maximum — 100% where small labels justify it (the Components sheet), 50% where they don't (the poster-sized Examples wall) — down to a level that fits one screen.

The classification is worth seeing, because it is the whole memory story in one image:

The Examples page overlaid with its finest-level tile grid: blue tiles cover the phone wireframes (textured), yellow tiles cover the white section backgrounds (solid), unshaded tiles are page background

The Examples page — sixteen sections of phone mockups on a 52×21 tile grid at the finest zoom level. Blue tiles carry pixels (615). Yellow tiles are solid colors — a directory entry, zero texture memory (441). Unshaded is page background (36). 44% of the sharpest level of the busiest page costs nothing to draw.

Streaming a canvas through 2 MB of VRAM

At runtime the viewer is a new PocketJS component, <DeepZoom>, and underneath it, the engine grew the plumbing every deep-zoom canvas needs — which was the real agenda all along. Pocket Figma is the first consumer; the machinery is generic.

<DeepZoom> — three nodes viewport · overflow-hidden, never moves the scissor comes from its own box — Gallery's rule overview world · coarsest level, pinned a not-yet-streamed tile shows its low-res self, never a hole active world · the mip that matches the zoom tiles mount imperatively · 2 streams/frame · LRU eviction per frame: 3 paint-only writes per world translateX · translateY · scale — native-ticked, no relayout loadTileTexture(key, index) TILESET bytes sit in .rodata — the EBOOT's read-only data, already in RAM RLE-decode one 64 KB tile → CLUT8 texture zero JavaScript-heap transit on the PSP handle = generation ⊕ slot — a freed handle draws nothing, never a stranger's texture GE samples through its 256-entry CLUT, bilinear solid tiles never reach this path at all — the manifest says “that tile is #ffffff” and a colored rect draws it

A few of these boxes deserve a sentence. Tile churn is the fastest way to discover why texture handles need generations: pan for ten seconds and hundreds of textures are created and freed; a stale handle that silently pointed at a recycled slot would paint the wrong tile somewhere on screen, rarely, unreproducibly. Generation-tagged handles (the same trick PocketJS node ids already use) turn that whole bug class into "draws nothing." The streaming budget — two tile decodes per frame, nearest-to-center first — keeps the worst frame bounded; the pinned overview underneath means the cost of not-yet-streamed is blur, not blankness. And bilinear filtering is per-texture opt-in: the rest of PocketJS stays on nearest sampling (its byte-exact goldens depend on it), while tiles get the GE's free smooth minification between mip levels.

The result, on hardware whose entire video memory is 2 MB: a 26,000-pixel-wide artboard you can sweep across with the nub while the mip ladder swaps under your thumb.

Pocket Figma on PSP showing the whole Welcome page fit to screen at 8% zoom: the cover and six document cards, with the HUD reading Welcome, the controls hint, and 8%

Frame 0 on the PSP: the Welcome page fit-to-screen at 8%. Every visible tile streamed from read-only memory during boot; the HUD is ordinary PocketJS text nodes.

The viewer at 100% zoom on the kit's calendar component: JANUARY 20.., weekday headers M T W Th and handwritten dates, dot-grid background on the left

100% zoom on the kit's calendar component — Patrick Hand digits from the file's own glyph outlines, antialiased at bake time, CLUT-sampled at runtime. This capture is from the wasm reference rasterizer; the PSP's GE output of the same frame differs only in its bilinear rounding.

Time is still an input

Everything above obeys the determinism rules — and panning a canvas turned out to be their nicest stress test yet, because it forced a real decision. The analog nub is new input surface: the frame contract grew from frame(buttons) to frame(buttons, analog), the DevTools flight recorder now tapes the stick alongside the buttons (a centered stick adds zero bytes, so every pre-analog tape replays unchanged), and hosts without a stick just... don't pass one.

The decision was about motion. The obvious pan integrator — "each frame, move by velocity" — quietly breaks the subsampling theorem: a 15 Hz world would pan a quarter the distance per virtual second. <DeepZoom> instead integrates once per 1/60 s tick, however many ticks the host's clock policy packs into a frame. Our CI drives the same scripted journey — zoom in, nub-pan to the cover, release into the momentum glide — at 60, 15, and 5 Hz, and asserts the settled screens are byte-identical. It also re-runs the journey with random sleeps, garbage-collector churn, and allocation pressure injected between frames. Nothing changes. The wall clock is not an input; now the stick is, and it is recorded.

The same property is why we could verify the PSP build without owning a fleet of them: the emulator's deterministic software renderer dumps framebuffers, and the byte-exact e2e goldens for every pre-existing PocketJS demo passed unchanged through the entire engine surgery — new texture formats, new ops, a re-keyed GPU cache, a two-argument frame contract. Thirty-five wasm goldens, four PSP demos, zero diffs. That is what let three parallel strands of this work land in one branch without fear.

What it costs

PSP user RAM                                24.0 MB
├─ executable (engine + QuickJS + bundle
│    + all four tile pyramids)               ~8.9 MB
├─ arena (QuickJS heap, core, resident
│    tile textures ≈ 24 × 65 KB) — budget    ~4 MB
└─ free                                     ~11 MB
frame budget: JS integrator + ≤2 tile decodes + GE ≪ 16.7 ms

The shipped EBOOT.PBP is 9.1 MB — 6.2 MB of that is the four tile pyramids, and another 150 KB is the XMB icon and backdrop, which are of course rendered from the .fig too. The viewer's per-frame JavaScript is a few hundred microseconds of integrator math plus at most two 64 KB RLE decodes in Rust; the GE draws a screenful of textured quads through its palette and is idle again. This machine has headroom to spare — the interesting ceiling is the Memory Stick's patience for nine-megabyte executables, not the frame.

Standing in Evan Wallace's world

One more thing, and it is the part we will remember.

Nearly every layer this project touched, on the file's side of the wall, traces back to one person. The .fig container announces itself with the name of kiwi, Evan Wallace's schema language — we decoded his file format using his own published tools. The derived geometry that made a weekend port possible — strokes pre-outlined, booleans pre-evaluated, text pre-shaped into glyph outlines — is the visible fingerprint of the rendering engine he built for Figma — C++ speaking WebGL inside a browser tab, started in 2012, when "a professional design tool on the web" was a bet most people considered lost in advance. The discipline of that engine — compute once, pin the result, replay it anywhere — is the same discipline this entire runtime family is built on. We just replayed it somewhere he probably didn't expect: a handheld from 2004, through a hardware palette lookup, under a thumb-stick.

Reading a format that describes itself, finding overrideKey by staring at GUIDs until the remap revealed itself, watching his smiley-document logo assemble out of derived-geometry overrides — deeply understanding, and then faithfully reconstructing, a world that one brilliant engineer built: doing that with an AI collaborator — asking it to hold the whole kiwi schema in its head while we argued about guidPath scoping at 2 a.m. — has been one of the most beautiful experiences we have had building software. The tools are new. The feeling — of finally seeing how a thing you admire actually works — is the oldest one in engineering.

Thank you, Evan. 🫡

Open it

  • pocket-stack/pocket-figma — MIT. bun run build for the bundle, bun run psp for the EBOOT (icon and XMB backdrop included — rendered, naturally, from the .fig), bun run desktop for a windowed build. The engine layer — TILESET format, streaming ops, <DeepZoom> — lives in PocketJS.
  • The kit is the Paper Wireframe Kit by Method (CC BY 4.0) — the baked tiles are committed; re-baking from the .fig is one command.
  • No PSP? PPSSPP runs the EBOOT as-is. Real hardware wants custom firmware and a Memory Stick with 10 MB to spare.
  • pocket-figma is the first pocket-<product> app. pocket-notion and pocket-linear want to exist; the manifest spec they will share is written. Pocket Studio and the Pocket Store are next.

Follow @pocket_js for what's next. The pocket keeps getting deeper.