Networking with Drip
The Bappa Framework includes the drip
package for implementing multiplayer networking in your games. Drip provides a simple server-authoritative architecture that handles client connections, state synchronization, and input processing.
Currently, Drip is designed for basic multiplayer experiences with these key characteristics:
- Single scene support: Drip currently supports networking for a single scene at a time (for now)
- No client-side prediction: The implementation uses a pure server-authoritative model without client-side prediction
- Hybrid architecture: Bappa’s decoupled architecture allows games to easily support both single-player and multiplayer modes with minimal code changes
Networking Architecture
Drip follows a server-authoritative architecture where:
- The server maintains the authoritative game state
- The server runs all core game systems (physics, collision, etc.)
- Clients send inputs to the server
- The server processes inputs and updates the game state
- The server broadcasts state updates to all clients
- Clients render the received state and handle local input processing
This architecture helps prevent cheating and ensures consistent gameplay across all clients.
Server Implementation
Setting Up a Server
Creating a Drip server requires a few key components:
// Set up callbacks for entity creation and serialization
drip.Callbacks.NewConnectionCreateEntity = NewConnectionEntityCreate
drip.Callbacks.Serialize = SerializeCallback
// Create server configuration
config := drip.DefaultServerConfig()
// Create the server with systems
server := drip.NewServer(config, drip_seversystems.ActionBufferSystem{})
// Register a scene
err := server.RegisterScene(
"MainScene",
sceneWidth,
sceneHeight,
sceneSetupFunction,
coreSystems,
)
if err != nil {
log.Fatalf("Failed to register scene: %v", err)
}
// Start the server
if err := server.Start(); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
Connection Entity Creation
When a client connects, you need to create an entity for them. This is handled by the NewConnectionCreateEntity
callback:
func NewConnectionEntityCreate(conn drip.Connection, s drip.Server) (warehouse.Entity, error) {
// Get the active scene
serverActiveScenes := s.ActiveScenes()
if len(serverActiveScenes) == 0 {
return nil, errors.New("No active scenes to find player spawn in")
}
scene := serverActiveScenes[0]
storage := scene.Storage()
// Find a spawn point in the scene
query := warehouse.Factory.NewQuery().And(components.PlayerSpawnComponent)
cursor := warehouse.Factory.NewCursor(query, storage)
var spawn components.PlayerSpawn
for range cursor.Next() {
match := components.PlayerSpawnComponent.GetFromCursor(cursor)
spawn = *match
break
}
// Create a new player entity at the spawn point
return scenes.NewPlayer(spawn.X, spawn.Y, storage)
}
State Serialization
Drip uses the warehouse serialization system to send game state to clients. You need to implement a serialization callback:
func SerializeCallback(scene drip.Scene) ([]byte, error) {
// Query for entities that have input buffers (typically player-controlled)
query := blueprint.Queries.ActionBuffer
cursor := warehouse.Factory.NewCursor(query, scene.Storage())
sEntities := []warehouse.SerializedEntity{}
for range cursor.Next() {
e, err := cursor.CurrentEntity()
if err != nil {
return nil, err
}
if !e.Valid() {
continue
}
// Exclude client-specific components from serialization
se := e.SerializeExclude(
client.Components.SpriteBundle,
client.Components.SoundBundle,
)
sEntities = append(sEntities, se)
}
// Create a serialized storage with all entities and the current tick
serSto := warehouse.SerializedStorage{
Entities: sEntities,
CurrentTick: scene.CurrentTick(),
Version: "net",
}
// Prepare for JSON marshaling
stateForJson, err := warehouse.PrepareForJSONMarshal(serSto)
if err != nil {
return nil, err
}
// Marshal to JSON
return json.Marshal(stateForJson)
}
Server Systems
The server runs both core systems and special server systems. Core systems handle game logic, while server systems handle networking concerns:
// ActionBufferSystem injects network-received actions into the core sim action buffers
type ActionBufferSystem struct{}
func (ActionBufferSystem) Run(s drip.Server) error {
actionsToProcess := s.ConsumeAllActions()
if len(actionsToProcess) > 0 {
activeScenesCopy := s.ActiveScenes()
// Process each action batch
for _, item := range actionsToProcess {
var targetEntity warehouse.Entity = nil
var found bool = false
// Find the target entity in active scenes
for _, scene := range activeScenesCopy {
potentialEntity, err := scene.Storage().Entity(int(item.TargetEntityID))
if err == nil && potentialEntity.Valid() && potentialEntity.Recycled() == item.Recycled {
targetEntity = potentialEntity
found = true
break
}
}
if !found {
continue
}
// Get action buffer component
actionBuffer := input.Components.ActionBuffer.GetFromEntity(targetEntity)
if actionBuffer == nil {
continue
}
// Verify receiver index matches
if actionBuffer.ReceiverIndex != item.ReceiverIndex {
continue
}
// Add the received actions to the entity's action buffer
actionBuffer.AddBatch(item.Actions)
}
}
return nil
}
Client Implementation
Setting Up a Network Client
On the client side, you need to create a NetworkClient
instead of a regular client:
// Create a network client
client := coldbrew.NewNetworkClient(
resolutionX,
resolutionY,
maxSpritesCached,
maxSoundsCached,
maxScenesCached,
embeddedFS,
)
// Set deserialization callback
client.SetDeserCallback(Deserializer)
// Configure client systems for networking
client.RegisterGlobalClientSystem(
&coldbrew_clientsystems.InputSenderSystem{},
coldbrew_clientsystems.InputBufferSystem{},
&coldbrew_clientsystems.CameraSceneAssignerSystem{},
)
// Connect to server
err := client.Connect(serverAddress)
if err != nil {
log.Fatalf("Failed to connect to server: %v", err)
}
Deserialization Callback
You need to implement a deserialization callback to process state updates from the server:
func Deserializer(nc coldbrew.NetworkClient, data []byte) error {
// Get the active scene
activeScenes := nc.ActiveScenes()
var scene coldbrew.Scene
for s := range activeScenes {
scene = s
break
}
if scene != nil && scene.Ready() {
storage := scene.Storage()
if storage != nil {
// Unmarshal the JSON data
var world warehouse.SerializedStorage
err := json.Unmarshal(data, &world)
if err != nil {
return err
}
seen := map[int]struct{}{}
// Process each entity in the update
for _, se := range world.Entities {
seen[int(se.ID)] = struct{}{}
// Create or update the entity without affecting client-specific components
en, err := storage.ForceSerializedEntityExclude(
se, client.Components.SoundBundle,
client.Components.SpriteBundle,
)
if err != nil {
return err
}
// Apply the server component values
err = se.SetValue(en)
if err != nil {
return err
}
// Add client-specific components if missing
if !en.Table().Contains(client.Components.SpriteBundle) {
err := en.AddComponentWithValue(client.Components.SpriteBundle, DefaultSpriteBundle)
if err != nil {
return err
}
err = en.AddComponentWithValue(client.Components.SoundBundle, DefaultSoundBundle)
if err != nil {
return err
}
}
}
// Remove entities that weren't in the server update
purge := []warehouse.Entity{}
query := blueprint.Queries.ActionBuffer
cursor := scene.NewCursor(query)
for range cursor.Next() {
e, _ := cursor.CurrentEntity()
if _, ok := seen[int(e.ID())]; !ok {
purge = append(purge, e)
}
}
err = storage.DestroyEntities(purge...)
if err != nil {
return err
}
// Update client tick to match server
coldbrew.ForceSetTick(world.CurrentTick)
}
}
return nil
}
Input Sender System
The InputSenderSystem
collects inputs from local receivers and sends them to the server:
// InputSenderSystem collects inputs and sends them to the server
type InputSenderSystem struct{}
func (s InputSenderSystem) Run(cli coldbrew.Client) error {
networkCli, ok := cli.(coldbrew.NetworkClient)
if !ok || !networkCli.IsConnected() {
return nil
}
// Check each potential receiver slot
for i := 0; i < coldbrew.MaxSplit; i++ {
receiver := cli.Receiver(i)
if !receiver.Active() {
continue
}
// Get actions since last frame
poppedActions := receiver.PopActions()
if len(poppedActions) == 0 {
continue
}
// Construct message payload
message := input.ClientActionMessage{
ReceiverIndex: i,
Actions: poppedActions,
}
// Serialize to JSON
jsonData, err := json.Marshal(message)
if err != nil {
continue
}
// Send to server
err = networkCli.Send(jsonData)
if err != nil {
log.Printf("Error sending input: %v", err)
}
}
return nil
}
Optimizing State Updates
For better performance, consider these optimizations:
- Selective Entity Serialization: Only serialize entities that are relevant to clients
- Component Filtering: Exclude components that don’t need network synchronization
- Delta Compression: Only send entities that have changed since the last update
- Area of Interest: Only send entities that are within a certain distance of the player
Handling Latency
Drip uses a simple server-authoritative model without client-side prediction, which means:
- Inputs sent from the client experience full round-trip latency
- The client does not predict the results of inputs
- The server is the authoritative source of truth for all game state
- The client simply renders the state it receives from the server
This approach is simple to implement and prevents cheating, but it does result in perceptible input lag, especially on higher-latency connections. For a more responsive feel in latency-sensitive games, you would need to implement additional techniques like client-side prediction and server reconciliation, which are not currently built into Drip.
Example Networking Setup
Server-Side Configuration
package main
import (
"log"
"os"
"os/signal"
"syscall"
"github.com/TheBitDrifter/bappa/drip"
"github.com/TheBitDrifter/bappa/drip/drip_seversystems"
"mygame/shared/coresystems"
"mygame/shared/scenes"
)
func main() {
// Register callbacks
drip.Callbacks.NewConnectionCreateEntity = NewConnectionEntityCreate
drip.Callbacks.Serialize = SerializeCallback
// Create server config
config := drip.DefaultServerConfig()
config.Port = 8080
config.TPS = 60
// Create server
server := drip.NewServer(config, drip_seversystems.ActionBufferSystem{})
// Register a scene
err := server.RegisterScene(
scenes.MainScene.Name,
scenes.MainScene.Width,
scenes.MainScene.Height,
scenes.MainScene.Plan,
coresystems.DefaultCoreSystems,
)
if err != nil {
log.Fatalf("Failed to register scene: %v", err)
}
// Start the server
if err := server.Start(); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
log.Println("Server running. Press Ctrl+C to stop.")
<-quit
// Shutdown gracefully
log.Println("Shutting down server...")
if err := server.Stop(); err != nil {
log.Printf("Error stopping server: %v", err)
}
}
Client-Side Configuration
package main
import (
"log"
"github.com/TheBitDrifter/bappa/coldbrew"
"github.com/TheBitDrifter/bappa/coldbrew/coldbrew_clientsystems"
"github.com/TheBitDrifter/bappa/coldbrew/coldbrew_rendersystems"
"mygame/shared/actions"
"mygame/shared/scenes"
"mygame/sharedclient/assets"
"mygame/sharedclient/clientsystems"
"mygame/sharedclient/rendersystems"
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
// Create network client
client := coldbrew.NewNetworkClient(
640, 360, // Resolution
100, 50, 10, // Cache sizes
assets.FS,
)
// Set deserializer callback
client.SetDeserCallback(Deserializer)
// Configure client
client.SetTitle("Networked Game")
client.SetResizable(true)
// Register scene
err := client.RegisterScene(
scenes.MainScene.Name,
scenes.MainScene.Width,
scenes.MainScene.Height,
scenes.MainScene.Plan,
rendersystems.DefaultRenderSystems,
clientsystems.DefaultClientSystemsNetworked,
[]blueprint.CoreSystem{}, // Core systems run on server only
)
if err != nil {
log.Fatalf("Failed to register scene: %v", err)
}
// Register global systems
client.RegisterGlobalRenderSystem(
coldbrew_rendersystems.GlobalRenderer{},
)
client.RegisterGlobalClientSystem(
&coldbrew_clientsystems.InputSenderSystem{},
coldbrew_clientsystems.InputBufferSystem{},
&coldbrew_clientsystems.CameraSceneAssignerSystem{},
)
// Set up input
camera, _ := client.ActivateCamera()
receiver, _ := client.ActivateReceiver()
receiver.RegisterKey(ebiten.KeyW, actions.Jump)
receiver.RegisterKey(ebiten.KeyA, actions.Left)
receiver.RegisterKey(ebiten.KeyD, actions.Right)
// Connect to server
err = client.Connect("localhost:8080")
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer client.Disconnect()
// Start game loop
if err := client.Start(); err != nil {
log.Fatalf("Game exited with error: %v", err)
}
}
Notice that we register an empty slice of core systems ([]blueprint.CoreSystem{}
). This is because in a networked setup, the core systems (physics, collision, etc.) only run on the server side. The client only renders the state and sends inputs. Allowing the client to also run the coresystems would be the first step
for prediction, but it’s not currently tested/setup.
Hybrid Architecture: Supporting Both Single-Player and Multiplayer
One of the significant advantages of Bappa’s architecture is how easily games can support both single-player and multiplayer modes without massive code changes. This is possible because:
- Shared Core Systems: The same core systems can run either on a standalone client or a server
- Scene Definitions: The same scene definitions can be used in both modes
- Component-Based Design: The ECS architecture keeps data and behavior separate
Example: System Registration Differences
// Single-player mode: Core systems run locally
client.RegisterScene(
scenes.MainScene.Name,
scenes.MainScene.Width,
scenes.MainScene.Height,
scenes.MainScene.Plan,
rendersystems.DefaultRenderSystems,
clientsystems.DefaultClientSystems, // Standalone client systems
coresystems.DefaultCoreSystems, // Core systems run locally
)
// Multiplayer mode: Core systems run on server only
client.RegisterScene(
scenes.MainScene.Name,
scenes.MainScene.Width,
scenes.MainScene.Height,
scenes.MainScene.Plan,
rendersystems.DefaultRenderSystems,
clientsystems.DefaultClientSystemsNetworked, // Network-aware client systems
[]blueprint.CoreSystem{}, // No core systems (run on server)
)
This design allows you to:
- Prototype in single-player mode for quick iteration
- Share most code between single-player and multiplayer
- Test features locally before implementing networked versions
Conclusion
The Drip package provides a simple foundation for implementing multiplayer networking in your Bappa games. By leveraging the warehouse serialization system for state synchronization and implementing proper client-server architecture, you can create basic multiplayer experiences with a server-authoritative model.
Currently, Drip has several limitations to be aware of:
- Support for only a single active scene
- No built-in client-side prediction (resulting in perceptible input lag)
- Basic TCP-based networking without optimization for high packet rates
Despite these limitations, the server-authoritative approach provided by Drip offers a good balance between simplicity, security, and implementation ease for many types of games. For games where responsiveness is critical, you would need to extend this foundation with more advanced techniques like prediction, interpolation, and delta compression.