Getting Started with Go on Nintendo 64 · Timur Çelik's Blog

9 min read Original article ↗

This post will guide you through building your first N64 ROM in Go. I’ll cover basic framebuffer output, controller polling, and even audio playback. My work on supporting the Nintendo 64 in EmbeddedGo was finally merged and recently introduced as a supported target in the latest go1.24.4-embedded release. But before we start, let me share some thoughts that motivated me to implement this.

Why the Nintendo 64?

The short answer is: It was the console of my childhood. But nevertheless the Nintendo 64 occupies an interesting niche in video game history. It was the first mainstream console to fully embrace 3D graphics, largely due to its analog stick. But it was also the last home console from a major manufacturer to rely entirely on ROM cartridges.

This choice came with trade-offs. Cartridges were expensive to manufacture and limited to 64 MB of storage, which looked constrained even at the time. But they weren’t just passive storage media, they were effectively hardware extensions. This nowadays provides a lot of untapped potential! Internally, they connect to the peripheral bus, include their own interrupt line, an additional controller port and even draw their own power. As a result, it’s technically feasible to build cartridges that contain extra storage, a Wi-Fi module, an LCD screen or whatever else you can imagine. The 64DD was Nintendo’s idea of how to extend the console and released only in Japan. Today’s community made flashcarts like the SummerCart64 provide a glimpse into what’s possible. And it’s not only the console itself which is extensible. Each of the four controllers include a memory card slot, used not just for save files, but also hardware like the Rumble Pak, Transfer Pak and possibly custom hardware.

On the development side, the N64 is well understood and thoroughly documented. Modern emulators like ares can replicate its behavior with high accuracy, which shortens the feedback loop dramatically. Lastly, community support is active and growing. FPGA-based reproductions like the Analogue 3D started shipping this year, further widening the audience.

Let’s Get Started

This tutorial is centered around a little tribute. Every N64 owner remembers having to blow into the cartridge once in a while to get games to start. Let’s turn that ritual into gameplay.

Final Application

The complete source code is available on GitHub.

First of all, you’ll need the EmbeddedGo toolchain. Assuming you already have a regular Go installation and added GOBIN to your shell’s path:

go install github.com/embeddedgo/dl/go1.24.4-embedded@latest
go1.24.4-embedded download

You’ll also need the n64go utility, which will help with ROM generation and asset conversion:

go install github.com/clktmr/n64/tools/n64go@v0.1.2

Configuring the Build Environment

Go makes cross-compilation straightforward. Configuring your build is all about setting environment variables and by setting only GOENV, you can let the go command read them from a file. Create a project directory with a single file, we’ll call it go.env, with the following content:

GOTOOLCHAIN=go1.24.4-embedded
GOOS=noos
GOARCH=mips64
GOFLAGS='-exec=n64go rom -run' '-toolexec=n64go toolexec' '-tags=n64' '-trimpath'

(I’ll give an explanation of these settings in part two. For now it’s sufficient to be able to build.)

For the rest of this tutorial, I’ll assume GOENV to be set in your shell, e.g. via export GOENV=go.env.

Note: If you are using code completion, make sure that gopls also runs in this environment. Typically it should be sufficient to start your editor with GOENV=go.env <editor-of-your-choice>.

We are ready to build!

We’ll start by creating a module and add the n64 module as a dependency:

go mod init n64tutorial
go get github.com/clktmr/n64@v0.1.2

Create a file named main.go with the following content:

package main

import _ "github.com/clktmr/n64/machine"

func main() {
	println("N⁶⁴ - Get N or Get Out ♫")
}

This differs from regular Go’s “Hello World” only by an additional import, which is mainly required for early hardware initialization. Now build it:

You have an n64tutorial.elf file in your project directory.

Executing the ROM file

To execute our application, we’ll need an emulator or a Nintendo 64 with a flashcart. Let’s start by emulating with ares, which is currently the only emulator accurate enough. Follow the official installation instructions: https://ares-emu.net/download

Once done and ares is in your executable from your shell’s path, try:

Note: This creates a temporary ROM on-the-fly and launches your emulator. To do these steps manually use the n64go rom command.

You should see an output similar to:

Vulkan Enabled: using paraLLEl-RDP
Loaded n64tutorial
N⁶⁴ - Get N or Get Out ♫
Unloaded n64tutorial

Enabling Video Output

Let’s continue drawing some text to the screen:

var face = gomono12.NewFace()
var background = &image.Uniform{color.RGBA{0x7f, 0x7f, 0xaf, 0x0}}

func main() {
	// Enable video output
	video.Setup(false)

	// Allocate framebuffer
	display := display.NewDisplay(image.Pt(320, 240), video.BPP16)

	for {
		fb := display.Swap() // Blocks until next VBlank

		textarea := fb.Bounds().Inset(15)
		pt := textarea.Min.Add(image.Pt(0, int(face.Ascent)))

		draw.Src.Draw(fb, fb.Bounds(), background, fb.Bounds().Min)

		text := fmt.Appendln(nil, "N⁶⁴ - Get N or Get Out ♫")
		pt = draw.DrawText(fb, textarea, face, pt, image.Black, nil, text)

		draw.Flush() // Blocks until everything is drawn
	}
}

Note: Add ‘-mod=mod’ to your GOFLAGS to avoid ‘missing go.sum entry’ build errors. The go command will then automatically resolve new dependencies.

