DIVYA JAIN

Build a Chip-8 Emulator in Rust - Part 7 - Desktop Frontend

Mar 2026 22 min read

This is the seventh part of the series on building a Chip-8 emulator in Rust. In part 6, we completed the keypad and timer components and added all their opcodes. The library can fetch, decode, and execute every Chip-8 instruction, but there’s nothing yet to put pixels on a real screen or play a real sound. This part adds that: a desktop frontend using winit, softbuffer, and cpal.

Restructuring as a workspace

Until now, chip8-emulator-core was a binary crate with a main.rs that ran the emulation loop. We need to flip it into a library so the desktop (and eventually a web frontend) can import it.

The workspace root Cargo.toml lists both crates:

Cargo.toml
[workspace]
members = [
    "crates/chip8-emulator-core",
    "crates/chip8-emulator-desktop",
]
resolver = "2"

Converting the core to a library

Replace src/main.rs with src/lib.rs. The lib just re-exports the three public types the frontend needs:

lib.rs
mod display;
mod emulator;
mod instruction;
mod keypad;
mod memory;
mod stack;
mod timer;
 
pub use display::Display;
pub use emulator::Emulator;
pub use keypad::Keypad;

The crate’s Cargo.toml needs a [lib] section to make the change explicit:

crates/chip8-emulator-core/Cargo.toml
[package]
name = "chip8-emulator-core"
version = "0.1.0"
edition = "2021"
 
[lib]
name = "chip8_emulator_core"
path = "src/lib.rs"
 
[dependencies]
rand = "0.8.5"

The desktop crate

Add a new binary crate that depends on the core and three platform crates:

crates/chip8-emulator-desktop/Cargo.toml
[package]
name = "chip8-emulator-desktop"
version = "0.1.0"
edition = "2021"
 
[dependencies]
chip8-emulator-core = { path = "../chip8-emulator-core" }
winit = "0.30"
softbuffer = "0.4"
cpal = "0.15"
  • winit handles window creation and keyboard events.
  • softbuffer gives us a CPU-side framebuffer we can write pixels into directly, without a GPU API.
  • cpal provides cross-platform audio output.

The desktop crate has five source files: main.rs, app.rs, window.rs, display.rs, audio.rs, and input.rs.

The skeleton

Before wiring up display, input, or audio, we need the basic loop that drives the emulator. Winit uses an event-driven model: we implement the ApplicationHandler trait and winit calls our methods when events occur.

Entry point

main.rs loads the ROM from a command-line argument, creates a winit event loop, and hands control to our App:

main.rs
mod app;
 
use winit::event_loop::EventLoop;
 
fn main() {
    let rom_path = std::env::args().skip(1).next().expect("rom path");
    let rom = std::fs::read(rom_path).expect("rom file");
 
    let event_loop = EventLoop::new().expect("create event loop");
    let mut app = app::App::new(&rom);
    event_loop.run_app(&mut app).expect("run app");
}

We’ll add mod declarations for the other files as we create them. EventLoop::new() creates the platform event loop. run_app takes ownership of the loop and drives it until event_loop.exit() is called from one of our event handlers.

The App struct

App holds the emulator and a timestamp for frame pacing. We’ll add display, input, and audio fields as we build each one:

app.rs
use chip8_emulator_core::Emulator;
use std::time::{Duration, Instant};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow};
use winit::window::WindowId;
 
pub struct App {
    emulator: Emulator,
    last_tick: Instant,
}
 
impl App {
    pub fn new(rom: &[u8]) -> Self {
        let mut emulator = Emulator::new(&rom);
 
        Self {
            emulator,
            last_tick: Instant::now(),
        }
    }
}

The emulation loop

Winit’s ApplicationHandler has three methods we care about. resumed is called when the event loop starts. window_event delivers input and redraw events. about_to_wait fires after all pending events have been processed, which is where the emulation loop lives.

For now, about_to_wait just runs CPU cycles at 60 Hz. We check whether enough time has passed for the next tick, run 10 CPU cycles, and decrement timers:

app.rs
impl ApplicationHandler for App {
    fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
 
