PIGO8 Multiplayer Networking Guide
Table of Contents
- Introduction
- Networking Architecture
- Setting Up a Multiplayer Game
- Network Callbacks
- Message Types and Data Structures
- Client-Side Prediction
- Case Study: Multiplayer Gameboy
- Advanced Topics
- Troubleshooting
Introduction
PIGO8 provides a built-in networking system that allows developers to add multiplayer functionality to their games. This guide will walk you through the process of converting a single-player game to multiplayer, with a focus on the Gameboy example.
The networking system in PIGO8 uses UDP for low-latency communication, making it suitable for real-time games. It follows a client-server architecture where one instance of the game acts as the server and other instances connect as clients.
Networking Architecture
Client-Server Model
PIGO8 uses a client-server architecture for multiplayer games:
- Server: Authoritative source of game state, processes game logic
- Clients: Send inputs to the server, receive and display game state
UDP Protocol
PIGO8 uses UDP (User Datagram Protocol) for networking:
- Advantages: Lower latency, better for real-time games
- Challenges: No guaranteed delivery, packets may arrive out of order
Network Roles
Each instance of a PIGO8 game can have one of two roles:
- Server: Hosts the game, processes game logic, broadcasts game state
- Client: Connects to server, sends inputs, receives game state
Setting Up a Multiplayer Game
Step 1: Initialize Network Roles
First, modify your game structure to include network-related fields:
type Game struct {
// Existing game fields
// Network-related fields
isServer bool
isClient bool
remotePlayerID string
lastStateUpdate time.Time
}
func (g *Game) Init() {
// Existing initialization code
// Set up network roles
g.isServer = p8.GetNetworkRole() == p8.RoleServer
g.isClient = p8.GetNetworkRole() == p8.RoleClient
g.lastStateUpdate = time.Now()
}
Step 2: Register Network Callbacks
In your main()
function, register the network callbacks before starting the game:
func main() {
// Create and initialize the game
game := &Game{}
p8.InsertGame(game)
game.Init()
// Register network callbacks
p8.SetOnGameStateCallback(handleGameState)
p8.SetOnPlayerInputCallback(handlePlayerInput)
p8.SetOnConnectCallback(handlePlayerConnect)
p8.SetOnDisconnectCallback(handlePlayerDisconnect)
// Start the game
settings := p8.NewSettings()
settings.WindowTitle = "PIGO8 Multiplayer Game"
p8.PlayGameWith(settings)
}
Step 3: Modify Game Loop for Network Play
Update your game's Update()
function to handle network roles:
func (g *Game) Update() {
// Handle waiting for players state
if p8.IsWaitingForPlayers() {
return
}
// Server-specific logic
if g.isServer {
// Process game physics, AI, etc.
// Send game state updates
if time.Since(g.lastStateUpdate) > 16*time.Millisecond {
g.sendGameState()
g.lastStateUpdate = time.Now()
}
}
// Client-specific logic
if g.isClient {
// Send player input
g.sendPlayerInput()
// Client-side prediction (optional)
}
// Common logic for both client and server
}
Step 4: Add Network Status Display
Update your game's Draw()
function to display network status:
func (g *Game) Draw() {
p8.Cls(0)
// Display network status
if p8.IsWaitingForPlayers() || p8.GetNetworkError() != "" {
p8.DrawNetworkStatus(10, 10, 7)
return
}
// Draw game elements
// ...
}
Network Callbacks
PIGO8 requires four callback functions for multiplayer functionality:
Game State Callback
Receives game state updates from the server:
func handleGameState(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok {
return
}
var state GameState
if err := json.Unmarshal(data, &state); err != nil {
log.Printf("Error unmarshaling game state: %v", err)
return
}
// Update game state with received data
// Example: Update player positions, game objects, etc.
}
Player Input Callback
Processes player input on the server:
func handlePlayerInput(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
var input PlayerInput
if err := json.Unmarshal(data, &input); err != nil {
log.Printf("Error unmarshaling player input: %v", err)
return
}
// Update game state based on player input
// Example: Move remote player based on input
}
Connect Callback
Handles new player connections:
func handlePlayerConnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Handle new player connection
// Example: Initialize player state, assign player ID
log.Printf("Player connected: %s", playerID)
}
Disconnect Callback
Handles player disconnections:
func handlePlayerDisconnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Handle player disconnection
// Example: Remove player from game
log.Printf("Player disconnected: %s", playerID)
}
Message Types and Data Structures
Game State Structure
Define a structure for your game state:
type GameState struct {
// Game state variables that need to be synchronized
PlayerPositions map[string]Position
GameObjects []GameObject
GameTime float64
}
type Position struct {
X float64
Y float64
}
type GameObject struct {
ID int
Position Position
Type int
State int
}
Player Input Structure
Define a structure for player input:
type PlayerInput struct {
// Player input variables
Up bool
Down bool
Left bool
Right bool
A bool
B bool
Start bool
Select bool
}
Sending Game State
Implement a function to send game state from server to clients:
func (g *Game) sendGameState() {
if !g.isServer {
return
}
// Create game state object
state := GameState{
PlayerPositions: make(map[string]Position),
GameObjects: make([]GameObject, 0),
GameTime: g.gameTime,
}
// Fill with current game state
for id, player := range g.players {
state.PlayerPositions[id] = Position{X: player.x, Y: player.y}
}
for i, obj := range g.gameObjects {
state.GameObjects = append(state.GameObjects, GameObject{
ID: i,
Position: Position{X: obj.x, Y: obj.y},
Type: obj.type,
State: obj.state,
})
}
// Serialize and send
data, err := json.Marshal(state)
if err != nil {
log.Printf("Error marshaling game state: %v", err)
return
}
p8.SendMessage(p8.MsgGameState, "all", data)
}
Sending Player Input
Implement a function to send player input from client to server:
func (g *Game) sendPlayerInput() {
if !g.isClient {
return
}
// Create input object based on current button states
input := PlayerInput{
Up: p8.Btn(p8.UP),
Down: p8.Btn(p8.DOWN),
Left: p8.Btn(p8.LEFT),
Right: p8.Btn(p8.RIGHT),
A: p8.Btn(p8.A),
B: p8.Btn(p8.B),
Start: p8.Btn(p8.START),
Select: p8.Btn(p8.SELECT),
}
// Serialize and send
data, err := json.Marshal(input)
if err != nil {
log.Printf("Error marshaling player input: %v", err)
return
}
p8.SendMessage(p8.MsgPlayerInput, "", data)
}
Client-Side Prediction
Client-side prediction improves the feel of multiplayer games by immediately showing the results of player input locally, then reconciling with the server's authoritative state.
Implementing Prediction
// In client's Update() function
if g.isClient {
// Send input to server
g.sendPlayerInput()
// Store current position
originalX := g.localPlayer.x
originalY := g.localPlayer.y
// Apply input locally for immediate feedback
if p8.Btn(p8.LEFT) {
g.localPlayer.x -= g.playerSpeed
}
if p8.Btn(p8.RIGHT) {
g.localPlayer.x += g.playerSpeed
}
if p8.Btn(p8.UP) {
g.localPlayer.y -= g.playerSpeed
}
if p8.Btn(p8.DOWN) {
g.localPlayer.y += g.playerSpeed
}
// Store prediction for reconciliation
g.predictions = append(g.predictions, Prediction{
Time: g.gameTime,
OriginalX: originalX,
OriginalY: originalY,
PredictedX: g.localPlayer.x,
PredictedY: g.localPlayer.y,
})
}
Reconciliation with Server State
// In handleGameState function
if game.isClient {
// Get server's position for local player
serverPos, ok := state.PlayerPositions[p8.GetPlayerID()]
if !ok {
return
}
// Calculate difference between prediction and server state
diffX := math.Abs(game.localPlayer.x - serverPos.X)
diffY := math.Abs(game.localPlayer.y - serverPos.Y)
// If significant difference, smoothly correct
if diffX > 5 || diffY > 5 {
// Smooth interpolation
game.localPlayer.x = game.localPlayer.x + (serverPos.X - game.localPlayer.x) * 0.3
game.localPlayer.y = game.localPlayer.y + (serverPos.Y - game.localPlayer.y) * 0.3
}
// Update other players' positions directly
for id, pos := range state.PlayerPositions {
if id != p8.GetPlayerID() {
if player, ok := game.players[id]; ok {
player.x = pos.X
player.y = pos.Y
game.players[id] = player
} else {
// Create new player if not exists
game.players[id] = Player{x: pos.X, y: pos.Y}
}
}
}
}
Case Study: Multiplayer Gameboy
Let's convert the Gameboy example to a multiplayer game where two players can move around the screen.
Step 1: Define Game Structures
type Player struct {
x float64
y float64
sprite int
speed float64
}
type Game struct {
// Game world
map [][]int
tileSize float64
// Players
players map[string]Player
localPlayer Player
// Network
isServer bool
isClient bool
lastStateUpdate time.Time
}
Step 2: Define Network Messages
type GameState struct {
Players map[string]PlayerState
}
type PlayerState struct {
X float64
Y float64
Sprite int
}
type PlayerInput struct {
Up bool
Down bool
Left bool
Right bool
A bool
B bool
}
Step 3: Initialize Game with Network Roles
func (g *Game) Init() {
// Initialize map and game world
g.map = loadMap()
g.tileSize = 8
// Initialize players
g.players = make(map[string]Player)
// Set up network roles
g.isServer = p8.GetNetworkRole() == p8.RoleServer
g.isClient = p8.GetNetworkRole() == p8.RoleClient
g.lastStateUpdate = time.Now()
// Initialize local player
g.localPlayer = Player{
x: 64,
y: 64,
sprite: 1,
speed: 1.0,
}
// Add local player to players map
playerID := "server"
if g.isClient {
playerID = p8.GetPlayerID()
}
g.players[playerID] = g.localPlayer
}
Step 4: Implement Network Callbacks
func handleGameState(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok {
return
}
var state GameState
if err := json.Unmarshal(data, &state); err != nil {
log.Printf("Error unmarshaling game state: %v", err)
return
}
// Update all players except local player
for id, playerState := range state.Players {
if game.isClient && id == p8.GetPlayerID() {
// For local player, apply reconciliation
diffX := math.Abs(game.localPlayer.x - playerState.X)
diffY := math.Abs(game.localPlayer.y - playerState.Y)
if diffX > 5 || diffY > 5 {
// Smooth interpolation
game.localPlayer.x = game.localPlayer.x + (playerState.X - game.localPlayer.x) * 0.3
game.localPlayer.y = game.localPlayer.y + (playerState.Y - game.localPlayer.y) * 0.3
// Update players map
game.players[id] = game.localPlayer
}
} else {
// For remote players, update directly
game.players[id] = Player{
x: playerState.X,
y: playerState.Y,
sprite: playerState.Sprite,
speed: 1.0,
}
}
}
}
func handlePlayerInput(playerID string, data []byte) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
var input PlayerInput
if err := json.Unmarshal(data, &input); err != nil {
log.Printf("Error unmarshaling player input: %v", err)
return
}
// Get player or create if not exists
player, ok := game.players[playerID]
if !ok {
player = Player{
x: 64,
y: 64,
sprite: 2, // Different sprite for client
speed: 1.0,
}
}
// Update player based on input
if input.Left {
player.x -= player.speed
}
if input.Right {
player.x += player.speed
}
if input.Up {
player.y -= player.speed
}
if input.Down {
player.y += player.speed
}
// Apply collision detection
player = game.applyCollision(player)
// Update player in map
game.players[playerID] = player
}
func handlePlayerConnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Initialize new player
game.players[playerID] = Player{
x: 64,
y: 64,
sprite: 2, // Different sprite for client
speed: 1.0,
}
log.Printf("Player connected: %s", playerID)
}
func handlePlayerDisconnect(playerID string) {
game, ok := p8.CurrentCartridge().(*Game)
if !ok || !game.isServer {
return
}
// Remove player
delete(game.players, playerID)
log.Printf("Player disconnected: %s", playerID)
}
Step 5: Implement Game Update Logic
func (g *Game) Update() {
// Handle waiting for players state
if p8.IsWaitingForPlayers() {
return
}
// Update local player based on input
if g.isServer {
// Server controls its local player
originalX := g.localPlayer.x
originalY := g.localPlayer.y
if p8.Btn(p8.LEFT) {
g.localPlayer.x -= g.localPlayer.speed
}
if p8.Btn(p8.RIGHT) {
g.localPlayer.x += g.localPlayer.speed
}
if p8.Btn(p8.UP) {
g.localPlayer.y -= g.localPlayer.speed
}
if p8.Btn(p8.DOWN) {
g.localPlayer.y += g.localPlayer.speed
}
// Apply collision detection
g.localPlayer = g.applyCollision(g.localPlayer)
// Update player in map
g.players["server"] = g.localPlayer
// Send game state to clients
if time.Since(g.lastStateUpdate) > 16*time.Millisecond {
g.sendGameState()
g.lastStateUpdate = time.Now()
}
} else if g.isClient {
// Client controls its local player with prediction
originalX := g.localPlayer.x
originalY := g.localPlayer.y
if p8.Btn(p8.LEFT) {
g.localPlayer.x -= g.localPlayer.speed
}
if p8.Btn(p8.RIGHT) {
g.localPlayer.x += g.localPlayer.speed
}
if p8.Btn(p8.UP) {
g.localPlayer.y -= g.localPlayer.speed
}
if p8.Btn(p8.DOWN) {
g.localPlayer.y += g.localPlayer.speed
}
// Apply collision detection
g.localPlayer = g.applyCollision(g.localPlayer)
// Update player in map
g.players[p8.GetPlayerID()] = g.localPlayer
// Send input to server
g.sendPlayerInput()
}
}
Step 6: Implement Send Functions
func (g *Game) sendGameState() {
if !g.isServer {
return
}
// Create game state object
state := GameState{
Players: make(map[string]PlayerState),
}
// Fill with current player states
for id, player := range g.players {
state.Players[id] = PlayerState{
X: player.x,
Y: player.y,
Sprite: player.sprite,
}
}
// Serialize and send
data, err := json.Marshal(state)
if err != nil {
log.Printf("Error marshaling game state: %v", err)
return
}
p8.SendMessage(p8.MsgGameState, "all", data)
}
func (g *Game) sendPlayerInput() {
if !g.isClient {
return
}
// Create input object based on current button states
input := PlayerInput{
Left: p8.Btn(p8.LEFT),
Right: p8.Btn(p8.RIGHT),
Up: p8.Btn(p8.UP),
Down: p8.Btn(p8.DOWN),
A: p8.Btn(p8.A),
B: p8.Btn(p8.B),
}
// Serialize and send
data, err := json.Marshal(input)
if err != nil {
log.Printf("Error marshaling player input: %v", err)
return
}
p8.SendMessage(p8.MsgPlayerInput, "", data)
}
Step 7: Implement Draw Function
func (g *Game) Draw() {
p8.Cls(0)
// Display network status
if p8.IsWaitingForPlayers() || p8.GetNetworkError() != "" {
p8.DrawNetworkStatus(10, 10, 7)
return
}
// Draw map
for y := 0; y < len(g.map); y++ {
for x := 0; x < len(g.map[y]); x++ {
tileType := g.map[y][x]
p8.Spr(tileType, float64(x)*g.tileSize, float64(y)*g.tileSize)
}
}
// Draw all players
for id, player := range g.players {
p8.Spr(player.sprite, player.x, player.y)
// Draw player ID above sprite
p8.Print(id, player.x, player.y-8, 7)
}
// Draw network role
if g.isServer {
p8.Print("SERVER", 2, 2, 8)
} else if g.isClient {
p8.Print("CLIENT", 2, 2, 12)
}
}
Step 8: Implement Collision Detection
func (g *Game) applyCollision(player Player) Player {
// Get tile coordinates
tileX := int(player.x / g.tileSize)
tileY := int(player.y / g.tileSize)
// Check surrounding tiles
for y := tileY - 1; y <= tileY + 1; y++ {
for x := tileX - 1; x <= tileX + 1; x++ {
// Check map bounds
if y >= 0 && y < len(g.map) && x >= 0 && x < len(g.map[y]) {
tileType := g.map[y][x]
// Check if tile is solid (e.g., tile type 1 is solid)
if tileType == 1 {
// Simple collision detection
tileLeft := float64(x) * g.tileSize
tileRight := tileLeft + g.tileSize
tileTop := float64(y) * g.tileSize
tileBottom := tileTop + g.tileSize
playerLeft := player.x
playerRight := player.x + g.tileSize
playerTop := player.y
playerBottom := player.y + g.tileSize
// Check for collision
if playerRight > tileLeft && playerLeft < tileRight &&
playerBottom > tileTop && playerTop < tileBottom {
// Resolve collision
overlapX := math.Min(playerRight - tileLeft, tileRight - playerLeft)
overlapY := math.Min(playerBottom - tileTop, tileBottom - playerTop)
if overlapX < overlapY {
if playerLeft < tileLeft {
player.x -= overlapX
} else {
player.x += overlapX
}
} else {
if playerTop < tileTop {
player.y -= overlapY
} else {
player.y += overlapY
}
}
}
}
}
}
}
return player
}
Step 9: Set Up Main Function
func main() {
// Create and initialize the game
game := &Game{}
p8.InsertGame(game)
game.Init()
// Register network callbacks
p8.SetOnGameStateCallback(handleGameState)
p8.SetOnPlayerInputCallback(handlePlayerInput)
p8.SetOnConnectCallback(handlePlayerConnect)
p8.SetOnDisconnectCallback(handlePlayerDisconnect)
// Start the game
settings := p8.NewSettings()
settings.WindowTitle = "PIGO8 Multiplayer Gameboy"
p8.PlayGameWith(settings)
}
Advanced Topics
Bandwidth Optimization
To reduce bandwidth usage:
- Send Only What Changed: Only include changed values in game state
- Compression: Compress data before sending
- Update Frequency: Adjust update frequency based on game needs
- Delta Encoding: Send only differences from previous state
Handling Latency
Strategies for handling network latency:
- Client-Side Prediction: Predict movement locally
- Server Reconciliation: Correct client predictions
- Entity Interpolation: Smooth movement of remote entities
- Input Buffering: Buffer inputs to handle jitter
Synchronization Strategies
Different approaches to game synchronization:
- Lockstep: All clients wait for all inputs before advancing
- Snapshot Interpolation: Interpolate between received snapshots
- State Synchronization: Server sends authoritative state
- Event-Based: Synchronize via events rather than full state
Troubleshooting
Common Issues
- Jittery Movement: Implement client-side prediction and interpolation
- Desynchronization: Ensure server is authoritative for game logic
- High Latency: Optimize message size and frequency
- Connection Issues: Check network configuration and firewalls
Debugging Tools
- Logging: Add detailed logging for network events
- Visualization: Visualize network state and predictions
- Artificial Latency: Test with artificial latency
- Packet Inspection: Analyze packet contents and timing
Best Practices
- Keep It Simple: Start with minimal networking and add complexity as needed
- Test Early and Often: Test multiplayer functionality throughout development
- Graceful Degradation: Handle network issues gracefully
- Security: Validate all inputs on the server
By following this guide, you should be able to convert any PIGO8 game to multiplayer, including the Gameboy example. The key is to identify what needs to be synchronized, implement proper client-server communication, and add client-side prediction for a smooth player experience.