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
- AEAD encryption — Optional ChaCha20-Poly1305 with anti-replay nonce tracking
- 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
- IPv4 and IPv6 — Automatic address family detection
- 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]
, ncEncryptionKey = Just (EncryptionKey myKey) -- optional AEAD encryption
}
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
-- Packet duplication and out-of-order delivery
let testCfg = defaultTestNetConfig
{ tncDuplicateChance = 0.05 -- 5% chance of duplicating packets
, tncOutOfOrderChance = 0.1 -- 10% chance of reordering
}
Encryption
Optional ChaCha20-Poly1305 AEAD encryption for post-handshake packets:
import GBNet
-- Pre-shared key (32 bytes) — both sides must use the same key
let key = EncryptionKey mySharedKey
let config = defaultNetworkConfig { ncEncryptionKey = Just key }
-- Handshake packets remain plaintext; all data packets are encrypted
-- Anti-replay: monotonic nonce counter rejects duplicate/old packets
Wire overhead: 24 bytes per encrypted packet (8-byte nonce + 16-byte auth tag).
IPv6
IPv6 works out of the box — address family is detected automatically:
-- IPv4 (existing)
let addr4 = anyAddr 7777
-- IPv6
let addr6 = anyAddr6 7777 -- bind to [::]:7777
let local = localhost6 7777 -- bind to [::1]:7777
let custom = ipv6 (0,0,0,1) 7777 -- specific address
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.Crypto |
ChaCha20-Poly1305 AEAD encryption and decryption |
GBNet.Security |
CRC32C integrity, rate limiting, connect tokens |
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)
import GBNet.Crypto (EncryptionKey(..), NonceCounter(..)) -- optional encryption
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)
crypto/encrypt/64B 1.9 us (526K ops/sec) -- ChaCha20-Poly1305
crypto/decrypt/1KB 4.2 us (238K 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