    fn window_event(
        &mut self,
        event_loop: &ActiveEventLoop,
        _window_id: WindowId,
        event: WindowEvent,
    ) {
        match event {
            WindowEvent::CloseRequested => event_loop.exit(),
            _ => {}
        }
    }
 
    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
        let frame_duration = Duration::from_nanos(1_000_000_000 / 60);
        let next_tick = self.last_tick + frame_duration;
        let now = Instant::now();
 
        if now >= next_tick {
            self.last_tick = now;
 
            for _ in 0..10 {
                self.emulator.run_cycle();
            }
            self.emulator.decrement_timers();
        }
 
        event_loop.set_control_flow(ControlFlow::WaitUntil(self.last_tick + frame_duration));
    }
}

Instead of std::thread::sleep, winit’s ControlFlow::WaitUntil tells the event loop to sleep until the next tick is due. This is more precise than a raw sleep because it accounts for the time spent processing events and running CPU cycles.

The emulator is now ticking, but we can’t see or interact with anything. Let’s fix that.

Wiring up the display

The display is split across two files. display.rs contains the pixel conversion logic. window.rs owns the window and its softbuffer surface.

Pixel conversion

The Chip-8 display is 64×32 pixels. We scale it up by 15× to get a 960×480 window. The constants live here so both the window and the render function can use them:

display.rs
pub const SCALE: u32 = 15;
pub const CHIP8_WIDTH: u32 = 64;
pub const CHIP8_HEIGHT: u32 = 32;
pub const WINDOW_WIDTH: u32 = CHIP8_WIDTH * SCALE;
pub const WINDOW_HEIGHT: u32 = CHIP8_HEIGHT * SCALE;

render receives the flat [bool; 2048] display buffer from the emulator and a mutable slice of u32 pixels from the softbuffer surface. For each pixel in the window, we divide by SCALE to find which Chip-8 pixel it belongs to, then write white or black:

display.rs
pub fn render(frame: &mut [u32], buffer: &[bool; 2048]) {
    for (i, pixel) in frame.iter_mut().enumerate() {
        let x = (i as u32) % WINDOW_WIDTH;
        let y = (i as u32) / WINDOW_WIDTH;
        let chip8_x = x / SCALE;
        let chip8_y = y / SCALE;
        let on = buffer[(chip8_y * CHIP8_WIDTH + chip8_x) as usize];
        *pixel = if on { 0x00ffffff } else { 0x00000000 };
    }
}

Each 15×15 block of window pixels maps to a single Chip-8 pixel through integer division.

The window

WindowState owns a winit Window and a softbuffer Surface. The window is wrapped in Arc because softbuffer needs a shared reference to it:

window.rs
use crate::display::{WINDOW_HEIGHT, WINDOW_WIDTH};
use softbuffer::{Context, Surface};
use std::num::NonZeroU32;
use std::sync::Arc;
use winit::dpi::LogicalSize;
use winit::event_loop::ActiveEventLoop;
use winit::window::Window;
 
pub struct WindowState {
    pub window: Arc<Window>,
    surface: Surface<Arc<Window>, Arc<Window>>,
}

We create the window from the event loop, then wrap it in an Arc so both the Context and Surface can hold references. After creating the surface, we resize it to match the window dimensions:

window.rs
impl WindowState {
    pub fn new(event_loop: &ActiveEventLoop) -> Self {
        let size = LogicalSize::new(WINDOW_WIDTH, WINDOW_HEIGHT);
        let attrs = Window::default_attributes()
            .with_title("Chip-8 Emulator")
            .with_inner_size(size)
            .with_resizable(false);
        let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
        let context = Context::new(Arc::clone(&window)).expect("create softbuffer context");
        let mut surface =
            Surface::new(&context, Arc::clone(&window)).expect("create softbuffer surface");
 
        let (w, h) = (
            NonZeroU32::new(WINDOW_WIDTH).unwrap(),
            NonZeroU32::new(WINDOW_HEIGHT).unwrap(),
        );
        surface.resize(w, h).expect("resize surface");
 
        Self { window, surface }
    }
 
    pub fn render(&mut self, chip8_buffer: &[bool; 2048]) {
        let mut frame = self.surface.buffer_mut().expect("get frame buffer");
        crate::display::render(&mut frame, chip8_buffer);
        frame.present().expect("present frame");
    }
}

buffer_mut() locks the surface’s pixel buffer for writing. After the render function fills it, present() flushes it to the screen.

Plugging display into the app

