sl0pboy — a Game Boy Color emulator that lives in your terminal
A from-scratch GB / GBC emulator in plain C11 that paints straight to the terminal — no window, no GPU. Two ways to draw it:
- half-blocks (the default): truecolor ANSI + the upper-half-block trick
(
▀), where every character cell = two stacked pixels (fg on top, bg on bottom), squeezing the 160×144 screen into 160×72 cells. Works anywhere with truecolor. - sixel (
--sixel): real pixels on terminals that speak it (see below). - kitty graphics (
--kitty): real pixels without sixel's 256-color palette, plus a translucent HUD layered over the game (see below).
Resize the terminal and SIGWINCH triggers a full repaint.
Build & run
git config core.hooksPath hooks # once: clang-format pre-commit hook
make
./build/sl0pboy path/to/rom.gb # just play it (ROM goes last)
./build/sl0pboy --test 30 rom.gb # run 30s, serial -> stderr (test roms)
./build/sl0pboy --shot 60 out.ppm rom.gb # 60 frames in, dump a PPM
./build/sl0pboy --sixel 3 rom.gb # real pixels via sixel, 3x zoom
./build/sl0pboy --kitty 3 rom.gb # ...or via the kitty protocol
./build/sl0pboy --sixel 3 --chrome rom.gb # ...in a lil' handheld body
./build/sl0pboy --bios rom.gb # boot through the real BIOS first
./build/sl0pboy --sock rom.gb # control/debug socket (/tmp/sl0pboy.sock)
./build/sl0pboy --headless rom.gb # no video, serial <-> stdio
./build/sl0pboy --help # the whole menu
Needs a truecolor terminal (COLORTERM=truecolor) at least 160 columns wide.
Boot ROM / BIOS (--bios)
By default we skip the boot ROM and drop you into the cartridge with the
documented post-boot register state. Want the real power-on show — the Nintendo
logo drop, the CGB color splash + chime? Pass --bios [path]:
./build/sl0pboy --bios roms/pky.gbc # defaults to bios/gbc_bios.bin
./build/sl0pboy --bios path/to/boot.bin roms/pky.gbc
./build/sl0pboy --bios --sixel 3 --chrome roms/pky.gbc # boot inside the shell
It maps the boot ROM over low memory (with the CGB header hole at
0x0100-0x01FF where the cart peeks through) and unmaps itself when the ROM
pokes 0xFF50, just like hardware. 256 bytes → boots as DMG; 2304 bytes (like
the bundled bios/gbc_bios.bin) → boots as CGB, which also colorizes plain DMG
games via the boot ROM's compatibility palette.
SL0PBOY boot logo (tools/mk_sl0pboy_bios.py)
Fork the CGB boot ROM so the power-on animation reads SL0PBOY instead of GAME BOY — same italic drop, colorize and chime:
pip install pillow # needs Pillow + a bold-italic TTF
tools/mk_sl0pboy_bios.py # bios/gbc_bios.bin -> bios/sl0pboy_bios.bin
./build/sl0pboy --bios bios/sl0pboy_bios.bin --sixel 3 --chrome roms/pky.gbc
SL0P_TEXT='SL0P' tools/mk_sl0pboy_bios.py # or brand it whatever you like
It also drops a bios/sl0pboy_bios.bin.preview.png so you can eyeball the
wordmark before booting. Env knobs: SL0P_TEXT (default SL0PBOY), SL0P_FONT
(default LiberationSans-BoldItalic).
How it works. The stock CGB boot ROM draws its GAME BOY wordmark from a
192-byte blob at 0x0607 — the routine at 0x03F0 copies it to VRAM tiles
0x08-0x37: 48 tiles (16×3, tile-sequential), 4 bytes each, every byte one
8-px row that the copy loop doubles vertically (plane 1 stays 0). The script
re-encodes that blob from a bold-italic bitmap and patches it in place —
no code moves, and the cart's own Nintendo logo (0x0104) is untouched, so the
boot ROM's integrity check still passes and it hands off to the game normally.
bios/is git-ignored (the stock boot ROM is copyrighted Nintendo material), so the forked binary lives only in your working tree — regenerate it any time from your localbios/gbc_bios.bin. The script itself ships only our original logo art + the patch offset.
Headless serial console (--headless)
Bringing up an OS/monitor on the emulated Game Boy? --headless kills all
rendering and wires the serial port straight to stdio:
- bytes the GB transmits (
SB/SCw/ internal clock) → stdout - stdin feeds back as the received byte each transfer (non-blocking;
0xFFopen-bus when nothing's waiting)
Paced to native speed by default; --uncapped runs flat out. It's just stdio,
so it pipes clean:
./build/sl0pboy --headless os.gb # interactive byte console
./build/sl0pboy --headless --uncapped os.gb # gotta go fast
printf 'ls\n' | ./build/sl0pboy --headless os.gb # feed input, grab output
./build/sl0pboy --headless os.gb | tee session.log
The model is what a GB program expects: write byte → SB ($FF01), kick with
SC = $81 ($FF02, start + internal clock). Output shows up instantly and the
transfer-complete interrupt still fires. (--test still logs serial to
stderr like before.)
Sixel output
On sixel-capable terminals (xterm -ti vt340, foot, WezTerm, mlterm, Contour,
recent iTerm2…) --sixel [scale] draws real pixels instead of half-blocks:
./build/sl0pboy --sixel rom.gb # 2x zoom (320×288)
./build/sl0pboy --sixel 4 rom.gb # 4x zoom
Each frame builds its own ≤256-color palette from the framebuffer (4 shades on
DMG, a small set on CGB; quantized coarser only if a frame overflows) and emits
standard sixel bands with per-column RLE. scale is 1–6. Sixel frames go out in
full (no diffing) — pair with --frameskip on slow terminals.
Kitty graphics (--kitty)
Sixel is a 1987 printer format wearing a terminal costume: 256 colors per image, no alpha, no compositing, one flat picture. On kitty, ghostty, WezTerm, Konsole and friends there's a better option:
./build/sl0pboy --kitty rom.gb # 2x zoom
./build/sl0pboy --kitty 4 rom.gb # 4x zoom (1-8)
./build/sl0pboy --kitty 3 --chrome rom.gb # the handheld body works here too
What it buys over --sixel:
- Truecolor, unquantized. The framebuffer goes over as raw RGB888. No per-frame palette build, no coarser quantization when a CGB game overflows 256 colors — what the PPU drew is what you see, byte for byte.
- A HUD that floats above the game. Cart title, live render rate, frameskip,
turbo, and a per-button readout that lights up as you play — drawn as a
separate alpha-blended image on z-index 1, so it's composited over the frame
by the terminal instead of being burned into the picture.
htoggles it,--no-hudstarts without it. - Idle frames cost nothing. Each image is diffed against the last one and dropped if identical, so a pause menu or a text box stops the traffic dead.
- Constant bandwidth at any zoom. The pixels ship at 1x and the terminal
scales them into a cell box, so
--kitty 6costs the same bytes as--kitty 1(the image is padded to a whole number of cells first, so the zoom stays exact instead of stretching by a few percent).
How the pixels get there
| transfer | escape carries | when |
|---|---|---|
shm |
the name of a POSIX shared-memory object | terminal is on this machine |
b64 |
the pixel bytes, base64, in 4KB chunks | anywhere (ssh, tmux) |
Shared memory means a ~60-byte escape per frame instead of ~90KB, so it's the
default when we can tell we're talking to a local kitty/ghostty; everything else
(including anything inside tmux, which might be attached from another machine)
uses inline base64. Force either with --kitty-shm / --kitty-b64. In shm mode
the frame is upscaled here rather than by the terminal, so pixel edges stay
hard. Objects are named /sl0pboy-<pid>-<n>; the terminal unlinks each one
after reading it and we reclaim any it didn't a few frames later.
Inside tmux every command is wrapped in a passthrough DCS (needs
set -g allow-passthrough on). tmux doesn't track images, so it repaints over
them — harmless here, since we redraw every frame anyway.
--kitty and --sixel are mutually exclusive; the kitty one wins.
Poking at the wire format
Nothing about a graphics stream is human-readable, so tools/kitty_decode.py
reassembles a capture back into images (chunked payloads, tmux wrapping and all)
and can assert a frame is pixel-identical to a --shot:
./build/sl0pboy --shot 1 /tmp/shot.ppm rom.gb
./build/sl0pboy --kitty 1 --kitty-b64 rom.gb > /tmp/cap.bin # ^C after a bit
tools/kitty_decode.py /tmp/cap.bin -o /tmp/frames --check /tmp/shot.ppm
Game Boy chrome
--chrome (sixel/kitty) wraps the LCD in a DMG-style sl0pboy handheld:
bezel + accent stripe, power LED, silk-screened branding (sl0pboy logo,
DOT MATRIX WITH STEREO SOUND, button labels), D-pad, A/B, Start/Select,
speaker grille. Buttons light up while held, so it doubles as an input display:
./build/sl0pboy --sixel 3 --chrome rom.gb
./build/sl0pboy --kitty 3 --chrome rom.gb
Flat colors → only a handful of extra palette entries. It composites onto a
bigger canvas (240×342 px pre-scale), so a given scale is proportionally
larger than the bare LCD.
LCD palette (--palette)
By default the LCD shows true colors (greenish DMG shades or full CGB). Want the
retro monochrome-LCD look? --palette NAME remaps every LCD pixel to a 4-shade
ramp by luminance — this even greens out full-color CGB games:
./build/sl0pboy --sixel 3 --chrome --palette dmg rom.gb # classic pea green
./build/sl0pboy --sixel 3 --green rom.gb # alias for --palette dmg
./build/sl0pboy --palette gray rom.gb # GB-Pocket grayscale
Names: dmg/green (the iconic #9bbc0f pea-green ramp) and pocket/gray.
Applies to every render path — half-block, sixel, kitty, chrome — and only
touches the LCD, never the shell.
Speed & frame skipping
Games always run at the correct rate. But writing a frame to the terminal is the expensive part, so drawing is decoupled from emulation — the machine keeps ticking in real time while the terminal refreshes less often:
./build/sl0pboy --frameskip 2 rom.gb # emulate 60fps, draw every 3rd (20fps)
./build/sl0pboy --fps 120 rom.gb # 2x game speed
./build/sl0pboy --uncapped rom.gb # turbo — as fast as it'll go
| Flag | Meaning |
|---|---|
--frameskip N |
draw 1 of every N+1 emulated frames (default 0) |
--fps N |
emulation speed cap (default 59.73 native) |
--uncapped/--turbo |
run emulation as fast as possible |
Frame skip and turbo are live-adjustable with [ / ] and f (turbo also
clamps redraws to ~60 Hz). On exit it prints frames emulated vs. actually drawn.
Controls
| Key | Button |
|---|---|
| arrows / WASD | D-pad |
z / j |
A |
x / k |
B |
| enter | Start |
| space | Select |
f |
toggle fast-forward (turbo) |
h |
toggle the HUD overlay (--kitty) |
[ / ] |
frame skip down / up |
q / Ctrl-C |
Quit |
Terminals don't report key-release, so buttons stay held for a few frames after each press (leaning on key-repeat for sustained holds).
External control channel (FIFO)
--fifo [path] (default /tmp/sl0pboy.fifo) opens a named pipe any process can
poke. Unlike the keyboard it can send true key-release events → deterministic
input for scripting and tests.
./build/sl0pboy --fifo /tmp/sl0pboy.fifo rom.gb &
echo 'a' > /tmp/sl0pboy.fifo # tap A
echo 'start' > /tmp/sl0pboy.fifo # tap Start
echo '+left' > /tmp/sl0pboy.fifo # press and HOLD Left
echo 'a b' > /tmp/sl0pboy.fifo # tap A+B (Left still held)
echo '-left' > /tmp/sl0pboy.fifo # release Left
echo 'release' > /tmp/sl0pboy.fifo # release everything
echo 'a:30' > /tmp/sl0pboy.fifo # tap A, held 30 frames
One line, space/comma-separated, case-insensitive:
| Token | Effect |
|---|---|
a b start select up down left right |
momentary tap |
name:N |
tap held for N frames |
+name |
press and hold until released |
-name |
release a held button |
release |
release everything held |
GBC_INPUT_DEBUG=1 traces joypad state to stderr. The FIFO is input-only and
can't reply — for introspection use the socket below (it's a strict superset).
Socket control & debug channel
--sock [path] (default /tmp/sl0pboy.sock) opens a Unix stream socket. It does
everything the FIFO does plus CPU/memory introspection, and — being a socket
— it replies and pushes async events when execution stops:
./build/sl0pboy --sock /tmp/sl0pboy.sock rom.gb &
socat - UNIX-CONNECT:/tmp/sl0pboy.sock # interactive
# or: nc -U /tmp/sl0pboy.sock
Line-based: one command per line. Replies start ok/err; async notifications
start event. Numbers take 0x hex or decimal; memory bytes are hex so
read output feeds straight back into write.
| Command | Effect |
|---|---|
ping |
pong liveness check |
help |
one-line command summary |
state |
ok state running\|paused |
cpu (regs) |
dump CPU context (af/bc/de/hl/sp/pc/ime/halted/cycles) |
reg <name> <val> |
set a register/flag (a..l, af..hl, sp, pc, ime) |
read <addr> [len] |
read len bytes (bus read), as hex |
write <addr> <bytes…> |
write bytes: de ad be ef or deadbeef |
step [n] |
single-step n instructions, replies w/ new context |
continue (c) |
resume free-running emulation |
pause |
halt at the next instruction |
break <addr> |
add a PC breakpoint (no arg lists them) |
delete <0xaddr\|#idx\|all> |
remove a breakpoint |
watch <addr> |
value-change watchpoint on a byte (no arg lists) |
unwatch <0xaddr\|#idx\|all> |
remove a watchpoint |
record start <path> [everyN] |
capture frames (1 of every N); GIF w/ tools/gbgif.py |
record stop |
finish the capture (replies w/ frame count) |
record (status) |
is a capture active? |
input play <path> [reset] |
replay an input movie (frame-locked); reset = from clean power-on |
input stop |
stop playback (releases all buttons) |
input (status) |
movie playback state |
quit |
shut it down |
| (any button tokens) | same vocabulary as the FIFO |
Video capture & input replay (demo GIFs)
Dump the on-screen frame to a flat file, then bake a GIF:
./gbctl record start /abs/out.gbv 2 # 1 of every 2 frames (~30fps)
...
./gbctl record stop
python3 tools/gbgif.py out.gbv out.gif --scale 3
Capture grabs exactly what's on screen — start with --palette/--chrome and
they're baked in (the .gbv header stores the real dimensions, so chrome frames
come out 240×362 and gbgif.py figures it out automatically). --shot
screenshots honor the same options. Skip them for the bare 160×144 LCD.
Want reproducible demos? Drive the ROM from a synthesized input movie instead
of live input. A movie is a text file of <frames> [buttons...] lines
(a b start select up down left right; none/- = released); the emulator
applies one line's joypad state per frame, so replay is deterministic. Build by
hand or with tools/gbmovie.py:
from gbmovie import Movie
m = Movie()
m.wait(340) # boot + fastboot -> overworld
m.hold("select", 8) # open the SL0P menu
m.tap("down", repeat=13) # cursor to the last item
m.hold("a", 6); m.wait(600)
m.save("demo.gbmv")
input play <path> reset gives a clean, repeatable power-on (keeps cart
ROM+SRAM, zeroes everything else), so a movie replays byte-identically. For a
frame-exact GIF, arm both while paused so they start on the same frame:
./gbctl pause
./gbctl input play /abs/demo.gbmv reset
./gbctl record start /abs/demo.gbv 2
./gbctl continue
... # let it play
./gbctl record stop
python3 tools/gbgif.py demo.gbv demo.gif --scale 3 --start 172
When free-running hits a breakpoint/watchpoint, every connected client gets an async line and the machine pauses:
event stop breakpoint af=0x0840 bc=0x0800 … pc=0x0048 … cycles=72654560
event stop watch 0xFF44 af=0x90C0 … pc=0x4812 …
Missed the event because you weren't connected yet? stopinfo (aka
why/laststop) reports the last stop reason (ok stop breakpoint 0x0150 …).
step runs synchronously and replies immediately with the stop reason + new
context. A quick session:
> pause
ok paused af=0x9040 … pc=0x021D …
> break 0x0150
ok break #0 0x0150
> read 0xFF44 1
ok read 0xFF44 1 90
> write 0xC000 de ad be ef
ok write 0xC000 4
> reg pc 0x0150
ok reg pc=0x0150
> continue
ok running
event stop breakpoint … pc=0x0150 …
gbctl — the CLI driver (agent/tmux friendly)
./gbctl wraps the socket in a terse CLI that spawns/stops a tmux pane running
the emulator — so an LLM agent (or you) can drive it one tool-call at a time.
Socket auto-resolves to the single live instance.
./gbctl spawn roms/game.gb --uncapped # tmux pane + --sock; waits ready
./gbctl cpu ; ./gbctl read 0xC000 16 ; ./gbctl write 0xC000 deadbeef
./gbctl break 0x0150 ; ./gbctl continue 5 # blocks up to 5s for the stop
./gbctl press a b ; ./gbctl hold left ; ./gbctl release
./gbctl screen ; ./gbctl stop # peek at the pane / tear down
Run ./gbctl bare for the full command list. Driven end-to-end by the
gbc-driver skill.
In the browser (make wasm)
The same core, compiled to WebAssembly, so anyone can try a ROM without building anything:
make wasm # -> web/sl0pboy.wasm (~44KB)
cp ~/dev/gbos/gbos.gb web/roms/ # the demo ROM (gitignored; deploy.sh ships it)
cd web && python3 -m http.server 8000
# / the network lab: several Game Boys on a virtual switch
# /play.html one Game Boy, drop in your own .gb/.gbc
Built with zig cc (no emscripten): it carries its own wasm backend, linker
and wasi-libc, so make wasm needs nothing but zig on PATH. Only the portable
core compiles in — render.c, kitty.c, control.c and main.c are terminal-
and POSIX-bound and are left out; src/wasm.c is the browser's host layer
instead. It's a reactor module, so the page calls _initialize() and then
drives the exports directly:
| export | does what |
|---|---|
gbw_alloc / gbw_free |
staging buffer for handing a ROM to wasm |
gbw_load(ptr,len) |
parse cart + reset (0 = ok) |
gbw_load_bootrom(ptr,len) |
boot through a real BIOS, logo and all |
gbw_run_frame() |
advance exactly one frame |
gbw_set_buttons(mask) |
BTN_* bits; browsers report key-release, so this is the true live state |
gbw_framebuffer_rgba() |
160×144×4, straight into putImageData |
gbw_framebuffer_rgb() |
the raw PPU buffer, same bytes --shot writes |
gbw_sram_ptr/_size/_load |
cartridge RAM — is the save file here |
The whole host interface is four WASI fd_* imports (stdio, from the cart
info banner), shimmed in ~20 lines of JS; the page routes them to the devtools
console. No filesystem, no sockets, no WASI runtime needed.
web/index.html is the front-end: drag-drop a ROM, pixelated canvas at any
zoom, keyboard mapped like the native build, and battery saves persisted to
localStorage per cart title (autosaved every 5s and on unload). Frame pacing
is an accumulator against 59.7275Hz with capped catch-up, so a backgrounded tab
skips ahead instead of running hundreds of frames in one burst.
No audio — the APU is still stubbed (see below).
The network lab (web/index.html)
The landing page runs several Game Boys in one tab, wired to a virtual switch: each one boots gbos, gets a DHCP lease, and they ping each other. Plug one in, plug in another, unplug either; addresses go back to the pool and get reused.
It is tools/gbhub from the gbos repo (a virtual switch + DHCP server) ported
to ~200 lines of JS in web/gbhub.js — SLIP framing, DHCP OFFER/ACK, unicast
routing, broadcast flooding, and ICMP echo for the gateway. The one thing it
cannot do is NAT to the real internet: that needs a TUN device, and a browser
tab has no raw sockets. So it is a closed network — the Game Boys reach each
other and 10.0.0.1, and nothing leaves the page.
Multiple machines are multiple instantiations of the same compiled module
(WebAssembly.compile once, instantiate N times). The core keeps its machine
in a static, so each Game Boy gets its own linear memory and total isolation
for free — no handle-based API in C.
The page caps at 8, which is a judgement call on top of two real limits. Measured on one thread, emulation only:
| Game Boys | per machine | headroom | wasm memory |
|---|---|---|---|
| 1 | 748 fps | 12.5x | 5MB |
| 4 | 185 fps | 3.1x | 19MB |
| 8 | 92 fps | 1.5x | 38MB |
| 12 | 62 fps | 1.0x | 57MB |
| 16 | 46 fps | 0.8x | 76MB |
So ~12 is where a single thread runs out of road with nothing left for
rendering; 8 holds a measured 60fps each in a real browser. Above that you'd
want a Worker per machine. The other ceiling is the DHCP pool (10.0.0.2–
.39, inherited from gbhub) at 38.
Each instance costs ~4.8MB of linear memory, which is mostly the 1MB ROM plus
allocator slack — it was 20.5MB until the wasm stack was capped at 256KB
(-Wl,-z,stack-size): zig defaults it to 16MB, and that lands in the module's
initial memory, so every Game Boy paid 16.25MB before executing anything.
Bytes that arrive outside a SLIP frame are console I/O, not packets — the
same split gbhub makes. That is why each Game Boy on the page has a text box:
typing into the link port lands on the shell's stdin, exactly like
tools/gbtype does natively. No on-screen keyboard needed to run ping.
The C side of this is two byte queues (gb_serial_rx_push /
gb_serial_tx_drain) standing in for the file descriptors the native build
uses, exposed as gbw_serial_drain / gbw_serial_push. The native link
(--serial-sock) is untouched: preprocessed for a native target, gb.c is
byte-identical to before the change.
Proving it's the same emulator
Both builds advance on the identical frame boundary (gb_run_frame in gb.c),
so the wasm core can be checked against the native one byte for byte rather
than by eyeballing a canvas:
./build/sl0pboy --shot 60 /tmp/ref.ppm roms/cgb-acid2.gbc
node tools/wasm_check.mjs roms/cgb-acid2.gbc 60 --check /tmp/ref.ppm
# OK: wasm frame 60 matches /tmp/ref.ppm exactly (160x144)
Any mismatch is a real portability bug (struct layout, endianness, UB the two
backends resolved differently), not a rendering difference. Currently exact on
cgb-acid2, dmg-acid2, 600 frames of cpu_instrs, MBC5 + battery with an
imported .sav, and a full CGB BIOS boot animation. --sram / --bios /
-o out.ppm are supported too.
Architecture
| File | Does what |
|---|---|
src/types.h |
fixed-width integer typedefs |
src/cart.c |
ROM loading, header parsing, MBC1/2/3/5, battery saves |
src/gb.c |
system bus / memory map, joypad, OAM-DMA, HDMA/GDMA, reset |
src/cpu.c |
Sharp SM83 core (full opcodes, cycle-accurate accesses) |
src/timer.c |
DIV/TIMA/TMA/TAC with falling-edge accuracy |
src/ppu.c |
LCD controller, scanline renderer (BG/window/sprites), CGB |
src/render.c |
terminal truecolor output + raw-mode keyboard/FIFO input |
src/kitty.c |
kitty graphics protocol: shm/base64 transfer, tmux wrap |
src/control.c |
socket control/debug: memory, CPU, step, breakpoints |
src/main.c |
arg parsing, frame loop, timing, screenshot/test modes |
src/wasm.c |
browser host layer: exports the core to JS (wasm build) |
web/index.html |
the browser front-end (canvas, input, localStorage saves) |
Emulation status
Passing:
- blargg
cpu_instrs— all 11 sub-tests - blargg
instr_timing - blargg
mem_timing/mem_timing-2— all 3 - dmg-acid2 + cgb-acid2 — both render correctly
Implemented:
- Full SM83 CPU incl. HALT bug, EI delay, interrupt dispatch
- Accurate timer edge behavior
- PPU modes 0–3, STAT/LYC/VBlank interrupts, window line counter
- CGB: double VRAM banks, BG/OBJ color palettes, tile attributes, master priority, WRAM banking, double-speed (KEY1), HDMA/GDMA
- MBC1/2/3/5 with RAM banking and battery
.savfiles
Not yet:
- APU (audio) — registers stubbed
- MBC3 RTC advancement
- Sub-instruction PPU FIFO timing edge cases
License
For educational use. Test ROMs are downloaded separately and not included.
