@rbayuokt/react-native-psp
v0.1.2
Published
React Native for the Sony PSP. Real React and react-reconciler on a custom renderer, with Yoga layout and QuickJS.
Maintainers
Readme

react-native-psp
Documentation · Install · Your first app
npm create @rbayuokt/react-native-psp@latest my-psp-appWatch it run on a real PSP-1000: deploying over USB, walking the showcase through layout, text, images, animation, lists, controls etc, then editing a screen and watching it live reload.
An experimental React Native target for the Sony PSP.
It uses real React and a real react-reconciler host config, with a custom PSP
renderer, layout engine, controller focus model, and JavaScript runtime sized
for 32 MB of RAM and a 333 MHz MIPS CPU.
It builds one EBOOT.PBP for the whole PSP family. Development and testing have
been on a PSP-1000, which is the model to target: it has half the RAM of
every later one, so what fits there fits everywhere. The 2000, 3000, Go and
Street run the same homebrew but have not been tried. The Street has no Wi-Fi at
all, so fetch and the log transport cannot work on it.
This is not a React-inspired UI library. React itself runs on the device.
Why this exists
My younger brother bought a PSP for Rp 250,000, about 14 US dollars. It sat around running games from 2004, and I wanted to do something else with it.
Specifically I wanted to write apps for it in React Native. Not a React-looking API on top of C, the actual thing, so the UI work I already know how to do would still apply.
So I read how you draw a UI on a PSP. It is all C. You talk to the graphics unit by building a display list of vertices and flushing it once a frame. The GU samples that ship with the SDK are the real documentation.
That is what made the rest possible. Yoga is C++, compiles for MIPS, and is the layout engine React Native already uses, so flexbox comes along with it. React and react-reconciler are plain JavaScript and run on anything that fits in memory, which QuickJS-ng does. So the whole thing became React on the device, a host config holding the node tree in C, Yoga for the rectangles, and the GU to paint them.
How it draws, since you were going to ask
Nothing on screen is a widget.
flowchart TD
subgraph js["JavaScript, runs when state changes"]
A["View, Text, Pressable"] --> B["react-reconciler"]
end
subgraph native["C, runs every frame"]
C["flat array of nodes"] --> D["Yoga resolves every rectangle"]
D --> E["paint walk"]
E --> F["GU display list"]
end
B -->|"int16 handles"| C
F --> G["flush, swap buffers, 480x272"]<View> and <Text> are host component names, and react-reconciler turns each
one into an int16 handle into a flat array of nodes that lives in C. JavaScript
holds the handles and never mirrors the tree.
Once a frame, Yoga walks that array and resolves every rectangle, then a paint pass walks it again and pushes two vertices per rectangle into the display list. Two, because an unrotated rectangle is a sprite to the GU. Rotate it and it becomes triangles. Text is one draw call per string out of a bitmap atlas baked at build time, tinted by the vertex colour so a colour change costs no extra texture. Then the list is flushed, the buffers swap, and that is the frame.
Live reload, so this is bearable to work on
Rebuilding the binary and copying it to a memory stick for every change would have killed the project early. So the running app watches for a new bundle and reloads itself instead.
flowchart TD
A["you save a file"] --> B["esbuild rebuilds the bundle"]
B --> C["the PSP sees the same folder over USB"]
C --> D["app checks the file every 30 frames"]
D --> E["tear down the JS runtime, boot a fresh one"]
E --> F["evaluate, with a progress bar on screen"]
F --> G["new UI, about 2.5 seconds after you saved"]Three terminals started once, and after that you just save. The progress bar matters more than it sounds, because a PSP sitting frozen for two seconds looks exactly like a PSP that has crashed. It is driven from QuickJS's interrupt handler, so it animates during the evaluation rather than freezing at whatever the last step set.
This is live reload and not Fast Refresh. The whole runtime is replaced, so hook state resets on every update. Details and the plan for real Fast Refresh are in docs/fast-refresh.md.
What was hard
I thought memory would be the problem. It is not. React, react-reconciler and QuickJS come to about half a megabyte, and the PSP gives you 21.75 MB.
The limit is how much of the tree React can rebuild in 16.6 ms. A 78 node screen takes 73 ms of JavaScript to build, and only 2.6 ms of that is the C side. So most of the work here went into not rebuilding things. Screens stay mounted and navigating flips a style prop. Animation runs in C, so a spinning logo costs zero renders. Anything that changes every frame lives outside React.
What cost me the most time was trusting the emulator. PPSSPP gets memory exactly right and runs between 1.6x and 3.2x too fast, which you can adjust for. The problem is the bugs it does not reproduce. Yoga uses a quiet NaN for "undefined" and does arithmetic on it. The PSP's FPU raises an exception on NaN where a normal FPU carries it through. Ran fine in PPSSPP, died on the first frame on hardware. Three of the four worst bugs in this project only happened on the device.
The rest is what the PSP does not have. No touchscreen, so the D-pad moves a focus cursor, and typing means handing over to the system keyboard. One font at one size in a baked atlas, so no font sizes. No JIT. 1024 nodes, fixed. Full list in docs/limitations.md.
Status
The MVP vertical slice is verified on real PSP-1000 hardware: the app boots,
renders, lays out with flexbox, takes D-pad focus, and fires onPress from the
Cross button back into React state.
Columns are where each thing has actually been run. PSP-1000 is the model on the desk; the rest of the family should behave the same but is untested.
| | PPSSPP | PSP-1000 |
| --- | --- | --- |
| Boot, GU renderer | Verified | Verified |
| View, Text, Pressable | Verified | Verified |
| Flexbox layout (Yoga) | Verified | Verified |
| D-pad focus, focus ring | Verified | Verified |
| Cross to onPress to setState | Verified | Verified |
| React 19 + react-reconciler on device | Verified | Verified |
| Live reload on save | Verified | Verified |
| Image, baked art with tinting | Verified | Verified |
| Animation, shared values driven in C | Verified | Verified |
| ScrollView, focus-driven | Verified | Verified |
| Stack navigation | Verified | Verified |
| Text wrapping, numberOfLines | Verified | Verified |
| borderRadius, borderWidth | Verified | Verified |
| FlatList, windowed | Verified | Verified |
| Modal with focus trap | Verified | Verified |
| TextInput on the system keyboard | Verified | Verified |
| AsyncStorage, battery | Verified | Verified |
| SectionList, onLongPress, onArrow | Verified | Verified |
| Focus restored on the screen you go back to | Verified | Verified |
| Button, Switch, Slider, ActivityIndicator | Verified | Verified |
| focusStyle, focus colours swapped in C | Verified | Verified |
| Live reload back onto the screen you were editing | Verified | Verified |
| Alert on the system dialog | Verified | Verified |
| textAlign | Verified | Verified |
| Sound effects | Verified | Verified |
| fetch over Wi-Fi | Verified | Verified |
| Wi-Fi handshake for the log transport | Verified | Needs testing |
| Fast Refresh (state preserving) | Planned | Planned |
Timings in this README come from PPSSPP, which is optimistic by 1.6x to 3.2x. The behaviour above is confirmed on a PSP-1000; the numbers are not.
Example
import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from '@rbayuokt/react-native-psp';
const s = StyleSheet.create({
screen: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 },
button: { paddingHorizontal: 10, paddingVertical: 5, backgroundColor: '#282c34' },
label: { color: '#61dafb' },
});
export default function App() {
const [count, setCount] = useState(0);
return (
<View style={s.screen}>
<Text style={s.label}>Hello PSP</Text>
<Pressable style={s.button} onPress={() => setCount((n) => n + 1)}>
<Text style={s.label}>{`count ${count}`}</Text>
</Pressable>
</View>
);
}The demo that ships with the repo is examples/showcase: a welcome screen with
the runtime facts, then a menu of eight topics covering layout, text, images,
animation, lists, controls, device APIs and the export list. There is also a
playground screen, which is an empty canvas you edit while the app is running to
watch live reload work. Run it with npm run build and open EBOOT.PBP.
Measurements
Taken on a physical PSP-1000 unless noted. PPSSPP matches hardware exactly on memory and is optimistic on timing by 1.6x to 3.2x, so never read speed off the emulator.
| | Value | Where |
| --- | --- | --- |
| Usable heap | 22,806,528 bytes (21.75 MB) | PSP-1000 |
| QuickJS runtime + context | 54,960 bytes | PSP-1000 |
| React + react-reconciler heap | 446,928 bytes | PPSSPP (memory matches hardware) |
| QuickJS engine code | 627,097 bytes text | MIPS build |
| Yoga | 84,962 bytes text | MIPS build |
| Bundle (React + reconciler + host config) | 180,371 bytes raw, 54,958 gzip | esbuild |
| Precompiled bytecode | 801,107 bytes | qjsc-host |
| EBOOT.PBP | 1,721,026 bytes | build output |
| Idle frame, 48 nodes | 59 fps, 23 us JS | PPSSPP |
| Loading precompiled bytecode | 139,255 us | PPSSPP |
| Worst frame during a navigate | 33,366 us | PPSSPP |
The idle figure matters more than it looks. An earlier demo that called
setState every single frame cost 12.6 ms of JS per frame. A normal
event-driven UI does no work between inputs. React's cost here is proportional
to how often state actually changes.
One React commit costs about 10 ms on this hardware whatever it touches, so
anything that changes on every button press has to stay out of React. That is
why the focused look is focusStyle, a set of colours the C side swaps during
paint: moving the cursor down a list renders nothing at all. Same reason
animation runs in C.
Tree size, not memory, is the practical constraint. Screens are kept mounted so navigating rebuilds nothing, which means the node count only grows. The showcase boots at 48 nodes with one screen built, and reaches 477 of the 1024 available once all twelve have been opened. See docs/navigation.md.
Requirements
- macOS (the font atlas generator uses CoreText)
- PSPDEV toolchain, plus
brew install libmpc - Node.js 22+
- PPSSPP for iteration
- A PSP with custom firmware for hardware validation. Any model runs the binary; a PSP-1000 is the one to test against, since it has the least RAM
See docs/getting-started.md for exact setup steps.
Development
Use live reload. Save a file and the running PSP updates itself, with a progress bar while the bundle re-evaluates. No rebuild command, no redeploy, no button press on the device.
Three terminals, started once:
npm run usb # 1: serves this directory to the PSP over USB
npm run shell # 2: pspsh, then run ./rnpsp.prx once
npm run watch # 3: rebuilds the bundle on every saveThen just edit. Terminal 3 reports each rebuild, terminal 2 shows
[Dev] bundle changed, reloading, and the PSP is updated about 2.5 seconds
after you hit save.
It works in PPSSPP too, which maps host0: to the EBOOT's directory.
This is live reload, not Fast Refresh: useState resets on each update. See
docs/fast-refresh.md for the difference and for what
Fast Refresh would add.
Build
npm install
npm run build # bundles TS, then builds EBOOT.PBP and rnpsp.prx
npm test # 377 host assertions + 24 navigator, host-side
npm run typecheckRun EBOOT.PBP in PPSSPP, or copy it to PSP/GAME/rnpsp/ on a Memory Stick.
Manual rebuild and redeploy is the fallback for when USB is unavailable; prefer
live reload above.
Architecture
App.tsx
-> esbuild (es2015, production)
-> QuickJS on MIPS
-> React 19.2.8 + react-reconciler 0.33.0
-> react-native-psp host config
-> C node tree (JS holds int16 handles, the tree lives in C)
-> Yoga layout
-> sceGu renderer
-> 480x272Controller input runs the other way: sceCtrl to a focus manager to an event
dispatched into React, which sets state and re-renders.
More detail in docs/architecture.md.
Documentation
Everything below is on the docs site too, with search.
- Installation
- Your first app
- Getting started
- Animation
- Navigation
- Native modules
- Live reload and Fast Refresh
- Debugging
- Architecture
- Compatibility with React Native
- Layout
- Text
- JavaScript runtime
- Limitations
- Roadmap
Installing
npm install @rbayuokt/react-native-psp [email protected]The scope is not a flourish. npm rejected the plain name for being too close to
react-native-fs, so it lives under @rbayuokt. Everything else, the repo, the
docs and the binary, is still react-native-psp.
Full steps in docs/installation.md.
Licence
MIT, see LICENSE. The vendored engines are MIT too; see NOTICE for what is bundled and where it came from.
Credits
Built on QuickJS-ng, PSPDEV, and React.
Created by @rbayuokt, made with ❤️ and 🎵