Add mod display; and mod window; to main.rs, then add a state: Option<WindowState> field to App. It starts as None because the window can only be created once the event loop is running. We create it in resumed, render in window_event on RedrawRequested, and request a redraw after each tick:

app.rs
use crate::window::WindowState;
 
pub struct App {
    emulator: Emulator,
    state: Option<WindowState>,
    last_tick: Instant,
}
 
impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        self.state = Some(WindowState::new(event_loop));
    }
 
    fn window_event(/* ... */) {
        match event {
            // ...
            WindowEvent::RedrawRequested => {
                if let Some(state) = &mut self.state {
                    state.render(self.emulator.display.get_buffer());
                }
            }
            _ => {}
        }
    }
 
    fn about_to_wait(/* ... */) {
        // ... after running cycles and decrementing timers:
        if let Some(state) = &self.state {
            state.window.request_redraw();
        }
    }
}

The emulator’s display buffer is now visible on screen. Next, let’s make it interactive.

Wiring up input

The original Chip-8 keypad was a 4×4 hex grid:

[1][2][3][C]
[4][5][6][D]
[7][8][9][E]
[A][0][B][F]

We map it to the left side of a QWERTY keyboard, preserving the 4×4 shape:

[1][2][3][4]
[Q][W][E][R]
[A][S][D][F]
[Z][X][C][V]

map_keycode translates a winit KeyCode into a Chip-8 key index. Winit uses PhysicalKey codes that refer to key positions on the keyboard, so the mapping works regardless of the user’s keyboard layout:

input.rs
use winit::keyboard::KeyCode;
 
pub fn map_keycode(key: KeyCode) -> Option<u8> {
    match key {
        KeyCode::Digit1 => Some(0x1),
        KeyCode::Digit2 => Some(0x2),
        KeyCode::Digit3 => Some(0x3),
        KeyCode::Digit4 => Some(0xC),
        KeyCode::KeyQ   => Some(0x4),
        KeyCode::KeyW   => Some(0x5),
        KeyCode::KeyE   => Some(0x6),
        KeyCode::KeyR   => Some(0xD),
        KeyCode::KeyA   => Some(0x7),
        KeyCode::KeyS   => Some(0x8),
        KeyCode::KeyD   => Some(0x9),
        KeyCode::KeyF   => Some(0xE),
        KeyCode::KeyZ   => Some(0xA),
        KeyCode::KeyX   => Some(0x0),
        KeyCode::KeyC   => Some(0xB),
        KeyCode::KeyV   => Some(0xF),
        _               => None,
    }
}

Plugging input into the app

Add mod input; to main.rs. Then we expand the window_event match to handle keyboard events. Key presses and releases are translated through map_keycode and forwarded to the emulator’s keypad. Escape exits the loop:

app.rs
use crate::input::map_keycode;
use winit::event::{ElementState, KeyEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
 
fn window_event(
    &mut self,
    event_loop: &ActiveEventLoop,
    _window_id: WindowId,
    event: WindowEvent,
) {
    match event {
        WindowEvent::CloseRequested => event_loop.exit(),
        WindowEvent::KeyboardInput {
            event:
                KeyEvent {
                    physical_key: PhysicalKey::Code(key),
                    state,
                    ..
                },
            ..
        } => {
            if key == KeyCode::Escape {
                event_loop.exit();
                return;
            }
            if let Some(chip8_key) = map_keycode(key) {
                match state {
                    ElementState::Pressed => self.emulator.keypad.key_down(chip8_key),
                    ElementState::Released => self.emulator.keypad.key_up(chip8_key),
                }
            }
        }
        WindowEvent::RedrawRequested => {
            if let Some(state) = &mut self.state {
                state.render(self.emulator.display.get_buffer());
            }
        }
        _ => {}
    }
}

Winit uses pattern matching on PhysicalKey::Code(key) to extract the key code. ElementState::Pressed and ElementState::Released distinguish between key down and key up. Games are now playable.

Wiring up audio

The sound timer in Chip-8 beeps as long as it’s non-zero. We implement that as a 440 Hz sine-wave oscillator driven by cpal.

The audio device

AudioDevice holds a cpal Stream and a shared boolean that gates whether the stream produces sound or silence:

audio.rs
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Stream, StreamConfig};
use std::f32::consts::PI;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
 
