The Main Loop
With CPU, memory, and display in place, the final piece is wiring everything together in a loop that runs at 60 frames per second and puts pixels on screen.
The structure
loop:
1. run N CPU cycles (fetch → decode → execute)
2. convert framebuffer to pixels
3. update the window
4. poll input
5. wait for next frame (60 Hz)
Opening the window
We use the minifb crate for windowing:
#![allow(unused)] fn main() { const SCALE: usize = 12; let mut window = Window::new( "CHIP-8", WIDTH * SCALE, HEIGHT * SCALE, WindowOptions::default(), ) .unwrap(); window.set_target_fps(60); }
SCALE multiplies the 64×32 framebuffer up to a visible size. minifb stretches the pixel buffer to fill the window automatically. unwrap() panics if the window cannot be opened — acceptable for this project (see Result and unwrap).
CPU cycles per frame
CHIP-8 ran at roughly 500–700 Hz while the display refreshed at 60 Hz — about 8–12 instructions per frame. We run 10:
#![allow(unused)] fn main() { while window.is_open() && !window.is_key_down(Key::Escape) { for _ in 0..10 { cpu.step(); } // render... } }
This number is not standardized. Different ROMs expect different speeds and it can be tuned.
Converting the framebuffer to pixels
minifb expects a flat Vec<u32> — one u32 per pixel in ARGB format (0xAARRGGBB):
#![allow(unused)] fn main() { let mut buffer = vec![0u32; WIDTH * HEIGHT]; for y in 0..HEIGHT { for x in 0..WIDTH { buffer[y * WIDTH + x] = if cpu.display.pixel(x, y) { 0xFFFFFFFF // white — pixel on } else { 0xFF000000 // black — pixel off }; } } window.update_with_buffer(&buffer, WIDTH, HEIGHT).unwrap(); }
The framebuffer is 2D ([y][x]), but minifb needs a 1D array. The index y * WIDTH + x maps 2D coordinates to the flat array position.
Why a plain loop, not an iterator
The first attempt used a nested iterator chain with flat_map and move closures. The compiler rejected it because cpu.display was being moved on every call to the outer closure. A plain for loop borrows by reference on each iteration — no ownership problem. This is covered in detail in Ownership in Closures.
The complete loop
#![allow(unused)] fn main() { while window.is_open() && !window.is_key_down(Key::Escape) { for _ in 0..10 { cpu.step(); } let mut buffer = vec![0u32; WIDTH * HEIGHT]; for y in 0..HEIGHT { for x in 0..WIDTH { buffer[y * WIDTH + x] = if cpu.display.pixel(x, y) { 0xFFFFFFFF } else { 0xFF000000 }; } } window.update_with_buffer(&buffer, WIDTH, HEIGHT).unwrap(); } }
set_target_fps(60) caps the loop at 60 iterations per second. Without it, the CPU would run as fast as possible, consuming 100% of a core and making the emulator run far too fast.
Rust concepts used here:
Vec<u32>for the pixel buffer is Vec<T>..unwrap()on fallible operations is Result and unwrap. The ownership issue with closures and why the plain loop was chosen is Ownership in Closures. Theminifbcrate dependency is in External Crates.