Best Practices

Best Practices

Practical guidance for using KravasNet safely, most of it learned from real failure modes of the underlying ByteNet 0.4.6 replication model.

Always call :build() explicitly

-- ReplicatedStorage/Networking/Combat.lua
local CombatNet = KravasNet.createNamespace("Combat")
	:add("Damage",   { amount = KravasNet.float32 })
	:add("Position", { pos = KravasNet.vec3 }, { reliability = "unreliable" })
 
CombatNet:build() -- 👈 do this, every time
 
return CombatNet

If you skip :build(), KravasNet builds the namespace automatically the first time any channel is read — but when that first read happens is entirely determined by your game logic, which is rarely the same on the server and the client.

🚫

Real failure mode: a server script only touched a namespace's channel inside a loop that started after task.wait(15), while a client UI script touched the same channel immediately on Init(). The client built (and tried to read) the namespace's replicated data 15 seconds before the server had written it, resulting in the client registering no packet IDs at all — and once the server actually sent data with the real ID, the client's reader had nothing to match it to, crashing with attempt to index nil with 'reader' deep inside ByteNet, repeatedly, on every incoming packet.

Calling :build() at the bottom of the shared ModuleScript, right after your :add() calls, means the namespace is registered as soon as anything on either side requires the module — which is about as close as you can get to "at the same moment" on both server and client, without depending on unrelated game logic timing.

Declare all of a namespace's channels in one place

Because :add() throws once a namespace is built, and because build timing is hard to fully control once it's automatic, keep every channel for a given namespace declared together, in the same module, before the single :build() call:

-- ✅ Good — everything declared up front, in one file
local CombatNet = KravasNet.createNamespace("Combat")
	:add("Damage", { amount = KravasNet.float32 })
	:add("Heal",   { amount = KravasNet.float32 })
	:add("Stun",   { duration = KravasNet.float32 })
 
CombatNet:build()
-- ❌ Risky — a second script adds to CombatNet later, hoping it isn't built yet
local CombatNet = require(ReplicatedStorage.Networking.Combat)
CombatNet:add("Knockback", { force = KravasNet.float32 }) -- may already be built!

If you truly need to extend a namespace from a different script than where it's created, do it before anything anywhere reads a channel off it — which is difficult to guarantee across an entire codebase. Prefer a second, dedicated namespace instead.

Group related, co-timed channels — don't over-fragment with createChannel

KravasNet.createChannel() is convenient, but each call registers its own anonymous ByteNet namespace immediately. If you find yourself calling it many times for packets that logically belong together (e.g. everything related to combat), prefer one shared createNamespace("Combat") instead — it's one network registration instead of many, and keeps related packets discoverable in one place (CombatNet.Damage, CombatNet.Heal, ...) rather than scattered across loose local variables.

Treat reliability choices deliberately

If the data...Use
Must never be lost (damage, purchases, inventory)reliable (the default — you can omit it)
Is superseded by the next update anyway (live position, aim direction)unreliable
Needs both "don't spam the network" and "must arrive in order"Neither, today — see the warning below
🚫

Don't reach for "unreliableOrdered". As documented in Middleware & Reliability, the ByteNet 0.4.6 packet type only actually implements "reliable" and "unreliable" — passing "unreliableOrdered" does not give you ordering guarantees today, even though KravasNet's type annotation allows it.

Use middleware for validation, not for business logic

Middleware is a great place for cheap, generic guards — data shape checks, rate limiting, permission checks. Keep it side-effect-light and fast, since it runs on every incoming packet before your real handler:

-- ✅ Good: a guard, returns quickly
local function mustBeAlive(data, player)
	return player.Character ~= nil and player.Character:FindFirstChild("Humanoid") ~= nil
end
-- ❌ Avoid: expensive or stateful work inside middleware
local function mustBeAlive(data, player)
	local profile = ProfileStore:GetProfile(player) -- yields, expensive, called on every packet
	return profile ~= nil
end

Do the heavier, stateful work inside your on() callback instead, after middleware has already filtered out anything obviously invalid.

Remember primitives are values, not calls

This is the single most common typo when writing a schema:

-- ❌ Wrong — int8/bool/vec3/etc. are pre-built values, calling them errors
{ amount = KravasNet.int8(), pos = KravasNet.vec3() }
 
-- ✅ Correct
{ amount = KravasNet.int8, pos = KravasNet.vec3 }
 
-- ✅ Composites ARE functions — these must be called
{ note = KravasNet.optional(KravasNet.string) }

See Data Types for the full primitive vs. composite breakdown.

Sanity-check your setup with a one-off diagnostic script

If a schema field ever behaves unexpectedly, a quick typeof() check tells you immediately whether you're dealing with a value or a function, without guessing:

local KravasNet = require(ReplicatedStorage.Packages.KravasNet)
 
print("int8:", typeof(KravasNet.int8))       -- expect "table"
print("optional:", typeof(KravasNet.optional)) -- expect "function"

Watch for duplicate-error signals, not just crashes

Errors like attempt to index function value with 'sendToAll' or attempt to index nil with 'reader' almost always mean a timing or registration problem (server/client namespace mismatch), not a schema typo — schema typos tend to surface immediately, on both sides, as soon as :build() runs. If you see errors that only appear on one side, or only after some delay, suspect build timing first.