L02 — Game loop & display
Goal
Turn the infinite redraw into an explicit update → render loop, see time pass via a frame counter and a pulsing clear color, and understand that display_get is the vsync-style wait on the N64 (VI + double buffering).
What you will see
Text plus a slowly pulsing blue-green background and a live frame counter.
Why it matters
Games are loops:
- Update — simulate (time, input, physics, AI)
- Render — draw the world for this frame
- Present — show the framebuffer; wait for the next slot
L01 did all of that implicitly. L02 names the phases so later lessons (input, 3D, gameplay) have a place to plug in.
Source walkthrough
Frame counter in update
uint32_t frame = 0;
while (1) {
/* -------- Update -------- */
frame++;
uint8_t pulse = (uint8_t)((frame / 2) & 0xFF);
/* ... derive bg color from pulse ... */No delta-time yet — we count presented frames. That is good enough for demos; real gameplay later can use timers (get_ticks_ms, etc.).
Render still owns the GPU work
surface_t *disp = display_get();
rdpq_attach(disp, NULL);
rdpq_clear(bg);
rdpq_text_print(/* ... */);
rdpq_detach_show();display_get() blocks until a framebuffer is free. With 2 buffers, that roughly paces you to the display’s refresh when the RDP finishes in time.
Mental model
┌─────────────┐
│ Update │ CPU: game logic
└──────┬──────┘
▼
┌─────────────┐
│ Render │ CPU builds RDP commands → RDP draws
└──────┬──────┘
▼
┌─────────────┐
│ Present │ show buffer; wait for next free buffer
└──────┬──────┘
└──────────► loopOn N64, the RDP does the heavy pixel work; your CPU should not busy-spin on pixels in software if the RDP can do it (we already clear via RDPQ).
Vsync on the N64 (important)
Same idea as older consoles
The N64 does sync to the display, just like classic “wait for vblank” loops on older systems. The hardware unit is the VI (Video Interface); libdragon’s double-buffered display API is how we participate in that timing.
What “vsync” meant on older machines
On many 8‑ and 16‑bit consoles you waited for vertical blank (vblank): the video chip finished scanning a frame, raised a flag or interrupt, and only then you swapped buffers or rewrote VRAM. That kept the game loop paced by the TV (~60 Hz NTSC / ~50 Hz PAL) and avoided tearing.
What the N64 has
The VI scans a framebuffer out to the TV and generates vertical retrace / field events. A normal game:
- Draws into a back buffer (usually via the RDP).
- At (or near) vertical blank, the VI is pointed at the completed buffer and the next frame begins.
There is not always a single function literally named vsync() in every API, but the concept is the same: do not free-run the loop as fast as the CPU can go; present on the display’s schedule.
How that maps to this lesson
| Classic pattern | This lesson (libdragon) |
|---|---|
| Wait for vblank | display_get() blocks until a free framebuffer is available |
| Flip / swap buffers | rdpq_detach_show() finishes RDP work and queues the buffer for display |
| Double buffering | display_init(..., 2, ...) — two framebuffers |
| Tear if you update mid-scan | Avoided by not showing a buffer still being drawn |
So when you see display_get() stall until the counter advances at a steady rate, you are not stuck — you are waiting on the display pipeline (VI + buffer ownership). That is the N64 homebrew equivalent of “wait for vsync, then flip.”
Mental model to keep
VI paces the show. Double buffering gives you a private canvas. display_get → draw → rdpq_detach_show is the vsync-friendly loop.
Caveats (know, don’t overthink yet)
- One loop iteration is usually one displayed frame when work fits the budget; if the CPU or RDP runs long, you can drop or stretch timing.
- We still count frames, not milliseconds. Later lessons may use timers (
get_ticks_ms, etc.) when movement needs real delta time. - PAL vs NTSC rates differ; we are not handling region logic here.
- You can go deeper later with VI interrupts and explicit retrace handling — Module 0’s hardware tour will name the chips; this lesson only needs the vsync idea.
Build & run
make -C lessons/l02-game-loop
# → lessons/l02-game-loop/l02_loop.z64Open in Ares (Homebrew mode). Confirm the number climbs and the background breathes.
Exercises
- Change how fast
pulsemoves (frame / 2→/ 4or useframedirectly). - Print
frame / 60as a rough “seconds” estimate (not exact — discuss why). - Sketch where controller read will go in L03 (hint: update, not render).
Troubleshooting
| Problem | Fix |
|---|---|
| Counter stuck at 0 | You are not rebuilding / running the new ROM |
| Flicker / tearing worries | Double-buffering helps; focus on structure for now |
| Color looks wrong | color_t fields are r,g,b,a 0–255 |
Full lesson source
The blocks below are imported from the real repository files at build time (VitePress <<< snippets). They are not hand-copied into this markdown.
lessons/l02-game-loop/Makefile · lessons/l02-game-loop/src/main.c
lessons/l02-game-loop/Makefile
# Lesson 02 — Game loop & display
ROMNAME := l02_loop
ROM_TITLE := "L02 Game Loop"
include ../../common/lesson.mklessons/l02-game-loop/src/main.c
/**
* L02 — Game loop & display
* ============================================================================
*
* LEARNING GOAL
* -------------
* Name the phases of a game frame and see time pass.
*
* UPDATE — change simulation state (here: frame counter, pulse color)
* RENDER — draw that state into a framebuffer
* PRESENT — show it (rdpq_detach_show); next display_get waits its turn
*
* VSYNC / VI (important)
* ----------------------
* display_get() blocks until a free framebuffer exists. With double buffering
* that paces you to the display pipeline — the N64 equivalent of waiting for
* vblank on older consoles. The VI (Video Interface) scans frames out to the
* TV. See docs/guide/m0/l02-game-loop.md for the full story.
*
* BUILD: make -C lessons/l02-game-loop
*/
#include <libdragon.h>
#include <stdio.h>
int main(void)
{
display_init(RESOLUTION_320x240, DEPTH_16_BPP, 2, GAMMA_NONE,
FILTERS_RESAMPLE);
rdpq_init();
rdpq_text_register_font(1, rdpq_font_load_builtin(FONT_BUILTIN_DEBUG_VAR));
uint32_t frame = 0; /* simulation state: how many frames we have presented */
while (1) {
/* -------- UPDATE --------
* Pure CPU work. No drawing yet.
* Later lessons put input, physics, AI here.
*/
frame++;
/*
* Build a slow pulse 0..128..0 from the frame counter.
* (frame/2) slows the sawtooth; fold it into a triangle wave.
*/
uint8_t pulse = (uint8_t)((frame / 2) & 0xFF);
if (pulse > 128) {
pulse = (uint8_t)(255 - pulse);
}
/* Map pulse into a readable blue-green background. */
color_t bg = {
.r = 8,
.g = (uint8_t)(24 + pulse / 2),
.b = (uint8_t)(48 + pulse / 3),
.a = 255,
};
char line[64];
snprintf(line, sizeof(line), "Frame: %lu", (unsigned long)frame);
/* -------- RENDER + PRESENT --------
* display_get may wait (display pacing / vsync-style).
*/
surface_t *disp = display_get();
rdpq_attach(disp, NULL);
rdpq_clear(bg);
rdpq_text_print(NULL, 1, 40, 70, "L02 — Game loop");
rdpq_text_print(NULL, 1, 40, 100, line);
rdpq_text_print(NULL, 1, 40, 130, "Update then render,");
rdpq_text_print(NULL, 1, 40, 146, "once per displayed frame.");
rdpq_detach_show();
}
}What you learned
- Update vs render vs present
- Frame pacing via
display_get - N64 vsync: VI-driven display timing; double-buffered present is the classic vblank idea under a modern API
- Driving visuals from simulation state (
frame,bg)
Next
L03 — Controllers reads the stick and buttons inside update.