Introduction

PIGO8 Logo

PIGO8 is a Go library for building retro-style games with intentional constraints that accelerate creativity. Rather than being overwhelmed by infinite possibilities, you work within a focused environment: a compact screen, limited colors, and a simple API that does one thing well.

Why Build Games with Constraints?

Game development often fails not from lack of tools, but from too many choices. PIGO8 gives you:

  • A fixed canvas: 128×128 pixels by default (customizable for Game Boy, NES, or other resolutions)
  • A curated palette: 16 colors that work beautifully together
  • A minimal API: Draw sprites, handle input, play sounds—nothing more
  • Instant feedback: See changes immediately with hot reload support

These constraints mirror what made 8-bit game development productive: small scope, clear boundaries, finished games.

What You Can Build

PIGO8 is ideal for:

  • Arcade games: Pong, Space Invaders, Breakout clones
  • Platformers: Side-scrolling adventures with tile-based maps
  • Puzzle games: Match-3, Tetris-style, or logic puzzles
  • Prototypes: Test game mechanics before committing to a larger engine
  • Game jams: Ship something playable in 48 hours

The PICO-8 Connection

PIGO8 is inspired by PICO-8, a fantasy console with a dedicated following. If you've written PICO-8 games in Lua, you'll find the API familiar. Functions like Spr(), Map(), Btn(), and Cls() work similarly.

However, PIGO8 is not PICO-8:

  • Written in Go, not Lua (arrays start at 0, not 1)
  • No artificial code/memory limits
  • Can target any resolution, not just 128×128
  • Exports to native binaries and WebAssembly
  • Open source under MIT license

What's in This Documentation?

This guide covers:

  1. Getting Started: Installation and your first game
  2. Graphics: Drawing pixels, shapes, text, and sprites
  3. Maps: Tile-based level design
  4. Input: Keyboard, gamepad, and mouse handling
  5. Audio: Playing sound effects and music
  6. Game Mechanics: Camera, collision detection, math utilities
  7. Advanced Topics: Web export, multiplayer, porting PICO-8 games
  8. Tutorials: Step-by-step game projects

Prerequisites

This documentation assumes you:

  • Know Go basics (packages, structs, methods, interfaces)
  • Have Go 1.21+ installed
  • Have a code editor with Go support

You don't need prior game development experience—that's what we're here to teach.

Installation

Prerequisites

PIGO8 requires:

  • Go 1.21 or later (download)
  • A C compiler for CGO (required by the underlying Ebitengine):
    • macOS: Xcode Command Line Tools (xcode-select --install)
    • Linux: GCC (sudo apt install gcc or equivalent)
    • Windows: TDM-GCC or MinGW-w64

Installing PIGO8

Create a new Go module and add PIGO8:

mkdir my-game && cd my-game
go mod init my-game
go get github.com/drpaneas/pigo8

Verifying Installation

Create a main.go file:

package main

import p8 "github.com/drpaneas/pigo8"

type game struct{}

func (g *game) Init()   {}
func (g *game) Update() {}
func (g *game) Draw()   { p8.Cls(1) }

func main() {
    p8.InsertGame(&game{})
    p8.Play()
}

Run it:

go run .

You should see a window with a dark blue background. Press Enter to open the pause menu, and select "Quit" to exit.

Project Structure

A typical PIGO8 project looks like:

my-game/
├── main.go              # Game code
├── embed.go             # Resource embedding (auto-generated)
├── spritesheet.json     # Sprite definitions
├── map.json             # Tile map data
├── palette.hex          # Custom color palette (optional)
├── music1.wav           # Sound effects (optional)
└── go.mod

Platform-Specific Notes

macOS Apple Silicon

Works out of the box. No additional configuration needed.

Linux

Install these dependencies for audio and graphics:

# Debian/Ubuntu
sudo apt install libasound2-dev libgl1-mesa-dev xorg-dev

# Fedora
sudo dnf install alsa-lib-devel mesa-libGL-devel xorg-x11-server-devel

Windows

Use PowerShell or CMD. If you encounter CGO errors, ensure your compiler is in PATH.

WebAssembly

See the Web Export guide for browser deployment.

Hello World

Let's build the simplest possible PIGO8 game: a screen that displays "Hello, World!"

The Complete Game

Create main.go:

package main

import p8 "github.com/drpaneas/pigo8"

type game struct{}

func (g *game) Init()   {}
func (g *game) Update() {}

func (g *game) Draw() {
    p8.Cls(1)                          // Clear screen with dark blue
    p8.Print("hello, world!", 40, 60)  // Draw text at (40, 60)
}

func main() {
    p8.InsertGame(&game{})
    p8.Play()
}

Run it:

go run .

What's Happening?

The Game Struct

type game struct{}

Your game state lives in a struct. For now it's empty, but you'll add player position, score, and other state here.

The Cartridge Interface

PIGO8 expects your game to implement three methods:

func (g *game) Init()   {}  // Called once at startup
func (g *game) Update() {}  // Called every frame for logic
func (g *game) Draw()   {}  // Called every frame for rendering

This is the game loop—the heartbeat of every game.

Inserting and Playing

p8.InsertGame(&game{})  // Register your game
p8.Play()               // Start the engine

Think of it like inserting a cartridge into a console.

Understanding the Draw Function

func (g *game) Draw() {
    p8.Cls(1)                          // Clear to color 1 (dark blue)
    p8.Print("hello, world!", 40, 60)  // White text by default
}
  • Cls(colorIndex) clears the screen. Color 1 is dark blue in the default palette.
  • Print(text, x, y) draws text. Coordinates start at (0, 0) in the top-left corner.

Next Steps

You now have a working PIGO8 game. In the next chapter, we'll explore the game loop in detail and add interactivity.

The Game Loop

Every game runs a continuous loop: read input, update state, render graphics. PIGO8 handles the loop for you through three methods.

Init, Update, Draw

type Cartridge interface {
    Init()   // Called once when the game starts
    Update() // Called every frame (default: 30 times/second)
    Draw()   // Called every frame after Update
}

Init

Runs once before the first frame. Use it to:

  • Set initial positions
  • Load level data
  • Initialize scores
func (g *game) Init() {
    g.playerX = 64
    g.playerY = 64
    g.score = 0
}

Update

Runs every frame before Draw. Use it to:

  • Read input
  • Move game objects
  • Check collisions
  • Update game state
func (g *game) Update() {
    if p8.Btn(p8.LEFT) {
        g.playerX--
    }
    if p8.Btn(p8.RIGHT) {
        g.playerX++
    }
}

Important: Never draw in Update. Drawing operations only work in Draw.

Draw

Runs every frame after Update. Use it to:

  • Clear the screen
  • Draw backgrounds, sprites, and UI
  • Render everything visible
func (g *game) Draw() {
    p8.Cls(0)                          // Always clear first
    p8.Spr(1, g.playerX, g.playerY)    // Draw player sprite
    p8.Print(g.score, 2, 2, 7)         // Draw score
}

Frame Timing

By default, PIGO8 runs at 30 FPS (frames per second). Each frame:

  1. Update() is called
  2. Draw() is called
  3. The screen is displayed
  4. Wait until the next frame is due

At 30 FPS, each frame is ~33 milliseconds. At 60 FPS, each frame is ~16 milliseconds.

Changing FPS

Use settings to change the frame rate:

func main() {
    settings := p8.NewSettings()
    settings.TargetFPS = 60  // Smoother animation
    
    p8.InsertGame(&game{})
    p8.PlayGameWith(settings)
}

Higher FPS means smoother animation but faster game speed if you're using fixed movement values. Consider using delta time for frame-rate independent movement.

Time Function

Get the elapsed time since the game started:

func (g *game) Update() {
    elapsed := p8.Time()  // Returns seconds as float64
    
    // Do something every 2 seconds
    if int(elapsed) % 2 == 0 {
        // ...
    }
}

Complete Example: Moving Square

package main

import p8 "github.com/drpaneas/pigo8"

type game struct {
    x, y float64
}

func (g *game) Init() {
    g.x = 60
    g.y = 60
}

func (g *game) Update() {
    if p8.Btn(p8.LEFT)  { g.x-- }
    if p8.Btn(p8.RIGHT) { g.x++ }
    if p8.Btn(p8.UP)    { g.y-- }
    if p8.Btn(p8.DOWN)  { g.y++ }
}

func (g *game) Draw() {
    p8.Cls(0)
    p8.Rectfill(g.x, g.y, g.x+8, g.y+8, 8)  // Red square
}

func main() {
    p8.InsertGame(&game{})
    p8.Play()
}

Arrow keys move the red square. The game loop makes it responsive.

Settings

PIGO8 provides sensible defaults, but you can customize window size, frame rate, resolution, and more.

Default Settings

settings := p8.NewSettings()
// Returns:
// ScaleFactor:  4      (window is 4x the game resolution)
// WindowTitle:  "PIGO-8 Game"
// TargetFPS:    30
// ScreenWidth:  128    (PICO-8 default)
// ScreenHeight: 128
// Multiplayer:  false
// Fullscreen:   false
// DisableHiDPI: true   (better performance for pixel art)

Using Custom Settings

func main() {
    settings := p8.NewSettings()
    settings.WindowTitle = "My Awesome Game"
    settings.TargetFPS = 60
    settings.ScaleFactor = 6
    
    p8.InsertGame(&game{})
    p8.PlayGameWith(settings)
}

Setting Reference

SettingTypeDefaultDescription
ScaleFactorint4Window size multiplier (4 = 512×512 window for 128×128 game)
WindowTitlestring"PIGO-8 Game"Title shown in window bar
TargetFPSint30Frames per second (30 or 60 recommended)
ScreenWidthint128Logical game width in pixels
ScreenHeightint128Logical game height in pixels
FullscreenboolfalseStart in fullscreen mode
MultiplayerboolfalseEnable networking features
DisableHiDPIbooltrueDisable HiDPI scaling (better for pixel art)

