Skip to content

PHP Terminal GameBoy Emulator

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.)

  • ControlsSettings::$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 modelKeyboard::check() does a single-byte non-blocking fread(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 loopboot.php is a bare while (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.
  • 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. The DrawContextInterface seam 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.

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.
  • 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.md for where a Braille/shading-glyph rendering primitive would be documented for cart authors, and docs/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.md Layer 2 (planned Unicode subset) if the Braille technique is adopted; the ░▒▓█ ramp is the always-available fallback today.