PHP Terminal GameBoy Emulator
What it is
Section titled “What it is”This is a working GameBoy emulator that runs real .gb ROMs and draws the screen as text characters in a terminal. The 160×144-pixel GameBoy LCD is rasterized into Unicode Braille characters and printed to stdout; the player controls the game with WASD for the D-pad and , . N M for A/B/Select/Start. There’s ASCII controller art for reference. The CPU, opcodes, LCD controller, and timing tables are all ported from the JS original (Core.php, Opcode.php, Cbopcode.php, LcdController.php, TICKTables.php).
For KN-86 this is the most directly validating reference in the whole research program: it is empirical proof that a terminal can emulate an actual handheld game console in real time. KN-86’s entire premise — a handheld terminal that runs games on an 80×25 amber grid — is exactly what this project demonstrates is possible, just with a different console and a different glyph palette. (Reference “CLAUDE.md Canonical Hardware Specification” for KN-86’s grid; not restated here.)
Rendering — the pixel→cell pipeline (headline validation)
Section titled “Rendering — the pixel→cell pipeline (headline validation)”The renderer lives in one file, src/Canvas/TerminalCanvas.php, and the technique is the thing to steal. Each terminal character cell is a Unicode Braille glyph (U+2800 block) that packs a 2-wide × 4-tall sub-pixel matrix — 8 dots per cell:
Braille dot layout per cell: ,___, |1 4| |2 5| |3 6| |7 8|Each dot maps to a bit in the Braille code point; turning on any subset of the 8 dots selects one of 256 glyphs in the block. The mapping:
```php$this->brailleCharOffset = U+2800; /* blank braille cell */$this->pixelMap = [ /* [row%4][col%2] -> dot bit */ [U+2801, U+2808], /* dots 1 / 4 */ [U+2802, U+2810], /* dots 2 / 5 */ [U+2804, U+2820], /* dots 3 / 6 */ [U+2840, U+2880], /* dots 7 / 8 */];The draw loop walks all 160×144 pixels, and for each on pixel OR-s the right Braille dot bit into the cell that owns it:
for ($y = 0; $y < 144; $y++) { for ($x = 0; $x < 160; $x++) { $charPosition = floor($x / 2) + (floor($y / 4) * 80); if ($canvasBuffer[$x + 160*$y]) { $chars[$charPosition] |= $this->pixelMap[$y % 4][$x % 2]; } }}So 160×144 pixels → 80×36 character cells (160/2 wide, 144/4 tall). The whole GameBoy screen becomes an 80-column block of Braille text — and crucially, the GameBoy’s grayscale pixels are reduced to on/off (monochrome) before mapping: a pixel is either a lit dot or it isn’t. The output is single-color by construction. The frame is emitted with an ANSI home-and-clear (\e[H\e[2J) on first paint, then cursor-relative repositioning (\e[{height}A\e[{width}D) on subsequent frames, with a FPS / Frame Skip readout printed above the frame. A dirty-frame check ($canvasBuffer != $this->lastFrameCanvasBuffer) skips redraw when nothing changed.
Why this matters for KN-86. This is the highest sub-cell-resolution monochrome technique available in a pure character grid: 8× the spatial resolution of one-glyph-per-pixel, with zero color. KN-86’s 80×25 grid using Braille sub-cells would yield an effective 160×100 monochrome bitmap field inside the text grid — without leaving TEXT mode and without touching the BITMAP framebuffer. That’s a real tool for the toolkit: dense gauges, waveforms, mini-maps, particle effects, and sprite-ish motion, all in amber-on-black, all in the character grid. (Note: KN-86’s font is Press Start 2P + CP437; a Braille subrange would need to be present in the glyph table for this exact trick — flag for the character-set spec. The CP437 shading glyphs ░▒▓█ are the always-available fallback at 1× sub-cell resolution.)
Navigation / keybindings / input loop
Section titled “Navigation / keybindings / input loop”- Controls —
Settings::$keyboardButtonMap = ['d','a','w','s',',','.','n','m'], i.e. Right, Left, Up, Down, A, B, Select, Start. (W=up,A=left,S=down,D=right;,=A,.=B,N=Select,M=Start.) - Input model —
Keyboard::check()does a single-byte non-blockingfread(STDIN, 1). A read produces key-down; the next empty read produces key-up for the previously-held key. There are no real key-up events in a cooked terminal, so this is a one-frame-debounce emulation of press/release. Simple, and a useful pattern: KN-86’s own input dispatch (the 31-key event model with hold detection) solves the same “terminal has no keyup” problem more robustly, but php-gameboy is the minimal version of the idea. - Main loop —
boot.phpis a barewhile (true) { $core->run(); }; the core drives CPU steps, LCD scanout, the canvas draw, and the keyboard poll. Frame skip is the headline performance lever:Settings::$frameskipAmout(auto-adjusting, base factor 10, max 29) drops rendered frames to keep emulation real-time when the terminal draw can’t keep up. KN-86’s runtime already idles redraw on no-input and caps animation at 20 fps — same instinct, and php-gameboy validates that emulation timing and render timing must be decoupled (the GameBoy CPU runs full-speed; only the draw is throttled). KN-86’s audio callback staying in C at 44.1 kHz independent of redraw is the same separation.
Architecture
Section titled “Architecture”Core.php— the CPU/system core (ported from GameBoy Online JS). The repo’s own TODO admits “Core is too big!” — it’s monolithic, a known wart, not a model to copy structurally.Opcode.php/Cbopcode.php/TICKTables.php— instruction dispatch as arrays of functions + cycle-count tables (the classic emulator interpreter pattern).LcdController.php— produces the 160×144 pixel framebuffer the canvas consumes.Canvas/DrawContextInterface.php+Canvas/TerminalCanvas.php— the render abstraction. TheDrawContextInterfaceseam means the terminal canvas is one implementation; a different backend (GD image, web canvas) could be dropped in. This is the same render-target-abstraction lesson Brogue teaches with its platform vtable — and the same seam KN-86 has between the logical grid and its SDL3/device targets.
Single-color adaptation
Section titled “Single-color adaptation”This project is the single-color adaptation, already done: a grayscale console screen is thresholded to monochrome and rendered as Braille dots. The lessons for KN-86:
- Threshold, don’t dither (or dither deliberately). php-gameboy thresholds each GameBoy pixel to on/off. For KN-86, the same choice applies to any image/sprite content: pick a luminance threshold, or use an ordered-dither into the
░▒▓█shading ramp if you want apparent gradients on the amber grid. - Braille sub-cells are the max-resolution monochrome primitive. 8 dots/cell beats every other in-grid technique for spatial density. Recommend KN-86 evaluate a Braille subrange in the glyph table for dense monochrome graphics that stay in TEXT mode.
- Decouple sim timing from draw timing. Frame-skip the render, never the simulation. This is the durable architectural lesson and it generalizes to any KN-86 cart doing real-time animation.
Deckline feature inspiration
Section titled “Deckline feature inspiration”- Existence proof for the KN-86 pitch. “A terminal can run a real handheld console” is no longer hypothetical — cite this in marketing / PR-FAQ contexts as prior art that the concept is sound (without implying KN-86 emulates GameBoy; KN-86 runs its own KEC Lisp carts).
- Braille-bitmap rendering primitive for the nOSh UI toolkit / cart authoring — dense gauges, mini-maps, waveforms in-grid.
- Frame-skip discipline as a named pattern in the runtime performance guidance.
- Batch 8, addendum project A3. The pixel→cell pipeline was the specific study target and is captured in full above.
- This is the handheld-console-emulation bookend to the cluster; pair it with brogue-ce.md, which is the color-coded-ASCII-game-state bookend. Together they cover both halves of “render a real-time game on a single-color character grid.”
- Cross-link
docs/software/cartridges/authoring/ui-patterns.mdfor where a Braille/shading-glyph rendering primitive would be documented for cart authors, anddocs/software/cartridges/authoring/clip-system.md(pre-rendered terminal animation) — Braille frames are a candidate clip encoding. - Glyph-table dependency: a Braille subrange is not in the v0.1 KN-86 Code Page (Press Start 2P + CP437). Flag for
docs/software/api-reference/grammars/character-set.mdLayer 2 (planned Unicode subset) if the Braille technique is adopted; the░▒▓█ramp is the always-available fallback today.