Custom Resolution

PIGO8 isn't limited to 128×128. Try these classic resolutions:

// Game Boy (160×144)
settings.ScreenWidth = 160
settings.ScreenHeight = 144

// NES (256×240)
settings.ScreenWidth = 256
settings.ScreenHeight = 240

// Commodore 64 (320×200)
settings.ScreenWidth = 320
settings.ScreenHeight = 200

Fullscreen Mode

settings.Fullscreen = true

Press Alt+Enter (or Cmd+Enter on macOS) to toggle fullscreen at runtime.

Reading Screen Dimensions

In your game code, get the current screen size:

func (g *game) Draw() {
    width := p8.GetScreenWidth()   // Returns 128 by default
    height := p8.GetScreenHeight() // Returns 128 by default
    
    // Center something on screen
    centerX := width / 2
    centerY := height / 2
}

Complete Example: Game Boy Style

package main

import p8 "github.com/drpaneas/pigo8"

type game struct{}

func (g *game) Init()   {}
func (g *game) Update() {}

func (g *game) Draw() {
    p8.Cls(0)
    p8.Print("game boy!", 52, 70, 3)
}

func main() {
    settings := p8.NewSettings()
    settings.ScreenWidth = 160
    settings.ScreenHeight = 144
    settings.WindowTitle = "Game Boy Style"
    settings.ScaleFactor = 4
    
    p8.InsertGame(&game{})
    p8.PlayGameWith(settings)
}

Graphics Overview

PIGO8's graphics system is designed for simplicity: a fixed-size screen, a limited color palette, and immediate-mode drawing.

Coordinate System

The screen uses a standard 2D coordinate system:

  • (0, 0) is the top-left corner
  • X increases to the right
  • Y increases downward
  • Default size is 128×128 pixels
(0,0) ────────────────► X (127,0)
  │
  │
  │
  │
  ▼
  Y
(0,127)                 (127,127)

Drawing Order

PIGO8 uses immediate-mode rendering. Each frame:

  1. Call Cls() to clear the screen
  2. Draw background elements first
  3. Draw foreground elements on top
  4. Draw UI elements last

Later draws appear on top of earlier draws—like painting on a canvas.

func (g *game) Draw() {
    p8.Cls(0)                    // 1. Clear (black background)
    p8.Map()                     // 2. Background tiles
    p8.Spr(1, g.playerX, g.playerY) // 3. Player sprite
    p8.Print(g.score, 2, 2, 7)   // 4. UI on top
}

The 16-Color Palette

PIGO8 uses a 16-color palette by default (the PICO-8 palette):

IndexColorHexIndexColorHex
0Black#0000008Red#FF004D
1Dark Blue#1D2B539Orange#FFA300
2Dark Purple#7E255310Yellow#FFEC27
3Dark Green#00875111Green#00E436
4Brown#AB523612Blue#29ADFF
5Dark Gray#5F574F13Indigo#83769C
6Light Gray#C2C3C714Pink#FF77A8
7White#FFF1E815Peach#FFCCAA

Use color indices (0-15) in all drawing functions.

Graphics Functions Summary

FunctionPurpose
Cls(color)Clear screen
Pset(x, y, color)Draw single pixel
Pget(x, y)Read pixel color
Rect(), Rectfill()Draw rectangles
Circ(), Circfill()Draw circles
Line()Draw lines
Print()Draw text
Spr()Draw sprites
Sspr()Draw sprite regions
Map()Draw tile maps

Screen Functions

Cls - Clear Screen

Cls(colorIndex) fills the entire screen with a single color.

func (g *game) Draw() {
    p8.Cls(0)   // Clear to black
    // ... draw everything else
}

Always call Cls at the start of Draw. Otherwise, you'll see ghosting from the previous frame.

Parameters

ParameterTypeDefaultDescription
colorIndexint0Color index (0-15)

Examples

p8.Cls()     // Clear to black (color 0)
p8.Cls(1)    // Clear to dark blue
p8.Cls(12)   // Clear to blue

ClsRGBA - Clear with Custom Color

For colors outside the palette, use ClsRGBA:

import "image/color"

func (g *game) Draw() {
    p8.ClsRGBA(color.RGBA{R: 50, G: 50, B: 80, A: 255})
    // ...
}

Screen Dimensions

Get the current screen size at runtime:

width := p8.GetScreenWidth()   // Default: 128
height := p8.GetScreenHeight() // Default: 128

Useful for centering objects or handling custom resolutions:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Center a message
    msg := "game over"
    x := (p8.GetScreenWidth() - len(msg)*4) / 2
    y := p8.GetScreenHeight() / 2
    p8.Print(msg, x, y, 8)
}

Colors and Palette

The Default Palette

PIGO8 starts with the classic PICO-8 palette:

IndexNameRGBDescription
0Black(0, 0, 0)Used for transparency
1Dark Blue(29, 43, 83)
2Dark Purple(126, 37, 83)
3Dark Green(0, 135, 81)
4Brown(171, 82, 54)
5Dark Gray(95, 87, 79)
6Light Gray(194, 195, 199)
7White(255, 241, 232)
8Red(255, 0, 77)
9Orange(255, 163, 0)
10Yellow(255, 236, 39)
11Green(0, 228, 54)
12Blue(41, 173, 255)
13Indigo(131, 118, 156)
14Pink(255, 119, 168)
15Peach(255, 204, 170)

Transparency

By default, color 0 (black) is transparent when drawing sprites. This means:

  • Pixels with color 0 in your sprites won't be drawn
  • You can layer sprites on top of each other
  • Change transparency settings with Palt()

Custom Palettes (palette.hex)

Load palettes from sites like Lospec:

  1. Download a palette in HEX format
  2. Save as palette.hex in your project:
c60021
e70000
e76121
e7a263
e7c384
  1. PIGO8 automatically loads it on startup

Note: Color 0 is always transparent, and color 1 becomes white. Your hex colors start at index 2.

Palette Functions

SetPaletteColor

Change a single color at runtime:

import "image/color"

func (g *game) Init() {
    // Make color 8 a custom blue
    p8.SetPaletteColor(8, color.RGBA{0, 100, 200, 255})
}

GetPaletteColor

Read the current color at an index:

col := p8.GetPaletteColor(8)  // Returns color.Color

SetPalette

Replace the entire palette:

import "image/color"

func (g *game) Init() {
    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
    }
    p8.SetPalette(grayscale)
}

GetPaletteSize

Get the number of colors:

count := p8.GetPaletteSize()  // Returns 16 for default palette

Pixel Manipulation

Pset - Set Pixel

Draw a single pixel at (x, y):

p8.Pset(64, 64, 8)  // Red pixel at center
p8.Pset(10, 20)     // Use current draw color

Parameters

ParameterTypeDescription
xintX coordinate
yintY coordinate
colorint (optional)Color index (0-15)

Note: Unlike shape functions, Pset and Pget take int parameters, not generic Number types. Use int() to convert if needed.

Pget - Get Pixel

Read the color index at (x, y):

color := p8.Pget(64, 64)  // Returns 0-15

Returns 0 if coordinates are out of bounds.

Use Cases

  • Collision detection: Check if a pixel is a certain color
  • Color picking: Find what color is at a position
  • Procedural generation: Read and modify existing pixels

Example: Starfield

type game struct {
    stars [][2]int  // x, y pairs
}

func (g *game) Init() {
    // Create 50 random stars
    for i := 0; i < 50; i++ {
        x := p8.Rnd(128)
        y := p8.Rnd(128)
        g.stars = append(g.stars, [2]int{x, y})
    }
}

func (g *game) Draw() {
    p8.Cls(0)
    for _, star := range g.stars {
        // Random twinkle: white or light gray
        color := 7
        if p8.Rnd(10) < 3 {
            color = 6
        }
        p8.Pset(star[0], star[1], color)
    }
}

Color Function

Set the current draw color used by functions when no color is specified:

p8.Color(8)        // Set current color to red
p8.Pset(10, 10)    // Draws red (uses current color)
p8.Pset(20, 20, 12) // Draws blue (overrides current color)

Drawing Shapes

Rectangles

Rect - Outline Rectangle

p8.Rect(x1, y1, x2, y2, color)

Draws a 1-pixel outline rectangle from corner (x1, y1) to corner (x2, y2).

p8.Rect(10, 10, 50, 30, 7)  // White outline

Rectfill - Filled Rectangle

p8.Rectfill(x1, y1, x2, y2, color)

Draws a filled rectangle.

p8.Rectfill(10, 10, 50, 30, 8)  // Red filled rectangle

Circles

Circ - Outline Circle

p8.Circ(x, y, radius, color)

Draws a 1-pixel outline circle centered at (x, y).

p8.Circ(64, 64, 20, 12)  // Blue circle at center, radius 20

Circfill - Filled Circle

p8.Circfill(x, y, radius, color)

Draws a filled circle.

p8.Circfill(64, 64, 20, 11)  // Green filled circle

Lines

p8.Line(x1, y1, x2, y2, color)

Draws a line from (x1, y1) to (x2, y2).

p8.Line(0, 0, 127, 127, 7)    // Diagonal white line
p8.Line(64, 0, 64, 127, 5)    // Vertical gray line

Note: Unlike Rect and Circ, the Line function does not apply camera offset. If you're using Camera() for scrolling, lines will remain fixed on screen.

Color Parameter

All shape functions accept an optional color parameter:

  • With color: Uses the specified color
  • Without color: Uses the current draw color (set by Color() or Cursor())
p8.Color(8)         // Set current color to red
p8.Rect(10, 10, 50, 30)  // Draws in red
p8.Rect(60, 10, 100, 30, 12)  // Draws in blue (override)

