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. The minifb crate dependency is in External Crates.