How this Game Boy emulator was made
An emulator is a program that pretends to be a piece of hardware so well that software written for that hardware cannot tell the difference. This page walks through how this one was built from scratch: what had to be programmed, how each part works, what decisions were made along the way, and where it got tricky.
01The idea
A Game Boy cartridge holds a ROM: a few hundred kilobytes of machine code and graphics data written for one specific chip. Your PC cannot run that code directly, because it speaks a completely different machine language. So the emulator does it the slow way: it reads the game's instructions one at a time and carries out what each would have done on the real hardware, and it simulates every other chip (graphics, sound, timer, buttons) alongside.
If every piece behaves like the original, the game cannot tell it is not running on a Game Boy. It draws its pictures into what it thinks is the LCD, and we copy those pixels onto a web page 60 times per second.
An emulator is not "a program that plays Game Boy games". It is a model of a circuit board. The game logic, the graphics, the music: all of that comes from the ROM. Our job is only to be a faithful machine. If you get the machine right, every game works without any game-specific code.
02Decisions up front
Before writing a line of code, a few choices shaped everything else.
| Question | Choice | Why |
|---|---|---|
| Use an existing emulator library? | No, write our own | The request was "from scratch", and an own core is fully understood, small, and can be tuned (rewind, save states) without fighting someone else's design. |
| Language | TypeScript, no framework | Runs in every browser. Types catch mistakes like mixing up 8-bit and 16-bit values. React was skipped: the screen is one canvas redrawn 60 times per second, which is exactly what frameworks are not for. |
| How exact should timing be? | Per instruction | The most exact emulators simulate every single clock tick. That is slower and much more work. Running a whole instruction, then letting the other chips catch up, is accurate enough for nearly all games. |
| Original boot screen? | Skipped | The Nintendo logo animation lives in a tiny copyrighted chip inside the console. We simply start in the state the console is in right after that animation. |
| Game Boy Color too? | Yes | About 300 extra lines (color palettes, a second video memory bank, double speed mode) for a much bigger game library. |
| Phone support? | Desktop first | As requested: keyboard, gamepad, big screen, sidebar. |
03The machine we have to imitate
The 1989 Game Boy is a small computer. Everything hangs off one 16-bit address bus: the CPU reads and writes numbered memory addresses, and depending on the address, the request lands in the cartridge, in RAM, or in a chip's control registers.
gameboy.ts creates them all and wires them together.| Part | Real hardware | What it means for us |
|---|---|---|
| CPU | Sharp SM83, 4.19 MHz, 8-bit | About 500 instruction variants to implement |
| Screen | 160 × 144 pixels, 4 shades (GBC: 32,768 colors) | An array of 23,040 pixels we fill line by line |
| Sound | 4 channels: 2 square waves, 1 custom wave, 1 noise | Generate 48,000 stereo samples per second |
| Memory | 64 KB address space, cartridges up to 8 MB | Bank switching tricks to see more than 64 KB |
04The heartbeat: one frame at a time
The real console's clock ticks 4,194,304 times per second. One full screen refresh takes exactly 70,224 of those ticks, which gives 59.73 frames per second. So the emulator's main loop is: run the CPU until one frame is done, then show it.
runFrame() {
while (!ppu.frameReady) {
let cycles = cpu.step(); // run ONE instruction, returns how long it took
timer.tick(cycles); // let every other chip catch up
ppu.tick(cycles); // by exactly the same amount of time
apu.tick(cycles);
}
}
This "run one instruction, then advance everyone else by the same number of ticks" pattern is the whole architecture in four lines. Each chip keeps its own little clock and reacts when enough time has passed: the PPU finishes a scanline every 456 ticks, the sound chip produces a sample every ~87 ticks, and so on.
The Game Boy Color can switch the CPU to double speed, while graphics and sound keep running at normal speed. So in double-speed mode, the CPU's cycles are halved before they are handed to the PPU and APU. One line: const n = doubleSpeed ? c >> 1 : c.
On the web page, requestAnimationFrame calls us roughly 60 times per second. We measure how much real time has passed and run as many Game Boy frames as that time is worth. Fast-forward simply multiplies the elapsed time by 3 (or 5, or 10).
05The CPU: fetch, decode, execute
The CPU has eight 8-bit registers (A F B C D E H L), which can pair up into 16-bit registers (BC DE HL), plus a stack pointer and a program counter (PC, the address of the next instruction). Its life is a loop:
- Fetch the byte at address
PCand movePCforward. - Decode: that byte (the "opcode") says which of 256 instructions to run.
- Execute it: add numbers, copy a value, jump somewhere, and so on.
Do not write 500 cases by hand
The opcodes are not random. Look at their bits and patterns appear. For example, all 64 opcodes from 0x40 to 0x7F are "copy register X into register Y", where the low 3 bits pick the source and the next 3 bits pick the destination. So one small block handles all 64:
if (op >= 0x40 && op < 0x80) {
const src = op & 7, dst = (op >> 3) & 7; // 0=B 1=C 2=D 3=E 4=H 5=L 6=(HL) 7=A
this.setR(dst, this.getR(src));
return (src === 6 || dst === 6) ? 8 : 4; // touching memory costs extra time
}
The same trick covers the 64 arithmetic opcodes (0x80 to 0xBF: 8 operations × 8 registers) and the 256 "CB-prefixed" bit operations. What remains is about 100 special cases in a switch statement. The whole CPU is ~280 lines.
The flags, and the weird one
After arithmetic, the CPU sets four flag bits in register F: Z (result was zero), N (it was a subtraction), H (half carry) and C (carry). Games branch on these constantly, so they must be exactly right. The half-carry flag is the strange one: it says whether the lower 4 bits overflowed. It exists for one instruction, DAA, which converts results into decimal digits for score counters. Try it:
Try it: 8-bit addition and its flags
Enter two numbers from 0 to 255. This is the same formula the emulator uses forADD A, x.
Interrupts
Chips can interrupt the CPU: "the screen just finished", "the timer overflowed", "a button was pressed". Before each instruction, the CPU checks whether an interrupt is both requested and enabled. If so, it saves where it was and jumps to a fixed address (0x40 for the screen, 0x50 for the timer, and so on). Games use this to update graphics at exactly the right moment.
The instruction EI ("enable interrupts") only takes effect after the next instruction. And the HALT instruction has a famous hardware bug: under certain conditions the CPU reads the following byte twice. Some games rely on both quirks, so both are emulated: imeScheduled counts down the delay, and haltBug skips one increment of PC.
06Memory and cartridges
The CPU only sees 65,536 addresses. The memory unit (mmu.ts) is a big switchboard that routes each address to the right place:
| Address | What lives there |
|---|---|
| 0000–3FFF | Cartridge ROM, first 16 KB (always the same) |
| 4000–7FFF | Cartridge ROM, a switchable 16 KB window |
| 8000–9FFF | Video RAM: tile graphics and background maps |
| A000–BFFF | Cartridge RAM (battery backed: your save game) |
| C000–DFFF | Work RAM |
| FE00–FE9F | Sprite table (40 sprites × 4 bytes) |
| FF00–FF7F | Hardware registers: buttons, timer, sound, screen control |
| FF80–FFFF | Fast "high" RAM and the interrupt enable switch |
Bank switching: seeing 2 MB through a 16 KB window
Pokémon Crystal is 2 MB, but the CPU can only see 32 KB of ROM at a time. Cartridges solve this with a small chip called a Memory Bank Controller (MBC). ROM can't be written, so the cartridge reuses writes to ROM addresses as commands: "write 5 to address 0x2000" means "show bank 5 in the switchable window".
// MBC5: reading from the switchable window
return this.rom[(bank & this.romMask) * 0x4000 + (addr - 0x4000)];
There are several MBC types with different rules. This emulator implements the four that cover nearly every game: MBC1 (early games), MBC2, MBC3 (with a real-time clock), and MBC5 (most Game Boy Color games). The cartridge header at address 0x147 says which one a game uses.
MBC3 cartridges (Pokémon Gold/Silver) contain a clock that keeps ticking even when the console is off. Instead of counting emulated time, the emulator stores "the clock started at this real-world timestamp" and derives the time from your PC's clock. Close the tab for three days, and the in-game clock moved three days, just like the real cartridge.
07The picture: how the PPU draws
The Picture Processing Unit (PPU) is the most interesting chip. There is no framebuffer that games draw into. Instead, games describe the picture with building blocks, and the PPU assembles them on the fly, one line at a time, racing the LCD.
Tiles: 8×8 pixels in 16 bytes
Everything on screen is made of tiles: 8×8 pixel squares with 4 possible colors each. Each row of a tile takes 2 bytes. Strangely, the two bits of each pixel's color number live in different bytes: the first byte holds every pixel's low bit, the second byte holds every pixel's high bit. Edit the bytes below and watch the tile change:
Try it: decode a Game Boy tile
Each row is two hex bytes (low bits, high bits). Pixel color = (high bit × 2) + low bit.// The same decoding inside renderLine()
const lo = vram[tileAddr + row * 2], hi = vram[tileAddr + row * 2 + 1];
const color = (((hi >> bit) & 1) << 1) | ((lo >> bit) & 1); // 0, 1, 2 or 3
Three layers
- Background: a 32×32 grid of tile numbers (256×256 pixels), larger than the screen. Two scroll registers (
SCX,SCY) choose which 160×144 part is visible. That is how side-scrolling works. - Window: a second, non-scrolling layer, usually for status bars and text boxes.
- Sprites: up to 40 freely movable tiles (Mario, enemies, bullets), max 10 per line. Each can be flipped, use one of two palettes, or hide behind the background.
The scanline dance
The PPU draws the screen line by line, like an old TV. Each of the 154 lines (144 visible plus 10 invisible) takes 456 ticks and goes through modes. Games watch these modes and change registers mid-frame for effects like wavy water or a fixed status bar.
Game Boy Color additions
The Color model keeps the same design and adds: a second bank of video memory, an extra "attribute" byte per background tile (palette, flip, priority), and 16 palettes of 4 colors stored as 15-bit RGB values. Raw GBC colors look harsh on a modern screen, because the original LCD was dim and blended colors. So the emulator runs each color through a small color-correction formula, precomputed once into a 32,768-entry lookup table.
Priority rules were the fiddliest graphics code. On the original Game Boy, when sprites overlap, the one further left wins. On the Color, the one earlier in the table wins. A sprite can ask to hide behind the background, but only behind colors 1 to 3, never color 0. On the Color, a background tile can also demand to be on top. And one control bit overrides all of that. The dmg-acid2 and cgb-acid2 test images (section 12) check every one of these rules.
08The timer
Games use the timer for music tempo and randomness. On real hardware it is a single 16-bit counter that ticks constantly. The programmable timer (TIMA) does not have its own clock. It increments whenever a chosen bit of that counter flips from 1 to 0 (a "falling edge").
Emulating that literally means checking the bit every 4 ticks. That was correct but showed up as a hot spot when profiling. The faster version counts the edges with arithmetic instead:
// How many times did bit N fall while the counter went from old to old+cycles?
const edges = ((old + cycles) >> (N + 1)) - (old >> (N + 1));
Bit N falls once every 2N+1 ticks, so counting how many multiples of 2N+1 we crossed gives the answer directly. The slow path is only used in the rare moment when the timer overflows.
09The sound
The audio chip has four voices, each a tiny synthesizer:
| Channel | Sound | How it works |
|---|---|---|
| 1 | Square wave + pitch sweep | Switches between on and off at a set pitch. The "duty" (12.5%, 25%, 50%, 75% on) changes the tone color. Can slide its pitch up or down (laser and jump sounds). |
| 2 | Square wave | Same, without the sweep. Usually the melody. |
| 3 | Custom wave | Plays a 32-step waveform that the game writes itself. Often the bass. |
| 4 | Noise | A 15-bit shift register produces pseudo-random bits. Drums, explosions, wind. |
A "frame sequencer" ticks 512 times per second and handles the slow changes: note length counters, volume fades (envelopes) and pitch sweeps. Every ~87 Game Boy ticks we take a snapshot of all four channels, mix them into left and right according to the panning register, and get one audio sample. That gives 48,000 samples per second.
// Noise channel: one step of the linear-feedback shift register
const x = (lfsr ^ (lfsr >> 1)) & 1; // XOR the two lowest bits
lfsr = (lfsr >> 1) | (x << 14); // shift right, feed the result in at the top
The real challenge: two clocks that disagree
The emulator produces samples whenever it runs a frame, driven by the screen's refresh rate. The sound card consumes samples at its own fixed rate, driven by its own crystal. Those two clocks never match exactly (the Game Boy runs at 59.73 Hz, your monitor at maybe 60 Hz or 144 Hz). Left alone, the sound buffer slowly runs empty (crackles) or overflows (growing delay).
The audio thread (an AudioWorklet) reports how full its buffer is. The emulator aims for about 60 ms of buffered sound. If the buffer is too empty, it produces samples 0.6% faster, and if it is too full, 0.6% slower. That is far too small a pitch change to hear, but it keeps the two clocks locked together indefinitely.
const target = sampleRate * 0.06; // ~60 ms of audio
const err = clamp((target - queued) / target, -1, 1);
apu.sampleRate = sampleRate * (1 + 0.006 * err);
One more detail: the Game Boy's raw output sits off-center (it is never negative), which speakers hear as a pop when sound starts. A one-line high-pass filter removes that offset, just like a capacitor does in the real console.
10Save states and rewind
There are two kinds of saving:
- Battery saves are what the game itself saves into its cartridge RAM. The emulator notices when that RAM changes and writes it to the browser's IndexedDB every 2 seconds. You can export it as a
.savfile that works in other emulators. - Save states freeze the entire machine: every register, every byte of RAM, the exact pixel the PPU was on. Load one and you're back in that exact moment, even mid-jump.
Instead of writing save and load code for every chip by hand (and forgetting a field when adding a feature later), state.ts walks through each chip's fields automatically and copies all numbers, flags and memory arrays. A short skip-list excludes things that are settings rather than state (like the color palette you picked). The whole save state system is 54 lines.
Rewind was then almost free: every 6th frame, take a save state and push it onto a list of the last 120 (about 12 seconds). Holding R pops them off one by one and loads them, so the game plays backwards.
11The web app around it
The emulator core knows nothing about browsers. It takes button presses in and gives pixels and audio samples out. The app (main.ts) connects it to the real world:
- Screen: the 160×144 pixels are copied into a canvas, then scaled up with CSS.
image-rendering: pixelatedkeeps the pixels crisp. Integer scaling (×3, ×4, ×5) avoids uneven pixel sizes. The optional LCD grid is a CSS gradient laid over the canvas. - Input: keyboard events and the Gamepad API both feed one bitmask of 8 buttons. Keys can be rebound in Settings.
- Library: ROMs, battery saves and save states live in IndexedDB, the browser's built-in database, so nothing is ever uploaded to the server. Zip files are unpacked in the browser with the small
fflatelibrary. - Hosting: Vite bundles everything into one 53 KB JavaScript file, served as static files by nginx at
gameboy.rob-all.duckdns.orgwith HTTPS from Let's Encrypt. No backend is needed.
12How we knew it worked
You can't test an emulator by clicking around in a game and hoping. Instead, the emulator community has test ROMs: tiny programs that check one hardware behavior at a time and report pass or fail.
- Blargg's cpu_instrs runs every CPU instruction with many inputs and compares the results and flags with a real Game Boy. It reports through the link cable port, so the emulator captures those bytes as text. A Node.js script runs all tests without a browser.
- instr_timing checks that every instruction takes the right number of ticks.
- dmg-acid2 and cgb-acid2 draw a smiley face using every graphics rule. If any rule is wrong, the face gets a wrong eye, a missing nose or a broken mouth. Our output was compared with the reference images.
- dmg_sound checks sound-chip edge cases.
| Test suite | Result |
|---|---|
| cpu_instrs (11 tests) | ✅ all passed, on the first run |
| instr_timing | ✅ passed |
| dmg-acid2 / cgb-acid2 | ✅ images match the reference |
| dmg_sound (12 tests) | 9 passed (6 at first, 3 more after fixes) |
| mem_timing | ❌ expected: would need tick-by-tick timing (section 2) |
Finally, a headless Chrome browser loaded the live site, uploaded test ROMs, saved and loaded states, held the rewind key, and took screenshots. That checked the whole app end to end, including that it holds 60 fps.
13The challenges, in order
14Deep dives for the curious
The sections above are the overview. Here are the details that took real thought, with the actual code.
14.1 Cycle counting: why every instruction returns a number
Every instruction costs a fixed number of clock ticks, always a multiple of 4, because the CPU does one memory access per 4 ticks (a "machine cycle"). NOP is 4 ticks. LD A,(HL) reads memory once more: 8. CALL nn reads 2 address bytes and writes 2 bytes to the stack: 24. Conditional jumps cost different amounts depending on whether they jump:
case 0x20: case 0x28: case 0x30: case 0x38: { // JR NZ/Z/NC/C, e8
const e = (this.fetch() << 24) >> 24; // sign-extend the byte: 0xFE becomes -2
if (this.cond((op >> 3) & 3)) { this.pc = (this.pc + e) & 0xffff; return 12; }
return 8; // not taken: 4 ticks cheaper
}
Two JavaScript tricks show up everywhere. (x << 24) >> 24 sign-extends an 8-bit value, because >> on a 32-bit int copies the sign bit back down. And & 0xffff after every 16-bit addition simulates the wraparound that real 16-bit registers do for free. Forgetting one of those masks is the classic emulator bug.
14.2 DAA: the instruction nobody understands
Games store scores in BCD ("binary-coded decimal"): the byte 0x42 means 42, one decimal digit per nibble. After adding two BCD numbers with normal binary math, DAA repairs the result back into valid BCD. It looks at the flags from the previous operation (that is why H and N exist) and adds or subtracts 6 or 0x60:
if (!(f & FN)) { // after an addition
if ((f & FC) || a > 0x99) { a += 0x60; carry = true; } // tens digit overflowed
if ((f & FH) || (a & 0xf) > 9) a += 0x06; // ones digit overflowed
} else { // after a subtraction
if (f & FC) { a -= 0x60; carry = true; }
if (f & FH) a -= 0x06;
}
Example: 0x19 + 0x28 = 0x41 in binary, but 19 + 28 = 47. The ones digits (9 + 8 = 17) overflowed 4 bits, so H is set, and DAA adds 6: 0x41 + 0x06 = 0x47. Correct. The order of the checks matters: the 0x60 test must look at the value before the 0x06 correction. Blargg's test 01-special runs DAA on all 65,536 combinations of input and flags and checksums the results, so there is no way to fake it.
14.3 Interrupt dispatch, step by step
const pending = mmu.ie & mmu.if & 0x1f; // enabled AND requested
if (pending) {
this.halted = false; // any pending interrupt wakes HALT, even with IME off
if (this.ime) {
this.ime = false; // no nested interrupts unless the handler re-enables
let bit = 0; while (!(pending & (1 << bit))) bit++; // lowest bit = highest priority
mmu.if &= ~(1 << bit); // acknowledge it
this.push(this.pc);
this.pc = 0x40 + bit * 8; // 0x40 VBlank, 0x48 STAT, 0x50 timer, 0x58 serial, 0x60 joypad
return 20;
}
}
Note the subtle part: HALT wakes up whenever an interrupt is pending, even if interrupts are globally disabled (IME off). Then the CPU just continues after the HALT without jumping to the handler. Some games use exactly that as a cheap "wait for the next frame".
14.4 The STAT interrupt is one wire, not four
The LCD can request an interrupt on four different events: mode 0, mode 1, mode 2, and "LY equals LYC" (a chosen line). The naive version fires an interrupt for each event. On real hardware, these four sources are OR'ed onto one internal signal, and the interrupt fires only when that signal goes from low to high. If two sources overlap, the second one is swallowed. This is called "STAT blocking", and games that do mid-frame effects depend on it.
const line =
((s & 0x40) && this.ly === this.lyc) ||
((s & 0x08) && this.mode === 0) ||
((s & 0x10) && this.mode === 1) ||
((s & 0x20) && this.mode === 2);
if (line && !this.statLine) mmu.if |= IRQ_STAT; // rising edge only
this.statLine = !!line;
This function is called every time the mode, LY, LYC or the STAT register changes, so the edge detection sees every transition.
14.5 MBC1 and the forbidden bank
MBC1 has a quirk worth knowing. The ROM bank register is 5 bits, and writing 0 selects bank 1 instead (bank 0 is already visible in the fixed window, so showing it twice would be pointless). But the check only looks at those 5 bits. Large MBC1 games put 2 more bits in a second register for the upper bank number, which means banks 0x20, 0x40 and 0x60 can never be selected: asking for 0x20 gives you 0x21. Getting this wrong breaks only the biggest MBC1 games, a bug that's easy to miss.
else if (addr < 0x4000) { this.romBank = v & 0x1f; if (this.romBank === 0) this.romBank = 1; }
else if (addr < 0x6000) this.bank2 = v & 3;
// ...later, when reading 0x4000-0x7FFF:
bank = (this.bank2 << 5) | this.romBank; // can be 0x21, never 0x20
ROMs are also padded to a power-of-two size on load. That allows bank & romMask to replace a slower modulo, and it mirrors what the real address lines do when a game asks for a bank that doesn't exist.
14.6 Producing exactly 48,000 samples per second
The Game Boy clock (4,194,304 Hz) divided by 48,000 is 87.381333... ticks per sample, not a whole number. Rounding to 87 would play everything 0.4% sharp, and floating-point accumulation slowly drifts. The solution is the same integer trick used to draw straight lines on pixel screens (Bresenham's algorithm): scale everything up so the fraction disappears.
this.sampleAcc += cycles * this.sampleRate; // add "ticks × 48000"
while (this.sampleAcc >= CLOCK) { // every time we pass 4,194,304...
this.sampleAcc -= CLOCK; // ...keep the remainder (no drift!)
this.emitSample(); // ...and output one sample
}
On average that is exactly 48,000 samples per emulated second, with zero accumulated error. It also makes the rate control from section 9 trivial: changing sampleRate to 48,288 just makes samples come a little more often.
14.7 Square waves without per-tick work
A channel's position in its waveform advances once every (2048 - frequency) × 4 ticks, where frequency is the 11-bit value the game writes. Instead of decrementing a counter on every tick, each channel subtracts the whole elapsed time and catches up in a loop:
tick(c) {
this.timer -= c;
while (this.timer <= 0) { this.timer += (2048 - this.freq) * 4; this.pos = (this.pos + 1) & 7; }
}
out() { return DUTY[this.duty][this.pos] * this.env.vol; } // 0 or volume (0..15)
The output 0..15 then goes through a "DAC": s / 7.5 - 1 maps it to −1..+1, like the analog converter in the console. Channels whose DAC is switched off contribute nothing at all (not "−1"). That distinction matters for the clicks you hear when games turn channels on and off.
14.8 Save states by reflection
Here is the core of state.ts. It recursively copies every field of every chip, and copies typed arrays (RAM) with .slice() so the snapshot doesn't change when the game keeps running:
function snap(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (SKIP.has(k)) continue; // config, back-references, the ROM itself
if (typeof v === 'number' || typeof v === 'boolean') out[k] = v;
else if (ArrayBuffer.isView(v)) out[k] = v.slice(); // RAM, VRAM, palettes, framebuffer
else if (v && typeof v === 'object') out[k] = snap(v); // nested: sound channels, envelopes
}
return out;
}
Restoring does the reverse, but copies into the existing arrays with .set() instead of replacing them, so other parts of the code that hold a reference to the same array keep working. Because the PPU's framebuffer is part of the state, a loaded state immediately shows the right picture, which is what makes rewind look smooth. A snapshot is roughly 150 KB, so the 120-entry rewind buffer costs about 18 MB of memory.
14.9 How the tests talk to us
Blargg's test ROMs print their results through the link cable port, the plug meant for connecting two Game Boys. A game sends a byte by writing it to FF01 and then 0x81 to FF02 ("start transfer, I'm the clock"). With no second Game Boy attached, the emulator completes the transfer instantly and appends the byte to a string:
case 0xff02:
if ((v & 0x81) === 0x81) {
this.serialOut += String.fromCharCode(this.sb); // "01:ok 02:ok ... Passed"
this.sb = 0xff; // nobody answered: received 0xFF
this.if |= IRQ_SERIAL;
}
The test runner (test/run-blargg.ts) runs frames until that string contains "Passed" or "Failed". The sound tests write their result as text into cartridge RAM after a magic signature (DE B0 61) instead, so a second runner reads it from there. This made it possible to test the whole emulator from the command line, in about 20 seconds, without ever opening a browser.
14.10 Where the time actually goes
Measured on the server running the full cpu_instrs ROM (1,776 frames), with V8's sampling profiler (node --cpu-prof). Inlining was switched off so every function shows up under its own name. That inflates call overhead a little, but the proportions are what matter:
| Area | Share | Biggest items |
|---|---|---|
| CPU | 25.8% | exec 9.0%, step 6.7%, fetch 6.2%, getR 2.5% |
| Sound | 25.4% | APU.tick 9.5%, noise channel 7.0%, square channels 4.8%, wave 2.4%, mixing 1.7% |
| Graphics | 17.3% | renderLine 9.5%, PPU.tick 5.4% |
| Main loop | 11.8% | runFrame 9.3%, the doubleSpeed getter 2.5% |
| Memory router | 8.5% | MMU.read: every byte the CPU touches |
| Timer | 2.9% | after the arithmetic rewrite from section 8 |
The surprise: sound costs as much as the entire CPU. Its timers advance even when a channel is silent, and the noise channel is the worst. At its highest pitch it shifts its random-number register every 8 ticks, so Noise.tick runs its inner loop several times for every single CPU instruction. A real fix would skip silent channels entirely and compute the noise register lazily, only when a sample is actually needed.
The other lesson: no single function dominates. There was no silly mistake to fix, only small costs spread everywhere. The next step would be a structural change. Serious emulators replace the memory router's switch with a 16-entry lookup table of read functions (one per 4 KB page), and drive all chips from one shared clock so they don't each run on every instruction. Comfortable full speed in a desktop browser means it hasn't been needed yet.
15What is not perfect (yet)
Every emulator is a trade-off between accuracy and effort. Known simplifications in this one:
- Instruction-level timing. Memory accesses inside one instruction all happen "at once". A handful of games and demos that time things to the exact tick may glitch.
- Whole-line rendering. Changing scroll registers in the middle of a scanline has no effect until the next line. Very rare in games.
- Instant sprite copy (OAM DMA). On hardware it takes 160 microseconds, here it happens immediately.
- Wave channel quirks. Reading or writing the wave memory while it plays behaves slightly differently than on hardware (the 3 failing sound tests).
- No link cable. Trading Pokémon between two browsers would need a server connection between them, a fun future project.