L03 — Controllers
Goal
Read the N64 controller every frame, apply a stick deadzone, and display buttons and stick values. Input belongs in update, not render.
What you will see
Live text for port 1:
- Connection status
- Stick X/Y (after deadzone)
- A / B / Z / Start / L / R held state
- A short flash line when A is newly pressed (edge detect)
In Ares, map keyboard/gamepad to N64 controls if the stick does nothing.
API pattern (libdragon joypad)
Once per project:
joypad_init();Once per frame, at the start of update:
joypad_poll();Then read what you need:
joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
joypad_buttons_t pressed = joypad_get_buttons_pressed(JOYPAD_PORT_1);| Call | Meaning |
|---|---|
joypad_get_inputs | Current stick + button held state |
joypad_get_buttons_pressed | Buttons that went down this frame (edges) |
joypad_get_buttons_released | Buttons that went up this frame |
joypad_get_style | What is plugged in (NONE, N64, …) |
Always poll first
Call joypad_poll() once per frame before any joypad_get_*. Otherwise you re-read stale or inconsistent data.
Deadzone
Raw stick values jitter around zero. A small deadzone keeps “idle” truly idle:
#define STICK_DEADZONE 8
static int apply_deadzone(int v)
{
if (v > -STICK_DEADZONE && v < STICK_DEADZONE)
return 0;
return v;
}Later 3D movement (Module 4) will use the same idea, then normalize into a direction vector.
Held vs pressed
| Kind | Use for |
|---|---|
Held (in.btn.a) | Continuous actions (aim, crouch) |
Pressed (pressed.a) | One-shots (jump, menu confirm, “+1 count”) |
The checkpoint after L04 uses pressed so holding A does not spam the counter every frame… wait — actually if we only use pressed, holding does not repeat. Good for collect / UI.
Where it sits in the loop
while (1) {
// UPDATE
joypad_poll();
// read stick / buttons → mutate game state
// RENDER (vsync-paced)
display_get();
// draw using current state
rdpq_detach_show();
}Recall L02: display_get is the display-paced wait (VI / vsync-style). Input is sampled once per displayed frame in this simple structure.
Build & run
source scripts/env.sh
make -C lessons/l03-controllers
# → lessons/l03-controllers/l03_pad.z64Exercises
- Draw a small crosshair that moves with the stick (clamped to the screen).
- Increment a counter only on Start pressed (edge), not held.
- Print whether a Rumble Pak is present (
joypad_get_accessory_type) — optional stretch.
Troubleshooting
| Problem | Fix |
|---|---|
| Everything zero | Emulator input not mapped; check Ares controller settings |
| Stick never centers | Raise STICK_DEADZONE slightly |
| A fires every frame | You used held instead of pressed for a one-shot |
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/l03-controllers/Makefile · lessons/l03-controllers/src/main.c
lessons/l03-controllers/Makefile
# Lesson 03 — Controllers
ROMNAME := l03_pad
ROM_TITLE := "L03 Controllers"
include ../../common/lesson.mklessons/l03-controllers/src/main.c
/**
* L03 — Controllers
* ============================================================================
*
* LEARNING GOAL
* -------------
* Read the N64 pad every frame and show stick + buttons on screen.
*
* RULES YOU MUST REMEMBER
* -----------------------
* 1. joypad_init() once at startup.
* 2. joypad_poll() once per frame *before* any joypad_get_*.
* 3. "Held" (inputs.btn.a) vs "Pressed this frame" (get_buttons_pressed)
* — use pressed for one-shots (jump, menu confirm, +1 count).
* 4. Stick needs a deadzone or noise becomes random movement later.
*
* INPUT LIVES IN UPDATE, NOT RENDER
* ---------------------------------
* Read the pad, store what you need, then draw. Don't call joypad_poll
* in the middle of drawing.
*
* BUILD: make -C lessons/l03-controllers
* DOCS: docs/guide/m0/l03-controllers.md
*/
#include <libdragon.h>
#include <stdio.h>
#include <stdlib.h>
/* Raw stick is roughly -80..+80; ignore small noise around center. */
#define STICK_DEADZONE 8
static int apply_deadzone(int v)
{
if (v > -STICK_DEADZONE && v < STICK_DEADZONE) {
return 0;
}
return v;
}
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));
/* Enable the joypad subsystem (all four ports). */
joypad_init();
char line_stick[64];
char line_btns[80];
char line_conn[48];
while (1) {
/* -------- UPDATE (input belongs here) -------- */
/* Refresh hardware state into libdragon's snapshot. */
joypad_poll();
/* What kind of device is in port 1? NONE if unplugged. */
joypad_style_t style = joypad_get_style(JOYPAD_PORT_1);
/* Continuous state: stick axes + which buttons are down *right now*. */
joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
/* Edges: buttons that went from up→down since last poll. */
joypad_buttons_t pressed = joypad_get_buttons_pressed(JOYPAD_PORT_1);
int sx = apply_deadzone(in.stick_x);
int sy = apply_deadzone(in.stick_y);
if (style == JOYPAD_STYLE_NONE) {
snprintf(line_conn, sizeof(line_conn), "Port 1: (no controller)");
} else {
snprintf(line_conn, sizeof(line_conn), "Port 1: connected");
}
snprintf(line_stick, sizeof(line_stick), "Stick: %+04d, %+04d", sx, sy);
/* .a .b etc. are 0 or 1 for held state. */
snprintf(line_btns, sizeof(line_btns),
"A:%d B:%d Z:%d Start:%d L:%d R:%d",
in.btn.a, in.btn.b, in.btn.z, in.btn.start,
in.btn.l, in.btn.r);
/* Edge demo: only true on the frame A is newly pressed. */
const char *edge = pressed.a ? "A pressed this frame!" : "";
/* -------- RENDER -------- */
surface_t *disp = display_get();
rdpq_attach(disp, NULL);
rdpq_clear((color_t){ .r = 12, .g = 18, .b = 32, .a = 255 });
rdpq_text_print(NULL, 1, 24, 40, "L03 — Controllers");
rdpq_text_print(NULL, 1, 24, 70, line_conn);
rdpq_text_print(NULL, 1, 24, 100, line_stick);
rdpq_text_print(NULL, 1, 24, 120, line_btns);
rdpq_text_print(NULL, 1, 24, 150, "D-pad / C / Z also on inputs.btn");
if (edge[0]) {
rdpq_text_print(NULL, 1, 24, 180, edge);
}
rdpq_text_print(NULL, 1, 24, 210, "Move stick, mash buttons (Ares: map input)");
rdpq_detach_show();
}
}What you learned
joypad_init/joypad_poll/joypad_get_inputs- Deadzone on analog sticks
- Held vs edge-triggered buttons
- Input belongs in update
Next
L04 — Assets on ROM (DFS) packs art into the cartridge image and loads it at runtime.