Introduction
PIGO8 Documentation is your guide to building retro-styled games for the PICO-8 fantasy console using the power of Go. With a simple API, minimal setup, and a dose of old-school charm, PIGO8 bridges modern Go programming with the nostalgic appeal of PICO-8.
This documentation is evolving as the library matures. Expect updates and improvements as new features are added.
What is PIGO8?
PIGO8 is a Go library designed to help you create games that run on the PICO-8 virtual console. It abstracts the lower-level technical details while keeping the distinctive PICO-8 aesthetic intact. Whether you're looking to prototype a game or build a full-featured project, PIGO8 offers the tools to bring your vision to life.
With PIGO8 you can:
- 🚀 Develop games using a clean and straightforward Go API
- 🎮 Leverage the retro charm of PICO-8 while benefiting from modern development practices
- 🛠Integrate seamlessly with Go’s tooling and ecosystem
Why Use Go for PICO-8 Development?
PICO-8 is a wonderful fantasy console with its own Lua-based game engine, but once your ideas outgrow the 128×128 constraint you may want to move to a general-purpose language. Go is a simple, fast, modern language – and thanks to the pigo8 library you can actually port PICO-8 code almost line-for-line.
Go is known for its simplicity, speed, and efficiency. By using Go as your development language, PIGO8 empowers you to:
- Write concise and maintainable code without sacrificing performance
- Utilize robust tooling for testing, building, and deployment
- Adopt modern programming patterns in an environment inspired by classic gaming
What’s in This Documentation?
This guide covers everything you need to get started with PIGO8, including:
- Setting up your development environment
- Creating and managing sprites and assets
- Building and deploying your PICO-8 games
- Tips and tricks for optimizing your retro game projects
Whether you're a seasoned Go developer or new to the PICO-8 scene, this documentation aims to help you start building games quickly and confidently.
Who Is This Documentation For?
This book is intended for:
- Developers interested in retro game development with Go
- Go enthusiasts looking to explore the PICO-8 creative space
- Hobbyists or indie game developers eager to experiment with a blend of modern and vintage technologies
Helpful Links
- PIGO8 GitHub Repository – main development repo
- PICO-8 Official Site – learn more about the fantasy console
- Go Documentation – resources to get started with Go
Dive in and explore the exciting possibilities with PIGO8 – where modern Go meets classic PICO-8 creativity!
Installing Go
Even if you’ve never used Go, we’ll explain how things like static typing and methods work along the way.
Go’s official website has downloads for all platforms.
Linux, Mac or Windows
On macOS or Linux you can download the archive and extract it to a fresh /usr/local/go
directory, then add /usr/local/go/bin
to your PATH
.
For example, on Linux you might run:
# replace 1.xx.x with the latest Go version (e.g. 1.24.2 as of writting this guide)
$ wget https://go.dev/dl/go1.xx.x.linux-amd64.tar.gz
$ sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.xx.x.linux-amd64.tar.gz
and then add to your shell profile:
export PATH=$PATH:/usr/local/go/bin
Alternatively, you could just simply trust your package manager and do something like brew install go
or apt-get install go
or something similar like that.
For Windows, download the MSI installer and run it.
Verify
In either case, once installed, open a terminal (or Command Prompt) and verify with:
$ go version
You should see the Go version printed, confirming the installation.
Run a simple PIGO8 example
First create a directory somewhere in your PC, for example:
mkdir helloworld
cd helloworld
touch main.go
Go uses modules to manage dependencies. To initialize the modules mechanism, in your project folder, run:
# usually this is the VCS clone URL, e.g. github.com/you/myapp
go mod init github.com/yourname/helloworld
# Expected Output
drpaneas@m2:~/helloworld% go mod init github.com/drpaneas/helloworld
go: creating new go.mod: module github.com/drpaneas/helloworld
go: to add module requirements and sums:
go mod tidy
The go mod init
command creates a go.mod file
that declares your module path (you can use any unique name or repository path).
This file will track any packages your code import
s.
As you add imports or libraries, please remember to always run go mod tidy
and this will automatically download and record needed versions​.
Think of go.mod
as similar to package.json
in Node or requirements.txt
in Python.
A Go module is a collection of related Go packages. The
go.mod
file at the root lists the module path and dependency versions. Runninggo mod init
followed by edits andgo mod tidy
is all you need to start.
Okay, now Go is ready to fetch dependencies you import, such as our PIGO8.
Let's write some code.
Copy and paste the following code into a file called main.go
:
package main
import p8 "github.com/drpaneas/pigo8"
type myGame struct{}
func (m *myGame) Init() {}
func (m *myGame) Update() {}
func (m *myGame) Draw() {
p8.Cls(1)
p8.Print("hello, world!", 40, 60)
}
func main() {
p8.InsertGame(&myGame{})
p8.Play()
}
Then pull in dependencies (downloads PIGO8 as it's part of your imports):
$ go mod tidy
# Expected Output:
go: finding module for package github.com/drpaneas/pigo8
go: downloading github.com/drpaneas/pigo8 v0.0.0-20250427151721-a6ef286ed98a
go: found github.com/drpaneas/pigo8 in github.com/drpaneas/pigo8 v0.0.0-20250427151721-a6ef286ed98a
Now use go build
when you want a standalone binary, or run go run
when you just want to compile-and-execute in one go without keeping the binary around.
go build . && ./helloworld # builds it, you run it manually
# or
go run . # run it without building, more useful while developing
If everything works as expected, you should see:
# Expecte log output
drpaneas@m2:~/helloworld% go run .
2025/04/29 16:25:42 Booting PIGO8 console...
2025/04/29 16:25:42 Cartridge Initializing...
Doesn't work?
If not, this means you are missing something in your system. Most likely this has to do with Ebiten, the engine PIGO8 uses to render graphics on screen. For that, please read Ebiten's installation instructions
Usual issues are missing C compiler.
In this case make sure Mac users have clang
which comes after doing xcode-select --install
which installs command-line tools.
For Linux users you might be missing some basic drivers, such as:
sudo apt-get update && sudo apt-get install -y libc6-dev libgl1-mesa-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libxxf86vm-dev libasound2-dev pkg-config
Next
If you've managed to run this simple hello-world style of example, you are ready to start developing with PIGO8!
Have fun!
Porting a PICO-8 game to PIGO8
PICO-8 is a wonderful fantasy console with its own Lua-based game engine, but once your ideas outgrow the 128Ă—128 constraint you may want to move to a general-purpose language.
Go is a simple, fast, modern language – and thanks to the pigo8 library you can actually port PICO-8 code almost line-for-line.
In this guide we’ll walk step-by-step through taking the NerdyTeachers “Animate Multiple Sprites” PICO-8 tutorial and rewriting it in Go.
We’ll start by setting up a project, extracting the sprite sheet with the parsepico tool, and then porting the Lua tables, animation timing, and update/draw loops into Go structs and methods.
Even if you’ve never used Go, we’ll explain how things like static typing and methods work along the way.
By the end you’ll have a running Go program with the same sprite animation logic which is essential for making games.
Parse PICO-8 sprites
Create a new folder, where you will place your project's code.
mkdir mygame; cd mygame
Fetch your p8 game into the directory
Copy from NerdyTeachers .p8 cartridge (p8
text file) into this folder.
PICO-8 carts come in two formats: the text-based .p8
format and the “.p8.png”
format which hides the code/data inside a PNG image.
The text .p8
file contains the Lua source and data sections in plain text, while the .p8.png
is a 128Ă—128 image containing the same data (along with a screenshot). See:
You can load the .p8 file in PICO-8 or view it in a text editor to see the Lua code and sprite data.
Get the graphics
We want the sprite graphics from this cart. Namely, we mean these sprites:
The simplest approach (but we won't do this) is to export the sprite sheet PNG from PICO-8 (by typing export sprites.png
in the console). This would mean we need to write code to improt this PNG sprisheet.png and slice it in the code, not to mention there is no way to go around PICO-8 flag's configuration for sprites. To avoid such a thing, we will use another tool. One such tool is parsepico which can read a .p8
file and spit out the sprite images, maps, along with JSON
s that have all the required metadata for PIGO8.
For example:
Fetch your *.p8
game and place it into that folder:
cp $PICO8/carts/animate_sprites.p8 .
Make sure the PICO-8 file is text file, and not PNG (e.g. p8.png
is not supported).
# This is supported:
% file animate_sprites.p8
animate_sprites.p8: ASCII text
# This is not supported (you have to open PICO-8 and save it as *.p8)
% file animate_sprites.p8.png
animate_sprites.p8.png: PNG image data, 160 x 205, 8-bit/color RGBA, non-interlaced
Great so, the next step is to extract the sprites for this game. To do that, we will use a tool called [parsepico]. You can either fetch it from the release page in Github, or use Go packaging mechanism to install it directly to your system.
$ go install github.com/drpaneas/parsepico@latest
# Expected Output
go: downloading github.com/drpaneas/parsepico v1.0.6
If you follow this way, Go will download and save it at $GOPATH/bin
.
You can verify this:
file `go env GOPATH`/bin/parsepico
# Output:
/Users/pgeorgia/gocode/bin/parsepico: Mach-O 64-bit executable arm64
To use it either call it from this location or add it to your PATH
:
$ export PATH="`go env GOPATH`/bin:$PATH"
Now, you should be able to run this:
$ parsepico --help
# Expected Output
Usage of parsepico:
-3 Include dual-purpose section 3 (sprites 128..191)
-4 Include dual-purpose section 4 (sprites 192..255)
-cart string
Path to the PICO-8 cartridge file (.p8)
-clean
Remove old sprites directory, map.png, spritesheet.png if they exist
Note:
Using Go package manager is not required. You can always fetch the executable/binary directly from the release page of [parsepico] for your OS and architecture.
So now we confirmed we have the *.p8
game and parsepico
installed, we can extract the graphics from this game:
$ parsepico --cart=animate_sprites.p8
This will parse the PICO-8 cart and generate several useful things.
From all of these, we are only interested in spritesheet.json
which will be using in our PIGO8 game.
# Expected Output
No __map__ section found. Skipping map processing.
Saved 4 sections into 'sprites' folder.
Created spritesheet.png with 4 sections.
Successfully generated spritesheet.json # we will need only this
Successfully created individual sprite PNGs
That said, feel free to delete the rest of the generated files, to free some disk space:
$ rm -r sprites
$ rm spritesheet.png
Ok, so no we are ready to start writing Go code!
Port Lua to Go code
Ok so let's start the usual Go procedure by initializing Go Modules.
$ go mod init github.com/yourname/myGame
$ go mod tidy
Now you’re set up: we have Go installed, a project folder, and the PICO-8 cart and sprite image in place. Next, let’s review the Lua code we want to port.
The NerdyTeachers “Animate Multiple Sprites” tutorial uses Lua tables and loops to animate a player, some enemies, and items. Let’s highlight the key parts:
Lua code analysis
Let us study the original Lua code written for PICO-8, before try to port it to Go and PIGO8. We need to understand it.
Variables and tables
In PICO-8 Lua, global tables hold object data. For example, in _init()
they create:
player = { sprite=1, x=-8, y=59, timing=0.25 }
enemies = {}
enemy1 = { sprite=5, x=-20, y=5, timing=0.1, speed=1.25, first=5, last=9 }
add(enemies, enemy1)
-- (and similarly enemy2, enemy3, items, etc)
Here each table has fields like sprite
, x
, y
, timing
, and (for enemies/items) first
, last
, speed
.
The player
table holds its current frame number and position​.
Animation timing
The key trick in the tutorial is that each object’s sprite
field is a number (not necessarily integer). Each update, they do:
object.sprite += object.timing
if object.sprite >= object.last then
object.sprite = object.first
end
This floats sprite
by a small increment so that frames advance more slowly than every tick. Because PICO-8 rounds down when drawing, a sprite index of 1.25 still draws sprite 1 until it reaches 2. This lets them animate at a fraction of the frame rate.
Movement
Each enemy moves horizontally by enemy.speed
(or player.x += 1
for the player), and when x > 127
, it resets to -8
to wrap around​. In the code:
x += 1
if x > 127 then x = -8 end
The tutorials explains that the screen is 128 pixels wide (0
–127
), so setting x = -8
places the sprite just off-screen on the left, giving a smooth wrap.
A simplified game loop
Putting it together, the full Lua update code looks like this (single-object version for simplicity):
function _update()
-- animate
sprite += timing
if sprite >= 5 then sprite = 1 end
-- move
x += 1
if x > 127 then x = -8 end
end
This updates the sprite index and position each tick. For multiple objects, they repeat similar blocks inside loops.
The _draw()
function simply loops through all objects and calls spr()
on each.
We’ll mirror each of these concepts in Go.
Translate concepts to Go
Now we port these ideas into Go. In Go we’ll define a struct to represent an animated object, write methods for animation and movement, and set up update/draw loops. Unlike Lua’s flexible tables, Go has static typing: every field has a declared type. We’ll use float64
for everything so we don't bother type-casting. Here’s a basic struct:
// Entity represents an animated object (player, enemy, or item).
type Entity struct {
Sprite float64 // current sprite index (can be fractional for timing)
X, Y float64 // position on screen
Timing float64 // how much to advance per frame
Speed float64 // horizontal movement speed (0 for static items)
First float64 // first sprite index in animation loop
Last float64 // one past the last sprite index in animation loop
}
Notice the fields correspond to the Lua table keys.
For example, player = {sprite=1, x=-8, y=59, timing=0.25}
becomes something like Entity{Sprite:1, X:-8, Y:59, Timing:0.25, First:1, Last:5}
.
We include First
and Last
so each entity knows its animation range (for the player in the tutorial, first=1
and last=5
since sprites 1
–4
are used). We’ll write a Factory constructor function to create these easily:
// NewEntity creates a new AnimatedEntity.
func NewEntity(sprite, x, y, timing, speed, first, last float64) Entity {
return Entity{
// Animation properties
sprite: sprite,
timing: timing,
first: first,
last: last,
// Movement properties
x: x,
y: y,
speed: speed,
}
}
This mirrors the Lua enemy1 = { sprite=5, x=-20, y=5, timing=0.1, speed=1.25, first=5, last=9 }
.
We have to pass numeric arguments in the correct order; Go’s strictness means we can’t omit fields like you can in Lua. Using a constructor helps avoid mistakes.
Next, we’ll give Entity
two methods:
Animate()
Move()
.
These will update the sprite index and position, similar to the Lua _update
logic:
// Animate updates the sprite based on the timing and resets it within its cycle.
// Requires first and last values for each entity.
func (ae *Entity) Animate() {
ae.sprite += ae.timing
if ae.sprite >= ae.last {
ae.sprite = ae.first
}
}
// Move updates the entity's x-coordinate using the provided offset.
// It wraps the position around if it exceeds the right boundary (128).
func (ae *Entity) Move(offset float64) {
ae.x += offset
if ae.x > 128 {
ae.x = -8
}
}
With our Entity
defined, let’s build the game. We can create slices (dynamic arrays) to hold enemies and items:
var player Entity
var enemies = []Entity{}
var items = []Entity{}
In the tutorial’s _init()
, they set up each enemy and then use add(enemies, enemy)
.
In Go we’ll do something like:
func (m *myGame) Init() {
player = NewEntity(1, -8, 59, 0.25, 1, 1, 5)
enemy1 := NewEntity(5, -20, 5, 0.1, 1.25, 5, 9)
enemy2 := NewEntity(9, -14, 30, 0.2, 0.4, 9, 13)
enemy3 := NewEntity(13, -11, 90, 0.4, 0.75, 13, 17)
enemies = append(enemies, enemy1, enemy2, enemy3)
item1 := NewEntity(48, 30, 110, 0.3, 48, 50, 56)
item2 := NewEntity(56, 60, 110, 0.25, 54, 56, 60)
item3 := NewEntity(60, 90, 110, 0.15, 4, 60, 64)
items = append(items, item1, item2, item3)
}
Here we’re mimicking the Lua tables from the tutorial​, just using Go syntax.
Note how we pack each enemy and item into Go slices; this replaces Lua’s add(enemies, enemy1)
and the for ... in all(enemies) logic​.
In Go, to loop over a slice we will later write for _, enemy := range g.Enemies { ... }
.
Building the Update and Draw Loop
func (m *myGame) Update() {
// Update player: animate and move (player moves by 1 unit per frame)
player.Animate()
player.Move(player.speed)
// Update enemies: animate and move based on each entity's speed
for i := range enemies {
enemies[i].Animate()
enemies[i].Move(enemies[i].speed)
}
// Update items: animate only, don't move
for i := range items {
items[i].Animate()
}
}
func (m *myGame) Draw() {
p8.Cls(0) // clear screen
player.Draw() // Draw the player
// Draw all enemies
for _, enemy := range enemies {
enemy.Draw()
}
// // Draw all items
for _, item := range items {
item.Draw()
}
}
In these snippets, we call a hypothetical pigo8.Spr(index, x, y)
function (mirroring PICO-8’s spr()
) and pigo8.Cls()
to clear the screen. The logic is the same as the Lua _draw()
: draw each object’s current frame at its position​.
Notice how we converted the Lua loops into Go for loops. For instance, the Lua code:
for enemy in all(enemies) do
spr(enemy.sprite, enemy.x, enemy.y)
end
becomes Go:
for _, enemy := range g.Enemies {
pigo8.Spr(enemy.Sprite, enemy.X, enemy.Y)
}
We use range
to iterate over the slice
.
Full Go Program
package main
import (
p8 "github.com/drpaneas/pigo8"
)
type Entity struct {
sprite, x, y, timing, speed, first, last float64
}
func NewEntity(sprite, x, y, timing, speed, first, last float64) Entity {
return Entity{
sprite: sprite,
timing: timing,
first: first,
last: last,
x: x,
y: y,
speed: speed,
}
}
func (ae *Entity) Animate() {
ae.sprite += ae.timing
if ae.sprite >= ae.last {
ae.sprite = ae.first
}
}
func (ae *Entity) Move(offset float64) {
ae.x += offset
if ae.x > 128 {
ae.x = -8
}
}
func (ae *Entity) Draw() {
p8.Spr(ae.sprite, ae.x, ae.y)
}
var player Entity
var enemies = []Entity{}
var items = []Entity{}
type myGame struct{}
func (m *myGame) Init() {
player = NewEntity(1, -8, 59, 0.25, 1, 1, 5)
enemy1 := NewEntity(5, -20, 5, 0.1, 1.25, 5, 9)
enemy2 := NewEntity(9, -14, 30, 0.2, 0.4, 9, 13)
enemy3 := NewEntity(13, -11, 90, 0.4, 0.75, 13, 17)
enemies = append(enemies, enemy1, enemy2, enemy3)
item1 := NewEntity(48, 30, 110, 0.3, 48, 50, 56)
item2 := NewEntity(56, 60, 110, 0.25, 54, 56, 60)
item3 := NewEntity(60, 90, 110, 0.15, 4, 60, 64)
items = append(items, item1, item2, item3)
}
func (m *myGame) Update() {
player.Animate()
player.Move(player.speed)
for i := range enemies {
enemies[i].Animate()
enemies[i].Move(enemies[i].speed)
}
for i := range items {
items[i].Animate()
}
}
func (m *myGame) Draw() {
p8.Cls(0)
player.Draw()
for _, enemy := range enemies {
enemy.Draw()
}
for _, item := range items {
item.Draw()
}
}
func main() {
p8.InsertGame(&myGame{})
p8.Play()
}
To try the game, use the Go tools. In your project directory, run:
go run .
This compiles and runs the main.g
o program (the .
means run the current module).
You should see a window or output with your animated sprites moving, just like in the PICO-8 demo.
To build a standalone executable, use:
go build -o mygame
This produces a binary named mygame
(or mygame.exe
on Windows).
You can then run ./mygame
anytime to play your game.
Building for Other Platforms (Cross-Compilation) and WebAssembly
Once you’ve confirmed your game runs locally, Go’s built-in cross-compilation makes it trivial to produce binaries for Windows, macOS, Linux—even for embedded ARM devices—and even WebAssembly for the browser. You don’t need any extra toolchain setup beyond Go itself.
Cross-compiling for Native Architectures
Go uses two environment variables to target a specific OS and CPU architecture:
-
GOOS
: target operating system (linux
,windows
,darwin
for macOS, etc.) -
GOARCH
: target CPU architecture (amd64
,386
,arm64
,arm
, etc.)
From your project folder, simply run:
# Linux on AMD64
GOOS=linux GOARCH=amd64 go build -o mygame-linux-amd64
# Windows on 386 (32-bit)
GOOS=windows GOARCH=386 go build -o mygame-windows-386.exe
# macOS on ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o mygame-darwin-arm64
# Linux on ARM (e.g. Raspberry Pi)
GOOS=linux GOARCH=arm go build -o mygame-linux-arm
You can mix and match any supported GOOS/GOARCH
. The output binary name (-o
) is up to you.
No extra downloads: Go’s standard distribution already contains everything needed.
To see all valid pairs, run:
$ go tool dist list
So you PIGO8 can run in the following computers:
aix/ppc64
android/386
android/amd64
android/arm
android/arm64
darwin/amd64
darwin/arm64
dragonfly/amd64
freebsd/386
freebsd/amd64
freebsd/arm
freebsd/arm64
freebsd/riscv64
illumos/amd64
ios/amd64
ios/arm64
js/wasm
linux/386
linux/amd64
linux/arm
linux/arm64
linux/loong64
linux/mips
linux/mips64
linux/mips64le
linux/mipsle
linux/ppc64
linux/ppc64le
linux/riscv64
linux/s390x
netbsd/386
netbsd/amd64
netbsd/arm
netbsd/arm64
openbsd/386
openbsd/amd64
openbsd/arm
openbsd/arm64
openbsd/ppc64
openbsd/riscv64
plan9/386
plan9/amd64
plan9/arm
solaris/amd64
wasip1/wasm
windows/386
windows/amd64
windows/arm64
By the way, since PIGO8 uses Ebiten, you can also build against Nintendo Switch if you like.
Building for WebAssembly
Go can compile to WebAssembly, letting you embed your pigo8 game in a webpage. Here’s how:
First, please set target to JavaScript/WASM
:
$ GOOS=js GOARCH=wasm go build -o main.wasm
Then copy the Go runtime support file. The Go distribution includes a small JavaScript shim (wasm_exec.js
) that initializes the WebAssembly module. You can find it in your Go root:
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .
Ok, now let's make it loading into a web browser. So we need to create a simple HTML.
Save this as index.html
alongside main.wasm
and wasm_exec.js
(all three of them in the same folder):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>My pigo8 Game</title>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
.then((res) => go.run(res.instance))
.catch(console.error);
</script>
</head>
<body>
<canvas id="ebiten_canvas"></canvas>
</body>
</html>
Now you can upload these to a web-server, or GitHub's actions for your repository and have people play your game. You can test it locally as well, You can use any static file server, for example Python’s:
$ python3 -m http.server 8080
Then open http://localhost:8080
in your browser.
With just these environment variables and a tiny HTML wrapper, you can ship your pigo8-powered game to nearly any platform—including the web—without touching C toolchains or external build systems.
Enjoy spreading your PICO-8 magic far and wide!
Resource Embedding in PIGO8
Quick Start Guide
To create a portable PIGO8 game that works anywhere:
-
Add this line at the top of your main.go file:
//go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .
-
Run these commands before distributing your game:
go generate go build
That's it! Your game binary will now include all necessary resources and work correctly even when moved to a different directory.
What This Does
PIGO8 uses the following resource files for games:
map.json
- Contains the game map dataspritesheet.json
- Contains sprite definitions and pixel datapalette.hex
- Contains custom color palette definitions
The go generate
command automatically creates an embed.go
file that embeds these resources into your binary, making your game fully portable.
How Resource Loading Works
PIGO8 uses a smart resource loading system with the following priority order:
- Files in the current directory (highest priority)
- Common subdirectories:
assets/
,resources/
,data/
,static/
- Embedded resources registered via
RegisterEmbeddedResources
- Default embedded resources in the PIGO8 library (lowest priority)
This approach gives you the best of both worlds:
- During development: Edit local files for quick iteration
- For distribution: Embed resources for portability
Detailed Usage Guide
Automatic Embedding with go:generate (Recommended)
PIGO8 provides a tool that automatically generates the necessary embedding code for your game. This is the recommended approach for distributing your game.
-
Add this line at the top of your main.go file:
//go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .
-
Run the generate command to create the embed.go file:
go generate
-
Build your game normally:
go build
The generated embed.go
file will embed your map.json, spritesheet.json, and palette.hex files into the binary. Your game will now work correctly even when moved to a different directory.
Manual Embedding (Alternative)
If you prefer to manually control the embedding process, you can create an embed.go
file in your project:
package main
import (
"embed"
p8 "github.com/drpaneas/pigo8"
)
// Embed the game-specific resources
//
//go:embed map.json spritesheet.json palette.hex
var resources embed.FS
func init() {
// Register the embedded resources with PIGO8
// Audio will be automatically initialized if audio files are present
p8.RegisterEmbeddedResources(resources, "spritesheet.json", "map.json", "palette.hex")
}
Adjust the go:embed
directive to include only the files you have. For example, if you only have a palette.hex file, your embed.go would look like:
package main
import (
"embed"
p8 "github.com/drpaneas/pigo8"
)
// Embed the game-specific resources
//
//go:embed palette.hex
var resources embed.FS
func init() {
// Register the embedded resources with PIGO8
// Audio will be automatically initialized if audio files are present
p8.RegisterEmbeddedResources(resources, "", "", "palette.hex")
}
Custom Color Palettes with palette.hex
PIGO8 now supports custom color palettes through a palette.hex
file. This allows you to use color palettes from sites like Lospec in your games.
Creating a palette.hex File
- Visit Lospec Palette List and find a palette you like
- Download the palette in HEX format
- Save it as
palette.hex
in your game directory
Each line in the palette.hex file should contain a single hex color code, for example:
c60021
e70000
e76121
e7a263
e7c384
How Palette Loading Works
When a palette.hex file is loaded:
- The first color (index 0) is automatically set to be fully transparent (rgba(0, 0, 0, 0))
- All colors from the palette.hex file are shifted up by one index
- The palette can be used like any other PIGO8 palette
Example Usage
See the examples/palette_hex
directory for a complete example of loading and using a custom palette.
Resource Loading Priority
PIGO8 uses the following priority order when looking for resources:
- Files in the current directory (highest priority)
- Custom embedded resources registered via
RegisterEmbeddedResources
- Default embedded resources in the PIGO8 library (lowest priority)
This allows you to:
- Develop with local files for quick iteration
- Distribute with embedded resources for portability
- Always have fallback resources from the library
Using Music in PIGO8
PIGO8 provides audio playback capabilities that allow you to incorporate music from PICO-8 into your game.
Exporting Music from PICO-8
To use music in your PIGO8 application, you first need to export the audio files from PICO-8:
-
In PICO-8, create your music using the sound editor and music tracker
-
Export each music pattern using the
export
command in the PICO-8 console:export music0.wav export music1.wav export music2.wav ... etc ...
or simply do:
export music%d.wav
Notice this will export all music patterns from 0 to 63, regardless if you have actually created them or not. That means you will get a lot of empty files, but don't worry, the
embedgen
tool will only embed valid music files.
PICO-8 will save these files in its current working directory.
You'll need to copy these files to your PIGO8 project directory, placing them next to your main.go
file.
Setting Up Your PIGO8 Project
To use music in your PIGO8 project:
-
Copy the exported
.wav
files to your project directory -
Add this specific
go:generate
directive to yourmain.go
file without changing it://go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .
-
Run
go generate
in your project directory to embed the music files
The embedgen
tool will automatically detect and embed valid music files. It analyzes each WAV file to ensure it contains actual audio data and is not silent or corrupted.
Playing Music in Your Game
PIGO8 provides the Music()
function to play audio files:
// Play music track 0, meaning music0.wav
p8.Music(0)
// Play music track 3 exclusively (stops any currently playing music)
p8.Music(3, true)
// Stop all music
p8.Music(-1)
Function Parameters
n
(int): The music track number to play (0-63), or -1 to stop all musicexclusive
(bool, optional): If true, stops any currently playing music before playing the new track
Example Usage
Here's a simple example of using music in a PIGO8 game:
package main
//go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .
import (
p8 "github.com/drpaneas/pigo8"
)
type Game struct {
// game state
}
func (g *Game) Init() {
// Play background music when the game starts
p8.Music(0)
}
func (g *Game) Update() {
// Play different music when a key is pressed
if p8.Btn(p8.UP) {
p8.Music(1)
}
// Play exclusive music (stops other tracks) when DOWN is pressed
if p8.Btn(p8.DOWN) {
p8.Music(2, true)
}
// Stop all music when LEFT+RIGHT are pressed together
if p8.Btn(p8.LEFT) && p8.Btn(p8.RIGHT) {
p8.Music(-1)
}
}
Audio File Validation
The PIGO8 embedgen tool performs validation on audio files to ensure they contain actual audio data:
- Checks for valid WAV file format (RIFF and WAVE markers)
- Verifies the file has a non-zero data chunk
- Analyzes a sample of the audio data to ensure it's not silent
Only valid audio files will be included in your application.
PIGO8 Palette Management
Functions
SetPalette
func SetPalette(newPalette []color.Color)
Replaces the current color palette with a new one. This also resizes the transparency array to match the new palette size, setting only the first color (index 0) as transparent by default.
Parameters:
newPalette
: Slice ofcolor.Color
values to use as the new palette.
Example:
// Create a 4-color grayscale palette
grayscale := []color.Color{
color.RGBA{0, 0, 0, 255}, // Black
color.RGBA{85, 85, 85, 255}, // Dark Gray
color.RGBA{170, 170, 170, 255}, // Light Gray
color.RGBA{255, 255, 255, 255}, // White
}
pigo8.SetPalette(grayscale)
GetPaletteSize
func GetPaletteSize() int
Returns the current number of colors in the palette.
Returns:
- The number of colors in the current palette.
Example:
// Get the current palette size
size := pigo8.GetPaletteSize()
pigo8.Print(fmt.Sprintf("Palette has %d colors", size), 10, 10, 7)
GetPaletteColor
func GetPaletteColor(colorIndex int) color.Color
Returns the color.Color
at the specified index in the palette. Returns nil
if the index is out of range.
Parameters:
colorIndex
: Index of the color to retrieve.
Returns:
- The color at the specified index, or
nil
if the index is out of range.
Example:
// Get the color at index 3
color3 := pigo8.GetPaletteColor(3)
SetPaletteColor
func SetPaletteColor(colorIndex int, newColor color.Color)
Replaces a single color in the palette at the specified index. If the index is out of range, the function does nothing.
Parameters:
colorIndex
: Index of the color to replace.newColor
: The newcolor.Color
to use.
Example:
// Change color 7 (white) to a light blue
pigo8.SetPaletteColor(7, color.RGBA{200, 220, 255, 255})
Advanced Usage Examples
Creating a Custom Palette
// Create a custom palette with 8 colors
customPalette := []color.Color{
color.RGBA{0, 0, 0, 255}, // Black
color.RGBA{29, 43, 83, 255}, // Dark Blue
color.RGBA{126, 37, 83, 255}, // Dark Purple
color.RGBA{0, 135, 81, 255}, // Dark Green
color.RGBA{171, 82, 54, 255}, // Brown
color.RGBA{95, 87, 79, 255}, // Dark Gray
color.RGBA{194, 195, 199, 255}, // Light Gray
color.RGBA{255, 241, 232, 255}, // White
}
// Set the palette
pigo8.SetPalette(customPalette)
Creating a Palette Programmatically
// Create a rainbow palette with 12 colors
rainbowPalette := make([]color.Color, 12)
for i := 0; i < 12; i++ {
// Convert hue (0-360) to RGB
hue := float64(i) * 30.0 // 12 colors * 30 degrees = 360 degrees
// Simple HSV to RGB conversion (simplified for this example)
h := hue / 60.0
sector := int(math.Floor(h))
f := h - float64(sector)
p := uint8(255 * 0.0)
q := uint8(255 * (1.0 - f))
t := uint8(255 * f)
v := uint8(255)
var r, g, b uint8
switch sector {
case 0:
r, g, b = v, t, p
case 1:
r, g, b = q, v, p
case 2:
r, g, b = p, v, t
case 3:
r, g, b = p, q, v
case 4:
r, g, b = t, p, v
default:
r, g, b = v, p, q
}
rainbowPalette[i] = color.RGBA{r, g, b, 255}
}
// Set the palette
pigo8.SetPalette(rainbowPalette)
Cycling Colors for Animation
// In your game's Update() function:
func (g *Game) Update() {
// Every 10 frames, cycle the colors
if g.frameCount % 10 == 0 {
g.cycleColors()
}
g.frameCount++
}
// Function to cycle colors
func (g *Game) cycleColors() {
// Save the first color
firstColor := pigo8.GetPaletteColor(0)
// Shift all colors down by one
for i := 0; i < pigo8.GetPaletteSize()-1; i++ {
pigo8.SetPaletteColor(i, pigo8.GetPaletteColor(i+1))
}
// Put the first color at the end
pigo8.SetPaletteColor(pigo8.GetPaletteSize()-1, firstColor)
}
Day/Night Cycle Effect
// Create a night-time version of the current palette
func createNightPalette() []color.Color {
size := pigo8.GetPaletteSize()
nightPalette := make([]color.Color, size)
for i := 0; i < size; i++ {
baseColor := pigo8.GetPaletteColor(i)
r, g, b, _ := baseColor.RGBA()
// Convert from color.Color's 16-bit per channel to 8-bit per channel
r8 := uint8(r >> 8)
g8 := uint8(g >> 8)
b8 := uint8(b >> 8)
// Make darker and blue-tinted (night effect)
r8 = uint8(float64(r8) * 0.5)
g8 = uint8(float64(g8) * 0.5)
b8 = uint8(float64(b8) * 0.7) // Less reduction for blue = blue tint
nightPalette[i] = color.RGBA{r8, g8, b8, 255}
}
return nightPalette
}
Best Practices
- Save the original palette before making changes if you need to restore it later.
- Check palette size before accessing colors to avoid out-of-range errors.
- Use meaningful colors for game elements - consider color blindness and accessibility.
- Be consistent with your color usage throughout your game.
- Use transparency carefully to create layering effects without overcomplicating your game.
Related Functions
SetTransparency()
- Controls which colors are transparent (see transparency.md)Palt()
- PICO-8 style function for setting transparency (see transparency.md)
PIGO8 Transparency Management
PIGO8 uses binary transparency, where each color in the palette is either fully visible or fully transparent. By default, only color 0 (black) is transparent. You can change which colors are transparent to create various visual effects.
Function
Palt
func Palt(args ...interface{})
PICO-8 style function for setting color transparency. This function has multiple usage patterns:
Palt()
- Reset all transparency settings to default (only color 0 is transparent)Palt(colorIndex, transparent)
- Set a specific color's transparency
Parameters:
colorIndex
: Index of the color in the palette.transparent
: Whether the color should be transparent (true
) or opaque (false
).
Example:
// Make color 0 opaque and color 1 transparent
pigo8.Palt(0, false)
pigo8.Palt(1, true)
// Reset to default (only color 0 is transparent)
pigo8.Palt()
How Transparency Works
When drawing sprites, pixels with transparent colors are not drawn, allowing the background to show through. This is useful for:
- Creating sprites with irregular shapes
- Layering sprites on top of each other
- Creating special effects
The transparency is checked in drawing functions like Spr()
, Sspr()
, and Pset()
.
Advanced Usage Examples
Creating a Sprite Mask
// Draw a background
pigo8.Rectfill(0, 0, 127, 127, 12) // Fill screen with blue
// Set red (color 8) as transparent
pigo8.Palt(8, true)
// Draw a sprite where all red pixels will be transparent
pigo8.Spr(0, 64, 64, 1, 1)
// Reset transparency to default
pigo8.Palt()
Multiple Transparent Colors
// Make both black (0) and white (7) transparent
pigo8.Palt(0, true)
pigo8.Palt(7, true)
// Draw sprite with both black and white areas transparent
pigo8.Spr(1, 10, 10, 1, 1)
// Reset to default
pigo8.Palt()
Swapping Transparent Colors
// Make black (0) opaque and blue (12) transparent
pigo8.Palt(0, false)
pigo8.Palt(12, true)
// Draw sprites with this new transparency setting
pigo8.Spr(2, 20, 20, 1, 1)
// Reset to default
pigo8.Palt()
Creating a Cutout Effect
// Draw a colorful background
for y := 0; y < 128; y += 8 {
for x := 0; x < 128; x += 8 {
pigo8.Rectfill(x, y, x+7, y+7, (x+y) % pigo8.GetPaletteSize())
}
}
// Make white (7) transparent
pigo8.Palt(7, true)
// Draw a sprite with white areas that act as "windows" to the background
pigo8.Spr(3, 32, 32, 2, 2)
Best Practices
- Reset transparency when you're done with special effects to avoid unexpected behavior.
- Be consistent with your transparency usage to avoid confusion.
- Document your transparency choices in your code with comments.
- Consider performance - using many transparent colors can make your code harder to understand.
- Test thoroughly - transparency effects can sometimes be subtle and may not work as expected on all backgrounds.
Transparency and Custom Palettes
When you use SetPalette()
to change the palette, the transparency array is automatically resized to match. The function preserves existing transparency settings for colors that still exist in the new palette and ensures that color 0 is transparent by default.
// Create a custom palette
customPalette := []color.Color{
color.RGBA{0, 0, 0, 255}, // Black
color.RGBA{255, 0, 0, 255}, // Red
color.RGBA{0, 255, 0, 255}, // Green
color.RGBA{0, 0, 255, 255}, // Blue
}
// Set the palette - color 0 will be transparent by default
pigo8.SetPalette(customPalette)
// Make red (color 1) transparent as well
pigo8.Palt(1, true)
Related Functions
SetPalette()
- Replace the entire palette (see palette-management.md)GetPaletteSize()
- Get the number of colors in the palette (see palette-management.md)
PIGO8 Editor
The PIGO8 editor is an extremely minimal tool that allows you to create and edit sprites and maps for your PIGO8 games. This documentation covers how to install, run, and use the editor effectively.
Installation
To install the PIGO8 editor, you need to have Go installed on your system. If you haven't installed Go yet, please refer to the Installing Go guide.
Once Go is installed, you can install the PIGO8 editor with the following command:
go install github.com/drpaneas/pigo8/cmd/editor@latest
This will download and compile the editor, making it available as a command-line tool in your system.
Running the Editor
After installation, you can run the editor with the following command:
editor
By default, the editor will open with the standard PICO-8 resolution (128x128). However, you can customize the window size using the -w
and -h
flags:
editor -w 640 -h 480
This will open the editor with a window size of 640x480 pixels, giving you more screen space to work with.
Editor Interface
The editor has two main modes:
- Sprite Editor: For creating and editing individual sprites
- Map Editor: For arranging sprites into a game map
Switching Between Modes
You can switch between the sprite editor and map editor by pressing the X
key on your keyboard. The current mode is displayed at the top of the editor window.
Sprite Editor
The sprite editor allows you to create and modify individual sprites pixel by pixel. Each sprite is 8x8 pixels in size, matching the PICO-8 standard.
Multi-Sprite Editing
The editor supports multi-sprite editing with different grid sizes. You can toggle between grid sizes using the mouse wheel
:
- 8x8 (1 sprite)
- 16x16 (4 sprites in a 2x2 grid)
- 32x32 (16 sprites in a 4x4 grid)
This feature allows you to work on larger sprites or sprite collections as a single unit, with proper mapping to the corresponding individual sprites.
Sprite Flags
Each sprite can have up to 8 flags (Flag0-Flag7) that can be used for game logic (like collision detection, animation states, etc.). You can toggle these flags in the editor interface.
When working with multi-sprite selections, flag changes apply to all selected sprites, with visual indication of mixed flag states when not all selected sprites have the same flag value.
Map Editor
The map editor allows you to arrange sprites into a game map. You can select sprites from your spritesheet and place them on the map grid.
Saving and Loading
The editor automatically saves your work to the following files:
spritesheet.json
: Contains all your spritesmap.json
: Contains your map data
These files are compatible with the PIGO8 library and can be loaded directly into your games.
Keyboard Shortcuts
Key | Function |
---|---|
x | Switch between Sprite Editor and Map Editor |
Mouse Wheel | Change grid size in Sprite Editor |
In Map Editor you can switch between screens using the arrow keys
.
The editor autosaves your work every time you switch between Sprite Editor and Map Editor.
Command Line Options
Flag | Description | Default |
---|---|---|
-w | Window width in pixels | 128 |
-h | Window height in pixels | 128 |
Example Usage
# Run editor with default settings
editor
# Run editor with custom window size
editor -w 640 -h 480
Next Steps
After creating your sprites and maps with the editor, you can use them in your PIGO8 games. See the Resource Embedding guide for details on how to include these resources in your game.
Custom Functions
PIGO8 extends the original PICO-8 API with additional custom functions that provide enhanced capabilities for your games. These functions are not part of the official PICO-8, but they follow the same design philosophy and integrate seamlessly with the rest of the PIGO8 API.
Available Custom Functions
- Color Collision Detection: Detect collisions between sprites based on their non-transparent pixels
- Map Collision Detection: Detect collisions with map tiles using flags
- Flag Constants: Pre-defined constants for easier flag operations
- Sget/Sset Functions: Get and set individual pixels on the spritesheet
- Alpha Transparency: Create semi-transparent colors with alpha values
- Fade System: Create smooth transitions between scenes or palettes
Why Custom Functions?
While PIGO8 aims to recreate the feel of PICO-8, it also takes advantage of Go's capabilities to provide additional features that can make game development easier and more powerful. These custom functions:
- Solve common game development challenges
- Reduce boilerplate code
- Enable effects and gameplay mechanics that would be difficult to implement otherwise
- Maintain the spirit of PICO-8 while extending its capabilities
Using Custom Functions
Custom functions follow the same naming conventions and design patterns as the standard PIGO8 functions. They can be used alongside the standard functions without any special setup.
// Example using both standard and custom functions
func (g *Game) Update() {
// Standard PICO-8 function to move the player
if p8.Btn(p8.ButtonRight) {
g.playerX++
}
// Custom function to check for collision with map
if p8.MapCollision(g.playerX, g.playerY, 0) {
// Handle collision
}
}
Refer to the specific function documentation pages for detailed usage examples and implementation details.
Color Collision Detection
Color collision detection is a custom function in PIGO8 that allows you to check if a pixel at specific coordinates matches a specified color. This is useful for color-based collision detection in games.
Overview
The ColorCollision
function checks if the pixel at the given coordinates (x, y) matches a specific color from the PICO-8 palette. This can be used for various collision detection scenarios, such as checking if a character is touching a specific terrain type or obstacle represented by a particular color.
Function Signature
func ColorCollision[X Number, Y Number](x X, y Y, color int) bool
Parameters
x
: The x-coordinate to check (0-127), can be any numeric typey
: The y-coordinate to check (0-127), can be any numeric typecolor
: The PICO-8 color index to check against (0-15)
Return Value
bool
: Returnstrue
if the pixel at (x, y) matches the specified color,false
otherwise
Example Usage
package main
import p8 "github.com/drpaneas/pigo8"
type Game struct {
playerX, playerY float64
wallColor int
}
func (g *Game) Init() {
g.playerX = 64
g.playerY = 64
g.wallColor = 3 // Assuming color 3 represents walls
}
func (g *Game) Update() {
// Store original position
origX, origY := g.playerX, g.playerY
// Move player based on input
if p8.Btn(p8.ButtonRight) {
g.playerX++
}
// Check if the player is touching a wall (color 3)
if p8.ColorCollision(g.playerX, g.playerY, g.wallColor) {
// Player is touching a wall, revert to previous position
g.playerX, g.playerY = origX, origY
// Play collision sound
p8.Music(0)
}
}
How It Works
The color collision detection function:
- Validates that the coordinates are within the screen bounds (0-127)
- Validates that the color index is valid (0-15)
- Gets the color of the pixel at the specified coordinates using
Pget
- Compares the pixel color with the specified color
- Returns true if they match, false otherwise
Use Cases
Color collision detection is useful for:
- Terrain-based collision (e.g., detecting water, lava, or solid ground)
- Color-coded obstacle detection
- Pixel-perfect collision in games with detailed environments
- Detecting when a character enters specific areas marked by color
Performance Considerations
For optimal performance:
- Use this function sparingly, as checking individual pixels can be CPU-intensive
- Consider checking only key points of your game objects (e.g., corners or center) rather than every pixel
- For larger objects, combine with bounding box checks first
Complete Example
You can find a complete example of color collision detection in the examples/colorCollision directory.
Map Collision Detection
Map collision detection is a custom function in PIGO8 that allows you to detect collisions between game objects and map tiles based on the tiles' flag values.
Overview
In many games, you need to check if a character or object is colliding with solid elements in the game map, such as walls, platforms, or obstacles. The MapCollision
function simplifies this process by checking if a point or sprite overlaps with map tiles that have specific flags set.
Function Signature
func MapCollision[X Number, Y Number](x X, y Y, flag int, size ...int) bool
Parameters
x
: The x-coordinate to check, can be any numeric type (will be converted to tile coordinates)y
: The y-coordinate to check, can be any numeric type (will be converted to tile coordinates)flag
: The flag number (0-7) to checksize
: (optional) The size of the sprite in pixels (default: 8 for standard PICO-8 sprites)
Return Value
bool
: Returnstrue
if the specified flag is set on the sprite at the tile coordinates,false
otherwise
Example Usage
package main
import p8 "github.com/drpaneas/pigo8"
type Game struct {
playerX, playerY float64
playerSize int
}
func (g *Game) Init() {
g.playerX = 64
g.playerY = 64
g.playerSize = 16 // 16x16 player sprite
}
func (g *Game) Update() {
// Store the current position
prevX, prevY := g.playerX, g.playerY
// Move player based on input
if p8.Btn(p8.ButtonRight) {
g.playerX++
}
// Check for collision with solid map tiles (using flag 0 for solid tiles)
if p8.MapCollision(g.playerX, g.playerY, 0, g.playerSize) {
// Collision detected, revert to previous position
g.playerX, g.playerY = prevX, prevY
}
}
Setting Up Map Flags
To use map collision detection, you need to set up flags for your map tiles:
- In the PIGO8 editor, select a sprite and toggle the flags you want to set
- Use these sprites in your map
- In your game code, check for collisions with specific flags
For example, you might use:
- Flag0 for solid/blocking tiles
- Flag1 for damage tiles (spikes, lava)
- Flag2 for collectible tiles
- Flag3 for special interaction tiles
Multiple Flag Checks
You can check for multiple flags by combining them with bitwise OR:
// Check if a tile has either Flag0 OR Flag1 set
if p8.MapRectCollision(g.playerX, g.playerY, g.playerWidth, g.playerHeight, p8.Flag0|p8.Flag1) {
// Handle collision with either solid tiles or damage tiles
}
Complete Example
You can find a complete example of map collision detection in the examples/map_layers directory.
PIGO8 Multiplayer Networking Guide
Table of Contents
- Introduction
- Networking Architecture
- Setting Up a Multiplayer Game
- Network Callbacks
- Message Types and Data Structures
- Client-Side Prediction
- Case Study: Multiplayer Gameboy
- Advanced Topics
- Troubleshooting
Introduction
PIGO8 provides a built-in networking system that allows developers to add multiplayer functionality to their games. This guide will walk you through the process of converting a single-player game to multiplayer, with a focus on the Gameboy example.
The networking system in PIGO8 uses UDP for low-latency communication, making it suitable for real-time games. It follows a client-server architecture where one instance of the game acts as the server and other instances connect as clients.
Networking Architecture
Client-Server Model
PIGO8 uses a client-server architecture for multiplayer games:
- Server: Authoritative source of game state, processes game logic
- Clients: Send inputs to the server, receive and display game state
UDP Protocol
PIGO8 uses UDP (User Datagram Protocol) for networking:
- Advantages: Lower latency, better for real-time games
- Challenges: No guaranteed delivery, packets may arrive out of order
Network Roles
Each instance of a PIGO8 game can have one of two roles:
- Server: Hosts the game, processes game logic, broadcasts game state
- Client: Connects to server, sends inputs, receives game state
Setting Up a Multiplayer Game
Step 1: Initialize Network Roles
First, modify your game structure to include network-related fields:
type Game struct {
// Existing game fields
// Network-related fields
isServer bool
isClient bool
remotePlayerID string
lastStateUpdate time.Time
}
func (g *Game) Init() {
// Existing initialization code
// Set up network roles
g.isServer = p8.GetNetworkRole() == p8.RoleServer
g.isClient = p8.GetNetworkRole() == p8.RoleClient
g.lastStateUpdate = time.Now()
}
Step 2: Register Network Callbacks
In your main()
function, register the network callbacks before starting the game:
func main() {
// Create and initialize the game
game := &Game{}
p8.InsertGame(game)
game.Init()
// Register network callbacks
p8.SetOnGameStateCallback(handleGameState)
p8.SetOnPlayerInputCallback(handlePlayerInput)
p8.SetOnConnectCallback(handlePlayerConnect)
p8.SetOnDisconnectCallback(handlePlayerDisconnect)
// Start the game
settings := p8.NewSettings()
settings.WindowTitle = "PIGO8 Multiplayer Game"
p8.PlayGameWith(settings)
}
Step 3: Modify Game Loop for Network Play
Update your game's Update()
function to handle network roles:
func (g *Game) Update() {
// Handle waiting for players state
if p8.IsWaitingForPlayers() {
return
}
// Server-specific logic
if g.isServer {
// Process game physics, AI, etc.
// Send game state updates
if time.Since(g.lastStateUpdate) > 16*time.Millisecond {
g.sendGameState()
g.lastStateUpdate = time.Now()
}
}
// Client-specific logic
if g.isClient {
// Send player input
g.sendPlayerInput()
// Client-side prediction (optional)
}
// Common logic for both client and server
}
Step 4: Add Network Status Display
Update your game's Draw()
function to display network status:
func (g *Game) Draw() {
p8.Cls(0)
// Display network status
if p8.IsWaitingForPlayers() || p8.GetNetworkError() != "" {
p8.DrawNetworkStatus(10, 10, 7)
return
}
// Draw game elements
// ...
}
Network Callbacks
PIGO8 requires four callback functions for multiplayer functionality:
Game State Callback
Receives game state updates from the server:
func handleGameState(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok {
return
}
var state GameState
if err := json.Unmarshal(data, &state); err != nil {
log.Printf("Error unmarshaling game state: %v", err)
return
}
// Update game state with received data
// Example: Update player positions, game objects, etc.
}
Player Input Callback
Processes player input on the server:
func handlePlayerInput(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
var input PlayerInput
if err := json.Unmarshal(data, &input); err != nil {
log.Printf("Error unmarshaling player input: %v", err)
return
}
// Update game state based on player input
// Example: Move remote player based on input
}
Connect Callback
Handles new player connections:
func handlePlayerConnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Handle new player connection
// Example: Initialize player state, assign player ID
log.Printf("Player connected: %s", playerID)
}
Disconnect Callback
Handles player disconnections:
func handlePlayerDisconnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Handle player disconnection
// Example: Remove player from game
log.Printf("Player disconnected: %s", playerID)
}
Message Types and Data Structures
Game State Structure
Define a structure for your game state:
type GameState struct {
// Game state variables that need to be synchronized
PlayerPositions map[string]Position
GameObjects []GameObject
GameTime float64
}
type Position struct {
X float64
Y float64
}
type GameObject struct {
ID int
Position Position
Type int
State int
}
Player Input Structure
Define a structure for player input:
type PlayerInput struct {
// Player input variables
Up bool
Down bool
Left bool
Right bool
A bool
B bool
Start bool
Select bool
}
Sending Game State
Implement a function to send game state from server to clients:
func (g *Game) sendGameState() {
if !g.isServer {
return
}
// Create game state object
state := GameState{
PlayerPositions: make(map[string]Position),
GameObjects: make([]GameObject, 0),
GameTime: g.gameTime,
}
// Fill with current game state
for id, player := range g.players {
state.PlayerPositions[id] = Position{X: player.x, Y: player.y}
}
for i, obj := range g.gameObjects {
state.GameObjects = append(state.GameObjects, GameObject{
ID: i,
Position: Position{X: obj.x, Y: obj.y},
Type: obj.type,
State: obj.state,
})
}
// Serialize and send
data, err := json.Marshal(state)
if err != nil {
log.Printf("Error marshaling game state: %v", err)
return
}
p8.SendMessage(p8.MsgGameState, "all", data)
}
Sending Player Input
Implement a function to send player input from client to server:
func (g *Game) sendPlayerInput() {
if !g.isClient {
return
}
// Create input object based on current button states
input := PlayerInput{
Up: p8.Btn(p8.UP),
Down: p8.Btn(p8.DOWN),
Left: p8.Btn(p8.LEFT),
Right: p8.Btn(p8.RIGHT),
A: p8.Btn(p8.A),
B: p8.Btn(p8.B),
Start: p8.Btn(p8.START),
Select: p8.Btn(p8.SELECT),
}
// Serialize and send
data, err := json.Marshal(input)
if err != nil {
log.Printf("Error marshaling player input: %v", err)
return
}
p8.SendMessage(p8.MsgPlayerInput, "", data)
}
Client-Side Prediction
Client-side prediction improves the feel of multiplayer games by immediately showing the results of player input locally, then reconciling with the server's authoritative state.
Implementing Prediction
// In client's Update() function
if g.isClient {
// Send input to server
g.sendPlayerInput()
// Store current position
originalX := g.localPlayer.x
originalY := g.localPlayer.y
// Apply input locally for immediate feedback
if p8.Btn(p8.LEFT) {
g.localPlayer.x -= g.playerSpeed
}
if p8.Btn(p8.RIGHT) {
g.localPlayer.x += g.playerSpeed
}
if p8.Btn(p8.UP) {
g.localPlayer.y -= g.playerSpeed
}
if p8.Btn(p8.DOWN) {
g.localPlayer.y += g.playerSpeed
}
// Store prediction for reconciliation
g.predictions = append(g.predictions, Prediction{
Time: g.gameTime,
OriginalX: originalX,
OriginalY: originalY,
PredictedX: g.localPlayer.x,
PredictedY: g.localPlayer.y,
})
}
Reconciliation with Server State
// In handleGameState function
if game.isClient {
// Get server's position for local player
serverPos, ok := state.PlayerPositions[p8.GetPlayerID()]
if !ok {
return
}
// Calculate difference between prediction and server state
diffX := math.Abs(game.localPlayer.x - serverPos.X)
diffY := math.Abs(game.localPlayer.y - serverPos.Y)
// If significant difference, smoothly correct
if diffX > 5 || diffY > 5 {
// Smooth interpolation
game.localPlayer.x = game.localPlayer.x + (serverPos.X - game.localPlayer.x) * 0.3
game.localPlayer.y = game.localPlayer.y + (serverPos.Y - game.localPlayer.y) * 0.3
}
// Update other players' positions directly
for id, pos := range state.PlayerPositions {
if id != p8.GetPlayerID() {
if player, ok := game.players[id]; ok {
player.x = pos.X
player.y = pos.Y
game.players[id] = player
} else {
// Create new player if not exists
game.players[id] = Player{x: pos.X, y: pos.Y}
}
}
}
}
Case Study: Multiplayer Gameboy
Let's convert the Gameboy example to a multiplayer game where two players can move around the screen.
Step 1: Define Game Structures
type Player struct {
x float64
y float64
sprite int
speed float64
}
type Game struct {
// Game world
map [][]int
tileSize float64
// Players
players map[string]Player
localPlayer Player
// Network
isServer bool
isClient bool
lastStateUpdate time.Time
}
Step 2: Define Network Messages
type GameState struct {
Players map[string]PlayerState
}
type PlayerState struct {
X float64
Y float64
Sprite int
}
type PlayerInput struct {
Up bool
Down bool
Left bool
Right bool
A bool
B bool
}
Step 3: Initialize Game with Network Roles
func (g *Game) Init() {
// Initialize map and game world
g.map = loadMap()
g.tileSize = 8
// Initialize players
g.players = make(map[string]Player)
// Set up network roles
g.isServer = p8.GetNetworkRole() == p8.RoleServer
g.isClient = p8.GetNetworkRole() == p8.RoleClient
g.lastStateUpdate = time.Now()
// Initialize local player
g.localPlayer = Player{
x: 64,
y: 64,
sprite: 1,
speed: 1.0,
}
// Add local player to players map
playerID := "server"
if g.isClient {
playerID = p8.GetPlayerID()
}
g.players[playerID] = g.localPlayer
}
Step 4: Implement Network Callbacks
func handleGameState(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok {
return
}
var state GameState
if err := json.Unmarshal(data, &state); err != nil {
log.Printf("Error unmarshaling game state: %v", err)
return
}
// Update all players except local player
for id, playerState := range state.Players {
if game.isClient && id == p8.GetPlayerID() {
// For local player, apply reconciliation
diffX := math.Abs(game.localPlayer.x - playerState.X)
diffY := math.Abs(game.localPlayer.y - playerState.Y)
if diffX > 5 || diffY > 5 {
// Smooth interpolation
game.localPlayer.x = game.localPlayer.x + (playerState.X - game.localPlayer.x) * 0.3
game.localPlayer.y = game.localPlayer.y + (playerState.Y - game.localPlayer.y) * 0.3
// Update players map
game.players[id] = game.localPlayer
}
} else {
// For remote players, update directly
game.players[id] = Player{
x: playerState.X,
y: playerState.Y,
sprite: playerState.Sprite,
speed: 1.0,
}
}
}
}
func handlePlayerInput(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
var input PlayerInput
if err := json.Unmarshal(data, &input); err != nil {
log.Printf("Error unmarshaling player input: %v", err)
return
}
// Get player or create if not exists
player, ok := game.players[playerID]
if !ok {
player = Player{
x: 64,
y: 64,
sprite: 2, // Different sprite for client
speed: 1.0,
}
}
// Update player based on input
if input.Left {
player.x -= player.speed
}
if input.Right {
player.x += player.speed
}
if input.Up {
player.y -= player.speed
}
if input.Down {
player.y += player.speed
}
// Apply collision detection
player = game.applyCollision(player)
// Update player in map
game.players[playerID] = player
}
func handlePlayerConnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Initialize new player
game.players[playerID] = Player{
x: 64,
y: 64,
sprite: 2, // Different sprite for client
speed: 1.0,
}
log.Printf("Player connected: %s", playerID)
}
func handlePlayerDisconnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Remove player
delete(game.players, playerID)
log.Printf("Player disconnected: %s", playerID)
}
Step 5: Implement Game Update Logic
func (g *Game) Update() {
// Handle waiting for players state
if p8.IsWaitingForPlayers() {
return
}
// Update local player based on input
if g.isServer {
// Server controls its local player
originalX := g.localPlayer.x
originalY := g.localPlayer.y
if p8.Btn(p8.LEFT) {
g.localPlayer.x -= g.localPlayer.speed
}
if p8.Btn(p8.RIGHT) {
g.localPlayer.x += g.localPlayer.speed
}
if p8.Btn(p8.UP) {
g.localPlayer.y -= g.localPlayer.speed
}
if p8.Btn(p8.DOWN) {
g.localPlayer.y += g.localPlayer.speed
}
// Apply collision detection
g.localPlayer = g.applyCollision(g.localPlayer)
// Update player in map
g.players["server"] = g.localPlayer
// Send game state to clients
if time.Since(g.lastStateUpdate) > 16*time.Millisecond {
g.sendGameState()
g.lastStateUpdate = time.Now()
}
} else if g.isClient {
// Client controls its local player with prediction
originalX := g.localPlayer.x
originalY := g.localPlayer.y
if p8.Btn(p8.LEFT) {
g.localPlayer.x -= g.localPlayer.speed
}
if p8.Btn(p8.RIGHT) {
g.localPlayer.x += g.localPlayer.speed
}
if p8.Btn(p8.UP) {
g.localPlayer.y -= g.localPlayer.speed
}
if p8.Btn(p8.DOWN) {
g.localPlayer.y += g.localPlayer.speed
}
// Apply collision detection
g.localPlayer = g.applyCollision(g.localPlayer)
// Update player in map
g.players[p8.GetPlayerID()] = g.localPlayer
// Send input to server
g.sendPlayerInput()
}
}
Step 6: Implement Send Functions
func (g *Game) sendGameState() {
if !g.isServer {
return
}
// Create game state object
state := GameState{
Players: make(map[string]PlayerState),
}
// Fill with current player states
for id, player := range g.players {
state.Players[id] = PlayerState{
X: player.x,
Y: player.y,
Sprite: player.sprite,
}
}
// Serialize and send
data, err := json.Marshal(state)
if err != nil {
log.Printf("Error marshaling game state: %v", err)
return
}
p8.SendMessage(p8.MsgGameState, "all", data)
}
func (g *Game) sendPlayerInput() {
if !g.isClient {
return
}
// Create input object based on current button states
input := PlayerInput{
Left: p8.Btn(p8.LEFT),
Right: p8.Btn(p8.RIGHT),
Up: p8.Btn(p8.UP),
Down: p8.Btn(p8.DOWN),
A: p8.Btn(p8.A),
B: p8.Btn(p8.B),
}
// Serialize and send
data, err := json.Marshal(input)
if err != nil {
log.Printf("Error marshaling player input: %v", err)
return
}
p8.SendMessage(p8.MsgPlayerInput, "", data)
}
Step 7: Implement Draw Function
func (g *Game) Draw() {
p8.Cls(0)
// Display network status
if p8.IsWaitingForPlayers() || p8.GetNetworkError() != "" {
p8.DrawNetworkStatus(10, 10, 7)
return
}
// Draw map
for y := 0; y < len(g.map); y++ {
for x := 0; x < len(g.map[y]); x++ {
tileType := g.map[y][x]
p8.Spr(tileType, float64(x)*g.tileSize, float64(y)*g.tileSize)
}
}
// Draw all players
for id, player := range g.players {
p8.Spr(player.sprite, player.x, player.y)
// Draw player ID above sprite
p8.Print(id, player.x, player.y-8, 7)
}
// Draw network role
if g.isServer {
p8.Print("SERVER", 2, 2, 8)
} else if g.isClient {
p8.Print("CLIENT", 2, 2, 12)
}
}
Step 8: Implement Collision Detection
func (g *Game) applyCollision(player Player) Player {
// Get tile coordinates
tileX := int(player.x / g.tileSize)
tileY := int(player.y / g.tileSize)
// Check surrounding tiles
for y := tileY - 1; y <= tileY + 1; y++ {
for x := tileX - 1; x <= tileX + 1; x++ {
// Check map bounds
if y >= 0 && y < len(g.map) && x >= 0 && x < len(g.map[y]) {
tileType := g.map[y][x]
// Check if tile is solid (e.g., tile type 1 is solid)
if tileType == 1 {
// Simple collision detection
tileLeft := float64(x) * g.tileSize
tileRight := tileLeft + g.tileSize
tileTop := float64(y) * g.tileSize
tileBottom := tileTop + g.tileSize
playerLeft := player.x
playerRight := player.x + g.tileSize
playerTop := player.y
playerBottom := player.y + g.tileSize
// Check for collision
if playerRight > tileLeft && playerLeft < tileRight &&
playerBottom > tileTop && playerTop < tileBottom {
// Resolve collision
overlapX := math.Min(playerRight - tileLeft, tileRight - playerLeft)
overlapY := math.Min(playerBottom - tileTop, tileBottom - playerTop)
if overlapX < overlapY {
if playerLeft < tileLeft {
player.x -= overlapX
} else {
player.x += overlapX
}
} else {
if playerTop < tileTop {
player.y -= overlapY
} else {
player.y += overlapY
}
}
}
}
}
}
}
return player
}
Step 9: Set Up Main Function
func main() {
// Create and initialize the game
game := &Game{}
p8.InsertGame(game)
game.Init()
// Register network callbacks
p8.SetOnGameStateCallback(handleGameState)
p8.SetOnPlayerInputCallback(handlePlayerInput)
p8.SetOnConnectCallback(handlePlayerConnect)
p8.SetOnDisconnectCallback(handlePlayerDisconnect)
// Start the game
settings := p8.NewSettings()
settings.WindowTitle = "PIGO8 Multiplayer Gameboy"
p8.PlayGameWith(settings)
}
Advanced Topics
Bandwidth Optimization
To reduce bandwidth usage:
- Send Only What Changed: Only include changed values in game state
- Compression: Compress data before sending
- Update Frequency: Adjust update frequency based on game needs
- Delta Encoding: Send only differences from previous state
Handling Latency
Strategies for handling network latency:
- Client-Side Prediction: Predict movement locally
- Server Reconciliation: Correct client predictions
- Entity Interpolation: Smooth movement of remote entities
- Input Buffering: Buffer inputs to handle jitter
Synchronization Strategies
Different approaches to game synchronization:
- Lockstep: All clients wait for all inputs before advancing
- Snapshot Interpolation: Interpolate between received snapshots
- State Synchronization: Server sends authoritative state
- Event-Based: Synchronize via events rather than full state
Troubleshooting
Common Issues
- Jittery Movement: Implement client-side prediction and interpolation
- Desynchronization: Ensure server is authoritative for game logic
- High Latency: Optimize message size and frequency
- Connection Issues: Check network configuration and firewalls
Debugging Tools
- Logging: Add detailed logging for network events
- Visualization: Visualize network state and predictions
- Artificial Latency: Test with artificial latency
- Packet Inspection: Analyze packet contents and timing
Best Practices
- Keep It Simple: Start with minimal networking and add complexity as needed
- Test Early and Often: Test multiplayer functionality throughout development
- Graceful Degradation: Handle network issues gracefully
- Security: Validate all inputs on the server
By following this guide, you should be able to convert any PIGO8 game to multiplayer, including the Gameboy example. The key is to identify what needs to be synchronized, implement proper client-server communication, and add client-side prediction for a smooth player experience.