Build a Chip-8 Emulator in Rust - Part 7 - Desktop Frontend
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:
[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:
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:
[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:
[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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.ch8Getting 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.