Introduction

GoSprite64 is your portal to building retro-fueled 2D games for the Nintendo 64, using the modern power of Go. With clean APIs, minimal setup (Linux, Mac, Windows are all supported), and a rebellious retro soul, it lets you bring your pixel dreams to lifeโon real N64 hardware.
The information here is targeted at homebrew development. This project just got born, expect heavy changes along the way...
What's GoSprite64?
GoSprite64 is a Go library for making 2D games that run natively on the Nintendo 64. It wraps low-level N64 quirks in a modern API inspired by modern game engines, so you can focus on your game logic -- not the hardware headaches.
Designed for developers who love Go and grew up on cartridges, GoSprite64 makes retro dev surprisingly fun and productive.
you can:
- ๐ A friendly API built on top of
clktmr/n64andembedded-go - ๐พ Builds real N64 ROMs you can flash and play (yes, on real hardware!)
Fixed Resolution
GoSprite64 exposes one official fixed resolution and drawing space: 288x216 logical pixels.
That is the public rendering contract for gameplay code. The runtime centers the canvas inside the framebuffer and presents it with square pixels, while public drawing APIs such as gosprite64.FillRect, gosprite64.DrawRect, gosprite64.DrawLine, and gosprite64.DrawText all operate in that same logical space.
If you build and run examples/calibration, you should see this reference frame:

