Skip to content

How this course works

Lesson shape

Each lesson has:

  1. Concept — mental model and diagrams
  2. N64 / Tiny3D notes — what is special on this platform
  3. Hands-on — code and/or Blender steps
  4. Build & run — exact commands, what you should see
  5. Exercises — optional stretch
  6. Next — bridge to the following lesson

Canonical prose lives under docs/. Runnable projects live under lessons/ (and later capstone/).

Repository map

text
lessons/l01-hello-rom/     # example lesson
  Makefile                 # sets ROMNAME, includes common/lesson.mk
  src/main.c
  README.md                # short pointer to the docs page
common/lesson.mk           # shared build rules
docs/guide/...             # what you are reading
assets-src/                # Blender, textures, audio masters

Build pattern

From the repo root (with toolchain available):

bash
make -C lessons/l01-hello-rom

Or with Docker CLI:

bash
libdragon make -C lessons/l01-hello-rom

Output ROM: lessons/l01-hello-rom/<romname>.z64.

Art and non-artists

Every art-facing lesson ships canonical assets. Completing the course never requires winning a modeling contest — replace assets when you want.

Animation & vertex color

These are core topics (not optional extras):

  • Vertex color blending — tint terrain/props with per-vertex color × texture (classic N64 look, cheap on TMEM).
  • Skeletal idle + walk — simple armature, Tiny3D skinning limits explained, clips driven by move speed.

Capstone

Starshard Cove reuses patterns from earlier lessons. Module 4–5 are aimed at that design on purpose — see the one-page GDD.

Shared course library source

The blocks below are imported from the real repository files at build time (VitePress <<< snippets). They are not hand-copied into this markdown.

common/lesson.mk · common/include/ng_math.h · common/src/ng_math.c · common/include/ng_game.h

common/lesson.mk
make
# Shared fragment for n64-educator lessons.
# Include after setting ROMNAME and (optionally) ROM_TITLE.
#
# Optional flags in the lesson Makefile:
#   USE_NG_MATH := 1   — link common/src/ng_math.c
#   USE_T3D     := 1   — Tiny3D headers + libt3d
#
# Optional assets:
#   assets/*.png  → filesystem/*.sprite
#   assets/*.glb  → filesystem/*.t3dm  (requires USE_T3D / gltf_to_t3d)

ifeq ($(N64_INST),)
  $(error N64_INST is not set. See docs/guide/setup.md)
endif

ifeq ($(wildcard $(N64_INST)/include/n64.mk),)
  $(error $(N64_INST)/include/n64.mk not found. Is N64_INST correct?)
endif

BUILD_DIR ?= build
SOURCE_DIR ?= .

include $(N64_INST)/include/n64.mk

# Tiny3D only when requested (do not auto-link just because T3D_INST is in the env).
ifeq ($(USE_T3D),1)
  ifneq ($(wildcard $(N64_INST)/include/t3d.mk),)
    include $(N64_INST)/include/t3d.mk
  else ifneq ($(T3D_INST),)
    include $(T3D_INST)/t3d.mk
  else
    $(error USE_T3D=1 but Tiny3D not found. Install Tiny3D or set T3D_INST.)
  endif
  # Examples use newer C; keep portable.
  N64_CFLAGS += -std=gnu17
endif

NG_ROOT ?= $(abspath ../..)
N64_CFLAGS += -I$(NG_ROOT)/common/include
N64_CXXFLAGS += -I$(NG_ROOT)/common/include

C_FILES := $(shell find src -name '*.c' 2>/dev/null)
OBJS := $(addprefix $(BUILD_DIR)/,$(C_FILES:.c=.o))

ifeq ($(USE_NG_MATH),1)
  OBJS += $(BUILD_DIR)/ng_math.o
endif

