Forest Logo
search
package_2

secnet

By @nivalis9

Roblox

Mirrored

SecNet

SecNet is a small, server-authoritative Roblox networking module.

It keeps the API simple:

  • SecNet.On(name, callback, options?) and SecNet.Send(...) for events
  • SecNet.SendBatch(...) for high-throughput reliable event batches
  • SecNet.Stream(name, options?) for high-rate unreliable updates
  • SecNet.Request(...) and SecNet.HandleRequest(...) for request/response
  • SecNet.Sync(...), SecNet.OnSync(...), and SecNet.GetSynced(...) for server-to-client state

It does not try to make the client trustworthy. Instead, it makes the server boundary harder to abuse with payload limits, route validation, per-player and per-route rate limits, trust scoring, unknown-route rejection, blocked client-to-server sync, and request response sender checks.

Internally, SecNet uses a compact positional wire format (kind, name, requestId, success, ...) instead of wrapping every send in packet/payload tables. That keeps the public API tiny while reducing hot-path allocations and serialization overhead.

Install

Use one of these layouts:

  1. Put the module in ReplicatedStorage as SecNet.
  2. Use the included Rojo project, which maps src to ReplicatedStorage.SecNet.

The server creates ReplicatedStorage._SecNetRuntime.Backbone and ReplicatedStorage._SecNetRuntime.Stream automatically. Clients wait for those runtime remotes.

Quick Start

Server

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SecNet = require(ReplicatedStorage:WaitForChild("SecNet"))

SecNet.On("Damage", function(player, amount)
	print(player.Name, "requested damage:", amount)
end, {
	RateLimit = 12,
	Validate = function(player, amount)
		return type(amount) == "number" and amount >= 0 and amount <= 50
	end,
})

SecNet.HandleRequest("GetCoins", function(player)
	return 500
end)

task.spawn(function()
	while true do
		SecNet.Sync("RoundTime", workspace:GetServerTimeNow())
		task.wait(1)
	end
end)

Client

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SecNet = require(ReplicatedStorage:WaitForChild("SecNet"))

SecNet.Send("Damage", 25)

local coins, err = SecNet.Request("GetCoins")
if err then
	warn("request failed:", err)
else
	print("coins:", coins)
end

SecNet.OnSync("RoundTime", function(value)
	print("round time:", value)
end)

Streams

Streams use UnreliableRemoteEvent, so they are for data where the newest packet matters more than guaranteed delivery: NPC snapshots, aim rays, cosmetic effects, sensor pings, and other high-rate state.

-- server
local NPCs = SecNet.Stream("N", { RateLimit = 20 })

task.spawn(function()
	while true do
		local snapshot = SecNet.PackNPCSnapshots({
			{ Id = 1, Position = Vector3.new(10, 4, -2), Yaw = 1.2, State = 3 },
			{ Id = 2, Position = Vector3.new(14, 4, -8), Yaw = 2.1, State = 1 },
		})

		NPCs:SendAll(snapshot)
		task.wait(1 / 20)
	end
end)

-- client
SecNet.OnStream("N", function(snapshot)
	SecNet.ReadNPCSnapshots(snapshot, function(id, position, yaw, state)
		-- apply latest visual state
	end)
end)

PackNPCSnapshots stores each NPC in 11 bytes: u16 id, three quantized i16 position axes, u16 yaw, and u8 state. At the default MaxStreamBytes = 900, one packet fits 81 NPCs. The default scale is 10, meaning one decimal place of positional precision. Pass { Origin = someVector, Scale = 20 } when you want a smaller local coordinate range or finer quantization.

Events

Shortcut API:

SecNet.On("Hit", function(player, targetId)
	print(player, targetId)
end, {
	RateLimit = 20,
	Validate = function(player, targetId)
		return type(targetId) == "number"
	end,
})

-- client -> server
SecNet.Send("Hit", 123)

-- server -> one client
SecNet.Send(player, "HitConfirmed", 123)

-- server -> all clients
SecNet.SendAll("RoundStarted", 60)

-- client -> server, one callback per record
SecNet.SendBatch("HitMarker", {
	{ Target = 101, Damage = 12 },
	{ Target = 102, Damage = 8 },
})

-- server, one callback for the whole batch
SecNet.OnBatch("HitMarkerBulk", function(player, records)
	for _, record in ipairs(records) do
		print(player, record.Target, record.Damage)
	end
end)

Object API:

local Hit = SecNet.Event("Hit", {
	RateLimit = 20,
	Validate = function(player, targetId)
		return type(targetId) == "number"
	end,
})

Hit:On(function(player, targetId)
	print(player, targetId)
end)

Hit:SetRateLimit(10)
Hit:SetValidator(function(player, targetId)
	return type(targetId) == "number"
end)

Object methods:

  • event:Send(player, ...) on the server
  • event:Send(...) on the client
  • event:SendAll(...) on the server
  • event:SendMultiple(players, ...) on the server
  • event:SendBatch(player, records) on the server
  • event:SendBatch(records) on the client
  • event:SendBatchAll(records) on the server
  • event:On(callback)
  • event:OnBatch(callback)
  • event:SetBatchValidator(callback)

Server listeners receive callback(player, ...). Client listeners receive callback(...).

OnBatch receives callback(player, records) on the server and callback(records) on the client. If a route has an OnBatch listener, SendBatch is delivered once as the whole records array; otherwise it falls back to one normal On callback per record.

Requests

Client calling the server:

-- server
SecNet.HandleRequest("PurchaseItem", {
	RateLimit = 5,
	Validate = function(player, itemId)
		return type(itemId) == "string" and #itemId <= 64
	end,
}, function(player, itemId)
	return true, "Purchased", itemId
end)

