The long journey of my CHIP-8 emulator

I first started working on my CHIP-8 emulator back in 2018, almost six years ago while I was still an undergraduate at Berkeley. I finally picked this project back up last year and finished it in July. You can try the finished project here!

What is CHIP-8?

CHIP-8 is a interpreted programming language originally developed for microcomputers in the 1970s such as the COSMIC VIP, DREAM 6800, Telmac 1800, and ETI 660. It was designed to be easy to developing video games in, and is now a popular programming project for developers wanting to write their first emulator. Programs in CHIP-8 were written directly in hexadecimal.

The CHIP-8 virtual machine contains 35 two-byte big-endian opcodes, 4 KiB of memory, 16 8-bit registers, a 16-key hex keyboard, a 64×32 pixel monochromatic screen, and two 60 Hz timers. As QWERTY keyboards are more popular nowadays, I remapped the hex keyboard to instead be the top-left corner of a standard QWERTY keyboard. The original and remapped layouts are as follows:

123C      1234
456D  ->  QWER
789E  ->  ASDF
A0BF      ZXCV

Project background

When I started this project in 2018, I was interested in learning Rust, and was specifically excited about the ability to compile to WebAssembly. However, the tooling around that workflow—notably, wasm-bindgen and its supporting crates js-sys and web-sys—was not super mature at the time. In particular, js-sys and web-sys had unstable Rust APIs as they were generating functions from Web IDLs that mapped to JavaScript functions. I ended up writing most of the core logic of the emulator and punted on the web UI until the Wasm libraries were more stable.

Fast forward to 2023, I decided to pick this project back up now that wasm-bindgen had stabilized quite a bit. I ended up having to spend considerable time debugging both the work I had done in 2018, as well as getting the compilation to WebAssembly to work correctly, but I ended up with a working emulator that runs in a web browser.

Implementation overview

My emulator has three main components:

  • The CPU, which keeps track of the registers, program and stack pointers, memory, timers, and references to other components
    • The opcodes and the logic that parses and executes them are in a separate module
  • The keypad, which keeps track of keypress events
  • The view, which manages the <canvas> and handles rendering sprites at 60 Hz

I used wasm-pack to generate chip_8_emulator.js and chip_8_emulator_bg.wasm, which are then loaded with some simple JavaScript:

import init, {} from './pkg/chip_8_emulator.js';