Example: Simple Scene

func (g *game) Draw() {
    p8.Cls(12)  // Sky blue background
    
    // Sun
    p8.Circfill(100, 20, 12, 10)  // Yellow sun
    
    // Ground
    p8.Rectfill(0, 100, 127, 127, 3)  // Dark green grass
    
    // House
    p8.Rectfill(40, 70, 80, 100, 4)   // Brown walls
    p8.Rectfill(55, 85, 65, 100, 5)   // Gray door
    
    // Roof (triangle using lines)
    p8.Line(35, 70, 60, 50, 8)   // Left roof edge
    p8.Line(60, 50, 85, 70, 8)   // Right roof edge
    p8.Line(35, 70, 85, 70, 8)   // Bottom edge
}

Text and Print

p8.Print(value, x, y, color)

Draws text at (x, y). The value can be any type—it's converted to a string.

Parameters

ParameterTypeDescription
valueanyText or value to display
xintX coordinate (optional)
yintY coordinate (optional)
colorintColor index (optional)

Examples

p8.Print("hello", 10, 10, 7)     // White text at (10, 10)
p8.Print(score, 10, 20, 11)      // Print an integer
p8.Print(3.14159, 10, 30, 9)     // Print a float
p8.Print(true, 10, 40, 8)        // Print a boolean

Flexible Arguments

Print supports multiple calling conventions:

// Position and color
p8.Print("text", 10, 20, 7)

// Position only (uses current color)
p8.Print("text", 10, 20)

// Color only (uses cursor position)
p8.Print("text", 12)

// No arguments (uses cursor position and color)
p8.Print("text")

Cursor Function

Set the text cursor position and color:

p8.Cursor(10, 20)      // Set position to (10, 20)
p8.Cursor(10, 20, 8)   // Set position and color
p8.Cursor()            // Reset to (0, 0)

After Print, the cursor moves down by one line (6 pixels).

Text Layout

Characters are 4 pixels wide (including spacing) and 6 pixels tall.

// Measure text width
text := "hello"
width := len(text) * 4  // 20 pixels

// Center text horizontally
x := (128 - width) / 2
p8.Print(text, x, 60, 7)

Return Values

Print returns the position after the text:

endX, endY := p8.Print("score: ", 10, 10, 7)
p8.Print(g.score, endX, 10, 11)  // Continue on same line

Example: Score Display

func (g *game) Draw() {
    p8.Cls(0)
    
    // Title
    p8.Print("SPACE SHOOTER", 30, 5, 7)
    
    // Score (right-aligned)
    scoreText := fmt.Sprintf("%05d", g.score)
    p8.Print("SCORE:", 85, 5, 6)
    p8.Print(scoreText, 105, 5, 11)
    
    // Lives
    p8.Print("LIVES:", 5, 5, 6)
    for i := 0; i < g.lives; i++ {
        p8.Spr(1, 30+i*10, 3)  // Heart sprites
    }
}

Palette Effects

Pal - Color Swapping

Pal remaps one color to another for all subsequent drawing operations.

Swap a Single Color

p8.Pal(8, 12)  // Red (8) draws as blue (12)

Now when you draw something in color 8 (red), it appears as color 12 (blue).

Reset All Mappings

p8.Pal()  // Reset to default (each color draws as itself)

Example: Character Variants

Use palette swapping to create color variants of the same sprite:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Original player (red shirt)
    p8.Spr(1, 20, 60)
    
    // Player 2 (swap red to blue)
    p8.Pal(8, 12)
    p8.Spr(1, 60, 60)
    p8.Pal()  // Reset
    
    // Player 3 (swap red to green)
    p8.Pal(8, 11)
    p8.Spr(1, 100, 60)
    p8.Pal()  // Reset
}

Palt - Transparency Control

Palt controls which colors are treated as transparent when drawing sprites.

Make a Color Transparent

p8.Palt(8, true)  // Red (8) becomes transparent

Make a Color Opaque

p8.Palt(0, false)  // Black (0) is now drawn (not transparent)

Reset Transparency

p8.Palt()  // Reset: only black (0) is transparent

Example: Glowing Effect

Make the background show through certain colors:

func (g *game) Draw() {
    p8.Cls(1)  // Dark blue background
    
    // Draw sprite normally
    p8.Spr(5, 40, 60)
    
    // Draw same sprite with yellow transparent (shows background)
    p8.Palt(10, true)  // Yellow transparent
    p8.Spr(5, 80, 60)
    p8.Palt()  // Reset
}

Combining Effects

Create powerful effects by combining Pal and Palt:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Silhouette effect
    for i := 1; i <= 15; i++ {
        p8.Pal(i, 1)  // All colors become dark blue
    }
    p8.Spr(1, 50, 60)
    p8.Pal()
    
    // Normal sprite next to it
    p8.Spr(1, 70, 60)
}

Flash Effect on Damage

func (g *game) Draw() {
    p8.Cls(0)
    
    if g.playerDamageFrames > 0 {
        // Flash white when damaged
        for i := 0; i <= 15; i++ {
            p8.Pal(i, 7)
        }
        g.playerDamageFrames--
    }
    
    p8.Spr(g.playerSprite, g.playerX, g.playerY)
    p8.Pal()  // Always reset
}

Sprites Overview

Sprites are small images used for characters, objects, and tiles in your game. PIGO8 loads sprites from a spritesheet.json file.

The Spritesheet

A spritesheet is a grid of small images (typically 8×8 pixels each). In PICO-8 style, the sheet is 128×128 pixels containing 256 sprites in a 16×16 grid.

Sprite IDs:
 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
32 33 34 35 ...

spritesheet.json Format

{
  "version": "1.0",
  "description": "Game sprites",
  "sprites": [
    {
      "id": 1,
      "x": 8,
      "y": 0,
      "width": 8,
      "height": 8,
      "flags": 0,
      "pixels": "0000000001111110011111100111111001111110011111100111111000000000"
    }
  ]
}

Sprite Fields

FieldDescription
idUnique sprite number
x, yPosition on the spritesheet (pixels)
width, heightDimensions (usually 8×8)
flagsBitfield for collision/layer filtering (0-255)
pixelsHex string of color indices

Creating Sprites

PIGO8 Editor

Run the built-in editor:

go run github.com/drpaneas/pigo8/cmd/editor

From PICO-8

If you have existing PICO-8 games:

go run github.com/drpaneas/parsepico -input game.p8 -output .

This extracts spritesheet.json and map.json.

Manual Creation

Create sprites programmatically or use image editors and convert to the JSON format.

Sprite Functions Summary

FunctionPurpose
Spr(n, x, y, ...)Draw sprite n at position (x, y)
Sspr(sx, sy, sw, sh, dx, dy, ...)Draw arbitrary spritesheet region
Sget(x, y)Get pixel color from spritesheet
Sset(x, y, c)Set pixel color on spritesheet
Fget(n, f)Get sprite flag value
Fset(n, f, v)Set sprite flag value

Drawing Sprites

Spr - Draw a Sprite

The most common way to draw sprites:

p8.Spr(spriteNumber, x, y)
p8.Spr(spriteNumber, x, y, width, height)
p8.Spr(spriteNumber, x, y, width, height, flipX)
p8.Spr(spriteNumber, x, y, width, height, flipX, flipY)

Parameters

ParameterTypeDefaultDescription
spriteNumberintrequiredSprite ID from spritesheet
xNumberrequiredScreen X position
yNumberrequiredScreen Y position
widthfloat641.0Width in sprite units (1 = 8 pixels)
heightfloat641.0Height in sprite units
flipXboolfalseFlip horizontally
flipYboolfalseFlip vertically

Basic Usage

// Draw sprite 1 at position (60, 60)
p8.Spr(1, 60, 60)

// Draw with float coordinates (for smooth movement)
p8.Spr(1, g.playerX, g.playerY)

Multi-Tile Sprites

Draw sprites that span multiple tiles:

// Draw a 2×2 sprite (16×16 pixels)
// This draws sprites 1, 2, 17, 18 as a block
p8.Spr(1, 50, 50, 2, 2)

// Draw a 1.5 width (12 pixels wide)
p8.Spr(1, 50, 50, 1.5, 1)

Flipping

Mirror sprites for left/right or up/down facing:

// Player facing right
p8.Spr(1, g.x, g.y)

// Player facing left (flip horizontally)
p8.Spr(1, g.x, g.y, 1, 1, true)

// Upside down
p8.Spr(1, g.x, g.y, 1, 1, false, true)

Animation Example

type game struct {
    frame     int
    animFrame int
}

func (g *game) Update() {
    g.frame++
    // Change animation frame every 8 game frames
    if g.frame % 8 == 0 {
        g.animFrame = (g.animFrame + 1) % 4
    }
}

func (g *game) Draw() {
    p8.Cls(0)
    // Sprites 1-4 are animation frames
    p8.Spr(1 + g.animFrame, 60, 60)
}

Transparency

By default, black pixels (color 0) are transparent. To change this:

// Make color 0 opaque (black is drawn)
p8.Palt(0, false)

// Make color 8 (red) transparent
p8.Palt(8, true)

// Reset to default
p8.Palt()

Arbitrary Sprite Regions

Sspr - Draw Spritesheet Region

Sspr draws any rectangular region from the spritesheet, with optional scaling and flipping.

p8.Sspr(sx, sy, sw, sh, dx, dy)
p8.Sspr(sx, sy, sw, sh, dx, dy, dw, dh)
p8.Sspr(sx, sy, sw, sh, dx, dy, dw, dh, flipX, flipY)

Parameters

ParameterTypeDefaultDescription
sxintrequiredSource X on spritesheet
syintrequiredSource Y on spritesheet
swintrequiredSource width (pixels)
shintrequiredSource height (pixels)
dxNumberrequiredDestination X on screen
dyNumberrequiredDestination Y on screen
dwfloat64swDestination width (pixels)
dhfloat64shDestination height (pixels)
flipXboolfalseFlip horizontally
flipYboolfalseFlip vertically

