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.