Skip to content

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:

c
joypad_init();

Once per frame, at the start of update:

c
joypad_poll();

Then read what you need:

c
joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
joypad_buttons_t pressed = joypad_get_buttons_pressed(JOYPAD_PORT_1);
CallMeaning
joypad_get_inputsCurrent stick + button held state
joypad_get_buttons_pressedButtons that went down this frame (edges)
joypad_get_buttons_releasedButtons that went up this frame
joypad_get_styleWhat 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:

c
#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

KindUse 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

text
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

bash
source scripts/env.sh
make -C lessons/l03-controllers
# → lessons/l03-controllers/l03_pad.z64

Exercises

  1. Draw a small crosshair that moves with the stick (clamped to the screen).
  2. Increment a counter only on Start pressed (edge), not held.
  3. Print whether a Rumble Pak is present (joypad_get_accessory_type) — optional stretch.

Troubleshooting

ProblemFix
Everything zeroEmulator input not mapped; check Ares controller settings
Stick never centersRaise STICK_DEADZONE slightly
A fires every frameYou 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
make
# Lesson 03 — Controllers
ROMNAME   := l03_pad
ROM_TITLE := "L03 Controllers"

include ../../common/lesson.mk
lessons/l03-controllers/src/main.c
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.

N64 Educator v1.2.2 — libdragon + Tiny3D · branch master