Basic Usage

// Draw 16×16 region from (8, 8) to screen at (50, 50)
p8.Sspr(8, 8, 16, 16, 50, 50)

Scaling

// Draw 8×8 source scaled to 32×32 on screen
p8.Sspr(0, 0, 8, 8, 50, 50, 32, 32)

// Draw 16×16 scaled down to 8×8
p8.Sspr(0, 0, 16, 16, 50, 50, 8, 8)

Use Cases

  • Irregular sprite sizes: Characters that aren't 8×8
  • Scaling: Boss sprites, zoom effects
  • Partial sprites: Only show part of a larger image
func (g *game) Draw() {
    p8.Cls(0)
    
    // Draw logo from spritesheet, scaled 2x
    p8.Sspr(0, 0, 32, 16, 32, 50, 64, 32)
}

Sget / Sset - Spritesheet Pixels

Read and write individual pixels on the spritesheet.

Sget

colorIndex := p8.Sget(x, y)  // Get pixel color (0-15)

Sset

p8.Sset(x, y, colorIndex)  // Set pixel color

Example: Procedural Sprite

func (g *game) Init() {
    // Draw a random pattern on sprite 255 (position 127×127 on sheet)
    for y := 0; y < 8; y++ {
        for x := 0; x < 8; x++ {
            color := p8.Rnd(16)  // Random color
            p8.Sset(120+x, 120+y, color)
        }
    }
}

Sprite Flags

Each sprite has 8 flags (bits 0-7) that can be used for game logic—commonly for collision detection and layer filtering.

Flag Numbers

Flags are numbered 0-7 and accessed by their number, not by constants:

// Flag numbers (0-7) for use with Fget/Fset
// Flag 0: Often used for "solid/collision"
// Flag 1-7: Custom usage

// When used as a bitfield (for Map layers parameter):
// Flag 0 = 1, Flag 1 = 2, Flag 2 = 4, Flag 3 = 8
// Flag 4 = 16, Flag 5 = 32, Flag 6 = 64, Flag 7 = 128

Fget - Get Flag

Fget returns two values: a bitfield and a boolean.

bitfield, isSet := p8.Fget(spriteNumber, flagNumber)

Get All Flags (Bitfield)

When checking all flags, use the first return value:

allFlags, _ := p8.Fget(1)  // Returns bitfield (0-255)

Check Specific Flag

When checking a specific flag, use the second return value:

_, isSolid := p8.Fget(1, 0)  // Check if flag 0 is set on sprite 1

Important: Always use both return values appropriately. When checking a specific flag, ignore the first value with _.

Fset - Set Flag

Set a Specific Flag

p8.Fset(1, 0, true)   // Set flag 0 on sprite 1
p8.Fset(1, 0, false)  // Clear flag 0 on sprite 1

Set All Flags at Once

p8.Fset(1, false)  // Clear all flags (bitfield = 0)
p8.Fset(1, true)   // Set all flags (bitfield = 255)
p8.Fset(1, 170)    // Set flags 1, 3, 5, 7 (binary: 10101010)

Common Use Cases

Collision Detection

Mark solid tiles with flag 0:

// In spritesheet.json, wall tiles have flags: 1
// In game code:
func (g *game) Update() {
    newX := g.playerX + g.dx
    
    // Check if destination tile is solid
    tileX := p8.Flr(newX / 8)
    tileY := p8.Flr(g.playerY / 8)
    spriteID := p8.Mget(tileX, tileY)
    
    _, isSolid := p8.Fget(spriteID, 0)
    if !isSolid {
        g.playerX = newX  // Allow movement
    }
}

Layer Filtering

Use flags to control which sprites appear:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Draw background layer (flag 1)
    p8.Map(0, 0, 0, 0, 16, 16, 2)  // Only sprites with flag 1
    
    // Draw foreground layer (flag 2)
    p8.Map(0, 0, 0, 0, 16, 16, 4)  // Only sprites with flag 2
}

Collectible Items

Mark collectibles with a flag:

// Flag 1 = collectible
func (g *game) Update() {
    tileX := p8.Flr(g.playerX / 8)
    tileY := p8.Flr(g.playerY / 8)
    spriteID := p8.Mget(tileX, tileY)
    
    _, isCollectible := p8.Fget(spriteID, 1)
    if isCollectible {
        g.score++
        p8.Mset(tileX, tileY, 0)  // Remove collected item
    }
}

Maps Overview

Maps are grids of tiles that form levels, backgrounds, and game worlds. Each cell references a sprite by ID.

The Map Grid

In PICO-8 style, the default map is:

  • 128 tiles wide × 128 tiles tall
  • Each tile is 8×8 pixels
  • Total: 1024×1024 pixels (when fully rendered)
Map coordinates (tiles):
(0,0)  (1,0)  (2,0)  ...  (127,0)
(0,1)  (1,1)  (2,1)  ...  (127,1)
  ...
(0,127)              ...  (127,127)

map.json Format

{
  "version": "1.0",
  "description": "Level 1",
  "width": 128,
  "height": 128,
  "name": "world1",
  "cells": [
    {"x": 0, "y": 0, "sprite": 1},
    {"x": 1, "y": 0, "sprite": 1},
    {"x": 2, "y": 0, "sprite": 2}
  ]
}

The cells array is sparse—only non-zero tiles are listed.

Map Functions Summary

FunctionPurpose
Map(mx, my, sx, sy, w, h, layers)Draw map tiles to screen
Mget(x, y)Get sprite ID at tile position
Mset(x, y, sprite)Set sprite ID at tile position

Creating Maps

PIGO8 Editor

go run github.com/drpaneas/pigo8/cmd/editor

From PICO-8

go run github.com/drpaneas/parsepico -input game.p8 -output .

Procedural Generation

func (g *game) Init() {
    for y := 0; y < 16; y++ {
        for x := 0; x < 16; x++ {
            if p8.Rnd(10) < 2 {
                p8.Mset(x, y, 5)  // Random obstacles
            }
        }
    }
}

Drawing Maps

Map Function

p8.Map(mx, my, sx, sy, w, h, layers)

Draws a rectangular region of the map to the screen.

Parameters

ParameterTypeDefaultDescription
mxint0Map X position (tiles)
myint0Map Y position (tiles)
sxint0Screen X position (pixels)
syint0Screen Y position (pixels)
wint128Width to draw (tiles)
hint128Height to draw (tiles)
layersint0Flag bitfield for filtering (0 = all)

Basic Usage

// Draw the entire visible area
p8.Map()

// Draw from map position (5, 10)
p8.Map(5, 10)

// Draw at screen position (10, 20)
p8.Map(0, 0, 10, 20)

// Draw a 16×16 tile area
p8.Map(0, 0, 0, 0, 16, 16)

Scrolling

For a scrolling game, offset the map position:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Calculate camera offset in tiles
    camX := int(g.playerX/8) - 8  // Center on player
    camY := int(g.playerY/8) - 8
    
    // Keep camera in bounds
    if camX < 0 { camX = 0 }
    if camY < 0 { camY = 0 }
    
    p8.Map(camX, camY, 0, 0, 16, 16)
}

Layer Filtering

Use the layers parameter to draw only sprites with specific flags:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Draw background (sprites with flag 0 set = bit 1)
    p8.Map(0, 0, 0, 0, 16, 16, 1)
    
    // Draw player
    p8.Spr(g.playerSprite, g.playerX, g.playerY)
    
    // Draw foreground (sprites with flag 1 set = bit 2)
    p8.Map(0, 0, 0, 0, 16, 16, 2)
}

Pixel-Perfect Scrolling

For smooth scrolling at sub-tile precision, use the Camera function:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Camera follows player with pixel precision
    p8.Camera(g.playerX - 64, g.playerY - 64)
    
    p8.Map()  // Map scrolls with camera
    p8.Spr(1, g.playerX, g.playerY)  // Player drawn in world coords
    
    // Draw UI without camera offset
    p8.Camera()  // Reset camera
    p8.Print(g.score, 2, 2, 7)
}

Map Data Access

Mget - Read Map Tile

spriteID := p8.Mget(column, row)

Returns the sprite ID at the specified tile position.

Parameters

ParameterTypeDescription
columnintX position in tiles (0-127)
rowintY position in tiles (0-127)

Returns

  • Sprite ID (0-255)
  • Returns 0 for out-of-bounds coordinates

Example

// Check what's at tile (5, 10)
spriteID := p8.Mget(5, 10)
if spriteID == 0 {
    // Empty tile
}

Mset - Write Map Tile

p8.Mset(column, row, spriteID)

Sets the sprite ID at the specified tile position.

Parameters

ParameterTypeDescription
columnintX position in tiles
rowintY position in tiles
spriteIDintSprite ID to place (0-255)

Example

// Place sprite 5 at tile (10, 10)
p8.Mset(10, 10, 5)

// Clear a tile (set to empty)
p8.Mset(10, 10, 0)

Dynamic Maps

Destructible Terrain

func (g *game) Update() {
    if p8.Btnp(p8.X) {  // Fire button
        // Calculate tile in front of player
        tileX := p8.Flr((g.playerX + 8) / 8)
        tileY := p8.Flr(g.playerY / 8)
        
        spriteID := p8.Mget(tileX, tileY)
        _, isBreakable := p8.Fget(spriteID, 2)  // Flag 2 = breakable
        
        if isBreakable {
            p8.Mset(tileX, tileY, 0)  // Remove tile
            g.score += 10
        }
    }
}

Collectibles

