Introduction

Gopher

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/n64 and embedded-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:

Calibration scene showing the fixed 288x216 logical canvas

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.

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

  1. Clone the repository:
git clone https://github.com/drpaneas/gosprite64.git
cd gosprite64
  1. Install the EmbeddedGo toolchain:
go install github.com/embeddedgo/dl/go1.24.5-embedded@latest
go1.24.5-embedded download
  1. If macOS aborts with the __DATA / __DWARF dyld error, retry once with:
BOOT_GO_LDFLAGS=-w go1.24.5-embedded download
  1. Install n64go:
go install github.com/clktmr/n64/tools/n64go@v0.1.2
  1. 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:

Calibration scene showing the fixed 288x216 logical canvas

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 same 40x40 rect at PAR 1.0, 0.5, and 1.2

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 288x216 logical canvas centered inside the 320x240 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:

Calibration scene showing the fixed 288x216 logical canvas in ares

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.

The six-step rendering pipeline from game code to display

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 288x216 centered at the top
  • Labels TL, TR, BL, BR near 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 startup
  • Update() runs every frame for game logic
  • Draw() 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:

  1. Put your .wav files in the right directories.
  2. Run go generate.
  3. Call gosprite64.PlaySoundEffect or gosprite64.PlayMusic from 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/ and assets/audio/music/ for .wav files
  • convert each WAV to mono, resample to the target rate, and compress with VADPCM
  • generate typed ID constants in sfx/ids.go and music/ids.go
  • generate audio_embed.go which 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
FlagDefaultDescription
-rom-budget524,288 bytes (512 KB)Maximum total size for all audio data in ROM
-sfx-resident-budget32,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 systemNew system
Format48 kHz stereo raw PCM16 kHz mono VADPCM
Total audio ROM662,896 bytes31,863 bytes
Reduction-20.8x smaller (95.2%)

Sample rates

Assets are resampled at build time. Gameplay code never sees sample rates.

Asset classNative rateRationale
SFX16,000 HzShort effects do not need high fidelity. 16 kHz is clean 3x resampling to 48 kHz.
Music22,050 HzLonger tracks benefit from slightly higher quality.
DAC output48,000 HzHardware 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:

ComponentSize
Voice states144 bytes
Source decode buffers288 bytes
Source structs2,304 bytes
Mixer accumulator2,048 bytes
Output buffer2,048 bytes
Command ring96 bytes
Fixed runtime total6,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:

OperationTime (Apple M2 Pro)Allocations
Decode one VADPCM block (16 samples)135 ns0
Mix 9 voices, 512 output frames9.0 us0
Fill 256 decoded samples from source2.4 us0
Command ring push + pop15 ns0

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:

  1. Drain all pending commands from the ring (play, stop, volume changes).
  2. For each active voice, decode enough VADPCM blocks to fill the source buffer.
  3. 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.
  4. 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.go exists and was generated from your current assets
  • your .wav files are in assets/audio/sfx/ or assets/audio/music/
  • your .wav files are 16-bit PCM (not 24-bit, not float, not compressed)
  • the build/ directory contains audio_v1.bin and audio_v1_aux.bin
  • your build uses the current n64.env with n64go toolexec
  • check build/audio_report.json for budget violations

If you hear distortion or clicking, verify that go generate ran successfully after your last asset change.