Here we first initialize the video subsystem. It auto-detects your consoles region and chooses NTSC or PAL accordingly. We disable interlacing because it looks ugly on LCDs. Finally, we’re setting video output to 320x240 pixels at 16 bit per pixel. Per default the image will be scaled to fill the screen. The Display type implements vsync via double buffering: display.Swap() will return the framebuffer to draw into for the next frame.

That’s pretty static. By polling the controllers we can make this demo interactive. Because the joybus (the serial bus to which the controllers are connected) is very slow, we’ll do this in our own goroutine to avoid blocking the main goroutine.

	controllers := make(chan [4]controller.Controller)
	go func() {
		var states [4]controller.Controller
		for {
			controller.Poll(&states)
			controllers <- states
		}
	}()

The call to Poll() will send a query over the joybus to get all controller states. It’ll block until the response was received. The goroutine will then park again until we’ve read the states from the channel. Now, whenever we consume the controller states by reading the channel, this goroutine will start updating them for us in the background.

Now we can have some more fun in the main loop. Add the following lines before text is drawn to print buttons pushed on the first controller:

		input := <-controllers
		text = fmt.Appendln(text, input[0].Down())

Note: Make sure to add controller mappings in ares under Settings->Input.

We can also draw images. We could just use png, but for faster loading times we’ll convert them to the n64’s native texture format. The n64go texture command will help us with that. Download the following to your project directory:

gopher-anim.png

Yes, our gopher is blowing past the cartridge, not even holding the right side up. Please bear with him.

The texture contains a sprite sheet, storing frames of an animation next to each other. To keep the image small we’ll convert it to the CI8 format, which stores a palette of up to 256 colors:

n64go texture -format CI8 -palette 128 gopher-anim.png

To get the image into the ROM we’ll not use Go’s embed, but it’s N64 equivalent cartfs (cartridge file-system). It’ll store the images on the cartridge, not the binary itself and will load them with a DMA accelerated driver. Don’t worry, usage is alike to embed. In fact, you define an embed.FS and initialize the cartfs from that:

var (
	//go:embed gopher-anim.CI8
	_tutorialFiles embed.FS
	tutorialFiles  cartfs.FS = cartfs.Embed(_tutorialFiles)
)

Before starting the main loop, we’ll load the texture into memory:

	gopherFile, err := tutorialFiles.Open("gopher-anim.CI8")
	if err != nil {
		panic(err)
	}
	gopherTexture, err := texture.Load(gopherFile)
	if err != nil {
		panic(err)
	}
	gopherRect := image.Rect(0, 0, 128, 128)
	blows := 0

In our main loop we’ll select the animation frame based on controller input:

		gopherFrame := image.Point{}
		if blows < 8 {
			if input[0].Pressed()&joybus.ButtonA != 0 {
				blows++
			}
			if input[0].Down()&joybus.ButtonA != 0 {
			    gopherFrame.X += 128 // blowing gopher
			}
		} else {
			gopherFrame.X += 256 // happy gopher
		}

		draw.Over.Draw(fb, gopherRect.Add(pt), gopherTexture, gopherFrame)

Let’s also print the number of blows. Add this line before text is drawn.

		text = fmt.Appendf(text, "blows: %v/8\n", blows)

Adding Sound Effects

To make this demo complete let’s play some audio. At the time of writing playback is limited to uncompressed mono samples. Download the following file, which is already converted to the correct format and a samplerate of 16kHz:

Audio Sample

To include the file in our ROM add it to tutorialFiles:

 var (
-	//go:embed gopher-anim.CI8
+	//go:embed gopher-anim.CI8 squeak.pcm_s16be
 	_tutorialFiles embed.FS
 	tutorialFiles  cartfs.FS = cartfs.Embed(_tutorialFiles)
 )

n64go has no audio conversion tool yet. For custom audio samples use ffmpeg in the meantime: ffmpeg -i <infile> -ac 1 -ar <samplerate> -f s16be -c:a pcm_s16be <outfile>

Before we can play any sounds we need to initialize the audio hardware with a samplerate. We could then write a PCM to audio.Buffer. But to make our lifes a bit easier we’ll use the mixer package, which will perform hardware accelerated mixing and resampling of multiple audio sources. Again, we want this decoupled from our main goroutine, so we’ll start a goroutine that feeds samples to audio.Buffer from the mixer whenever possible:

	audio.Start(48000)
	mixer.Init()

	go func() {
		audio.Buffer.ReadFrom(mixer.Output)
	}()

Before the main loop we’ll also prepare an audio source which will stream the data from the cartridge:

	squeakFile, err := tutorialFiles.Open("squeak.pcm_s16be")
	if err != nil {
		panic(err)
	}
	squeakReader := squeakFile.(io.ReadSeeker)
	squeakSource := mixer.NewSource(squeakReader, 16000)

Let’s extend our main loop by rewinding the audio back to the beginning when A is pressed. We’ll also connect the audio source to channel 0 of the mixer, which will start playback immediately.

			if input[0].Pressed()&joybus.ButtonA != 0 {
				squeakReader.Seek(0, io.SeekStart)
				mixer.SetSource(0, squeakSource)
			}

Wrapping Things Up

That’s it for now! Let’s have a look at our final game:

Further Reading

From here you can take a look at the github.com/clktmr/n64 module’s docs. Each package’s tests provide the most complete and up-to-date usage examples at the moment. As a challenge, try storing our little game’s state onto a Controller Pak, the memory card of the Nintendo 64!

Have fun and thanks for reading!