func (g *game) Update() {
    // Player tile position
    tileX := p8.Flr(g.playerX / 8)
    tileY := p8.Flr(g.playerY / 8)
    
    spriteID := p8.Mget(tileX, tileY)
    
    // Sprite 10 = coin
    if spriteID == 10 {
        p8.Mset(tileX, tileY, 0)  // Remove coin
        g.coins++
        p8.Music(1)  // Coin sound
    }
}

Doors and Switches

// Pressing a switch opens a door
func (g *game) Update() {
    tileX := p8.Flr(g.playerX / 8)
    tileY := p8.Flr(g.playerY / 8)
    
    // Sprite 20 = switch
    if p8.Mget(tileX, tileY) == 20 && p8.Btnp(p8.X) {
        // Change switch sprite
        p8.Mset(tileX, tileY, 21)  // Activated switch
        
        // Open door at known position
        p8.Mset(g.doorX, g.doorY, 0)
    }
}

Keyboard Input

Button Constants

PIGO8 uses PICO-8 style button indices:

ConstantKeyIndex
p8.LEFTArrow Left0
p8.RIGHTArrow Right1
p8.UPArrow Up2
p8.DOWNArrow Down3
p8.OZ4
p8.XX5
p8.ButtonStartEnter6
p8.ButtonSelectTab7

Btn - Is Button Held?

if p8.Btn(p8.LEFT) {
    // Left arrow is currently pressed
    g.playerX--
}

Returns true every frame the button is held down.

Note: The optional player index parameter (e.g., Btn(p8.LEFT, 1)) is currently not used. All input is treated as player 0.

Continuous Movement

func (g *game) Update() {
    if p8.Btn(p8.LEFT)  { g.x-- }
    if p8.Btn(p8.RIGHT) { g.x++ }
    if p8.Btn(p8.UP)    { g.y-- }
    if p8.Btn(p8.DOWN)  { g.y++ }
}

Btnp - Was Button Just Pressed?

if p8.Btnp(p8.X) {
    // X button was just pressed this frame
    g.jump()
}

Returns true only on the first frame the button is pressed.

func (g *game) Update() {
    if p8.Btnp(p8.UP) {
        g.menuSelection--
    }
    if p8.Btnp(p8.DOWN) {
        g.menuSelection++
    }
    if p8.Btnp(p8.X) {
        g.selectMenuItem()
    }
}

Common Patterns

Movement with Speed

const speed = 2

func (g *game) Update() {
    if p8.Btn(p8.LEFT)  { g.x -= speed }
    if p8.Btn(p8.RIGHT) { g.x += speed }
    if p8.Btn(p8.UP)    { g.y -= speed }
    if p8.Btn(p8.DOWN)  { g.y += speed }
}

Jump with Btnp

func (g *game) Update() {
    // Ground check
    if g.onGround && p8.Btnp(p8.O) {
        g.velocityY = -5
        g.onGround = false
    }
    
    // Apply gravity
    g.velocityY += 0.2
    g.y += g.velocityY
}

Fire Rate Limiting

func (g *game) Update() {
    if g.fireCooldown > 0 {
        g.fireCooldown--
    }
    
    if p8.Btn(p8.X) && g.fireCooldown == 0 {
        g.shoot()
        g.fireCooldown = 10  // 10 frame cooldown
    }
}

Gamepad Support

PIGO8 automatically detects gamepads. The standard buttons work without any configuration.

Button Mapping

PIGO8 ConstantXbox ControllerPlayStationSteam Deck
p8.OXSquareX
p8.XAXA
p8.ButtonStartMenuOptionsMenu
p8.ButtonSelectViewShareView

Directional Input

D-pad and left analog stick both work for directional buttons:

// Works with keyboard arrows, D-pad, and analog stick
if p8.Btn(p8.LEFT)  { g.x-- }
if p8.Btn(p8.RIGHT) { g.x++ }

Additional Gamepad Buttons

For games needing more buttons:

ConstantDescription
p8.ButtonJoyAA button (Xbox layout)
p8.ButtonJoypadBB button
p8.ButtonJoypadXX button
p8.ButtonJoypadYY button
p8.ButtonJoypadL1Left shoulder
p8.ButtonJoypadR1Right shoulder
p8.ButtonJoypadL2Left trigger
p8.ButtonJoypadR2Right trigger

Steam Deck

PIGO8 includes specific support for Steam Deck:

  • D-pad and analog stick work for movement
  • Face buttons map to PIGO8 buttons
  • Back buttons (L4, R4, L5, R5) are accessible
// Steam Deck back buttons
if p8.Btn(p8.ButtonJoypadL4) {
    // Left back button
}

Testing Gamepad

func (g *game) Draw() {
    p8.Cls(0)
    
    // Show button states
    y := 10
    for i := 0; i <= 5; i++ {
        color := 5  // Gray
        if p8.Btn(i) {
            color = 11  // Green
        }
        p8.Print(i, 10, y, color)
        y += 8
    }
}

Mouse Input

Mouse Position

x, y := p8.GetMouseXY()

Returns the current mouse position in game coordinates.

Example: Cursor

func (g *game) Draw() {
    p8.Cls(0)
    
    mx, my := p8.GetMouseXY()
    p8.Circ(mx, my, 2, 7)  // Draw cursor
}

Mouse Buttons

ConstantButton
p8.ButtonMouseLeftLeft click
p8.ButtonMouseRightRight click
p8.ButtonMouseMiddleMiddle click
p8.ButtonMouseWheelUpScroll up
p8.ButtonMouseWheelDownScroll down

Click Detection

func (g *game) Update() {
    mx, my := p8.GetMouseXY()
    
    if p8.Btnp(p8.ButtonMouseLeft) {
        // Left mouse button just clicked
        g.handleClick(mx, my)
    }
    
    if p8.Btn(p8.ButtonMouseLeft) {
        // Left mouse button held (dragging)
        g.handleDrag(mx, my)
    }
}

Scroll Wheel

func (g *game) Update() {
    if p8.Btn(p8.ButtonMouseWheelUp) {
        g.zoom++
    }
    if p8.Btn(p8.ButtonMouseWheelDown) {
        g.zoom--
    }
}

Example: Paint Program

type game struct {
    lastX, lastY int
}

func (g *game) Update() {
    mx, my := p8.GetMouseXY()
    
    if p8.Btn(p8.ButtonMouseLeft) {
        // Draw line from last position to current
        p8.Line(g.lastX, g.lastY, mx, my, 7)
    }
    
    g.lastX = mx
    g.lastY = my
}

Audio

PIGO8 plays WAV files for sound effects and music.

Setting Up Audio

Name your audio files music1.wav, music2.wav, etc., and place them in your project directory.

Music Function

p8.Music(n)           // Play audio file n
p8.Music(n, true)     // Play exclusively (stops other audio)
p8.Music(-1)          // Stop all audio

Examples

// Play sound effect 1
p8.Music(1)

// Play background music, stop other sounds
p8.Music(0, true)

// Play jump sound
func (g *game) jump() {
    g.velocityY = -5
    p8.Music(2)  // Jump sound effect
}

StopMusic Function

p8.StopMusic(-1)  // Stop all audio
p8.StopMusic(1)   // Stop specific audio

Audio Tips

Short Sound Effects

Keep sound effects short (< 1 second) for responsive gameplay.

Background Music

For looping music, ensure your WAV file loops cleanly.

Web Browsers

Due to browser autoplay policies, audio starts only after user interaction. The web export handles this automatically.

Example: Game with Sound

func (g *game) Update() {
    // Player movement with footstep sound
    if p8.Btn(p8.LEFT) || p8.Btn(p8.RIGHT) {
        if g.frame % 20 == 0 {  // Every 20 frames
            p8.Music(4)  // Footstep sound
        }
    }
    
    // Collision sound
    if g.hitEnemy() {
        p8.Music(3)  // Hit sound
    }
}

func (g *game) Init() {
    p8.Music(0, true)  // Start background music
}

Embedding Audio

For standalone builds, embed audio files:

//go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .

The embedgen tool automatically detects and embeds music*.wav files.

Camera

The camera offsets all drawing operations, creating the illusion of a scrolling world.

Camera Function

p8.Camera(x, y)  // Set camera offset
p8.Camera()      // Reset to (0, 0)

When the camera is at (x, y), everything draws shifted by (-x, -y).

Following a Player

func (g *game) Draw() {
    p8.Cls(0)
    
    // Center camera on player
    camX := g.playerX - 64  // Center of 128-pixel screen
    camY := g.playerY - 64
    
    p8.Camera(camX, camY)
    
    // Draw world
    p8.Map()
    p8.Spr(1, g.playerX, g.playerY)  // Use world coordinates
    
    // Draw UI (reset camera first)
    p8.Camera()
    p8.Print("SCORE: " + fmt.Sprint(g.score), 2, 2, 7)
}

Camera Bounds

Keep the camera within map boundaries:

func (g *game) updateCamera() {
    // Target camera position
    camX := g.playerX - 64
    camY := g.playerY - 64
    
    // Clamp to map boundaries
    mapWidth := 128 * 8   // 128 tiles × 8 pixels
    mapHeight := 128 * 8
    
    if camX < 0 { camX = 0 }
    if camY < 0 { camY = 0 }
    if camX > mapWidth - 128 { camX = mapWidth - 128 }
    if camY > mapHeight - 128 { camY = mapHeight - 128 }
    
    g.camX = camX
    g.camY = camY
}

Smooth Camera

Add smoothing for a less jarring follow:

func (g *game) updateCamera() {
    targetX := g.playerX - 64
    targetY := g.playerY - 64
    
    // Smooth interpolation (10% per frame)
    g.camX += (targetX - g.camX) * 0.1
    g.camY += (targetY - g.camY) * 0.1
}

Screen Shake

Add impact effects:

