BiPolar networking library documentation

BiPolar docs README Benchmarks

BiPolar

A kernel-based, buffer-serialized networking library for Roblox, in strict-mode Luau.

Overview

BiPolar funnels all networking through one kernel. Events and request/response are batched once per frame into a single buffer per channel over two random-named remotes (a reliable RemoteEvent and an opt-in UnreliableRemoteEvent). Booleans and optional fields fold into a packed bitmask, the wire protocol is randomized per server, and inbound traffic is rate-limited both globally and per packet.

Install & setup

BiPolar is a Rojo project. Sync the src/shared/BiPolar folder into ReplicatedStorage.Shared.BiPolar (see project layout). Define your packets in a shared module required by both the client and the server — that's what keeps packet ids in sync.

Packet ids derive from the sorted set of names, so both sides must define the same packets. Put every define in one shared module.

Quick start

shared/Packets.luau

local BiPolar = require(ReplicatedStorage.Shared.BiPolar)
local t = BiPolar.types
local Direction = BiPolar.Direction

local PlayerState = BiPolar.define("PlayerState", {
    position  = t.vector3half, -- 6 B
    health    = t.u8,
    sprinting = t.boolean,     -- ┐
    crouching = t.boolean,     -- ├ all share one mask byte
    jumping   = t.boolean,     -- │
    aiming    = t.boolean,     -- ┘
    target    = t.optional(t.u32), -- present? 1 bit. value? only if present.
}, { direction = Direction.Both, unreliable = true })

return { PlayerState = PlayerState }

client

BiPolar:Fire("Unreliable", PlayerState, {
    position = p, health = 100,
    sprinting = true, crouching = false, jumping = false, aiming = false,
})

server

BiPolar:On(PlayerState, function(player, data)
    -- data.position, data.sprinting, ...
end)

Defining packets

Two constructors, both registered in your shared module:

BiPolar.define(name, schema, config?)                       -- event packet
BiPolar.defineFunction(name, requestSchema, responseSchema, config?) -- function packet

schema / requestSchema / responseSchema is a { field = type } table (becomes a struct) or a single type.

Packets can be defined any time — even after networking has started. The server assigns and publishes a fresh wire id and the client reads it, so mid-session definitions are safe.

Packet config

KeyApplies toMeaning
unreliableeventsThe default channel when you Fire without naming one. Override per call with "Reliable"/"Unreliable".
directionboth"c2s", "s2c", or "both" (default). One-way packets sent the wrong way are dropped by the kernel.
rateLimitbothServer-side lock — max inbound occurrences per player per second. See server-side lock. nil = unlimited.

Sending & receiving

The channel — "Reliable" or "Unreliable" — is the first argument to Fire, and one Fire infers direction from where it runs.

CallSideDescription
BiPolar:Fire("Reliable", packet, value)clientsend to the server
BiPolar:Fire("Reliable", packet, player, value)serversend to one player
BiPolar:Fire("Reliable", packet, "All", value)serversend to everyone
BiPolar:Fire("Reliable", packet, { except = p }, value)servereveryone but p
BiPolar:Fire("Reliable", packet, { p1, p2 }, value)servera list of players
BiPolar:On(packet, handler)both→ disconnect fn; server gets (player, value), client gets (value)
Listeners run inline. Handlers are called directly on the kernel's receive path, not in a fresh thread per packet. A handler that needs to yield for a while should task.spawn its own thread to avoid stalling the rest of the batch.

Request / response

Emulated over the batched reliable event (no RemoteFunction); times out after 10s. Responses are matched to the peer the request was sent to — one client cannot forge the answer to a request the server made to another client.

CallSideDescription
BiPolar:Invoke(packet, request)clientcall the server, yields for the response
BiPolar:Invoke(packet, player, request)servercall a client, yields
BiPolar:OnInvoke(packet, handler)bothserver handler gets (player, request), client gets (request)
BiPolar:InvokeExpect(packet, player, request, expected, kickReason?)servercall a client; kick on a wrong / timed-out answer

Types BiPolar.types

TypeWire sizeLua type
u8 i81 bytenumber
u16 i162 bytesnumber
u32 i324 bytesnumber
uint1+ bytesnumber (varint, auto-sized)
int1+ bytesnumber (zigzag varint, signed)
f162 bytesnumber (half float)
f243 bytesnumber (truncated f32)
f324 bytesnumber
f64 / number8 bytesnumber
boolean / bool1 bit*boolean
string1+ bytesstring
vector312 bytesVector3
vector3half6 bytesVector3 (half precision)
vector28 bytesVector2
cframe48 bytesCFrame
color33 bytesColor3
optional(T)1 bit* + TT?
array(T)1+ bytes{T}
map(K, V)1+ bytes{[K]: V}
struct(fields)variesrecord
any1+ bytesdynamic tagged value (incl. buffer)

