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.
- Two-state values are the cheapest thing on the wire. Eight booleans cost one byte; an absent optional costs one bit.
- One unified call API, deliberately not shaped like Roblox remotes:
BiPolar:Fire("Reliable", packet, …)— the channel is the first argument and a singleFire/Invokeinfers direction from where it runs. - No RemoteFunction. Request/response is emulated over the batched reliable event, so invokes ride the same batch as events.
- Reliable batches self-compress. Batches of 64+ bytes are Zstd-compressed (engine-native
EncodingService) whenever that makes them smaller — fully lossless, the receiver decodes identical bytes. No API to call; it happens on flush.
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.
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.
Packet config
| Key | Applies to | Meaning |
|---|---|---|
| unreliable | events | The default channel when you Fire without naming one. Override per call with "Reliable"/"Unreliable". |
| direction | both | "c2s", "s2c", or "both" (default). One-way packets sent the wrong way are dropped by the kernel. |
| rateLimit | both | Server-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.
| Call | Side | Description |
|---|---|---|
| BiPolar:Fire("Reliable", packet, value) | client | send to the server |
| BiPolar:Fire("Reliable", packet, player, value) | server | send to one player |
| BiPolar:Fire("Reliable", packet, "All", value) | server | send to everyone |
| BiPolar:Fire("Reliable", packet, { except = p }, value) | server | everyone but p |
| BiPolar:Fire("Reliable", packet, { p1, p2 }, value) | server | a list of players |
| BiPolar:On(packet, handler) | both | → disconnect fn; server gets (player, value), client gets (value) |
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.
| Call | Side | Description |
|---|---|---|
| BiPolar:Invoke(packet, request) | client | call the server, yields for the response |
| BiPolar:Invoke(packet, player, request) | server | call a client, yields |
| BiPolar:OnInvoke(packet, handler) | both | server handler gets (player, request), client gets (request) |
| BiPolar:InvokeExpect(packet, player, request, expected, kickReason?) | server | call a client; kick on a wrong / timed-out answer |
Types BiPolar.types
| Type | Wire size | Lua type |
|---|---|---|
| u8 i8 | 1 byte | number |
| u16 i16 | 2 bytes | number |
| u32 i32 | 4 bytes | number |
| uint | 1+ bytes | number (varint, auto-sized) |
| int | 1+ bytes | number (zigzag varint, signed) |
| f16 | 2 bytes | number (half float) |
| f24 | 3 bytes | number (truncated f32) |
| f32 | 4 bytes | number |
| f64 / number | 8 bytes | number |
| boolean / bool | 1 bit* | boolean |
| string | 1+ bytes | string |
| vector3 | 12 bytes | Vector3 |
| vector3half | 6 bytes | Vector3 (half precision) |
| vector2 | 8 bytes | Vector2 |
| cframe | 48 bytes | CFrame |
| color3 | 3 bytes | Color3 |
| optional(T) | 1 bit* + T | T? |
| array(T) | 1+ bytes | {T} |
| map(K, V) | 1+ bytes | {[K]: V} |
| struct(fields) | varies | record |
| any | 1+ bytes | dynamic tagged value (incl. buffer) |
* inside a struct mask; 1 byte standalone.
UI, tween & engine datatypes
| Type | Wire size | Lua type |
|---|---|---|
| udim | 8 bytes | UDim |
| udim2 | 16 bytes | UDim2 UI |
| rect | 16 bytes | Rect |
| vector2int16 | 4 bytes | Vector2int16 |
| vector3int16 | 6 bytes | Vector3int16 |
| region3 | 24 bytes | Region3 |
| ray | 24 bytes | Ray |
| numberRange | 8 bytes | NumberRange |
| brickColor | 2 bytes | BrickColor |
| dateTime | 8 bytes | DateTime |
| physicalProperties | 20 bytes | PhysicalProperties |
| tweenInfo | 12 bytes | TweenInfo tween |
| font | 4+ bytes | Font |
| faces / axes | 1 byte | Faces / Axes (packed) |
| numberSequence | 1+ bytes | NumberSequence |
| colorSequence | 1+ bytes | ColorSequence |
| color3uint8 / content | 3 B / 1+ B | aliases of color3 / string |
| enum(Enum.X) | 1+ bytes | EnumItem (varint of .Value) enum |
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:
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 per-message id is 1 byte automatically when you have ≤255 packets (grows to 2 only beyond that).
- Use
uint/intfor integers usually small — a value under 128 costs 1 byte instead of 2/4. - Use
u8for 0–255,f16for low-range floats,f24when you need more than f16 but not full f32. - Use
vector3half(6 B) for positions where sub-stud precision isn't critical. - Booleans and absent optionals are nearly free — they live in the mask.
- Compression is automatic. Reliable batches of 64+ bytes are Zstd-compressed when that genuinely shrinks them, so big or repetitive payloads (broadcasts, snapshots, shop lists) get smaller for free. Small batches and the unreliable channel are sent as-is — no overhead where compression can't win.
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:
- CLIENT — FPS/frame ms, ping, net recv/send, physics, Lua heap, memory, instances, uptime, and your hottest script scopes.
- SERVER — the same server-side metrics, streamed over the network while the panel is open.
- NET — a MicroProfiler-style packet timeline (one bar per frame; spikes turn red), P to pause and click a bar to scrub, a bandwidth-share bar, a heaviest-first packet list, and a decoded-args + raw-buffer hex inspector.
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