How this course works
Lesson shape
Each lesson has:
- Concept — mental model and diagrams
- N64 / Tiny3D notes — what is special on this platform
- Hands-on — code and/or Blender steps
- Build & run — exact commands, what you should see
- Exercises — optional stretch
- Next — bridge to the following lesson
Canonical prose lives under docs/. Runnable projects live under lessons/ (and later capstone/).
Repository map
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 mastersBuild pattern
From the repo root (with toolchain available):
make -C lessons/l01-hello-romOr with Docker CLI:
libdragon make -C lessons/l01-hello-romOutput 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
# 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)
endifcommon/include/ng_math.h
/**
* 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
#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
/**
* 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
- Re-read the lesson’s troubleshooting box
- Check Glossary and Versions
- N64brew Discord for libdragon / Tiny3D community help
Next: L01 — Hello ROM.