pub struct AudioDevice {
    _stream: Stream,
    playing: Arc<AtomicBool>,
}

The _stream field keeps the stream alive. It’s prefixed with an underscore because we never call methods on it directly. It runs on a background thread and references the playing flag through its closure. We use an AtomicBool instead of a Mutex<bool> because it’s lock-free, which is a better fit for a real-time audio callback.

Creating the stream

We ask cpal for the default output device and its sample rate, then set up the shared playing flag and the phase state for the oscillator:

audio.rs
impl AudioDevice {
    pub fn new() -> Self {
        let host = cpal::default_host();
        let device = host
            .default_output_device()
            .expect("no output audio device");
        let config = device
            .default_output_config()
            .expect("default output config");
        let sample_rate = config.sample_rate().0 as f32;
        let channels = config.channels() as usize;
 
        let playing = Arc::new(AtomicBool::new(false));
        let playing_clone = playing.clone();
 
        let mut phase: f32 = 0.0;
        let phase_inc = 440.0 / sample_rate;
        // ...
    }
}

phase_inc is 440.0 / sample_rate. At 48000 Hz, each sample advances the phase by a tiny fraction, completing 440 full cycles per second.

Building the output stream

The stream’s callback closure captures the playing flag and the phase variable. When playing is false, it outputs silence. When true, it generates a 440 Hz sine wave at quarter volume:

audio.rs
        let stream_config: StreamConfig = config.into();
        let stream = device
            .build_output_stream(
                &stream_config,
                move |data: &mut [f32], _| {
                    let is_playing = playing_clone.load(Ordering::Relaxed);
                    for frame in data.chunks_mut(channels) {
                        let sample = if is_playing {
                            0.25 * (2.0 * PI * phase).sin()
                        } else {
                            0.0
                        };
                        if is_playing {
                            phase = (phase + phase_inc) % 1.0;
                        }
                        for s in frame.iter_mut() {
                            *s = sample;
                        }
                    }
                },
                |err| eprintln!("audio stream error: {err}"),
                None,
            )
            .expect("build output stream");
 
        stream.play().expect("start audio stream");

The chunks_mut(channels) loop writes the same sample to every channel, so mono and stereo devices both work. The stream starts immediately and runs forever. We control it by flipping the playing flag.

Controlling playback

play and pause just flip the shared boolean. The stream callback picks up the change on its next iteration:

audio.rs
impl AudioDevice {
    // ... new() ...
 
    pub fn play(&mut self) {
        self.playing.store(true, Ordering::Relaxed);
    }
 
    pub fn pause(&mut self) {
        self.playing.store(false, Ordering::Relaxed);
    }
}

The atomic store is lock-free and setting a boolean is idempotent, so redundant calls are harmless.

Plugging audio into the app

Add mod audio; to main.rs. Then add an audio: AudioDevice field to App, create it in new, and toggle it in about_to_wait based on the sound timer:

app.rs
use crate::audio::AudioDevice;
 
pub struct App {
    emulator: Emulator,
    audio: AudioDevice,
    state: Option<WindowState>,
    last_tick: Instant,
}
 
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
    // ... after running cycles and decrementing timers:
 
    if self.emulator.is_sound_playing() {
        self.audio.play();
    } else {
        self.audio.pause();
    }
 
    // ... request redraw, set control flow ...
}

That’s the last piece. To run it:

cargo run -p chip8-emulator-desktop -- path/to/rom.ch8

Getting ROMs

You’ll need a ROM to actually test the emulator. A few reliable sources:

  • Chip-8 Archive: a curated collection of public-domain games and demos, with screenshots and descriptions for each ROM.
  • kripod/chip8-roms: a GitHub repo with a large selection of ROMs organised by category.
  • Zophar’s Domain: another well-known archive with a variety of ROMs.

The Chip-8 Archive is the best starting point: every ROM lists its control scheme, which you’ll need for the keyboard mapping from the previous section.

Wrapping up

The emulator is now fully playable on the desktop. We restructured the core into a library crate, built a frontend with winit, softbuffer, and cpal, and wired them into a 60 Hz game loop. Load a ROM, hit cargo run, and you’ve got something that actually runs Pong.

The core library is separate from the frontend, so the same Emulator struct can be imported by anything else.