* inside a struct mask; 1 byte standalone.

UI, tween & engine datatypes

TypeWire sizeLua type
udim8 bytesUDim
udim216 bytesUDim2 UI
rect16 bytesRect
vector2int164 bytesVector2int16
vector3int166 bytesVector3int16
region324 bytesRegion3
ray24 bytesRay
numberRange8 bytesNumberRange
brickColor2 bytesBrickColor
dateTime8 bytesDateTime
physicalProperties20 bytesPhysicalProperties
tweenInfo12 bytesTweenInfo tween
font4+ bytesFont
faces / axes1 byteFaces / Axes (packed)
numberSequence1+ bytesNumberSequence
colorSequence1+ bytesColorSequence
color3uint8 / content3 B / 1+ Baliases of color3 / string
enum(Enum.X)1+ bytesEnumItem (varint of .Value) enum
Every Roblox Enum is a ready-made serializer. t.enums.KeyCode, t.enums.Material, t.enums.UserInputState … one per Enum (200+), built at runtime — so with the datatypes above there are well over 100 types. Use t.enum(Enum.X) directly for any enum.
-- enums in a packet
local Input = BiPolar.define("Input", {
    key   = t.enums.KeyCode,          -- any KeyCode
    state = t.enum(Enum.UserInputState), -- equivalent, explicit
    tween = t.tweenInfo,
    anchor = t.udim2,
}, { direction = "c2s" })

Bitmask layout

A struct lays its bytes out as:

mask bytes present optional values plain field values

The mask holds one bit per boolean (its value) and one bit per optional (presence). Fields start name-sorted so both sides agree, then the per-runtime seed shuffles the order identically on client and server. Result: 8 booleans = 1 byte, and an absent optional = 0 payload bytes.

Keeping packets small

The dominant cost is almost always full-precision floats (a vector3 is 12 B), so that's where downscaling helps most. Floats can't be shrunk losslessly, so the library never silently reduces precision — compression, like everything else on the wire, is byte-exact and you choose the types.

Examples

Three complete, copy-pasteable patterns. Each packet lives in your shared module; the client/server halves go in their respective scripts.

1 · Movement replication (events, unreliable, enum)

High-frequency state on the unreliable channel — the booleans share the mask, the humanoid state is an enum, and a rateLimit caps it server-side.

-- shared
local Move = BiPolar.define("Move", {
    position  = t.vector3half,                 -- 6 B
    velocity  = t.vector3half,
    state     = t.enums.HumanoidStateType,     -- enum, ~1 B
    sprinting = t.boolean,
    fromUserId = t.optional(t.uint),          -- absent c2s, stamped on relay
}, { direction = Direction.Both, unreliable = true, rateLimit = 60 })

-- client
BiPolar:Fire("Unreliable", Move, {
    position = hrp.Position, velocity = hrp.AssemblyLinearVelocity,
    state = humanoid:GetState(), sprinting = true,
})

-- server: relay to everyone else, stamping who it came from
BiPolar:On(Move, function(player, data)
    data.fromUserId = player.UserId
    BiPolar:Fire("Unreliable", Move, { except = player }, data)
end)

2 · UI / VFX broadcast (udim2, color3, tweenInfo)

Server-to-client only. The whole tween description travels in 12 bytes and the client feeds it straight into TweenService.

-- shared
local Popup = BiPolar.define("Popup", {
    anchor = t.udim2,
    size   = t.udim2,
    color  = t.color3,
    tween  = t.tweenInfo,
    text   = t.string,
}, { direction = Direction.ServerToClient })

-- server
BiPolar:Fire("Reliable", Popup, "All", {
    anchor = UDim2.fromScale(0.5, 0.4),
    size   = UDim2.fromOffset(320, 120),
    color  = Color3.fromRGB(120, 200, 255),
    tween  = TweenInfo.new(0.4, Enum.EasingStyle.Quad),
    text   = "Boss incoming!",
})

-- client
BiPolar:On(Popup, function(data)
    label.Position, label.Size, label.BackgroundColor3 = data.anchor, data.size, data.color
    label.Text = data.text
    TweenService:Create(label, data.tween, { BackgroundTransparency = 0 }):Play()
end)

3 · Shop request (request/response, array of struct)

Emulated over the batched reliable event — no RemoteFunction. The handler auto-receives the player; rateLimit stops a client hammering it.

-- shared
local GetShop = BiPolar.defineFunction("GetShop", t.u8, {
    items = t.array(t.struct({ id = t.u16, price = t.uint, name = t.string })),
}, { direction = Direction.ClientToServer, rateLimit = 4 })