func (g *game) Draw() {
    p8.Cls(0)
    
    // Apply shake offset
    shakeX := 0.0
    shakeY := 0.0
    if g.shakeFrames > 0 {
        shakeX = float64(p8.Rnd(5)) - 2
        shakeY = float64(p8.Rnd(5)) - 2
        g.shakeFrames--
    }
    
    p8.Camera(g.camX + shakeX, g.camY + shakeY)
    // ... draw world
}

Color Collision

Check if a pixel on the screen matches a specific color.

ColorCollision Function

p8.ColorCollision(x, y, colorIndex) bool

Returns true if the pixel at (x, y) is the specified color.

Parameters

ParameterTypeDescription
xNumberScreen X coordinate
yNumberScreen Y coordinate
colorIndexintColor to check (0-15)

Example

func (g *game) Update() {
    newX := g.playerX + g.dx
    
    // Check if player would hit a wall (color 3)
    if p8.ColorCollision(newX, g.playerY, 3) {
        // Don't move
        return
    }
    
    g.playerX = newX
}

How It Works

  1. All objects are drawn to the screen
  2. ColorCollision reads the pixel color using Pget
  3. Returns true if it matches the target color

Use Cases

  • Simple platformer collision (walls are a specific color)
  • Trigger zones (invisible areas using specific colors)
  • Art-based collision shapes

Limitations

  • Must draw before checking collision
  • Only works with screen pixels (affected by camera)
  • Single-pixel checks need multiple calls for bounding boxes

Full Bounding Box Check

func hitWall(x, y, w, h float64, wallColor int) bool {
    // Check corners and edges
    return p8.ColorCollision(x, y, wallColor) ||
           p8.ColorCollision(x+w, y, wallColor) ||
           p8.ColorCollision(x, y+h, wallColor) ||
           p8.ColorCollision(x+w, y+h, wallColor)
}

Map Collision

Check if a rectangular area overlaps with flagged map tiles.

MapCollision Function

p8.MapCollision(x, y, flag, width, height) bool

Returns true if any tile under the area has the specified flag set.

Parameters

ParameterTypeDefaultDescription
xNumberrequiredLeft edge (pixels)
yNumberrequiredTop edge (pixels)
flagintrequiredFlag to check (0-7)
widthint8Area width (pixels)
heightint8Area height (pixels)

Basic Usage

// Check 8×8 area at player position
if p8.MapCollision(g.playerX, g.playerY, 0) {
    // Hit a tile with flag 0 (solid)
}

Custom Hitbox

// Player is 6×12 pixels
playerWidth := 6
playerHeight := 12
if p8.MapCollision(g.x, g.y, 0, playerWidth, playerHeight) {
    // Collision
}

Platformer Movement

func (g *game) Update() {
    // Horizontal movement
    newX := g.x + g.velocityX
    if !p8.MapCollision(newX, g.y, 0, 8, 8) {
        g.x = newX
    } else {
        g.velocityX = 0
    }
    
    // Vertical movement
    newY := g.y + g.velocityY
    if !p8.MapCollision(g.x, newY, 0, 8, 8) {
        g.y = newY
    } else {
        if g.velocityY > 0 {
            g.onGround = true
        }
        g.velocityY = 0
    }
}

Different Collision Types

Use different flags for different behaviors:

const (
    FlagSolid    = 0  // Can't pass through
    FlagPlatform = 1  // Pass through from below
    FlagHazard   = 2  // Damages player
    FlagWater    = 3  // Slows movement
)

func (g *game) Update() {
    // Check solid collision
    if p8.MapCollision(g.x, g.y+1, FlagSolid) {
        g.onGround = true
    }
    
    // Check hazards
    if p8.MapCollision(g.x, g.y, FlagHazard) {
        g.takeDamage()
    }
    
    // Check water (for movement speed)
    g.inWater = p8.MapCollision(g.x, g.y, FlagWater)
}

Math Functions

Flr - Floor

Round down to the nearest integer:

p8.Flr(1.9)   // Returns 1
p8.Flr(-1.1)  // Returns -2
p8.Flr(5)     // Returns 5

Common Uses

// Convert pixel position to tile position
tileX := p8.Flr(g.playerX / 8)
tileY := p8.Flr(g.playerY / 8)

// Grid snapping
snappedX := p8.Flr(g.x/8) * 8

Rnd - Random Number

Generate random integers:

p8.Rnd(10)    // Returns 0-9
p8.Rnd(6)     // Dice roll (0-5, add 1 for 1-6)
p8.Rnd(128)   // Random screen position

Examples

// Random position
x := p8.Rnd(128)
y := p8.Rnd(128)

// Random color
color := p8.Rnd(16)

// 50% chance
if p8.Rnd(2) == 0 {
    // Do something
}

// 1 in 100 chance
if p8.Rnd(100) == 0 {
    g.spawnEnemy()
}

Sqrt - Square Root

p8.Sqrt(16)   // Returns 4.0
p8.Sqrt(2)    // Returns ~1.414
p8.Sqrt(-1)   // Returns 0.0 (not NaN)

Distance Calculation

func distance(x1, y1, x2, y2 float64) float64 {
    dx := x2 - x1
    dy := y2 - y1
    return p8.Sqrt(dx*dx + dy*dy)
}

Sign - Get Sign

Returns -1 for negative numbers, 1 for zero or positive:

p8.Sign(5.0)    // Returns 1.0
p8.Sign(-3.0)   // Returns -1.0
p8.Sign(0.0)    // Returns 1.0 (not 0!)

Note: Sign takes a float64 parameter and returns float64. Zero is treated as positive, returning 1.

Use Case: Direction

// Move toward target
if g.x != targetX {
    g.x += p8.Sign(float64(targetX - g.x))
}

Time - Elapsed Time

Returns seconds since game started:

elapsed := p8.Time()  // e.g., 10.5 seconds

Use Cases

// Animation timing
frame := int(p8.Time() * 10) % 4  // Cycle through 4 frames

// Spawn enemies every 5 seconds
if int(p8.Time()) % 5 == 0 && g.lastSpawn != int(p8.Time()) {
    g.spawnEnemy()
    g.lastSpawn = int(p8.Time())
}

T - Time Alias

p8.T()  // Same as p8.Time()

Resource Embedding

Embed game assets into your binary for portable distribution.

Quick Setup

Add to the top of main.go:

//go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .

Then build:

go generate
go build

Your executable now includes all assets.

What Gets Embedded

The embedgen tool detects and embeds:

  • spritesheet.json - Sprite data
  • map.json - Map data
  • palette.hex - Custom palette
  • music*.wav - Audio files

Manual Embedding

For custom control, create embed.go:

package main

import (
    "embed"
    p8 "github.com/drpaneas/pigo8"
)

//go:embed spritesheet.json map.json
var resources embed.FS

func init() {
    p8.RegisterEmbeddedResources(
        resources,
        "spritesheet.json",
        "map.json",
        "",  // No palette
    )
}

Loading Priority

PIGO8 loads resources in this order:

  1. Current directory (highest priority)
  2. Common subdirectories: assets/, resources/, data/, static/
  3. Embedded resources
  4. Library defaults (lowest priority)

This allows local files to override embedded resources during development.

Custom Palettes

Create palette.hex with hex colors:

c60021
e70000
e76121
e7a263

Color 0 is automatically transparent, colors shift to start at index 2.

Complete Example

Project structure:

my-game/
├── main.go
├── embed.go          # Auto-generated
├── spritesheet.json
├── map.json
├── palette.hex
└── music1.wav

main.go:

//go:generate go run github.com/drpaneas/pigo8/cmd/embedgen -dir .
package main

import p8 "github.com/drpaneas/pigo8"

type game struct{}

func (g *game) Init()   {}
func (g *game) Update() {}
func (g *game) Draw()   { p8.Cls(1) }

func main() {
    p8.InsertGame(&game{})
    p8.Play()
}

Build and distribute:

go generate
go build -o my-game
./my-game  # Works anywhere!

Web Export

Compile your game to WebAssembly for browser play.

Quick Export

go run github.com/drpaneas/pigo8/cmd/webexport -game ./your-game -o ./dist

Options

FlagDefaultDescription
-game(required)Game directory path
-o./web-buildOutput directory
-title"PIGO-8 Game"Browser title
-servefalseStart local server
-port8080Server port

Output Files

dist/
├── index.html    # Game page with virtual controls
├── game.wasm     # Compiled game
└── wasm_exec.js  # Go WASM runtime

Testing Locally

cd dist && python3 -m http.server 8080

Open http://localhost:8080

Or use the built-in server:

go run github.com/drpaneas/pigo8/cmd/webexport -game ./my-game -o ./dist -serve

Deployment

GitHub Pages

go run github.com/drpaneas/pigo8/cmd/webexport -game ./my-game -o ./docs

Enable Pages in repo settings, point to /docs.

itch.io

go run github.com/drpaneas/pigo8/cmd/webexport -game ./my-game -o ./dist
cd dist && zip -r ../game-web.zip .

Upload game-web.zip as HTML game.

Netlify / Vercel

Just deploy the output directory as a static site.

Mobile Support

The generated page includes touch controls:

  • D-pad for movement
  • A/B buttons for actions
  • Haptic feedback on supported devices

The interface is styled like a Game Boy for nostalgia.

Audio Notes

Browser autoplay policies require user interaction before audio. The page handles this automatically—audio works after the first button press.

Customization

The generated index.html can be edited for custom styling:

  • Change the Game Boy color scheme
  • Add your own branding
  • Modify button layout
  • Add external links

Complete Workflow

# 1. Create your game
mkdir my-game && cd my-game
go mod init my-game
go get github.com/drpaneas/pigo8

# 2. Write game code (main.go)

# 3. Add resources (spritesheet.json, etc.)

# 4. Export to web
go run github.com/drpaneas/pigo8/cmd/webexport -game . -o ./dist -title "My Game"

# 5. Test locally
cd dist && python3 -m http.server 8080