-- client
local ok, message, itemId = SecNet.Request("PurchaseItem", "sword")

Server calling a client:

-- client
SecNet.HandleRequest("GetAimPosition", function()
	return workspace.CurrentCamera.CFrame.Position
end)

-- server
local aimPosition, err = SecNet.Request(player, "GetAimPosition")

Rules:

  • Client usage is SecNet.Request(name, ...)
  • Server usage is SecNet.Request(player, name, ...)
  • On success, response values are returned directly
  • On timeout or handler failure, SecNet returns nil, errorMessage
  • Server requests use unguessable request IDs and only accept responses from the requested player

Sync

Sync is server-to-client only. Use events or requests for client-to-server messages.

-- server
SecNet.Sync("MatchState", "Lobby")
SecNet.Sync(player, "Loadout", { Primary = "Rifle" })
SecNet.Sync({ playerA, playerB }, "ZoneWarning", true)
SecNet.Sync(workspace.Flag, "Owner", player.UserId)

-- client
SecNet.OnSync("MatchState", function(value, key)
	print(key, value)
end)

SecNet.OnSync(workspace.Flag, "Owner", function(userId)
	print("flag owner:", userId)
end)

Config

Set config before heavy traffic starts:

SecNet.Config.MaxPacketsPerSecond = 80
SecNet.Config.MaxRoutePacketsPerSecond = 30
SecNet.Config.MaxStreamBytes = 900
SecNet.Config.MaxBatchEvents = 2048
SecNet.Config.MaxCoalescedEvents = 256
SecNet.Config.RequestTimeoutSeconds = 5
SecNet.Config.CoalesceEvents = true
SecNet.Config.AllowClientInstances = false
SecNet.Config.ValidateServerPackets = false

Common options:

  • MaxPacketsPerSecond
  • MaxRoutePacketsPerSecond
  • BurstMultiplier
  • MaxPacketBytes
  • MaxStreamBytes
  • MaxBatchEvents
  • MaxCoalescedEvents
  • MaxStringBytes
  • MaxTableEntries
  • MaxTableDepth
  • MaxArguments
  • RequestTimeoutSeconds
  • CoalesceEvents
  • AllowClientInstances
  • RejectUnknownClientRoutes
  • ValidateServerPackets
  • ValidateOutbound
  • TrackBytes

ValidateServerPackets, ValidateOutbound, and TrackBytes default to false for reliable events/requests. Client-origin traffic is still validated on the server; ValidateServerPackets only enables extra client-side validation for server-origin packets. Stream sends always enforce MaxStreamBytes so oversized unreliable packets do not silently disappear.

Performance Notes

SecNet is optimized for simple runtime ergonomics: no IDL, no generated files, and no packet tables on the wire. For tiny messages, this keeps CPU overhead very low.

Send auto-coalesces same-frame reliable events by default, so a burst of logical sends can flush as a few argument-batch packets. Client-origin sends still go through unknown-route checks, rate limits, payload validation, and route validators on the server. Set a route option { Coalesce = false } or { Immediate = true } only for latency-critical control messages.

For bandwidth-heavy state, use streams with a single buffer payload. The NPC snapshot helper is the intended "extreme but still simple" path: one unreliable packet can carry dozens of NPCs with predictable byte cost and almost no Lua allocation.

For reliable floods, use SendBatch instead of thousands of Send calls in the same frame. Add OnBatch for the hottest routes so the receiver handles the whole chunk in one callback. Without OnBatch, each batch record is delivered to the normal On listener as one payload.

For fast client-to-server batches, prefer a route ValidateBatch option that checks the whole array's schema once. Without ValidateBatch, SecNet keeps the safer generic per-record validation path.

Server-to-client packets skip the deep generic validator by default because that does not protect the server and costs a lot in hot paths. Turn on ValidateServerPackets only when debugging server payload shape issues.

Zap can still win broad typed workloads because it generates route-specific serializers and packs data into buffers. SecNet does not try to be a general codegen serializer; it gives you a tiny manual buffer path for the hottest updates.

Security Notes

No Roblox network wrapper can make client input inherently safe. Treat every client event/request as a suggestion and validate game rules on the server.

SecNet helps by default:

  • Unknown client routes are rejected
  • Unknown client streams are rejected
  • Client-to-server sync packets are rejected
  • Client-sent Instance values are rejected unless AllowClientInstances is enabled
  • Payloads are capped by argument count, string size, table size, table depth, and estimated byte size
  • Per-player and per-route token buckets limit spam
  • Repeated malformed traffic lowers trust and can throttle or block a player
  • Server-to-client request responses must come from the specific requested player

Stats

local stats = SecNet.GetStats()
local score, level = SecNet.GetTrust(player)

SecNet.GetProfilerSnapshot() is kept as a compatibility alias for SecNet.GetStats().

Tests

The Rojo project maps the integration suite into Studio:

  • ServerScriptService.SecNetServerTestRunner
  • StarterPlayer.StarterPlayerScripts.SecNetClientTestRunner

Run Play or Start Server with at least one client. Passing output includes:

[SecNetTests] Client runner completed.
[SecNetTests] All integration tests passed.

The project also includes a SecNet vs BridgeNet2 speed benchmark:

  • ServerScriptService.NetworkSpeedBenchmarkServer
  • StarterPlayer.StarterPlayerScripts.NetworkSpeedBenchmarkClient

Benchmark output is prefixed with [SecNetBench].

Package Details

Install command (Click to copy)


Version

1.2.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.