async function run() {
  await init();


Technical challenges

I ran into a number of technical challenges that broadly fell under three categories:

  • Rendering the sprites on screen inside of a <canvas>
  • Working around differences in Rust when compiling to WebAssembly
  • Fighting Rust’s borrow checker to render updates within requestAnimationFrame

Rendering sprites

The opcode for displaying a sprite is Dxyn, short for DRW Vx, Vy, nibble. When run, the interpreter fetches an n-byte sprite that is stored starting at the memory location in the I register, and displays the sprite on screen starting from coordinates (Vx, Vy). Sprites are XOR-ed onto the existing screen, and if this causes any pixels to be reset from 1 to 0, register VF should be set to 1; otherwise, VF should remain at 0.

One confusing part of this opcode is determining whether the sprite should wrap around to the other side of the screen. Unlike what Cowgod’s guide claims, and following Tobias’s explanation instead, the values of Vx and Vy should be computed mod 64 and 32, respectively (the dimensions of the screen). However, the sprites should not wrap around, but instead be truncated at the screen boundaries. Here’s that logic in View::draw_sprite:

let sx = sx % WIDTH;
let sy = sy % HEIGHT;

let x_count = 8.min(WIDTH - sx);
let y_count = n.min(HEIGHT - sy);

self.draw_contiguous_sprite(sprite, x_count, y_count, sx, sy)

Separately, working with the <canvas> was a bit complicated. A 64×32-sized canvas looks too small on most screens, so I chose to scale up the canvas by a factor of 10 on both sides. The scale function led to blurriness when drawing to my canvas, so I instead manually painted a 10×10 square for every original pixel.

To actually update the canvas, I grabbed the current state as an ImageData using getImageData, drew the new sprite onto the canvas, and then wrote the new image data using putImageData.

This was rather tricky to get right, because returns a Uint8ClampedArray of size 4 × the total number of pixels in the canvas, with one byte for each of R, G, B, A. The number of pixels is already scaled by a factor of 100, so some tricky indexing math is necessary to figure out which exact pixels to update. Here’s the relevant logic in View::draw_contiguous_sprite:

let base_pos =
    (IMAGE_DATA_ENTRIES_PER_PIXEL * SCALE * (SCALE * iy * x_count + ix)) as usize;

for scale_dx in 0..SCALE {
    for scale_dy in 0..SCALE {
        let pos = base_pos
                * (SCALE * scale_dy * x_count + scale_dx))
                as usize;

        if new_is_filled {
            image_data[pos] = 255;
            image_data[pos + 1] = 255;
            image_data[pos + 2] = 255;
            image_data[pos + 3] = 255;
        } else {
            image_data[pos] = 0;
            image_data[pos + 1] = 0;
            image_data[pos + 2] = 0;
            image_data[pos + 3] = 255;

Differences when compiling Rust to WebAssembly

While it’s pretty easy to compile Rust to WebAssembly, not all functions in std are fully supported in Wasm. In particular, Wasm does not natively support atomics and threads (until the threads proposal is implemented). One hack is to spin up Web Workers instead, but interop between Wasm modules and Web Workers does not fully replicate threads in Wasm. As a result, Rust by default does not support threading in std when targeting Wasm.

The opcode Fx0A, short for LD Vx, K, has the behavior where the program should stop until a key is pressed, and then store the value of that key in the Vx register. To implement this functionality, I was initially using a Condvar, but unfortunately I learned that Condvar.wait is not supported in Wasm. Instead, I had to update my implementation to busy-wait for a keypress instead, by decrementing the program counter so that the CPU sees the same instruction again:

let last_keypress = cpu.keypad.borrow_mut().try_take_last_keypress();
if let Some(last_keypress) = last_keypress {
    cpu.regs[vx as usize] = last_keypress as u8;
} else {

As the CPU runs at 60 Hz already, busy-waiting is okay as there is no risk of the code freezing up the browser’s main thread.

Borrow checker issues with requestAnimationFrame

Passing functions between Rust and JavaScript through wasm-bindgen is a bit of a hassle, due to needing to convert values into JsValues and functions into Closures. Here are the requirements for Closure::new, copied from the docs:

Creates a new instance of Closure from the provided Rust function.

Note that the closure provided here, F, has a few requirements associated with it:

  • It must implement Fn or FnMut (for FnOnce functions see Closure::once and Closure::once_into_js).
  • It must be 'static, aka no stack references (use the move keyword).
  • It can have at most 7 arguments.
  • Its arguments and return values are all types that can be shared with JS (i.e. have #[wasm_bindgen] annotations or are simple numbers, etc.)

Quite a mouthful!

The wasm-bindgen docs do have an example for using requestAnimationFrame, but unfortunately they don’t store the render_id needed to cancel an AnimationFrame early, or any logic to ensure the function runs at around 60 Hz. After a lot of back-and-forth with the borrow checker, I ended up with the following:

pub fn set_up_render_loop(mut f: impl FnMut() + 'static) -> AnimationFrame {
    let mut last_time_ms = 0.;

    let closure = Rc::new(RefCell::new(None));
    let render_id = Rc::new(RefCell::new(None));

    let closure_internal = Rc::clone(&closure);
    let render_id_internal = Rc::clone(&render_id);

    *closure.borrow_mut() = Some(Closure::new(move |v: JsValue| {
        let time_ms = v.as_f64().unwrap_or(0.);
        if time_ms - last_time_ms >= SIXTY_FPS_FRAME_MS {
            last_time_ms = time_ms;

        if let Some(closure_internal) = closure_internal.borrow().as_ref() {
            *render_id_internal.borrow_mut() = Some(request_animation_frame(closure_internal));

    *render_id.borrow_mut() = Some(request_animation_frame(closure.borrow().as_ref().unwrap()));

    AnimationFrame { closure, render_id }

Other issues

In addition to the technical challenges mentioned earlier, I ran into a few smaller issues.

Screen flickering

Some games like Pong will flicker on every update due to moving game elements. This is a normal effect of how sprites are XOR-ed onto the screen, but it can be somewhat distracting. The Chip8 Wiki has a number of suggestions for reducing flicker, but in the interest of time I did not implement them.

Key state incorrectly defaulting to down

This was a silly mistake, but I accidentally set the default key value to down instead of up, which led to funny gameplay bugs such as the Pong players’ paddles moving by default. The fix was pretty straightforward.

Distinguishing between set and unset pixels

By default, all <canvas> pixels default to transparent black, i.e., RGBA all being set to zero. This makes it easy to check if a pixel is set: just check if R == 255 (white), and optionally A == 255 (zero transparency). Initially, I had a separate array to keep track of set pixels that I needed to keep in sync with the canvas. I simplified my implementation by removing it as it was easier to just check the source of truth—the canvas itself.

Concluding thoughts

This was a very fun project, although it took me a while to complete! There are plenty of UI improvements that I could make to the emulator to make it more accessible or introspectable, such as displaying the current instruction being run, but I’m happy with how it is.

If you’re interested in building your own CHIP-8 emulator, I would recommend reading Cowgod’s technical reference and Tobias’s guide. Good luck!