What is gbnet-hs?
A transport-level networking library providing:
- Zero-copy serialization — Storable-based, C-level speed (14ns per type)
- Reliable UDP — Connection-oriented with ACKs, retransmits, and ordering
- Unified Peer API — Same code for client, server, or P2P mesh
- Effect abstraction —
MonadNetwork typeclass enables pure deterministic testing
- Congestion control — Dual-layer: binary mode + TCP New Reno window, with application-level backpressure
- Zero-poll receive — Dedicated receive thread via GHC IO manager (epoll/kqueue), STM TQueue delivery
- Connection migration — Seamless IP address change handling
Quick Start
Add to your .cabal file:
build-depends:
gbnet-hs
Simple Game Loop
import GBNet
import Control.Monad.IO.Class (liftIO)
main :: IO ()
main = do
-- Create peer (binds UDP socket)
let addr = anyAddr 7777
now <- getMonoTimeIO
Right (peer, sock) <- newPeer addr defaultNetworkConfig now
-- Wrap socket in NetState (starts dedicated receive thread)
netState <- newNetState sock addr
-- Run game loop inside NetT IO
evalNetT (gameLoop peer) netState
gameLoop :: NetPeer -> NetT IO ()
gameLoop peer = do
-- Single call: receive, process, broadcast, send
let outgoing = [(ChannelId 0, encodeMyState myState)]
(events, peer') <- peerTick outgoing peer
-- Handle events
liftIO $ mapM_ handleEvent events
gameLoop peer'
handleEvent :: PeerEvent -> IO ()
handleEvent = \case
PeerConnected pid dir -> putStrLn $ "Connected: " ++ show pid
PeerDisconnected pid _ -> putStrLn $ "Disconnected: " ++ show pid
PeerMessage pid ch msg -> handleMessage pid ch msg
PeerMigrated old new -> putStrLn "Peer address changed"
Connecting to a Remote Peer
-- Initiate connection (handshake happens automatically)
let peer' = peerConnect (peerIdFromAddr remoteAddr) now peer
-- The PeerConnected event fires when handshake completes
Networking
The peerTick Function
The recommended API for game loops — handles receive, process, and send in one call:
peerTick
:: MonadNetwork m
=> [(ChannelId, ByteString)] -- Messages to broadcast (channel, data)
-> NetPeer -- Current peer state
-> m ([PeerEvent], NetPeer) -- Events and updated state
Peer Events
data PeerEvent
= PeerConnected !PeerId !ConnectionDirection -- Inbound or Outbound
| PeerDisconnected !PeerId !DisconnectReason
| PeerMessage !PeerId !ChannelId !ByteString -- channel, data
| PeerMigrated !PeerId !PeerId -- old address, new address
Channel Reliability Modes
import GBNet
-- Unreliable: fire-and-forget (position updates)
let unreliable = defaultChannelConfig { ccDeliveryMode = Unreliable }
-- Reliable ordered: guaranteed delivery, in-order (chat, RPC)
let reliable = defaultChannelConfig { ccDeliveryMode = ReliableOrdered }
-- Reliable sequenced: latest-only, drops stale (state sync)
let sequenced = defaultChannelConfig { ccDeliveryMode = ReliableSequenced }
Configuration
let config = defaultNetworkConfig
{ ncMaxClients = 32
, ncConnectionTimeoutMs = 10000.0
, ncKeepaliveIntervalMs = 1000.0
, ncMtu = 1200
, ncEnableConnectionMigration = True
, ncChannelConfigs = [unreliableChannel, reliableChannel]
}
Serialization
Zero-Copy Storable Serialization
{-# LANGUAGE TemplateHaskell #-}
import GBNet
data PlayerState = PlayerState
{ psX :: !Float
, psY :: !Float
, psHealth :: !Word8
} deriving (Eq, Show)
deriveStorable ''PlayerState
-- Serialize (14ns, zero-copy)
let bytes = serialize playerState
-- Deserialize
let Right player = deserialize bytes :: Either String PlayerState
Nested Types Just Work
data Vec3 = Vec3 !Float !Float !Float
deriveStorable ''Vec3
data Transform = Transform !Vec3 !Float -- position + rotation
deriveStorable ''Transform
-- Nested types compose via Storable
let bytes = serialize (Transform pos angle) -- still 14ns
Why Storable?
- C-level speed — 14ns serialization via direct memory layout
- Standard Haskell — uses base
Storable typeclass
- Composable — nested types work automatically
- Pure API —
serialize/deserialize are pure functions
Testing
Pure Deterministic Testing with TestNet
The MonadNetwork typeclass allows swapping real sockets for a pure test implementation:
import GBNet
import GBNet.TestNet
-- Run peer logic purely — no actual network IO
testHandshake :: ((), TestNetState)
testHandshake = runTestNet action (initialTestNetState myAddr)
where
action = do
-- Simulate sending
netSend remoteAddr someData
-- Advance simulated time (absolute MonoTime in nanoseconds)
advanceTime (100 * 1000000) -- 100ms
-- Check what would be received
result <- netRecv
pure ()
Multi-Peer World Simulation
import GBNet.TestNet
-- Create a world with multiple peers
let world0 = newTestWorld
-- Run actions for each peer
let (result1, world1) = runPeerInWorld addr1 action1 world0
let (result2, world2) = runPeerInWorld addr2 action2 world1
-- Advance to absolute time and deliver ready packets
let world3 = worldAdvanceTime (100 * 1000000) world2 -- 100ms
Simulating Network Conditions
-- Add 50ms latency
simulateLatency 50
-- 10% packet loss
simulateLoss 0.1
Architecture
┌─────────────────────────────────────────┐
│ User Application │
├─────────────────────────────────────────┤
│ GBNet (top-level re-exports) │
│ import GBNet -- gets everything │
├─────────────────────────────────────────┤
│ GBNet.Peer │
│ peerTick, peerConnect, PeerEvent │
├─────────────────────────────────────────┤
│ GBNet.Net (NetT transformer) │
│ Carries socket state for IO │
├──────────────┬──────────────────────────┤
│ NetT IO │ TestNet │
│ TQueue + │ (pure, deterministic) │
│ recv thread │ │
├──────────────┴──────────────────────────┤
│ GBNet.Class │
│ MonadTime, MonadNetwork typeclasses │
└─────────────────────────────────────────┘
Module Overview
| Module |
Purpose |
GBNet |
Top-level facade — import this for convenience |
GBNet.Class |
MonadTime, MonadNetwork typeclasses |
GBNet.Net |
NetT monad transformer with receive thread + TQueue |
GBNet.Net.IO |
initNetState — create real UDP socket and start receive thread |
GBNet.Peer |
NetPeer, peerTick, connection management |
GBNet.Congestion |
Dual-layer congestion control and backpressure |
GBNet.TestNet |
Pure test network, TestWorld for multi-peer |
GBNet.Serialize.TH |
deriveStorable TH for zero-copy serialization |
GBNet.Serialize |
serialize/deserialize pure functions |
Explicit Imports (for larger codebases)
-- Instead of `import GBNet`, be explicit:
import GBNet.Class (MonadNetwork, MonadTime, MonoTime(..))
import GBNet.Types (ChannelId(..), SequenceNum(..), MessageId(..))
import GBNet.Net (NetT, runNetT, evalNetT)
import GBNet.Net.IO (initNetState)
import GBNet.Peer (NetPeer, peerTick, PeerEvent(..))
import GBNet.Config (NetworkConfig(..), defaultNetworkConfig)
Replication Helpers
Delta Compression
Only send changed fields:
import GBNet.Replication.Delta
instance NetworkDelta PlayerState where
type Delta PlayerState = PlayerDelta
diff new old = PlayerDelta { ... }
apply state delta = state { ... }
Interest Management
Filter by area-of-interest:
import GBNet.Replication.Interest
let interest = newRadiusInterest 100.0
if relevant interest entityPos observerPos
then sendEntity entity
else skip
Priority Accumulator
Fair bandwidth allocation:
import GBNet.Replication.Priority
let acc = register npcId 2.0
$ register playerId 10.0
newPriorityAccumulator
let (selected, acc') = drainTop 1200 entitySize acc
Snapshot Interpolation
Smooth client-side rendering:
import GBNet.Replication.Interpolation
let buffer' = pushSnapshot serverTime state buffer
case sampleSnapshot renderTime buffer' of
Nothing -> waitForMoreSnapshots
Just interpolated -> render interpolated
Congestion Control
gbnet-hs uses a dual-layer congestion control strategy:
Binary Mode
A send-rate controller that tracks Good/Bad network conditions:
- Good mode — additive increase (AIMD): ramps send rate up to 4x base rate
- Bad mode — multiplicative decrease: halves current send rate on loss/high RTT
- Adaptive recovery timer with quick re-entry detection (doubles on rapid Good→Bad transitions)
Window-Based (TCP New Reno)
A cwnd-based controller layered alongside binary mode:
- Slow Start — exponential growth until ssthresh
- Congestion Avoidance — additive increase per RTT
- Recovery — halves cwnd on packet loss (triggered by fast retransmit)
- Slow Start Restart — resets stale cwnd after idle periods (RFC 2861)
Backpressure API
Applications can query congestion pressure and adapt:
case peerStats peerId peer of
Nothing -> pure () -- Peer not connected
Just stats -> case nsCongestionLevel stats of
CongestionNone -> sendFreely
CongestionElevated -> reduceNonEssential
CongestionHigh -> dropLowPriority
CongestionCritical -> onlySendEssential
Build & Test
Requires GHCup with GHC >= 9.6.
cabal build # Build library
cabal test # Run all tests
cabal build --ghc-options="-Werror" # Warnings as errors
cabal haddock # Generate docs
Optimized for game networking:
- Zero-allocation serialization — Storable-based
poke/peek, 14ns for user types (~70M ops/sec)
- Zero-allocation packet headers — direct memory writes, 17ns serialize
- Nested types same speed — Storable composition has no overhead
- Strict fields with bang patterns throughout
- GHC flags:
-O2 -fspecialise-aggressively -fexpose-all-unfoldings
- INLINE pragmas on hot paths
- Hardware-accelerated CRC32C via SSE4.2/ARMv8 CRC
- Zero-poll receive — dedicated thread blocks on epoll/kqueue, delivers via STM TQueue
Benchmarks
storable/vec3/serialize 18.98 ns (52M ops/sec) -- user types
storable/transform/serialize 20.80 ns (48M ops/sec) -- nested types
packetheader/serialize 16.49 ns (60M ops/sec)
packetheader/deserialize 15.95 ns (62M ops/sec)
Run with cabal bench --enable-benchmarks.
Features
Core Transport
Congestion Control
Effect Abstraction
Replication Helpers
Contributing
cabal test && cabal build --ghc-options="-Werror"
MIT License · Gondola Bros Entertainment