-- client (yields for the response)
local shop = BiPolar:Invoke(GetShop, 0)
for _, item in shop.items do print(item.name, item.price) end

-- server
BiPolar:OnInvoke(GetShop, function(player, page)
    return { items = loadShopPage(player, page) }
end)

Server-side lock rateLimit

The global flood guard watches a player's whole traffic. The per-packet lock is finer: it caps how often each player can land one specific packet per second, so an individual action can't be spammed even when total traffic is low. Excess is dropped server-side, before any listener or invoke handler runs.

-- attack at most 20x/sec per player; respawn at most 2x/sec
local Attack  = BiPolar.define("Attack",  { dir = t.vector3half }, {
    direction = "c2s", unreliable = true, rateLimit = 20 })
local Respawn = BiPolar.define("Respawn", t.boolean, {
    direction = "c2s", rateLimit = 2 })

-- works on function packets too (caps invokes/sec per player)
local Buy = BiPolar.defineFunction("Buy", t.u16, { ok = t.boolean }, {
    direction = "c2s", rateLimit = 5 })

A per (player, packet) counter reset each second, enforced only on the server (the server is trusted) and freed automatically when a player leaves.

Flood protection BiPolar.setSecurity

Adaptive by default — no fixed numbers to hand-tune and false-trip on. The guard learns each player's own busiest legitimate second and only acts on traffic many times larger, never below a generous floor and never above an absolute hard ceiling. Strikes decay on clean seconds, and kicking is off by default.

-- adaptive defaults (you usually don't need to set any)
BiPolar.setSecurity({
    auto = true, factor = 8, warmupSeconds = 5,
    floorPacketsPerSecond = 1000, floorBytesPerSecond = 1048576,
    hardPacketsPerSecond = 4000, hardBytesPerSecond = 4194304,
    maxBufferBytes = 65536, maxStrikes = 8, kick = false,
})

-- or the old fixed caps
BiPolar.setSecurity({ auto = false, maxPacketsPerSecond = 240, maxBytesPerSecond = 131072 })

Middleware hooks

BiPolar.addInboundHook(packet?, fn) / addOutboundHook(packet?, fn) → disconnect. Pass a packet to scope the hook to it, or nil for a global hook. The hook gets { name, kind, dir, player, value }; mutate info.value to transform the payload, or return false to veto (drop inbound / cancel outbound).

-- drop empty chat before any listener sees it
BiPolar.addInboundHook(Chat, function(info)
    if info.value.message == "" then return false end
end)

Profiler BiPolar.profiler

Measures every packet the local peer sends/receives.

local snap  = BiPolar.profiler.snapshot() -- cumulative totals
local rates = BiPolar.profiler.rates()    -- bytes/sec & count/sec per packet
BiPolar.profiler.onInterval(function(rates) end) -- live, every second

BiPolar.profiler.setLogging(true)
for _, e in BiPolar.profiler.getLog() do
    -- e.name, e.dir, e.kind, e.bytes, e.value, e.raw
end

Debug dashboard

Press F3 on the client for three draggable, resizable panels:

Instrument your own code so the dashboard shows what's heavy:

local stop = BiPolar.debug.scope("Pathfinding")
-- ...work...
stop()
-- or
BiPolar.debug.measure("Pathfinding", computePath, start, goal)

Benchmarks

Real, runnable benchmarks live in benchmarks/. The offline serializer bench (lune run benchmarks/serialize.bench.luau) reports exact wire size against a JSON baseline:

payload                            bytes    json     vs     encode    decode
Booleans x32 (struct/bitmask)          4     367  91.8x       ...        ...
Entities x100 (6x u8 struct)         601    6253  10.4x       ...        ...
Strings x50 (~16 chars)              851     952   1.1x       ...        ...
Numbers x500 (u8 array)              502    1784   3.6x       ...        ...
Dictionary x50 (string->u8)          451     643   1.4x       ...        ...

Bytes are exact and identical everywhere; the in-Studio harness reports throughput in the native VM and exact outbound bytes from the profiler.

Project layout

src/shared/BiPolar/
  init.luau        -- public API (Fire / Invoke / hooks / define)
  Kernel.luau      -- remotes, batching, protocol, hooks, flood guard, lock
  Types.luau       -- serializers + bitmask struct        (--!native)
  BitBuffer.luau   -- growable buffer Writer/Reader       (--!native)
  Profiler.luau    -- packet stats / rates / per-frame history
  Diagnostics.luau -- client/server metrics + script-timing scopes
  DebugUI.luau     -- F3 debug dashboard (client)
src/shared/Packets.luau   -- your shared packet definitions