ASSETS_PNG := $(wildcard assets/*.png)
ASSETS_GLB := $(wildcard assets/*.glb)
ASSETS_T3DM_PRE := $(wildcard assets/*.t3dm)
ASSETS_SDATA := $(wildcard assets/*.sdata)
ASSETS_WAV := $(wildcard assets/*.wav)
ASSETS_XM := $(wildcard assets/*.xm)
ASSETS_SPRITES := $(addprefix filesystem/,$(notdir $(ASSETS_PNG:%.png=%.sprite)))
# Convert .glb → filesystem/*.t3dm (output path MUST be under filesystem/ so
# gltf_to_t3d embeds rom:/… paths for animation .sdata — bare "foo.2.sdata"
# fails open() with "File not found").
# Prefer glb conversion over a prebuilt .t3dm of the same base name.
ASSETS_T3DM_FROM_GLB := $(addprefix filesystem/,$(notdir $(ASSETS_GLB:%.glb=%.t3dm)))
ASSETS_T3DM_FROM_PRE_ALL := $(addprefix filesystem/,$(notdir $(ASSETS_T3DM_PRE)))
ASSETS_T3DM_FROM_PRE := $(filter-out $(ASSETS_T3DM_FROM_GLB),$(ASSETS_T3DM_FROM_PRE_ALL))
ASSETS_T3DM := $(ASSETS_T3DM_FROM_PRE) $(ASSETS_T3DM_FROM_GLB)
ASSETS_SDATA_OUT := $(addprefix filesystem/,$(notdir $(ASSETS_SDATA)))
ASSETS_WAV64 := $(addprefix filesystem/,$(notdir $(ASSETS_WAV:%.wav=%.wav64)))
ASSETS_XM64 := $(addprefix filesystem/,$(notdir $(ASSETS_XM:%.xm=%.xm64)))
# strip: empty wildcards must not become a " " that looks non-empty to ifneq
DFS_INPUTS := $(strip $(ASSETS_SPRITES) $(ASSETS_T3DM) $(ASSETS_SDATA_OUT) $(ASSETS_WAV64) $(ASSETS_XM64))
MKSPRITE_FLAGS ?=
# Optional inject step for vanilla Blender exports (no Fast64 addon)
GLTF_INJECT ?= $(NG_ROOT)/scripts/gltf_inject_f3d.py

.DEFAULT_GOAL := all
all: $(ROMNAME).z64
.PHONY: all

$(BUILD_DIR)/$(ROMNAME).elf: $(OBJS)

$(ROMNAME).z64: N64_ROM_TITLE = $(or $(ROM_TITLE),"$(ROMNAME)")

ifneq ($(ASSETS_PNG),)
filesystem/%.sprite: assets/%.png
	@mkdir -p $(dir $@)
	@echo "    [SPRITE] $@"
	@$(N64_MKSPRITE) $(MKSPRITE_FLAGS) -o filesystem "$<"
endif

ifneq ($(ASSETS_T3DM_PRE),)
filesystem/%.t3dm: assets/%.t3dm
	@mkdir -p $(dir $@)
	@echo "    [T3D-COPY] $@"
	@cp "$<" $@
endif

ifneq ($(ASSETS_SDATA),)
filesystem/%.sdata: assets/%.sdata
	@mkdir -p $(dir $@)
	@echo "    [SDATA] $@"
	@cp "$<" $@
endif

ifneq ($(ASSETS_GLB),)
# $@ is filesystem/name.t3dm — required so animation streams bake as rom:/name.N.sdata
filesystem/%.t3dm: assets/%.glb
	@mkdir -p $(dir $@) $(BUILD_DIR)
	@echo "    [T3D-MODEL] $@"
	@python3 $(GLTF_INJECT) "$<" "$(BUILD_DIR)/$(basename $(notdir $<))_f3d.glb"
	@$(T3D_GLTF_TO_3D) "$(BUILD_DIR)/$(basename $(notdir $<))_f3d.glb" $@
	@$(N64_BINDIR)/mkasset -c 2 -o filesystem $@
endif

ifneq ($(ASSETS_WAV),)
filesystem/%.wav64: assets/%.wav
	@mkdir -p $(dir $@)
	@echo "    [AUDIO] $@"
	@$(N64_AUDIOCONV) --wav-compress 3 -o filesystem "$<"
endif

ifneq ($(ASSETS_XM),)
filesystem/%.xm64: assets/%.xm
	@mkdir -p $(dir $@)
	@echo "    [XM] $@"
	@$(N64_AUDIOCONV) -o filesystem "$<"
endif

ifneq ($(DFS_INPUTS),)
$(BUILD_DIR)/$(ROMNAME).dfs: $(DFS_INPUTS)
$(ROMNAME).z64: $(BUILD_DIR)/$(ROMNAME).dfs
endif

ifeq ($(USE_NG_MATH),1)
$(BUILD_DIR)/ng_math.o: $(NG_ROOT)/common/src/ng_math.c
	@mkdir -p $(dir $@)
	@echo "    [CC] $<"
	$(N64_CC) -c $(N64_CFLAGS) -I$(NG_ROOT)/common/include -o $@ $<
endif

clean:
	$(RM) -r $(BUILD_DIR) $(ROMNAME).z64 filesystem
.PHONY: clean

ifneq ($(wildcard $(BUILD_DIR)),)
  -include $(shell find $(BUILD_DIR) -name '*.d' 2>/dev/null)
endif
common/include/ng_math.h
c
/**
 * ng_math — small course math helpers for N64 Educator
 * =====================================================
 *
 * WHAT YOU GET
 * ------------
 * vec2 / vec3 helpers (add, scale, length, normalize, cross)
 * mat4 helpers (identity, multiply, translate/rotate/scale, look_at, perspective)
 *
 * CONVENTIONS (see docs/reference/conventions.md)
 * -----------------------------------------------
 *   +Y is "up" in world diagrams
 *   Matrices are **column-major** (m[col*4 + row])
 *   Transform a point as **M * v** (matrix times vector)
 *   Angles for these helpers are in **radians**
 *     (use ng_deg_to_rad / ng_rad_to_deg at the bottom)
 *
 * HOW BEGINNERS SHOULD USE THIS
 * -----------------------------
 * You do NOT need to derive every formula. Call the helpers, print results,
 * and match them to the Module 1 ROMs. Read function bodies when curious.
 *
 * Intentionally simple — clarity over micro-optimizations.
 */
#ifndef NG_MATH_H
#define NG_MATH_H

#include <math.h>
#include <stdbool.h>

#ifndef NG_PI
#define NG_PI 3.14159265358979323846f
#endif

typedef struct {
    float x, y;
} ng_vec2;

typedef struct {
    float x, y, z;
} ng_vec3;

/* Column-major 4x4: m[col * 4 + row] */
typedef struct {
    float m[16];
} ng_mat4;

/* ---- vec2 ---- */
static inline ng_vec2 ng_v2(float x, float y) { return (ng_vec2){ x, y }; }
static inline ng_vec2 ng_v2_add(ng_vec2 a, ng_vec2 b) { return ng_v2(a.x + b.x, a.y + b.y); }
static inline ng_vec2 ng_v2_sub(ng_vec2 a, ng_vec2 b) { return ng_v2(a.x - b.x, a.y - b.y); }
static inline ng_vec2 ng_v2_scale(ng_vec2 a, float s) { return ng_v2(a.x * s, a.y * s); }
static inline float ng_v2_dot(ng_vec2 a, ng_vec2 b) { return a.x * b.x + a.y * b.y; }
static inline float ng_v2_len(ng_vec2 a) { return sqrtf(ng_v2_dot(a, a)); }
static inline ng_vec2 ng_v2_normalize(ng_vec2 a)
{
    float len = ng_v2_len(a);
    if (len < 1e-6f) {
        return ng_v2(0.f, 0.f);
    }
    return ng_v2_scale(a, 1.f / len);
}

/* ---- vec3 ---- */
static inline ng_vec3 ng_v3(float x, float y, float z) { return (ng_vec3){ x, y, z }; }
static inline ng_vec3 ng_v3_add(ng_vec3 a, ng_vec3 b) { return ng_v3(a.x + b.x, a.y + b.y, a.z + b.z); }
static inline ng_vec3 ng_v3_sub(ng_vec3 a, ng_vec3 b) { return ng_v3(a.x - b.x, a.y - b.y, a.z - b.z); }
static inline ng_vec3 ng_v3_scale(ng_vec3 a, float s) { return ng_v3(a.x * s, a.y * s, a.z * s); }
static inline float ng_v3_dot(ng_vec3 a, ng_vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
static inline float ng_v3_len(ng_vec3 a) { return sqrtf(ng_v3_dot(a, a)); }
static inline ng_vec3 ng_v3_normalize(ng_vec3 a)
{
    float len = ng_v3_len(a);
    if (len < 1e-6f) {
        return ng_v3(0.f, 0.f, 0.f);
    }
    return ng_v3_scale(a, 1.f / len);
}
static inline ng_vec3 ng_v3_cross(ng_vec3 a, ng_vec3 b)
{
    return ng_v3(
        a.y * b.z - a.z * b.y,
        a.z * b.x - a.x * b.z,
        a.x * b.y - a.y * b.x);
}

/* ---- mat4 ---- */
void ng_mat4_identity(ng_mat4 *out);
void ng_mat4_mul(ng_mat4 *out, const ng_mat4 *a, const ng_mat4 *b);
ng_vec3 ng_mat4_mul_point(const ng_mat4 *m, ng_vec3 p);
ng_vec3 ng_mat4_mul_vector(const ng_mat4 *m, ng_vec3 v);

void ng_mat4_translation(ng_mat4 *out, float x, float y, float z);
void ng_mat4_scale(ng_mat4 *out, float x, float y, float z);
void ng_mat4_rotation_z(ng_mat4 *out, float radians);
void ng_mat4_rotation_y(ng_mat4 *out, float radians);
void ng_mat4_rotation_x(ng_mat4 *out, float radians);

/** Build TRS as T * R_z * S (course default for 2D-ish demos). */
void ng_mat4_trs_z(ng_mat4 *out, ng_vec3 t, float rot_z_rad, ng_vec3 s);

/** Look-at view matrix (camera at eye, looking at target, +Y up). */
void ng_mat4_look_at(ng_mat4 *out, ng_vec3 eye, ng_vec3 target, ng_vec3 up);

/** Perspective projection (FOV in radians, aspect = width/height). */
void ng_mat4_perspective(ng_mat4 *out, float fovy_rad, float aspect, float znear, float zfar);

static inline float ng_deg_to_rad(float d) { return d * (NG_PI / 180.f); }
static inline float ng_rad_to_deg(float r) { return r * (180.f / NG_PI); }

#endif /* NG_MATH_H */
common/src/ng_math.c
c
#include "ng_math.h"

void ng_mat4_identity(ng_mat4 *out)
{
    for (int i = 0; i < 16; i++) {
        out->m[i] = 0.f;
    }
    out->m[0] = out->m[5] = out->m[10] = out->m[15] = 1.f;
}

void ng_mat4_mul(ng_mat4 *out, const ng_mat4 *a, const ng_mat4 *b)
{
    ng_mat4 r;
    for (int col = 0; col < 4; col++) {
        for (int row = 0; row < 4; row++) {
            r.m[col * 4 + row] =
                a->m[0 * 4 + row] * b->m[col * 4 + 0] +
                a->m[1 * 4 + row] * b->m[col * 4 + 1] +
                a->m[2 * 4 + row] * b->m[col * 4 + 2] +
                a->m[3 * 4 + row] * b->m[col * 4 + 3];
        }
    }
    *out = r;
}

ng_vec3 ng_mat4_mul_point(const ng_mat4 *m, ng_vec3 p)
{
    float x = m->m[0] * p.x + m->m[4] * p.y + m->m[8] * p.z + m->m[12];
    float y = m->m[1] * p.x + m->m[5] * p.y + m->m[9] * p.z + m->m[13];
    float z = m->m[2] * p.x + m->m[6] * p.y + m->m[10] * p.z + m->m[14];
    float w = m->m[3] * p.x + m->m[7] * p.y + m->m[11] * p.z + m->m[15];
    if (fabsf(w) > 1e-6f) {
        float inv = 1.f / w;
        x *= inv;
        y *= inv;
        z *= inv;
    }
    return ng_v3(x, y, z);
}

ng_vec3 ng_mat4_mul_vector(const ng_mat4 *m, ng_vec3 v)
{
    /* Ignore translation column */
    return ng_v3(
        m->m[0] * v.x + m->m[4] * v.y + m->m[8] * v.z,
        m->m[1] * v.x + m->m[5] * v.y + m->m[9] * v.z,
        m->m[2] * v.x + m->m[6] * v.y + m->m[10] * v.z);
}

void ng_mat4_translation(ng_mat4 *out, float x, float y, float z)
{
    ng_mat4_identity(out);
    out->m[12] = x;
    out->m[13] = y;
    out->m[14] = z;
}

void ng_mat4_scale(ng_mat4 *out, float x, float y, float z)
{
    ng_mat4_identity(out);
    out->m[0] = x;
    out->m[5] = y;
    out->m[10] = z;
}

void ng_mat4_rotation_z(ng_mat4 *out, float radians)
{
    float c = cosf(radians);
    float s = sinf(radians);
    ng_mat4_identity(out);
    out->m[0] = c;
    out->m[1] = s;
    out->m[4] = -s;
    out->m[5] = c;
}

void ng_mat4_rotation_y(ng_mat4 *out, float radians)
{
    float c = cosf(radians);
    float s = sinf(radians);
    ng_mat4_identity(out);
    out->m[0] = c;
    out->m[2] = -s;
    out->m[8] = s;
    out->m[10] = c;
}

void ng_mat4_rotation_x(ng_mat4 *out, float radians)
{
    float c = cosf(radians);
    float s = sinf(radians);
    ng_mat4_identity(out);
    out->m[5] = c;
    out->m[6] = s;
    out->m[9] = -s;
    out->m[10] = c;
}

void ng_mat4_trs_z(ng_mat4 *out, ng_vec3 t, float rot_z_rad, ng_vec3 s)
{
    ng_mat4 T, R, S, RS;
    ng_mat4_translation(&T, t.x, t.y, t.z);
    ng_mat4_rotation_z(&R, rot_z_rad);
    ng_mat4_scale(&S, s.x, s.y, s.z);
    ng_mat4_mul(&RS, &R, &S);
    ng_mat4_mul(out, &T, &RS);
}

void ng_mat4_look_at(ng_mat4 *out, ng_vec3 eye, ng_vec3 target, ng_vec3 up)
{
    ng_vec3 f = ng_v3_normalize(ng_v3_sub(target, eye));
    ng_vec3 s = ng_v3_normalize(ng_v3_cross(f, up));
    ng_vec3 u = ng_v3_cross(s, f);

    ng_mat4_identity(out);
    out->m[0] = s.x;
    out->m[4] = s.y;
    out->m[8] = s.z;
    out->m[1] = u.x;
    out->m[5] = u.y;
    out->m[9] = u.z;
    out->m[2] = -f.x;
    out->m[6] = -f.y;
    out->m[10] = -f.z;
    out->m[12] = -ng_v3_dot(s, eye);
    out->m[13] = -ng_v3_dot(u, eye);
    out->m[14] = ng_v3_dot(f, eye);
}

void ng_mat4_perspective(ng_mat4 *out, float fovy_rad, float aspect, float znear, float zfar)
{
    float f = 1.f / tanf(fovy_rad * 0.5f);
    for (int i = 0; i < 16; i++) {
        out->m[i] = 0.f;
    }
    out->m[0] = f / aspect;
    out->m[5] = f;
    out->m[10] = (zfar + znear) / (znear - zfar);
    out->m[11] = -1.f;
    out->m[14] = (2.f * zfar * znear) / (znear - zfar);
}
common/include/ng_game.h
c
/**
 * ng_game.h — tiny shared helpers for Module 4+ gameplay lessons
 * =================================================================
 *
 * WHY THIS FILE EXISTS
 * --------------------
 * Every gameplay ROM needs the same boring utilities:
 *   - ignore stick noise near the center (deadzone)
 *   - measure seconds for delta-time movement
 *   - smooth blend numbers and angles
 *   - clamp values into a range
 *
 * Keeping them here means lessons stay focused on *game ideas*, not copy-paste
 * math bugs. All functions are static inline — no .c file to link.
 *
 * Include after libdragon if you want, but only math.h is required beyond that.
 */

#ifndef NG_GAME_H
#define NG_GAME_H

#include <libdragon.h>
#include <math.h>

/** Stick axes near 0 are treated as 0 (see Module 0 controllers). */
#define NG_DEADZONE 10

#ifndef NG_PI
#define NG_PI 3.14159265358979323846f
#endif

/**
 * Deadzone: return 0 if v is "close enough" to center, else v unchanged.
 * Use on raw joypad stick_x / stick_y before building a move vector.
 */
static inline int ng_dz(int v)
{
    return (v > -NG_DEADZONE && v < NG_DEADZONE) ? 0 : v;
}

/**
 * Seconds since boot as a float (from the CPU timer).
 * Subtract two samples to get delta time for one frame:
 *   float dt = ng_time_s() - last; last = ng_time_s();
 */
static inline float ng_time_s(void)
{
    return (float)((double)get_ticks_us() / 1000000.0);
}

/** Linear interpolate: a when t=0, b when t=1, mix in between. */
static inline float ng_lerp(float a, float b, float t)
{
    return a + (b - a) * t;
}

/**
 * Interpolate angles in radians the short way around the circle.
 * Plain lerp(6.0, 0.1) would spin the long way; this unwraps the difference
 * into (-pi, pi] first — essential for smooth character turning.
 */
static inline float ng_lerp_angle(float a, float b, float t)
{
    float diff = b - a;
    while (diff > NG_PI) {
        diff -= 2.f * NG_PI;
    }
    while (diff < -NG_PI) {
        diff += 2.f * NG_PI;
    }
    return a + diff * t;
}

/** Keep v inside [lo, hi]. */
static inline float ng_clamp(float v, float lo, float hi)
{
    if (v < lo) {
        return lo;
    }
    if (v > hi) {
        return hi;
    }
    return v;
}

#endif /* NG_GAME_H */

Getting help

Next: L01 — Hello ROM.

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