# 6. Deploy to your favorite hosting

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:

  1. Sprite Editor: For creating and editing individual sprites
  2. 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

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)

Multi-Sprite Editing

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

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 sprites
  • map.json: Contains your map data

These files are compatible with the PIGO8 library and can be loaded directly into your games.

Keyboard Shortcuts

KeyFunction
xSwitch between Sprite Editor and Map Editor
Mouse WheelChange 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

FlagDescriptionDefault
-wWindow width in pixels128
-hWindow height in pixels128

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.

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!

PIGO8 Multiplayer Networking Guide

Table of Contents

  1. Introduction
  2. Networking Architecture
  3. Setting Up a Multiplayer Game
  4. Network Callbacks
  5. Message Types and Data Structures
  6. Client-Side Prediction
  7. Case Study: Multiplayer Gameboy
  8. Advanced Topics
  9. 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:

  1. Send Only What Changed: Only include changed values in game state
  2. Compression: Compress data before sending
  3. Update Frequency: Adjust update frequency based on game needs
  4. Delta Encoding: Send only differences from previous state

Handling Latency

Strategies for handling network latency:

  1. Client-Side Prediction: Predict movement locally
  2. Server Reconciliation: Correct client predictions
  3. Entity Interpolation: Smooth movement of remote entities
  4. Input Buffering: Buffer inputs to handle jitter

Synchronization Strategies

Different approaches to game synchronization:

  1. Lockstep: All clients wait for all inputs before advancing
  2. Snapshot Interpolation: Interpolate between received snapshots
  3. State Synchronization: Server sends authoritative state
  4. Event-Based: Synchronize via events rather than full state

Troubleshooting

Common Issues

  1. Jittery Movement: Implement client-side prediction and interpolation
  2. Desynchronization: Ensure server is authoritative for game logic
  3. High Latency: Optimize message size and frequency
  4. Connection Issues: Check network configuration and firewalls

Debugging Tools

  1. Logging: Add detailed logging for network events
  2. Visualization: Visualize network state and predictions
  3. Artificial Latency: Test with artificial latency
  4. Packet Inspection: Analyze packet contents and timing

Best Practices

  1. Keep It Simple: Start with minimal networking and add complexity as needed
  2. Test Early and Often: Test multiplayer functionality throughout development
  3. Graceful Degradation: Handle network issues gracefully
  4. 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.

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:

Game

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:

PICO8Sprites

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 JSONs 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 (0127), 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 14 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:

  1. Animate()
  2. 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.go 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.

Tutorial: Building Pong

Let's build a complete Pong game step by step.

Final Result

Two paddles, a ball, scoring, and AI opponent.

Step 1: Project Setup

mkdir pong && cd pong
go mod init pong
go get github.com/drpaneas/pigo8

Create main.go:

package main

import p8 "github.com/drpaneas/pigo8"

type Game struct{}

func (g *Game) Init()   {}
func (g *Game) Update() {}
func (g *Game) Draw()   { p8.Cls(0) }

func main() {
    p8.InsertGame(&Game{})
    p8.Play()
}

Step 2: Define Game Objects

type Paddle struct {
    x, y, width, height, speed float64
    color int
}

type Ball struct {
    x, y, size   float64
    dx, dy       float64
    color        int
}

type Game struct {
    player   Paddle
    computer Paddle
    ball     Ball
    playerScore   int
    computerScore int
}

Step 3: Initialize Positions

func (g *Game) Init() {
    // Player paddle on the left
    g.player = Paddle{
        x: 4, y: 54,
        width: 4, height: 20,
        speed: 2, color: 12,
    }
    
    // Computer paddle on the right
    g.computer = Paddle{
        x: 120, y: 54,
        width: 4, height: 20,
        speed: 1.5, color: 8,
    }
    
    // Ball in center
    g.ball = Ball{
        x: 62, y: 62,
        size: 4,
        dx: 2, dy: 1,
        color: 7,
    }
}

Step 4: Player Input

func (g *Game) Update() {
    // Player movement
    if p8.Btn(p8.UP) && g.player.y > 0 {
        g.player.y -= g.player.speed
    }
    if p8.Btn(p8.DOWN) && g.player.y+g.player.height < 128 {
        g.player.y += g.player.speed
    }
}

Step 5: Ball Movement and Wall Bouncing

func (g *Game) Update() {
    // ... player input ...
    
    // Move ball
    g.ball.x += g.ball.dx
    g.ball.y += g.ball.dy
    
    // Bounce off top/bottom walls
    if g.ball.y <= 0 || g.ball.y+g.ball.size >= 128 {
        g.ball.dy = -g.ball.dy
    }
}

Step 6: Paddle Collision

func collides(ball Ball, paddle Paddle) bool {
    return ball.x+ball.size >= paddle.x &&
           ball.x <= paddle.x+paddle.width &&
           ball.y+ball.size >= paddle.y &&
           ball.y <= paddle.y+paddle.height
}

func (g *Game) Update() {
    // ... previous code ...
    
    // Paddle collision
    if collides(g.ball, g.player) || collides(g.ball, g.computer) {
        g.ball.dx = -g.ball.dx
    }
}

Step 7: Scoring

func (g *Game) Update() {
    // ... previous code ...
    
    // Score when ball exits
    if g.ball.x < 0 {
        g.computerScore++
        g.resetBall()
    }
    if g.ball.x > 128 {
        g.playerScore++
        g.resetBall()
    }
}

func (g *Game) resetBall() {
    g.ball.x = 62
    g.ball.y = 62
    g.ball.dx = -g.ball.dx  // Serve toward last scorer
}

Step 8: AI Opponent

func (g *Game) Update() {
    // ... previous code ...
    
    // Simple AI: follow the ball
    if g.ball.dx > 0 {  // Ball moving toward AI
        mid := g.computer.y + g.computer.height/2
        if mid < g.ball.y && g.computer.y+g.computer.height < 128 {
            g.computer.y += g.computer.speed
        }
        if mid > g.ball.y && g.computer.y > 0 {
            g.computer.y -= g.computer.speed
        }
    }
}

Step 9: Drawing

func (g *Game) Draw() {
    p8.Cls(0)
    
    // Center line
    for y := 0; y < 128; y += 8 {
        p8.Line(64, float64(y), 64, float64(y+4), 5)
    }
    
    // Paddles
    p8.Rectfill(g.player.x, g.player.y,
        g.player.x+g.player.width, g.player.y+g.player.height,
        g.player.color)
    p8.Rectfill(g.computer.x, g.computer.y,
        g.computer.x+g.computer.width, g.computer.y+g.computer.height,
        g.computer.color)
    
    // Ball
    p8.Rectfill(g.ball.x, g.ball.y,
        g.ball.x+g.ball.size, g.ball.y+g.ball.size,
        g.ball.color)
    
    // Score
    p8.Print(g.playerScore, 32, 4, 12)
    p8.Print(g.computerScore, 92, 4, 8)
}

Complete Code

package main

import p8 "github.com/drpaneas/pigo8"

type Paddle struct {
    x, y, width, height, speed float64
    color                      int
}

type Ball struct {
    x, y, size float64
    dx, dy     float64
    color      int
}

type Game struct {
    player        Paddle
    computer      Paddle
    ball          Ball
    playerScore   int
    computerScore int
}

func (g *Game) Init() {
    g.player = Paddle{x: 4, y: 54, width: 4, height: 20, speed: 2, color: 12}
    g.computer = Paddle{x: 120, y: 54, width: 4, height: 20, speed: 1.5, color: 8}
    g.ball = Ball{x: 62, y: 62, size: 4, dx: 2, dy: 1, color: 7}
}

func (g *Game) Update() {
    // Player input
    if p8.Btn(p8.UP) && g.player.y > 0 {
        g.player.y -= g.player.speed
    }
    if p8.Btn(p8.DOWN) && g.player.y+g.player.height < 128 {
        g.player.y += g.player.speed
    }

    // AI
    if g.ball.dx > 0 {
        mid := g.computer.y + g.computer.height/2
        if mid < g.ball.y && g.computer.y+g.computer.height < 128 {
            g.computer.y += g.computer.speed
        }
        if mid > g.ball.y && g.computer.y > 0 {
            g.computer.y -= g.computer.speed
        }
    }

    // Ball movement
    g.ball.x += g.ball.dx
    g.ball.y += g.ball.dy

    // Wall bounce
    if g.ball.y <= 0 || g.ball.y+g.ball.size >= 128 {
        g.ball.dy = -g.ball.dy
    }

    // Paddle collision
    if collides(g.ball, g.player) || collides(g.ball, g.computer) {
        g.ball.dx = -g.ball.dx
    }

    // Scoring
    if g.ball.x < 0 {
        g.computerScore++
        g.resetBall()
    }
    if g.ball.x > 128 {
        g.playerScore++
        g.resetBall()
    }
}

func (g *Game) Draw() {
    p8.Cls(0)

    // Center line
    for y := 0; y < 128; y += 8 {
        p8.Line(64, float64(y), 64, float64(y+4), 5)
    }

    // Game objects
    p8.Rectfill(g.player.x, g.player.y, g.player.x+g.player.width, g.player.y+g.player.height, g.player.color)
    p8.Rectfill(g.computer.x, g.computer.y, g.computer.x+g.computer.width, g.computer.y+g.computer.height, g.computer.color)
    p8.Rectfill(g.ball.x, g.ball.y, g.ball.x+g.ball.size, g.ball.y+g.ball.size, g.ball.color)

    // Score
    p8.Print(g.playerScore, 32, 4, 12)
    p8.Print(g.computerScore, 92, 4, 8)
}

func (g *Game) resetBall() {
    g.ball.x = 62
    g.ball.y = 62
    g.ball.dx = -g.ball.dx
}

