@hackage gbnet-hs0.1.1.0

Transport-level networking library with zero-copy Storable serialization

gbnet-hs

Transport-Level Networking for Haskell

Zero-copy Storable serialization. Reliable UDP transport. Effect-abstracted design for pure testing.

Quick Start · Networking · Serialization · Testing · Architecture

CI Hackage Haskell License


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 abstractionMonadNetwork 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 APIserialize/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

Performance

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

  • Zero-copy Storable serialization (sub-20ns roundtrips)
  • Nested type composition via Storable typeclass
  • Template Haskell deriveStorable for automatic instances
  • Type-safe newtypes (ChannelId, SequenceNum, MonoTime, MessageId)
  • Reliable/unreliable/sequenced delivery modes
  • RTT estimation and adaptive retransmit
  • Large message fragmentation
  • Connection migration
  • Hardware-accelerated CRC32C validation (SSE4.2/ARMv8/software fallback)
  • Self-cleaning rate limiter

Congestion Control

  • Binary mode (Good/Bad with AIMD recovery)
  • TCP New Reno window-based control (slow start, avoidance, recovery)
  • Slow Start Restart for idle connections (RFC 2861)
  • Application-level backpressure via CongestionLevel
  • CWND loss signal from fast retransmit
  • Adaptive recovery timer with quick re-entry detection

Effect Abstraction

  • MonadNetwork typeclass
  • NetT monad transformer with dedicated receive thread + STM TQueue
  • TestNet pure deterministic network
  • TestWorld multi-peer simulation

Replication Helpers

  • Delta compression
  • Interest management
  • Priority accumulator
  • Snapshot interpolation

Contributing

cabal test && cabal build --ghc-options="-Werror"

MIT License · Gondola Bros Entertainment