For a deep dive into why pixels on the N64 are not always square and how GoSprite64 solves this, read the Square Pixels and the Fixed Canvas chapter.
Why Go?
Go is a clean, fast, pragmatic and efficient language.
By using Go for Nintendo 64 development, GoSprite64 opens the door for cloud developers to create retro-style games with confidence and speed.
The library bridges modern programming concepts with the raw power of a classic console.
Whatโs in this book?
This book introduces you to GoSprite64, guiding you through everything from setup to building full 2D games.
You'll learn how to:
- Build and flash N64 ROMs
- Draw and move sprites
- Design and scroll tilemaps
- Handle input
- and more ...
Whether you're nostalgic for the era or just curious about console programming, this book aims to get you productive with GoSprite64 as fast as possible.
Who is this book for?
This book is for:
- Developers interested in retro console programming
- Go programmers curious about low-level game development
- Hobbyists or indie devs looking to make something fun for the Nintendo 64
Some experience with Go is recommended. If you're brand new to game development or Go, consider starting with a simpler platform or tutorial first.
Helpful links
- GoSprite64 GitHub โ main development repo
- GoSprite64 Website โ official docs and examples
- GoSprite64 Discussions โ get help, share ideas, or show off your projects
- clktmr/n64 โ low-level Go SDK for N64
- Embedded-Go โ support for N64's architecture
- Awesome N64 Dev โ great collection of tools, docs, and inspiration
This book also includes step-by-step guides and real-world examples to help you build your own Nintendo 64 games using Go.
Getting Started
This repository supports one setup path: build natively with go1.24.5-embedded and n64go@v0.1.2. If that native bootstrap is unavailable on your host, use the Linux fallback below.
Installation
- Clone the repository:
git clone https://github.com/drpaneas/gosprite64.git
cd gosprite64
- Install the EmbeddedGo toolchain:
go install github.com/embeddedgo/dl/go1.24.5-embedded@latest
go1.24.5-embedded download
- If macOS aborts with the
__DATA/__DWARFdyld error, retry once with:
BOOT_GO_LDFLAGS=-w go1.24.5-embedded download
- Install
n64go:
go install github.com/clktmr/n64/tools/n64go@v0.1.2
- Build all examples with the supported native-first workflow:
chmod +x ./build_examples.sh
./build_examples.sh
n64.env is the only tracked toolchain configuration file:
GOTOOLCHAIN=go1.24.5-embedded
GOOS=noos
GOARCH=mips64
GOFLAGS='-tags=n64' '-trimpath' '-ldflags=-M=0x00000000:8M -F=0x00000400:8M -stripfn=1'
Cursor / VS Code
If gopls reports embedded/* packages as missing or does not recognize files guarded by //go:build n64, configure the workspace so the editor uses the same build tag and toolchain environment as the terminal.
Create .vscode/settings.json in the repository with:
{
"go.buildTags": "n64",
"go.toolsEnvVars": {
"GOENV": "${workspaceFolder}/n64.env"
},
"gopls": {
"build.buildFlags": ["-tags=n64"],
"env": {
"GOENV": "${workspaceFolder}/n64.env"
}
}
}
If the Go extension still invokes the wrong go binary for editor actions, add:
"go.alternateTools": {
"go": "go1.24.5-embedded"
}
After changing the settings, run Go: Restart Language Server or Developer: Reload Window.
GoSprite64 exposes one official fixed resolution and drawing canvas: 288x216 logical pixels. The runtime centers that canvas and handles presentation scaling for you, so gameplay code should not manage borders, safe areas, or video-mode presets directly.
If you want to verify the fixed-resolution presentation visually, run examples/calibration/game.z64 in ares after the build. The expected calibration frame looks like this:

Windows
On Windows, install Git for Windows and run all commands from a Git Bash terminal. The same steps above apply - Git Bash provides the bash environment the build scripts require.
Linux Fallback
If the native bootstrap fails on your macOS host, ./build_examples.sh prints Linux fallback instructions and exits. Run the Linux fallback yourself:
docker run --rm --platform linux/arm64 \
-v "$PWD:/workspace/gosprite64" \
-v gosprite64-gomod:/go/pkg/mod \
-v gosprite64-gobuild:/root/.cache/go-build \
-v gosprite64-sdk:/root/sdk \
-w /workspace/gosprite64 \
golang:1.26-bookworm \
bash ./scripts/dev-linux-build.sh
The generated *.z64 ROMs are written under examples/ and can be run with your emulator, for example ares. If you want to verify the square-pixel layout visually, start with examples/calibration/game.z64.
Square Pixels and the Fixed Canvas
The problem: my square is a rectangle
When I started building GoSprite64, one of the first things I tried was drawing a square. A simple 40x40 filled rectangle. The code was obvious:
gosprite64.FillRect(100, 80, 139, 119, gosprite64.Red)
That is a 40x40 filled rectangle. It should be a square.
It was not a square. On screen it looked stretched - wider than it was tall, or taller than it was wide, depending on which resolution I was testing with. I changed the resolution constants to compensate, and that fixed it for one video mode but broke it for another. No matter what I tried, I could not get a square to look like a square across both NTSC and PAL without adding aspect-ratio correction math into my game code.
This was driving me nuts. I was writing a 2D game library, not a video-signal processing toolkit. If I draw 40x40 pixels, I want a square on screen. Period.
It took a while to understand why this was happening, and the answer turned out to be surprisingly fundamental. The problem is that pixels are not always square.
Why this happens - pixels are not always square
What is a pixel aspect ratio?
Every pixel on a display has a physical shape. On a modern LCD monitor, each pixel is a tiny square element in a fixed grid. One pixel wide equals one pixel tall. But on older displays - particularly CRT televisions, which is what the N64 was designed for - that is not always the case.
The pixel aspect ratio (PAR) is the physical width of one pixel divided by its physical height:
PAR = physical width of one pixel / physical height of one pixel
- PAR = 1.0 means the pixel is square. A 40x40 rect looks like a square.
- PAR < 1.0 means the pixel is taller than it is wide. A 40x40 rect looks like a tall column.
- PAR > 1.0 means the pixel is wider than it is tall. A 40x40 rect looks like a wide bar.
The math for 320x240 on NTSC
Let's start with the simplest case. The N64 outputs 320 horizontal pixels and 240 vertical lines. The NTSC television standard displays this signal on a screen with a 4:3 physical aspect ratio.
The display aspect ratio (DAR) is the physical shape of the screen:
DAR = 4 / 3 = 1.333
The image aspect ratio is the shape of the pixel grid itself:
Image AR = 320 / 240 = 4 / 3 = 1.333
The pixel aspect ratio is the relationship between these two:
PAR = DAR / Image AR = (4/3) / (320/240) = (4/3) / (4/3) = 1.0
The numbers happen to line up perfectly. 320x240 on a 4:3 display gives square pixels. A 40x40 rect looks like a square. Everything is fine.
What breaks it
The N64's Video Interface is flexible. It can output different horizontal pixel counts: 256, 320, 512, or 640 pixels per line. But the CRT television does not change. It always stretches the signal across the same physical 4:3 screen width.
When you change the horizontal pixel count, each pixel gets wider or narrower. The pixel height stays the same (it is determined by the number of scan lines). So the pixel aspect ratio changes.
Let's work through a concrete example.
Worked example: 640x240 non-interlaced
Suppose the N64 outputs 640 horizontal pixels and 240 vertical lines (non-interlaced) onto the same 4:3 NTSC display.
Image AR = 640 / 240 = 8 / 3 = 2.667
PAR = DAR / Image AR = (4/3) / (8/3) = 4 / 8 = 0.5
Each pixel is now half as wide as it is tall. If you draw a 40x40 rectangle in this mode, it has 40 pixels of width but each pixel is only half as wide as it is tall. On screen, it looks like a tall narrow column - not a square.
PAL makes it worse
PAL televisions use 576 visible lines instead of NTSC's 480. A typical PAL field for the N64 uses 288 visible lines with 320 horizontal pixels.
PAR = (4/3) / (320/288) = (4/3) / (10/9) = (4/3) * (9/10) = 36/30 = 1.2
Each pixel is 20% wider than it is tall. A 40x40 rect now looks like a wide, squat bar instead of a square.
Seeing it visually
Here is the same gosprite64.FillRect(0, 0, 39, 39, gosprite64.Red) call rendered at three different pixel aspect ratios:

The code is identical in all three cases. The pixels just have different physical shapes.
The same pixel coordinates produce different shapes on screen depending on the video mode and the display standard. This is not a bug. It is how analog video works.
This is normal - it is how analog video works
CRT televisions did not have a fixed pixel grid. There were no tiny square elements arranged in rows and columns. Instead, an electron beam swept across the screen in horizontal lines, painting brightness and color as it went. The horizontal resolution of the image was determined by how fast the signal changed during each sweep - the signal bandwidth - not by a fixed number of physical dots.
The concept of "one pixel equals one square dot" is a modern assumption. It comes from LCD displays, where the screen really is a physical grid of fixed-size rectangular elements. On a CRT, a "pixel" was just one sample point along a scan line, and its effective physical shape depended entirely on how many samples the signal packed into the available screen width.
Early console developers knew this. They designed their sprites with the display's pixel aspect ratio in mind. If they needed a circle to look circular on a PAL television, they drew it as a vertical oval in the pixel grid so the wider PAL pixels would stretch it back into a circle on screen. This was not a workaround. It was standard practice for decades of console and broadcast development.
The N64 is no exception. Its Video Interface was designed to be flexible across NTSC and PAL markets, and that flexibility means the pixel aspect ratio is not fixed. It depends on the video mode configuration.
The GoSprite64 answer - one canvas, always square
I did not want to think about pixel aspect ratios in my game code. I did not want to adjust my sprites for NTSC versus PAL. I did not want to multiply my X coordinates by a correction factor every time I drew a rectangle.
So I made it the library's problem.
This is my personal preference, and since this is my library, I can make that call. GoSprite64 exposes exactly one public coordinate space:
288 x 216 logical pixels.
One logical pixel always maps to one square pixel on the final display. No configuration. No presets. No choices. You call gosprite64.FillRect(0, 0, 39, 39, gosprite64.Red) and you get a square on screen. Always.
Why 288x216 specifically?
The internal framebuffer is 320x240 pixels. That is a hardware-friendly size for the N64. But we do not expose the full 320x240 to game code. Instead, we carve out a centered region:
- 320 - 2 x 16 = 288 pixels wide
- 240 - 2 x 12 = 216 pixels tall
That leaves a 16-pixel border on each side horizontally and a 12-pixel border top and bottom. The remaining canvas has an aspect ratio of:
288 / 216 = 4 / 3
That matches the display aspect ratio exactly. Since the framebuffer is presented at the correct physical size for a 4:3 display, and the canvas aspect ratio matches 4:3, each logical pixel in the 288x216 canvas is square.
Here is how the canvas sits inside the framebuffer:

The surrounding 16px/12px borders are the framebuffer gutters. ClearScreen fills the entire 320x240 framebuffer, so the gutters take on the clear color. But game drawing APIs are clipped to the inner 288x216 canvas and cannot address the gutters directly.
Less is more
This is an opinionated library. GoSprite64 is built for people who are learning N64 homebrew development, not for people who already have years of experience with the hardware.
You get one resolution. That is it. There is no Config struct, no VideoPreset enum, no LowRes versus HighRes mode selector. You call gosprite64.Run(&Game{}) and the library sets up the display for you.
Fewer options means less confusion. A newcomer does not need to understand pixel aspect ratios, video interface registers, or interlacing modes to draw a game. They need to know that X goes from 0 to 287, Y goes from 0 to 215, and a square is a square.
If you are already a pro at N64 development and you want raw framebuffer access with multiple video modes, full RDP control, and manual scaling - use libdragon in C. It gives you full power and full responsibility. GoSprite64 trades that flexibility for simplicity on purpose.
Here is what the calibration example looks like in the ares emulator:

The white border marks the 288x216 canvas. The corner markers sit at the four logical corners. The center box is a perfect square. The diagonal lines form a symmetric X. If any of those look distorted, something is wrong with the presentation scaling.
How it works under the hood
Let's trace a single drawing call through the entire rendering pipeline, step by step.

Step 1: Logical space (288x216)
Your game code calls:
gosprite64.FillRect(10, 10, 50, 50, gosprite64.Red)
These are logical coordinates. The game only thinks in terms of the 288x216 canvas. It does not know or care about the framebuffer, the video interface, or the display standard. X=10, Y=10, width=40, height=40. That is a square.
Step 2: Clipping
The rendergeom package checks whether the rectangle fits inside the logical bounds [0..287, 0..215]. If any part of the rectangle falls outside those bounds, it is clipped to the edge. If the entire rectangle is outside, it is silently dropped and nothing is drawn.
In our case, (10,10)-(50,50) is well within bounds, so it passes through unchanged.
Step 3: Mapping
The clipped rectangle is offset by the canvas origin to place it inside the 320x240 framebuffer. The origin is (16, 12) - the top-left corner of the logical canvas in framebuffer space.
Logical: (10, 10) - (50, 50)
+ (16, 12) <- add origin
Framebuffer: (26, 22) - (66, 62)
The logical 40x40 square is now a 40x40 region inside the framebuffer, shifted 16 pixels right and 12 pixels down.
Step 4: Framebuffer (320x240)
The N64's Reality Display Processor (RDP) draws the filled rectangle at framebuffer coordinates (26,22)-(66,62). At this point it is just colored pixels sitting in a 320x240 memory buffer. Nothing has been sent to the display yet.
Step 5: Presentation scaling
This is where the square-pixel guarantee is enforced.
When the library initializes the display, it calls video.SetScale() with a calibrated rectangle. This rectangle tells the N64 Video Interface exactly how to map the 320x240 framebuffer onto the analog output signal.
The rectangle is different for NTSC and PAL:
- NTSC: The 320x240 framebuffer is scaled to fit the visible area of an NTSC signal, centered within the standard 640x480 output window.
- PAL: The 320x240 framebuffer is scaled to fit the visible area of a PAL signal, centered within the standard 640x576 output window.
In both cases, the scaling is chosen so that the 288x216 logical canvas - which has a 4:3 aspect ratio - lands on a 4:3 region of the physical display. Since the canvas ratio matches the display ratio, each logical pixel ends up square.
The library detects the video standard at startup and picks the right scaling rectangle automatically. Game code never sees this. It just draws in logical coordinates and the pixels come out square.
Step 6: On screen
The CRT television or emulator receives the analog signal and paints the image. Because the scaling rectangle was calibrated to make PAR=1.0 for the canvas region, the 40x40 rectangle from step 1 actually looks like a 40x40 square on screen.
That is the whole pipeline. Six steps, fully automatic, and the game developer only ever touches step 1.
Verify it yourself
The repository includes a calibration example at examples/calibration that makes the fixed-resolution presentation easy to verify visually.
Build and run it:
./build_examples.sh
Then open examples/calibration/game.z64 in an emulator like ares. You should see:
- A white border outlining the full 288x216 canvas
- Corner markers (red, orange, green, blue) at the four logical corners
- The text
288x216centered at the top - Labels
TL,TR,BL,BRnear each corner - A pink square in the center with diagonal lines forming a symmetric X
- A small yellow dot at the exact center of the canvas
If the center box looks like a rectangle instead of a square, or if the diagonal lines look asymmetric, the presentation scaling is off. On a correctly configured emulator or real hardware with the right video standard, everything should look clean and symmetric.
Try it yourself. That is the best way to trust the math.
Hello World
This guide walks you through creating a standalone N64 game project from scratch using GoSprite64. By the end you will have a blue screen ROM that proves your toolchain is set up correctly.
Prerequisites
Complete the Getting Started guide first. You need:
go(standard Go, for dependency resolution)go1.24.5-embedded(EmbeddedGo toolchain, for building)n64go(ROM tool)
Create the project
mkdir -p ~/gocode/src/github.com/yourname/mygame
cd ~/gocode/src/github.com/yourname/mygame
Initialize the module
go mod init github.com/yourname/mygame
Write main.go
Create main.go with the following content:
package main
import "github.com/drpaneas/gosprite64"
type Game struct{}
func (g *Game) Init() {}
func (g *Game) Update() {}
func (g *Game) Draw() { gosprite64.ClearScreenWith(gosprite64.Blue) }
func main() { gosprite64.Run(&Game{}) }
Every GoSprite64 game implements three methods on a struct:
Init()runs once at startupUpdate()runs every frame for game logicDraw()runs every frame for rendering
gosprite64.Run() starts the game loop and never returns.
GoSprite64 exposes one official fixed resolution and drawing space: 288x216 logical pixels. gosprite64.ClearScreen() is the frame-start background clear, while drawing helpers such as gosprite64.FillRect, gosprite64.DrawRect, gosprite64.DrawLine, and gosprite64.DrawText use logical coordinates inside that fixed canvas.
Add the n64.env file
Create n64.env in the project root:
GOTOOLCHAIN=go1.24.5-embedded
GOOS=noos
GOARCH=mips64
GOFLAGS='-tags=n64' '-trimpath' '-ldflags=-M=0x00000000:8M -F=0x00000400:8M -stripfn=1'
This tells the Go toolchain to cross-compile for the N64 (MIPS64, no OS).
Resolve dependencies
Run go mod tidy with a clean Go environment to avoid interference from any inherited N64 variables:
env -u GOENV -u GOOS -u GOARCH -u GOFLAGS -u GOTOOLCHAIN go mod tidy
This downloads GoSprite64 and its transitive dependencies.
Build
Compile the project and produce the ROM:
GOENV=n64.env go1.24.5-embedded build -o game.elf .
GOENV=n64.env n64go rom game.elf
The first command cross-compiles your code for the N64 (MIPS64, no OS) using the settings in n64.env, producing game.elf. The second converts the ELF into an N64 ROM (game.z64).
Run
Load game.z64 in an emulator like ares to see a blue screen. When you want to inspect the canvas boundaries and square-pixel presentation, compare it with the repository's examples/calibration ROM.
Editor support
No special editor configuration is needed. The GoSprite64 source files have no build tags, so gopls and the VS Code Go extension work with standard Go for code navigation and autocompletion. The EmbeddedGo toolchain is only needed at build time.
Project structure
Your project should now look like this:
mygame/
main.go # your game code
go.mod # module definition + dependencies
go.sum # dependency checksums (auto-generated)
n64.env # N64 build target configuration
game.elf # compiled binary (after build)
game.z64 # N64 ROM (after build)
Next steps
Now that your toolchain works, try changing gosprite64.ClearScreenWith(gosprite64.Blue) to another color like gosprite64.Red, gosprite64.Green, or gosprite64.DarkPurple and rebuild. Then explore the examples in the GoSprite64 repository to learn about input handling, drawing shapes, text rendering, and audio.
Using Audio in GoSprite64
This chapter covers how to add audio to your game and how the audio system works behind the scenes.
Part 1: Adding audio to your game
Quick start
GoSprite64 uses a build-time pipeline. You provide .wav files, the audiogen tool compresses them into a compact format, and the engine plays them at runtime. You never deal with codecs, sample rates, or streaming in your gameplay code.
Three steps:
- Put your
.wavfiles in the right directories. - Run
go generate. - Call
gosprite64.PlaySoundEffectorgosprite64.PlayMusicfrom your game.
Project layout
Organize audio files under assets/audio/ with separate subdirectories for sound effects and music:
mygame/
main.go
audio_embed.go (generated)
assets/
audio/
sfx/
jump.wav
coin.wav
hit.wav
music/
overworld.wav
boss.wav
sfx/
ids.go (generated)
music/
ids.go (generated)
build/
audio_v1.bin (generated)
audio_v1_aux.bin (generated)
audio_report.json (generated)
n64.env
The .wav files are your source assets. Everything in build/, sfx/, music/, and audio_embed.go is generated and should not be edited by hand.
Setting up audiogen
Add a go:generate line to your main.go:
//go:generate go run github.com/drpaneas/gosprite64/cmd/audiogen -dir .
Then run:
go generate ./...
This will:
- scan
assets/audio/sfx/andassets/audio/music/for.wavfiles - convert each WAV to mono, resample to the target rate, and compress with VADPCM
- generate typed ID constants in
sfx/ids.goandmusic/ids.go - generate
audio_embed.gowhich registers all assets with the engine at startup - write a size and performance report to
build/audio_report.json
WAV file requirements
audiogen accepts:
- PCM WAV files
- 16-bit samples
- mono or stereo input (stereo is downmixed to mono automatically)
- any sample rate (resampled automatically to the target rate)
If the file is not PCM 16-bit, audiogen rejects it with a clear error message.
Playing sound effects
Import the generated sfx package and call gosprite64.PlaySoundEffect:
import "github.com/drpaneas/gosprite64/examples/pong/sfx"
func (g *Game) Update() {
if playerScored {
gosprite64.PlaySoundEffect(sfx.ScorePlayer)
}
if ballHitWall {
gosprite64.PlaySoundEffect(sfx.Wall)
}
}
gosprite64.PlaySoundEffect returns true if the sound was accepted, false if it was dropped (engine not ready or command ring full). Sound effects are one-shot and can overlap. The same effect can play up to 4 times simultaneously. If you trigger a 5th instance, the oldest one is evicted.
Playing background music
Import the generated music package and call gosprite64.PlayMusic:
import "github.com/drpaneas/gosprite64/examples/mygame/music"
func (g *Game) Init() {
gosprite64.PlayMusic(music.Overworld)
}
Music always loops. If a different track is already playing, it stops and the new one starts. Calling gosprite64.PlayMusic with the same track that is already playing does nothing.
To stop music:
gosprite64.StopMusic()
Volume control
gosprite64.SetSoundEffectVolume(0.5) // SFX at half volume
gosprite64.SetMusicVolume(0.8) // music at 80%
Volume is a float32 from 0.0 (silent) to 1.0 (full). Values outside that range are clamped. Music and SFX volumes are independent.
Complete example: Pong
Here is how the Pong example uses audio:
//go:generate go run github.com/drpaneas/gosprite64/cmd/audiogen -dir .
package main
import (
"github.com/drpaneas/gosprite64"
"github.com/drpaneas/gosprite64/examples/pong/sfx"
)
func (g *Game) Init() {
switch g.Scored {
case "Player":
gosprite64.PlaySoundEffect(sfx.ScorePlayer)
case "Computer":
gosprite64.PlaySoundEffect(sfx.ScoreComputer)
default:
gosprite64.PlaySoundEffect(sfx.Start)
}
}
func (g *Game) Update() {
if collide(g.ball, g.computer) {
gosprite64.PlaySoundEffect(sfx.PaddleComputer)
}
if collide(g.ball, g.player) {
gosprite64.PlaySoundEffect(sfx.PaddlePlayer)
}
if g.ball.y <= courtTop || g.ball.y >= courtBottom {
gosprite64.PlaySoundEffect(sfx.Wall)
}
}
The generated sfx/ids.go provides typed constants like sfx.PaddleComputer, sfx.Wall, etc. No strings, no maps, no runtime lookups.
Build budget flags
audiogen enforces size budgets by default. If your audio exceeds the limits, the build fails with a clear message. You can override the defaults:
//go:generate go run github.com/drpaneas/gosprite64/cmd/audiogen -dir . -rom-budget=1048576 -sfx-resident-budget=65536
| Flag | Default | Description |
|---|---|---|
-rom-budget | 524,288 bytes (512 KB) | Maximum total size for all audio data in ROM |
-sfx-resident-budget | 32,768 bytes (32 KB) | Maximum compressed SFX data resident in memory |
The budget report at build/audio_report.json shows exactly where your audio bytes are going.
Part 2: How it works behind the scenes
The pipeline at a glance
BUILD TIME RUNTIME
.wav files --> audiogen --> VADPCM compressed --> embedded in ROM
|
pure Go mixer
|
48 kHz stereo
|
N64 DAC out
At build time, audiogen converts WAV files into 4-bit VADPCM compressed mono audio. At runtime, a pure Go software mixer decodes, resamples, mixes, and writes stereo output to the N64 DAC at 48 kHz.
VADPCM compression
GoSprite64 uses 4-bit VADPCM (Vector Adaptive Differential Pulse Code Modulation), the same compression family used by Nintendo for N64 audio. Each 9-byte compressed block decodes to 16 mono samples.
The codec achieves roughly 3.5:1 compression on the raw PCM data. Combined with mono downmix (2x) and lower sample rates (up to 3x), the total size reduction compared to the old 48 kHz stereo raw format is typically around 20x.
For the Pong example with 6 sound effects:
| Old system | New system | |
|---|---|---|
| Format | 48 kHz stereo raw PCM | 16 kHz mono VADPCM |
| Total audio ROM | 662,896 bytes | 31,863 bytes |
| Reduction | - | 20.8x smaller (95.2%) |
Sample rates
Assets are resampled at build time. Gameplay code never sees sample rates.
| Asset class | Native rate | Rationale |
|---|---|---|
| SFX | 16,000 Hz | Short effects do not need high fidelity. 16 kHz is clean 3x resampling to 48 kHz. |
| Music | 22,050 Hz | Longer tracks benefit from slightly higher quality. |
| DAC output | 48,000 Hz | Hardware output rate, unchanged from previous versions. |
Voice model
The engine pre-allocates a fixed set of voices at startup:
- 1 music voice - reserved, never stolen by sound effects
- 8 SFX voices - shared pool with priority-based stealing
When all 8 SFX voices are busy and a new effect is triggered, the oldest playing SFX is evicted. Music is never a victim.
The same SFX can overlap up to 4 times. If a 5th instance is triggered, the oldest instance of that specific effect is evicted first.
Memory usage
The engine uses a fixed memory budget that does not grow after initialization:
| Component | Size |
|---|---|
| Voice states | 144 bytes |
| Source decode buffers | 288 bytes |
| Source structs | 2,304 bytes |
| Mixer accumulator | 2,048 bytes |
| Output buffer | 2,048 bytes |
| Command ring | 96 bytes |
| Fixed runtime total | 6,928 bytes |
SFX data stays resident in compressed form. For the Pong example, that is 31 KB of compressed audio in ROM versus 663 KB of raw PCM that the old system loaded into RDRAM.
Zero allocations after init
Every hot-path operation runs with zero heap allocations:
| Operation | Time (Apple M2 Pro) | Allocations |
|---|---|---|
| Decode one VADPCM block (16 samples) | 135 ns | 0 |
| Mix 9 voices, 512 output frames | 9.0 us | 0 |
| Fill 256 decoded samples from source | 2.4 us | 0 |
| Command ring push + pop | 15 ns | 0 |
The old system allocated memory on the first play of every SFX (about 114 KB per 50 KB asset for the read + cache copy).
Concurrency
Gameplay code and the audio feeder run in separate goroutines. They communicate through a lock-free single-producer single-consumer command ring. Gameplay calls like gosprite64.PlaySoundEffect push a small command struct into the ring. The feeder drains all pending commands at the top of each fill cycle before touching any voice state. No mutexes are used in the audio hot path.
The feeder loop
A background goroutine runs continuously:
- Drain all pending commands from the ring (play, stop, volume changes).
- For each active voice, decode enough VADPCM blocks to fill the source buffer.
- The mixer resamples each voice from its native rate to 48 kHz using linear interpolation, applies bus gain, and mixes into a stereo output buffer.
- Write the output buffer to the N64 DAC. The hardware write blocks until the DAC is ready for more data, which naturally paces the loop at the output sample rate.
Anti-click ramp
When music is stopped via gosprite64.StopMusic(), the engine applies a short linear ramp to zero over 1-2 ms (about 22-44 samples) before releasing the voice. This prevents the audible click that would otherwise occur from abruptly zeroing a playing waveform.
Loop handling
Music assets always loop. Loop boundaries are enforced at build time by audiogen:
- Only forward loops are supported
- Loop start and length must be aligned to 16 decoded samples (one VADPCM block)
- The decoder state at the loop start is captured and saved during encoding
- At runtime, when the source reaches the loop end, it restores the saved decoder state and jumps to the loop start
This avoids any "decode from the beginning" logic or runtime loop repair.
Build-time budget report
Every audiogen run produces build/audio_report.json with metrics including:
- Total ROM bytes used by audio
- SFX resident vs music streamed breakdown
- Estimated streaming bandwidth for active music (about 12.4 KB/sec at 22,050 Hz VADPCM)
- Estimated decode CPU cost per 10 ms of audio
- Fixed runtime RAM breakdown by component
If any hard budget limit is exceeded, audiogen exits with a non-zero status so CI catches the problem.
Troubleshooting
If audio does not work, check these first:
audio_embed.goexists and was generated from your current assets- your
.wavfiles are inassets/audio/sfx/orassets/audio/music/ - your
.wavfiles are 16-bit PCM (not 24-bit, not float, not compressed) - the
build/directory containsaudio_v1.binandaudio_v1_aux.bin - your build uses the current
n64.envwithn64go toolexec - check
build/audio_report.jsonfor budget violations
If you hear distortion or clicking, verify that go generate ran successfully after your last asset change.