func collides(ball Ball, paddle Paddle) bool {
    return ball.x+ball.size >= paddle.x &&
        ball.x <= paddle.x+paddle.width &&
        ball.y+ball.size >= paddle.y &&
        ball.y <= paddle.y+paddle.height
}

func main() {
    settings := p8.NewSettings()
    settings.TargetFPS = 60
    settings.WindowTitle = "PIGO-8 Pong"
    p8.InsertGame(&Game{})
    p8.PlayGameWith(settings)
}

Next Steps

  • Add sound effects for paddle hits and scoring
  • Implement difficulty levels
  • Add a two-player mode
  • Create a title screen
  • Add ball speed increase over time

See examples/pong/main.go for the full implementation with sound effects.

PIGO8 Cheatsheet

Quick Reference

Initialization

p8.InsertGame(&myGame{})  // Register game
p8.Play()                 // Start with defaults
p8.PlayGameWith(settings) // Start with custom settings

Screen

p8.Cls(color)            // Clear screen
p8.GetScreenWidth()      // Get width (128)
p8.GetScreenHeight()     // Get height (128)

Drawing

p8.Pset(x, y, color)     // Set pixel
p8.Pget(x, y)            // Get pixel color
p8.Line(x1, y1, x2, y2, color)
p8.Rect(x1, y1, x2, y2, color)
p8.Rectfill(x1, y1, x2, y2, color)
p8.Circ(x, y, r, color)
p8.Circfill(x, y, r, color)
p8.Print(text, x, y, color)

Sprites

p8.Spr(n, x, y)                    // Draw sprite
p8.Spr(n, x, y, w, h, flipX, flipY)
p8.Sspr(sx, sy, sw, sh, dx, dy)    // Draw region
p8.Sget(x, y)                      // Get spritesheet pixel
p8.Sset(x, y, color)               // Set spritesheet pixel
bitfield, isSet := p8.Fget(sprite, flag)  // Get flag (returns 2 values)
p8.Fset(sprite, flag, value)              // Set sprite flag

Maps

p8.Map(mx, my, sx, sy, w, h, layers)
p8.Mget(x, y)            // Get tile sprite
p8.Mset(x, y, sprite)    // Set tile sprite

Input

p8.Btn(button)           // Is button held?
p8.Btnp(button)          // Was button just pressed?
p8.GetMouseXY()          // Get mouse position
// Buttons: LEFT, RIGHT, UP, DOWN, O (Z), X (X)
// ButtonStart (Enter), ButtonMouseLeft, etc.

Audio

p8.Music(n)              // Play music/sfx
p8.Music(-1)             // Stop all
p8.StopMusic(n)          // Stop specific

Camera

p8.Camera(x, y)          // Set offset
p8.Camera()              // Reset to (0,0)

Palette

p8.Pal(c0, c1)           // Swap colors
p8.Pal()                 // Reset palette swap
p8.Palt(color, transparent)  // Set transparency
p8.Palt()                // Reset transparency
p8.Color(c)              // Set draw color

Collision

p8.ColorCollision(x, y, color)
p8.MapCollision(x, y, flag, w, h)

Math

p8.Flr(n)                // Floor (returns int)
p8.Rnd(n)                // Random 0 to n-1 (returns int)
p8.Sqrt(n)               // Square root (returns float64)
p8.Sign(float64(n))      // -1.0 or 1.0 (takes float64)
p8.Time()                // Seconds elapsed (returns float64)

Settings

settings := p8.NewSettings()
settings.ScreenWidth = 160
settings.ScreenHeight = 144
settings.ScaleFactor = 4
settings.TargetFPS = 60
settings.WindowTitle = "My Game"
settings.Fullscreen = true
settings.DisableHiDPI = true  // Default: true

Color Palette

01234567
BlackDarkBlueDarkPurpleDarkGreenBrownDarkGrayLightGrayWhite
89101112131415
RedOrangeYellowGreenBlueIndigoPinkPeach

Button Constants

DirectionFaceMenuMouse
LEFT (0)O (4)ButtonStart (6)ButtonMouseLeft
RIGHT (1)X (5)ButtonSelect (7)ButtonMouseRight
UP (2)ButtonMouseMiddle
DOWN (3)

Game Structure

type game struct {
    // Your state
}

func (g *game) Init()   { /* once at start */ }
func (g *game) Update() { /* every frame */ }
func (g *game) Draw()   { /* every frame */ }

func main() {
    p8.InsertGame(&game{})
    p8.Play()
}

Common Patterns

Movement

if p8.Btn(p8.LEFT)  { g.x-- }
if p8.Btn(p8.RIGHT) { g.x++ }
if p8.Btn(p8.UP)    { g.y-- }
if p8.Btn(p8.DOWN)  { g.y++ }

Animation

frame := int(p8.Time() * 10) % 4
p8.Spr(1 + frame, x, y)

Tile Collision

tileX := p8.Flr(g.x / 8)
tileY := p8.Flr(g.y / 8)
if p8.MapCollision(g.x, g.y, 0) {
    // Hit solid tile
}

PICO-8 to PIGO8 Comparison

Key Differences

AspectPICO-8PIGO8
LanguageLuaGo
Array indexingStarts at 1Starts at 0
Code limits8192 tokensNone
Memory limits32KBNone
ResolutionFixed 128×128Configurable
PlatformFantasy consoleNative + WebAssembly
LicenseCommercial ($15)MIT (free)

Function Mapping

Graphics

PICO-8PIGO8Notes
cls(c)p8.Cls(c)Same
pset(x,y,c)p8.Pset(x,y,c)Same
pget(x,y)p8.Pget(x,y)Same
line(x1,y1,x2,y2,c)p8.Line(x1,y1,x2,y2,c)Same
rect(x1,y1,x2,y2,c)p8.Rect(x1,y1,x2,y2,c)Same
rectfill(...)p8.Rectfill(...)Same
circ(x,y,r,c)p8.Circ(x,y,r,c)Same
circfill(...)p8.Circfill(...)Same
print(s,x,y,c)p8.Print(s,x,y,c)Same
color(c)p8.Color(c)Same
cursor(x,y,c)p8.Cursor(x,y,c)Same

Sprites

PICO-8PIGO8Notes
spr(n,x,y,w,h,fx,fy)p8.Spr(n,x,y,w,h,fx,fy)Same
sspr(...)p8.Sspr(...)Same
sget(x,y)p8.Sget(x,y)Same
sset(x,y,c)p8.Sset(x,y,c)Same
fget(n,f)p8.Fget(n,f)Returns (bitfield, isSet)
fset(n,f,v)p8.Fset(n,f,v)Same

Maps

PICO-8PIGO8Notes
map(mx,my,sx,sy,w,h,l)p8.Map(mx,my,sx,sy,w,h,l)Same
mget(x,y)p8.Mget(x,y)Same
mset(x,y,s)p8.Mset(x,y,s)Same

Input

PICO-8PIGO8Notes
btn(b)p8.Btn(p8.LEFT)Use constants
btnp(b)p8.Btnp(p8.X)Use constants

Palette

PICO-8PIGO8Notes
pal(c0,c1)p8.Pal(c0,c1)Same
pal()p8.Pal()Reset
palt(c,t)p8.Palt(c,t)Same

Math

PICO-8PIGO8Notes
flr(x)p8.Flr(x)Same
rnd(x)p8.Rnd(x)Returns int, not float
sqrt(x)p8.Sqrt(x)Same
sin(x)math.Sin(x)Use Go's math package
cos(x)math.Cos(x)Use Go's math package
abs(x)math.Abs(x)Use Go's math package
min(a,b)math.Min(a,b)Use Go's math package
max(a,b)math.Max(a,b)Use Go's math package

Audio

PICO-8PIGO8Notes
sfx(n)p8.Music(n)Plays WAV files
music(n)p8.Music(n)Same function

Time

PICO-8PIGO8Notes
t()p8.T() or p8.Time()Same
time()p8.Time()Same

Camera

PICO-8PIGO8Notes
camera(x,y)p8.Camera(x,y)Same
camera()p8.Camera()Reset to (0,0)

PIGO8 Extras

These functions are unique to PIGO8:

FunctionDescription
p8.ColorCollision(x,y,c)Pixel-based collision
p8.MapCollision(x,y,f,w,h)Tile-based collision
p8.GetScreenWidth()Get screen dimensions
p8.GetScreenHeight()Get screen dimensions
p8.SetPalette(colors)Custom palettes
p8.SetPaletteColor(i,c)Change single color
p8.ClsRGBA(c)Clear with any color

Code Example Comparison

PICO-8 (Lua)

function _init()
  x = 64
  y = 64
end

function _update()
  if btn(0) then x -= 1 end
  if btn(1) then x += 1 end
  if btn(2) then y -= 1 end
  if btn(3) then y += 1 end
end

function _draw()
  cls(0)
  spr(1, x, y)
end

PIGO8 (Go)

type game struct {
    x, y float64
}

func (g *game) Init() {
    g.x = 64
    g.y = 64
}

func (g *game) Update() {
    if p8.Btn(p8.LEFT)  { g.x-- }
    if p8.Btn(p8.RIGHT) { g.x++ }
    if p8.Btn(p8.UP)    { g.y-- }
    if p8.Btn(p8.DOWN)  { g.y++ }
}

func (g *game) Draw() {
    p8.Cls(0)
    p8.Spr(1, g.x, g.y)
}

func main() {
    p8.InsertGame(&game{})
    p8.Play()
}

Migration Tips

  1. Arrays: Change arr[1] to arr[0]
  2. Variables: Declare with var or :=
  3. Functions: Use func keyword
  4. Methods: Attach to structs with receivers
  5. Math: Import math package for sin/cos/etc.
  6. Strings: Use fmt.Sprintf for formatting
  7. Tables: Use structs or maps
  8. Local: All Go variables are local by default