gameboyjs
gameboyjs is a pure-JavaScript Nintendo Game Boy (DMG) emulator by Rob Louie. It interprets the Sharp SM83 CPU and renders the picture processor scanline by scanline to a canvas, so it runs a Game Boy directly in the browser with no install. Here it is wired into the in-page debugger, where you can single-step the SM83, set breakpoints and watchpoints, and inspect the whole memory map.
Runs on: Web browser
gameboyjs Online Emulator
Play gameboyjs using JavaScript directly in your browser.
Controls
Configurations
| Configuration | Emulator | Machine | OS | Legal | |
|---|---|---|---|---|---|
| Libbet and the Magic Flute | gameboyjs | Game Boy | open | Open ⛶ |
Machines emulated
Chips
Notes
Embedding
gameboy-emulator (roblouie) is a pure-JavaScript Game Boy emulator shipped as a UMD bundle that exposes a single global, gameboy.Gameboy. Vendor gameboy.js and drive the machine yourself instead of calling gameboy.run() — its internal requestAnimationFrame loop cannot be paused or single-stepped, which the debugger needs.
Boot and load a ROM. Construct the machine, hand it the cartridge as an ArrayBuffer, replicate run()'s init, then drive your own loop on cpu.tick() (one instruction, returns its cycle count) with gpu.tick() and apu.tick() clocked in lockstep:
const gb = new gameboy.Gameboy();
const buf = await (await fetch(romUrl)).arrayBuffer();
gb.loadGame(buf);
gb.cpu.initialize(); // PC=00, post-boot register values
gb.memory.reset();
gb.memory.writeByte(0xFF40, 0x83); // turn the LCD on (LCDC)
const ctx = canvas.getContext('2d');
(function frame(){
let cycles = 0;
while (cycles <= 70224) { // one video frame of dot clocks
const c = gb.cpu.tick(); // exactly one SM83 instruction
gb.gpu.tick(c); gb.apu.tick(c); cycles += c;
}
ctx.putImageData(gb.gpu.screen, 0, 0); // gpu.screen is a 160x144 ImageData
requestAnimationFrame(frame);
})();
The machine is plain objects. Everything the debugger needs is a live property — no wasm heap to reach into:
| Member | Kind | What it does |
|---|---|---|
cpu.tick() | method | Execute exactly one SM83 instruction; returns its cycle count. The single-step primitive. |
cpu.initialize() | method | Set PC to 00 and the registers to their documented post-boot values. |
cpu.registers | field | Live registers: .A .F .B .C .D .E .H .L, the pairs .AF .BC .DE .HL, and .programCounter / .stackPointer. Each has a .value get/set. |
cpu.registers.F | field | The flag register with .Z .N .H .CY bit accessors (bits 7/6/5/4). |
gpu.tick(cycles) | method | Advance the PPU; when a scanline completes it rasters pixels into gpu.screen. |
gpu.screen | field | A 160×144 ImageData (subclass) painted straight to a 2D canvas with putImageData. |
memory.readByte(a) / writeByte(a, v) | method | The CPU bus: routes cartridge, I/O and RAM. writeByte pokes memory live. |
memory.memoryBytes | field | The raw 64 KB internal store (VRAM, WRAM, OAM, I/O, HRAM) as a Uint8Array — side-effect-free reads. |
cartridge | field | The loaded cartridge: .readByte, .romSize, .title, MBC banking. |
input | field | Eight plain booleans — isPressingUp/Down/Left/Right/A/B/Start/Select — set by a pad or keyboard. |
Because the CPU, PPU and memory are ordinary JavaScript, the debugger single-steps with cpu.tick(), reads and writes registers straight off cpu.registers, and implements breakpoints and watchpoints as host-side checks around those calls — no changes to the emulator core.
Debugger integration
Wiring gameboy-emulator into the shared in-browser debugger needed one piece of custom work — a boot shim that owns the loop — plus a set of techniques for reaching into the live machine.
A boot shim, because run() owns an un-pausable loop. The library's Gameboy.run() drives itself with an internal requestAnimationFrame loop that cannot be paused or single-stepped from outside — which the debugger needs. So the boot replicates run()'s init (cpu.initialize(), memory.reset(), LCD on) and then drives the machine from a loop we control:
// our loop, not the library's — so pause / step / breakpoints work
function runFrame(){
let cyc = 0;
while (cyc <= 70224) {
if (bps.size && bps.has(cpu.registers.programCounter.value)) { running = false; return; }
const c = cpu.tick(); gpu.tick(c); apu.tick(c); cyc += c;
}
}
function stepInsn(){ const c = cpu.tick(); gpu.tick(c); apu.tick(c); }
Techniques for deeper access. The plug-in then reaches into the running machine through the library's own surfaces — no fork of the emulator core:
- Side-effect-free reads. The hex and disassembly views read VRAM, WRAM, OAM, I/O and HRAM straight off
memory.memoryBytes(the rawUint8Array), so auto-polling a view never routes through the joypad or DMA logic. The cartridge chip reads throughcartridge.readByte, which is a pure ROM lookup. - Direct state. Registers are plain objects with a
.valueget/set, read and written live; the flag register exposesZ/N/H/CYbits, surfaced as clickable flag chips. - Single instruction step.
cpu.tick()already executes exactly one instruction and returns its cycles, so one call is one step — withgpu.tick/apu.tickclocked by the same count to keep the machine coherent. - Execution breakpoints. Because we own the loop, a breakpoint is a host-side
Setof PC values checked before eachtick(); when the loop is stepping instruction-by-instruction it halts the moment PC matches. - Write watchpoints. The boot wraps
memory.writeByte; when a write hits a watched address it stops the loop, so a watchpoint pauses on the store.
Everything the debugger shows — registers read/write, memory hex/disassembly, follow-PC, single-step, breakpoints and watchpoints — is built from these, with no changes to the emulator itself.
Architecture
gameboy-emulator is a readable, interpreted Game Boy (DMG) by Rob Louie. Each hardware block is its own object hanging off the top-level Gameboy:
CPU— an interpreter of the Sharp SM83 (LR35902) core;tick()fetches, decodes and executes one instruction from a map of operation objects and returns its cycle count. It boots at00with the documented post-boot register state (the internal boot ROM / logo scroll is skipped).GPU— the pixel-processing unit: background, window and sprites rendered scanline-by-scanline into a 160×144ImageDatausing the four-shade DMG palette.APU— the four sound channels (two pulse, wave, noise), fed through a ring buffer to Web Audio when enabled.Memory— the 64 KB bus: cartridge (via the MBC), VRAM, WRAM, OAM, I/O registers and HRAM, plus DMA and the joypad register.Cartridge— parses the header and implements the mapper (ROM-only, MBC1, MBC3), including battery-backed save RAM.Input— eight booleans polled through the joypad register at$FF00.
Each frame runs the CPU for one video frame's worth of dot clocks while stepping the GPU and APU in lockstep, then paints the finished framebuffer. Because every component is an ordinary object, the whole machine state is inspectable — which is what the debugger reads each refresh.
Sound
Sound is a Pattern V-stub integration: the core already emulates the four-channel DMG APU, so no chip was written — its output was re-enabled and re-routed to the shared EmuAudio sink. The core's own output path (a SharedArrayBuffer feeding an AudioWorkletNode) needs cross-origin isolation (COOP/COEP) and is blocked under the site's strict CSP, and its audio was gated off by default. Both are handled at boot without touching the vendored gameboy.js:
- Ungate the APU. Set
apu._isAudioEnabled = truesoapu.tick()clocks the channels and mixes samples. The core's own suspendedAudioContextis left alone, so nothing plays through it. - Bypass the worklet. Override
apu.ringBufferPlayer.writeSampleto capture each mono mix sample into a small ring buffer instead of the blockedSharedArrayBufferpath. - Match the sink rate. Retune
apu.cyclesPerSample = 70224 / Math.round(EmuAudio.sampleRate / 60)so exactly one video frame of samples is produced at the sink's rate — no resampling. Only the sampling cadence changes; the channel frequency timers still run on real dot clocks, so pitch is unaffected. - Push per frame. Inside the same run-loop the debugger owns, take that frame's mono samples, apply a one-pole DC blocker (the mix is unsigned) and a fixed gain, duplicate mono to interleaved-stereo
Int16, and callEmuAudio.push().
// re-enable + re-route the vendored APU at boot (no core edit)
apu.ringBufferPlayer.writeSample = function (m) { ring[wr++ & MASK] = m; }; // capture the mono mix
apu.cyclesPerSample = 70224 / Math.round(EmuAudio.sampleRate / 60); // one frame of samples at the sink rate
apu._isAudioEnabled = true; // ungate ticking + mixing
// each video frame: DC-block + gain, mono -> stereo Int16, hand it to the sink
const n = Math.round(EmuAudio.sampleRate / 60), out = new Int16Array(n * 2);
for (let i = 0; i < n; i++) { const s = clampInt16(dcBlock(ring[rd++ & MASK]) * GAIN); out[i * 2] = out[i * 2 + 1] = s; }
EmuAudio.push(out);
The transport exposes the mute contract the shell's Sound button uses — isMuted() and setMute() delegate straight to EmuAudio — and audio starts muted, because browsers block an AudioContext until a real user gesture (the first click on the Sound button resumes it).