RoExpress — structured networking for Roblox
One remote. Every request protected, routed, and rate-limited automatically. Stop creating a new RemoteEvent for every feature in your game — RoExpress gives you a single organised pipeline with built-in exploit protection, typed routes, server push, and a 60hz binary streaming layer for FPS games. Everything your game needs to communicate safely at any scale.
The next big thing in Roblox networking
Every other library solves one problem. ByteNet gives you buffer serialization but nothing else. Warp gives you fast remotes but no structure. BridgeNet gave you batching but no routing, no security, no push — and then got archived. You always ended up stitching three different libraries together just to build one game.
RoExpress does all of it in one place. Typed routes with seven Luau types coerced automatically. A middleware pipeline that blocks exploiters before they touch your handlers. Reliable server push with no extra setup. LZ77 buffer compression that makes large payloads 90% smaller. A 60hz binary streaming layer for FPS games that beats ByteNet on raw packet size. Named ports so a combat flood can never choke your inventory system. An internal event bus so your server modules stop depending on each other. And passive exploit detection that watches every request without you writing a single extra line.
ByteNet is abandoned. Warp is archived. RoExpress is here — actively maintained, documented, and built for games that actually ship.
Why RoExpress?
A typical Roblox game scatters dozens of RemoteEvents with no structure, no rate limiting, and no error handling. RoExpress replaces that with one disciplined pipeline:
Two network channels
| Channel | Remote | Use for |
|---|---|---|
App / Network |
RemoteEvent (reliable) | Data fetches, mutations, server push |
Broadcast / Listener |
UnreliableRemoteEvent | HUD pings, position hints, cosmetic events |
Context access
| Call | Context | Returns |
|---|---|---|
RoExpress("App") |
Server only | App instance |
RoExpress("Network") |
Client only | Network instance |
RoExpress("Broadcast") |
Server only | Broadcast instance |
RoExpress("Listener") |
Client only | Listener instance |
RoExpress("Bridge") |
Both | Shared singleton event bus |
RoExpress("Tamper") |
Server only | Exploit detection singleton |
RoExpress("Stream") |
Both | 60hz binary FPS streaming singleton |
RoExpress("TypeCoercer") |
Both | Type serialisation utility singleton |
RoExpress("Promise") |
Client only | Promise factory (use via GetAsync/PostAsync) |
RoExpress("Base64") |
Both | Base64 utility |
Installation
Drop the RoExpress folder into ReplicatedStorage, install via Wally, or clone from
GitHub. RoExpress creates its own remotes automatically — you don't touch them.
Option A — Wally recommended
Add RoExpress to your wally.toml dependencies:
[dependencies]
RoExpress = "unofficialrobloxtutor/roexpress@2.0.0"
Then run:
wally install
Wally drops it into your Packages/ folder. Require it from there:
local RoExpress = require(game.ReplicatedStorage.Packages.RoExpress)
Option B — Creator Store
Get it directly from the Roblox Creator Store and drop the ModuleScript into ReplicatedStorage.
Option C — Manual / GitHub
Clone or download from GitHub and place the folder in ReplicatedStorage:
game.ReplicatedStorage
└── Modules
└── Libraries
└── RoExpress ← root ModuleScript (init.luau)
├── App ← ModuleScript
├── Network ← ModuleScript
├── Broadcast ← ModuleScript
├── Listener ← ModuleScript
├── Router ← ModuleScript
├── Codec ← ModuleScript
├── Bridge ← ModuleScript
├── Tamper ← ModuleScript
├── Port ← ModuleScript (v2.1)
├── Stream ← ModuleScript (v2.1)
├── TokenBucket ← ModuleScript
└── Base64 ← ModuleScript
Quick Start
Server
local RoExpress = require(game.ReplicatedStorage.Modules.Libraries.RoExpress)
local app = RoExpress("App")
local broadcast = RoExpress("Broadcast")
local bridge = RoExpress("Bridge")
-- middleware — runs before every request
app:Use("logger", function(Player, Payload)
print(Player.Name, Payload.method, Payload.route)
end)
-- typed param — req.params.userId is already a Lua number
app:Get("player/:userId=number", function(Player, Payload, req, res)
res:Send({ userId = req.params.userId })
end)
-- server push — reliable, no client request needed
app:PushAll("roundEnd", { winner = "PlayerName" })
-- internal bus — fire to other server modules
bridge.Fire("playerJoined", { player = game.Players.LocalPlayer })
Client
local RoExpress = require(game.ReplicatedStorage.Modules.Libraries.RoExpress)
local network = RoExpress("Network")
local listener = RoExpress("Listener")
local bridge = RoExpress("Bridge")
-- GET request
network:Get("player/123", nil, function(res)
if res.type == "error" then return end
print(res.data.userId)
end)
-- listen to reliable push AND unreliable broadcast — same API
listener:On("roundEnd", function(data)
print("Winner:", data.winner)
end)
-- yield until an internal event fires (client-side bus)
local data = bridge.Wait("uiReady", 10)
Request Pipeline
Every incoming request flows through a fixed sequence. Each step can terminate the request early with a specific status code.
Status codes
| Code | Meaning |
|---|---|
200 |
Success |
400 |
Version mismatch or invalid typed param |
403 |
Blocked by middleware returning false |
404 |
No route matched |
408 |
Client-side timeout |
429 |
Rate limited by TokenBucket |
500 |
Handler threw / middleware crashed |
App
Handles all incoming client requests. Owns its own Router and TokenBucket instance — no shared global state.
local app = RoExpress("App")
Routing
| Parameter | Type | Description |
|---|---|---|
route |
string | Supports typed params, wildcards, globs, inline constraints. See Router. |
handler |
RouteHandler | (Player, Payload, req, res) → () |
options.compress |
boolean? | Enable LZ77 compression on the response. Default false. |
Middleware
Return false to block with 403. Throw to send 500. Return nothing to continue.
app:Use("auth", function(Player, Payload)
if not isAuth(Player) then return false end
end)
Server Push
Reliable server-to-client events over the RemoteEvent. Received by listener:On(event).
Guaranteed delivery.
req object
| Field | Type | Description |
|---|---|---|
req.params |
{[string]: any} | Named route params, coerced to declared type. |
req.captures |
{any} | Positional wildcard / glob captures. |
req.query |
{[string]: string} | Query string params e.g. ?limit=5 |
req.data |
any | Raw payload from client. |
res object
| Method | Description |
|---|---|
res:Send(data) |
Send success response. Callable once. |
res:Error(message) |
Send error response. Callable once. |
res:Status(code) |
Set status code. Chainable. |
Called when a typed route param fails coercion. If not registered, failures return 400 automatically.
Broadcast
Unreliable fire-and-forget events to clients over the UnreliableRemoteEvent. Subject to per-event and per-player rate limiting. Use for drop-tolerant, high-frequency events.
local broadcast = RoExpress("Broadcast")
Rate limits
| Limit | Value | Behaviour on exceed |
|---|---|---|
| Per event | 20 tokens, refill 10/s | Warn + drop |
| Per player | Shared TokenBucket | Player skipped individually |
| Data cap | 900 bytes | Warn + drop |
| Bucket TTL | 30s idle | Auto cleared |
| Max unique events | 64 | New events dropped |
app:PushAll when delivery must be
guaranteed.Network
Fires requests to the server and resolves responses via callbacks or blocking yields. Client-only.
local network = RoExpress("Network")
| Parameter | Type | Description |
|---|---|---|
route |
string | Route path including params and query strings |
data |
any? | Optional payload |
callback |
NetworkCallback? | Called with NetworkResponse on resolution. If omitted, the call yields the current thread and returns requestId, response directly. |
timeout |
number? | Seconds before 408. Default: 10 |
-- with callback (non-blocking) — returns requestId
local id = network:Get("player/123", nil, function(res)
print(res.data)
end)
-- no callback (blocking) — yields until response arrives
local id, res = network:Post("stats/save", data)
print(res.status, res.data)
-- blocking with timeout override + cancel from another thread
local id, res = network:Post("stats/save", data, nil, 5)
network:Cancel(id) -- cancel if needed
NetworkResponse
| Field | Description |
|---|---|
res.type |
"response" or "error" |
res.status |
HTTP-style status code |
res.data |
Payload — decompressed automatically if server used Codec |
res.message |
Error message (nil on success) |
Listener
Subscribes to events from the server. Handles both unreliable broadcasts and reliable server pushes through a single API — two connections, one interface.
local listener = RoExpress("Listener")
Persistent subscription. Fires every time the event arrives from either channel.
Fires once then auto-unsubscribes. Safe against race conditions — handler is removed before being called.
Removes both On and Once handlers for the event.
Middleware runs before every handler regardless of source channel.
-- both events arrive through the same listener
listener:On("roundEnd", function(data) -- reliable push
UI:ShowWinner(data.winner)
end)
listener:On("killPing", function(data) -- unreliable broadcast
HUD:FlashKill()
end)
Benchmark
Fires real requests through your existing Network instance and measures
round-trip latency. All timing is wall-clock — it covers serialisation, network transit, server handler
execution, and deserialisation. Client-only.
local network = RoExpress("Network")
local Benchmark = require(game.ReplicatedStorage.RoExpress.Benchmark)
local bench = Benchmark.New(network)
Run
Fires iterations sequential requests and returns a statistics table. A warmup pass runs
first and is discarded so JIT and connection overhead don't skew the numbers.
| Config field | Type | Default | Description |
|---|---|---|---|
method | "GET" | "POST" | "GET" | HTTP method |
data | any? | nil | Request payload |
iterations | number? | 20 | Measured requests |
warmup | number? | 3 | Discarded warm-up requests |
interval | number? | 0 | Seconds between requests |
timeout | number? | 10 | Per-request timeout (seconds) |
label | string? | route | Display name in Print output |
Results
| Field | Type | Description |
|---|---|---|
min | number | Fastest request (ms) |
avg | number | Mean RTT (ms) |
median | number | p50 (ms) |
p95 | number | 95th percentile (ms) |
p99 | number | 99th percentile (ms) |
max | number | Slowest request (ms) |
errors | number | Failed request count |
errorCodes | { [number]: number } | Error count per status code |
throughput | number | Requests per second (wall-clock) |
total | number | Total wall time for all iterations (ms) |
Writes a formatted summary to the Studio output window.
── player/123 GET × 20 (warmup: 3) ───────────────
min 4.21 ms
avg 6.83 ms
median 6.10 ms
p95 12.44 ms
p99 18.07 ms
max 18.71 ms
─────────────────────────────────────────────────────
errors 0 / 20
throughput 8.3 req/s
──────────────────────────────────────────────────────
RunAndPrint
Runs and prints in one call. Returns the Results table so you can store it for
Compare.
bench:RunAndPrint("player/123", { method = "GET", iterations = 30 })
Compare
Prints multiple Results side-by-side — useful for comparing routes, methods, or payload sizes.
bench:Compare({
bench:Run("player/123", { label = "GET player" }),
bench:Run("stats/save", { method = "POST", label = "POST stats" }),
bench:Run("leaderboard", { label = "GET board" }),
})
interval = 0 and high iteration counts you will see 429 errors in
results.errorCodes. Set interval = 0.5 to stay under the default limit, or
configure the server bucket to be more permissive during profiling.Drops the internal reference. Does not destroy the Network instance — the caller owns
it.
Router NEW
The advanced route matching engine powering app:Get and app:Post.
Routes are sorted by specificity on registration — most specific always wins regardless of declaration order.
Segment syntax
| Syntax | Example | Description |
|---|---|---|
| Literal | player/coins |
Exact match. Highest priority. |
| Plain param | :name |
Any single segment → string in req.params |
| Typed param | :id=number |
Coerced to declared type in req.params |
| Constrained | :id(\d+) |
Raw string must match Lua pattern |
| Typed + constrained | :id(\d+)=number |
Pattern then coerce |
| Wildcard | * |
One segment → req.captures[n] |
| Glob | ** |
Zero-or-more segments → req.captures[n] as table |
Supported param types
| Type | Wire format | Lua result |
|---|---|---|
string |
"hello" |
"hello" |
number |
"42" |
42 |
boolean |
"true" / "1" |
true |
vector2 |
"1,2" |
Vector2.new(1,2) |
vector3 |
"1,2,3" |
Vector3.new(1,2,3) |
color3 |
"255,0,128" |
Color3.fromRGB(255,0,128) |
cframe |
"0,5,0,0,90,0" |
CFrame with position + euler angles |
-- all four coexist, priority order is automatic
app:Get("player/:id=number/coins", h1) -- wins for "player/123/coins"
app:Get("player/:id/coins", h2)
app:Get("player/*/coins", h3) -- req.captures[1] = segment
app:Get("player/**", h4) -- req.captures[1] = table
Server Push NEW
Reliable one-way server-to-client communication. No request needed — the server initiates. The client receives via the existing Listener.
Push vs Broadcast
| Push (app:Push) | Broadcast (broadcast:Emit) | |
|---|---|---|
| Remote | Reliable RemoteEvent | UnreliableRemoteEvent |
| Delivery | Guaranteed | May drop under load |
| Rate limited | No (server-initiated) | Yes — per-event + per-player |
| Use for | Inventory, state sync, round events | HUD pings, position hints |
-- server
app:Push(player, "inventoryUpdate", { item = "sword" })
app:PushAll("roundEnd", { winner = "PlayerA" })
app:PushTo({ p1, p2 }, "zoneAlert", { zoneId = 3 })
-- client — same listener, zero API change
listener:On("inventoryUpdate", function(data) print(data.item) end)
listener:Once("roundEnd", function(data) UI:ShowWinner(data.winner) end)
type = "push" internally. Listener filters them so Network
response packets on the same remote are never intercepted.Codec NEW
LZ77 sliding-window compression using Roblox's native buffer type. Opt-in per
route. Completely transparent to both the handler and the client callback.
-- server — add { compress = true }
app:Get("feed/all", function(Player, Payload, req, res)
res:Send(bigTable) -- auto-compressed
end, { compress = true })
-- client — arrives already decompressed
network:Get("feed/all", nil, function(res)
print(res.data) -- plain Lua table
end)
How it works
The payload is JSON-encoded, compressed with LZ77 (4096-byte sliding window, minimum match 4 bytes), prefixed
with a 2-byte magic header 0xC0 0xDE, then base64-encoded for transport. Network detects the
magic header and decompresses before your callback fires.
Direct API
local Codec = require(script.Parent.Codec)
Codec.Compress(data) -- any → base64 string
Codec.Decompress(str) -- base64 string → any
Codec.IsCompressed(str) -- → boolean
Bridge NEW
A shared internal event bus — a structured replacement for raw BindableEvents. Available in both server and client contexts as a singleton. No remote involved: this is purely in-process communication between modules within the same context.
app:Push and broadcast:Emit for that. Bridge is for decoupling modules
within the same context.
local bridge = RoExpress("Bridge") -- same instance everywhere in this context
Core API
Register a handler on a named channel. Multiple handlers per channel are supported — all fire in registration order.
Remove a specific handler by reference. Logs a warning if not found.
Clear all handlers on one channel, or every channel if no name is given.
Fire to all handlers on the channel. Handlers are snapshot'd before iteration so Unbind inside a
handler is safe. Each handler is pcall'd — a crash in one doesn't stop the rest.
Returns true if the channel has at least one bound handler. Useful to guard Fire calls in hot
paths.
-- ModuleA (DataService) — no reference to App needed
bridge.Bind("playerDataLoaded", function(data)
LeaderboardService.Refresh(data.userId)
end)
-- ModuleB (PlayerService) — just fire, no coupling
bridge.Fire("playerDataLoaded", { userId = player.UserId, coins = 500 })
Yieldable variants
All three yield the current coroutine and resume it when the condition is met or the timeout expires. A temporary internal handler is registered and always cleaned up — no coroutine leaks.
Yields until the named channel fires once. Returns the data, or nil on timeout. Default timeout:
10 seconds.
Yields until the channel fires AND the predicate returns true. Non-matching fires are silently skipped — the coroutine stays yielded.
Yields until any one of the listed channels fires. Returns the winning channel name and its data. All internal handlers on losing channels are cleaned up after resolution.
-- Wait — yield until channel fires
local data = bridge.Wait("round.start", 30)
if data then print("started:", data.duration) end
-- WaitUntil — skip fires that don't match predicate
local data = bridge.WaitUntil("kill", function(d)
return d.victim == LocalPlayer.Name
end, 60)
-- WaitFirst — whichever channel fires first wins
local event, data = bridge.WaitFirst({
"round.start",
"server.shutdown"
}, 60)
if event == "round.start" then
print("Round started")
elseif event == "server.shutdown" then
print("Server going down")
else
print("Timed out")
end
Wait when bridge.Destroy() is called will stay yielded permanently. Call
Destroy only on full shutdown or test teardown.
Ports v2.1
Named isolated request pipelines. Each port owns its own RemoteEvent, Router, and TokenBucket. Global App middleware is inherited automatically. The direct answer to the MMO scaling problem — a combat flood cannot choke an inventory request when they run on separate ports.
Server
local app = RoExpress("App")
-- global middleware — inherited by ALL ports automatically
app:Use("logger", function(Player, Payload)
print(Player.Name, Payload.method, Payload.route)
end)
-- combat port — higher rate limit for fast-paced traffic
app:Listen("combat", function(port)
port:Post("gun/fire/:damage=number", handler)
port:Get("player/:id=number/health", handler)
end, { Max = 30, Refill = 10 })
-- inventory port — port-local auth middleware
app:Listen("inventory", function(port)
port:Use("auth", function(Player, Payload)
if not Sessions[Player.UserId] then return false end
end)
port:Get("player/:id=number/items", handler)
port:Post("player/buy", handler)
end)
-- push to a port outside its callback
app:GetPort("combat"):PushAll("roundEnd", { winner = "PlayerA" })
Client
-- connect to named port remotes
local combat = RoExpress("Network", "combat")
local inventory = RoExpress("Network", "inventory")
local combatPush = RoExpress("Listener", "combat")
combat:Post("gun/fire/50", nil, callback)
inventory:Get("player/123/items", nil, callback)
combatPush:On("roundEnd", function(data)
print(data.winner)
end)
app:Listen API
Creates a named port. Each port gets its own RemoteEvent in
ReplicatedStorage/RoExpressPorts. The callback receives the port instance for route registration.
Returns a registered port by name. Useful for pushing outside the Listen callback.
Port API
Ports expose the same API as App — Get, Post, Use, Unuse,
Push, PushAll, PushTo, OnParamError. Port-local middleware
runs after inherited App middleware.
Infrastructure
| Item | Detail |
|---|---|
| Remote location | ReplicatedStorage/RoExpressPorts/{name} |
| Default rate limit | Max 10, Refill 2/s (same as App) |
| Middleware inheritance | App global middleware runs first, then port-local |
| Tamper integration | Full — all signals fire with port name in evidence |
Stream v2.1
60hz binary state streaming for FPS games, MMOs, and anything requiring high-frequency position
synchronisation. Raw buffer packing — no JSON, no Base64. Up to 90% smaller packets than
traditional approaches.
Wire format
| Packet | Size | vs JSON | Contents |
|---|---|---|---|
| Player state | 21 bytes | ~90% smaller | userId, position, rotY, flags, health |
| Projectile | 18 bytes | ~85% smaller | id, position, velocity XY |
| Hit | 10 bytes | ~87% smaller | victimUserId, damage, hitPart, timestamp |
| Weapon state | 3 bytes | ~92% smaller | weaponId, state |
| World broadcast (20 players) | 420 bytes | ~89% smaller | all player states in one buffer |
Two dedicated remotes
| Remote | Type | Used for |
|---|---|---|
StreamState |
UnreliableRemoteEvent | Position, rotation, flags — drop-tolerant at 60hz |
StreamEvent |
RemoteEvent | Hits, weapon state, projectile lifecycle — reliable |
Server
local stream = RoExpress("Stream")
-- opt-in lag compensation
stream.EnableLagCompensation({ windowMs = 200, tickRate = 60 })
-- receive player state updates (up to 60x/second per player)
stream.OnState(function(player, state)
worldStates[player.UserId] = state
end)
-- validate hits server-side
stream.OnHit(function(player, hit, valid)
if not valid then
tamper.Strike(player, "Invalid hit")
return
end
applyDamage(hit.victimUserId, hit.damage, hit.hitPart)
stream.ConfirmHit(victim, hit)
end)
-- broadcast world state at 60hz — one remote call for all players
RunService.Heartbeat:Connect(function()
stream.Broadcast(worldStates)
end)
Client
local stream = RoExpress("Stream")
-- send position at 60hz
RunService.Heartbeat:Connect(function()
stream.SendState({
userId = LocalPlayer.UserId,
position = HRP.Position,
rotY = math.deg(math.atan2(HRP.CFrame.LookVector.X, HRP.CFrame.LookVector.Z)),
health = Humanoid.Health,
firing = isFiring,
ads = isADS,
jumping = isJumping,
})
end)
-- receive world state and interpolate other players
stream.OnWorld(function(states)
for _, state in ipairs(states) do
if state.userId ~= LocalPlayer.UserId then
interpolateCharacter(state)
end
end
end)
-- report a hit
stream.SendHit({
victimUserId = victim.UserId,
damage = 35,
hitPart = stream.HitPart.HEAD,
})
-- weapon state change
stream.SendWeapon({ weaponId = 1, state = stream.WeaponState.RELOADING })
Full API
| Method | Context | Description |
|---|---|---|
stream.OnState(fn) |
Server | Receive player state updates |
stream.OnHit(fn) |
Server | Receive hit reports with lag-comp validity flag |
stream.OnProjectile(fn) |
Server | Receive projectile position updates |
stream.OnWeapon(fn) |
Server | Receive weapon state changes |
stream.Broadcast(states) |
Server | Push all player states in one buffer |
stream.BroadcastTo(players, states) |
Server | Zone-based streaming to a subset |
stream.ConfirmHit(player, hit) |
Server | Send hit confirmation to victim |
stream.EnableLagCompensation(settings) |
Server | Enable snapshot ring buffer + hit validation |
stream.SendState(state) |
Client | Send position/state to server at up to 60hz |
stream.SendHit(hit) |
Client | Report a hit — timestamp auto-stamped |
stream.SendProjectile(proj) |
Client | Send projectile position update |
stream.SendWeapon(weapon) |
Client | Report weapon state change |
stream.OnWorld(fn) |
Client | Receive world state broadcast |
stream.OnHitConfirmed(fn) |
Client | Server confirmed a hit this client reported |
Constants
stream.HitPart.HEAD -- 0
stream.HitPart.TORSO -- 1
stream.HitPart.LIMB -- 2
stream.WeaponState.IDLE -- 0
stream.WeaponState.FIRING -- 1
stream.WeaponState.RELOADING -- 2
stream.WeaponState.ADS -- 3
stream.WeaponState.EMPTY -- 4
stream.Flags.JUMPING stream.Flags.CROUCHING stream.Flags.ADS
stream.Flags.RELOADING stream.Flags.FIRING stream.Flags.DEAD
stream.Flags.SPRINTING
Lag compensation
When enabled the server maintains a ring buffer of world snapshots. Hit packets carry a u16
timestamp (ms since stream start). On receipt the server finds the closest snapshot and validates the hit
geometrically before passing valid = true to your OnHit handler.
stream.EnableLagCompensation({
windowMs = 200, -- store 200ms of snapshots
tickRate = 60, -- 60 snapshots/second = 12 snapshots max
})
TokenBucket
Per-instance rate limiter. App and Broadcast each own independent instances — they can never affect each other's state. Buckets are seeded for players already in the server on creation.
-- exposed on the App instance
local tb = app.TokenBucket
tb:GrantAll(5) -- reward burst after round
tb:GrantExact(winnerPlayer, 10) -- ignores Max cap
tb:Reset(player) -- refill to Max immediately
Methods
| Method | Description |
|---|---|
tb:Consume(player, cost) |
Returns false if insufficient tokens |
tb:HasTokens(player) |
Returns true if any tokens remain |
tb:HasEnoughTokens(player, cost) |
Returns true if ≥ cost tokens remain |
tb:Grant(player, amount) |
Add tokens up to Max |
tb:GrantAll(amount) |
Add tokens to all players up to Max |
tb:GrantExact(player, amount) |
Add tokens ignoring Max |
tb:GrantAllExact(amount) |
Add to all ignoring Max |
tb:Reset(player) |
Refill to Max immediately |
tb:Destroy() |
Disconnect events, clear all buckets |
Default: Max = 10, Refill = 2 tokens/second.
Base64
Lightweight encoder/decoder. Used internally for POST responses. Available directly if needed.
local Base64 = RoExpress("Base64")
Base64.Encode("hello") -- → base64 string
Base64.Decode("aGVsbG8=") -- → "hello"
Base64.EncodeTable({ x = 1 }) -- JSONEncode then base64
Base64.DecodeTable("eyJ4IjoxfQ==") -- base64 then JSONDecode
Exported Types
local RoExpress = require(path.RoExpress)
-- envelope
type Payload = RoExpress.Payload
type Request = RoExpress.Request
type Response = RoExpress.Response
type NetworkResponse = RoExpress.NetworkResponse
type BroadcastEnvelope = RoExpress.BroadcastEnvelope
-- handlers
type RouteHandler = RoExpress.RouteHandler
type MiddlewareHandler = RoExpress.MiddlewareHandler -- (Player, Payload) -> boolean?
type BroadcastHandler = RoExpress.BroadcastHandler
type ListenerMiddleware = RoExpress.ListenerMiddleware -- (event, data) -> ()
type NetworkCallback = RoExpress.NetworkCallback
-- modules (server)
type App = RoExpress.App
type Broadcast = RoExpress.Broadcast
type Port = RoExpress.Port
type Stream = RoExpress.Stream
type Tamper = RoExpress.Tamper
type TokenBucket = RoExpress.TokenBucket
-- modules (client)
type Network = RoExpress.Network
type Listener = RoExpress.Listener
type Promise = RoExpress.Promise
-- modules (shared)
type Bridge = RoExpress.Bridge
type Router = RoExpress.Router
type Codec = RoExpress.Codec
-- bridge sub-types
type BridgeHandler = Bridge.Handler
type BridgePredicate = Bridge.Predicate
-- router sub-types
type ParamType = RoExpress.ParamType
type SegmentKind = RoExpress.SegmentKind
type MatchResult = RoExpress.MatchResult
Tamper NEW
Exploit detection and tamper reporting. Passively hooks into App's request pipeline and surfaces suspicious activity as structured reports. Server-only singleton.
local tamper = RoExpress("Tamper")
-- subscribe to all detection events
tamper.On(function(report)
print(report.player.Name, report.reason, report.severity, report.strikes)
end)
-- optional: auto-kick at 10 strikes
tamper.AutoKick(10, "Exploiting detected")
Detection tiers
| Reason | Tier | Trigger |
|---|---|---|
VERSION_SPOOF |
immediate | Client version doesn't match server |
MALFORMED_PAYLOAD |
immediate | Payload fails structure validation |
INVALID_PARAM |
immediate | Typed param coercion fails |
UNKNOWN_ROUTE |
immediate | Route does not exist on the server |
RATE_FLOOD |
pattern | Repeated 429s within a short window |
ROUTE_SCAN |
pattern | Many distinct unknown routes fired |
PARAM_FLOOD |
pattern | Repeated param failures on same route |
MANUAL |
immediate | Developer called tamper.Strike() directly |
Report object
| Field | Type | Description |
|---|---|---|
report.player |
Player | The player who triggered the detection |
report.reason |
Reason | Named reason string (see table above) |
report.severity |
"immediate" | "pattern" | Detection tier |
report.route |
string? | Route involved, if applicable |
report.evidence |
any? | Raw context — payload, values, counts |
report.strikes |
number | Total accumulated strikes for this player |
report.firstSeen |
number | tick() of first strike |
report.lastSeen |
number | tick() of this strike |
Manual strikes
Use tamper.Strike() inside your own route handlers for business-logic violations Tamper can't
detect generically — negative currency, impossible positions, out-of-range values.
app:Post("shop/buy", function(Player, Payload, req, res)
if req.data.amount < 0 then
tamper.Strike(Player, "Negative purchase amount", "shop/buy", req.data)
res:Status(400):Error("Invalid amount")
return
end
end)
Full API
Register the detection handler. One handler supported — calling again replaces the previous.
Enable auto-kick when a player reaches the strike threshold. Fires after the handler so you can log first.
Manually issue an immediate strike from your own validation logic.
Returns the full accumulated record for a player, or nil if no strikes on record.
Returns the current strike count. Returns 0 if no record exists.
Reset a player's record. Useful after a false positive or manual review.
Reset all player records. Useful on round transitions.
tamper.SetThresholds({
rateFloodWindow = 10, -- seconds before rate flood window resets
rateFloodCount = 5, -- 429s within window to trigger pattern strike
routeScanCount = 8, -- distinct unknown routes to trigger scan strike
paramFloodCount = 5, -- repeated param fails on same route
})
Full example
local tamper = RoExpress("Tamper")
tamper.AutoKick(10, "Cheating detected")
tamper.On(function(report)
if report.severity == "immediate" then
warn(string.format(
"[Tamper] %s — %s on '%s' (strike %d)",
report.player.Name, report.reason,
report.route or "?", report.strikes
))
elseif report.severity == "pattern" then
warn(string.format(
"[Tamper] PATTERN DETECTED — %s: %s (total strikes: %d)",
report.player.Name, report.reason, report.strikes
))
-- notify admins, log to external service, etc.
end
end)
Kill Feed
Touches every layer — typed params, compression, server push, broadcast, wildcard routes, and middleware.
Server
local app = RoExpress("App")
local broadcast = RoExpress("Broadcast")
local bridge = RoExpress("Bridge")
local killFeed = {}
app:Use("logger", function(Player, Payload)
print(Player.Name, Payload.method, Payload.route)
end)
app:Post("stats/save", function(Player, Payload, req, res)
local entry = { killer=req.data.killerName, victim=req.data.victimName, weapon=req.data.weapon, timestamp=os.time() }
table.insert(killFeed, 1, entry)
app:PushAll("killFeed.entry", entry) -- reliable
broadcast:EmitAll("killFeed.ping", { killer=req.data.killerName, victim=req.data.victimName }) -- unreliable
bridge.Fire("kill.registered", entry) -- internal bus
res:Send({ success = true })
end)
app:Get("stats/:userId=number", function(Player, Payload, req, res)
res:Send(stats[req.params.userId]) -- userId already a number
end)
app:Get("feed/all", handler, { compress = true })
app:Get("feed/recent", handler) -- ?limit=20
app:Get("feed/*/kills", handler) -- req.captures[1] = period
Client
listener:On("killFeed.entry", function(data) FeedUI:Add(data) end)
listener:On("killFeed.ping", function(data) HUD:Flash(data) end)
network:Get("feed/all", nil, function(res) FeedUI:Load(res.data.entries) end)
Round Manager
Countdown via broadcast ticks, start/end via reliable push, Bridge for internal module coordination.
local ROUND_TIME = 120
local roundActive = false
app:Post("round/start", function(Player, Payload, req, res)
if roundActive then res:Status(400):Error("Already active"); return end
roundActive = true
app:PushAll("round.start", { duration = ROUND_TIME })
bridge.Fire("round.began", { duration = ROUND_TIME }) -- notify server modules
task.spawn(function()
local t = ROUND_TIME
while t > 0 and roundActive do
broadcast:EmitAll("round.tick", { timeLeft = t })
task.wait(1); t -= 1
end
if roundActive then
roundActive = false
app:PushAll("round.end", { reason = "timeout" })
end
end)
res:Send({ started = true })
end)
app:Post("round/end", function(Player, Payload, req, res)
roundActive = false
app:PushAll("round.end", { winner = req.data.winner })
app.TokenBucket:GrantAll(5)
res:Send({ ok = true })
end)
-- another module waits for round start without coupling
bridge.Bind("round.began", function(data)
SpawnService.SpawnAllPlayers()
end)
Shop / Purchase Flow
Server-authoritative — validates stock and currency, deducts, then pushes the inventory update reliably.
app:Get("shop/catalogue", function(Player, Payload, req, res)
res:Send(catalogue)
end)
app:Get("shop/balance/:userId=number", function(Player, Payload, req, res)
res:Send({ balance = currency[req.params.userId] or 0 })
end)
app:Post("shop/buy", function(Player, Payload, req, res)
local item = catalogue[req.data and req.data.itemId]
if not item then res:Status(404):Error("Not found"); return end
if item.stock <= 0 then res:Status(400):Error("Out of stock"); return end
if (currency[Player.UserId] or 0) < item.price then res:Status(400):Error("Insufficient funds"); return end
currency[Player.UserId] -= item.price; item.stock -= 1
app:Push(Player, "shop.purchased", { item = req.data.itemId, balance = currency[Player.UserId] })
res:Send({ success = true })
end)
Admin Commands
Route-level auth via middleware, typed boolean and number params, push-based announcements.
local ADMINS = { [123456789] = true }
app:Use("admin-guard", function(Player, Payload)
if Payload.route:match("^admin/") and not ADMINS[Player.UserId] then
return false -- 403
end
end)
app:Post("admin/kick/:userId=number", function(Player, Payload, req, res)
local target = game.Players:GetPlayerByUserId(req.params.userId)
if not target then res:Status(404):Error("Not in server"); return end
target:Kick(req.data and req.data.reason or "Kicked")
res:Send({ kicked = target.Name })
end)
app:Post("admin/god/:userId=number/:state=boolean", function(Player, Payload, req, res)
-- req.params.state is already a boolean, req.params.userId already a number
app:Push(game.Players:GetPlayerByUserId(req.params.userId), "admin.god", { enabled = req.params.state })
res:Send({ ok = true })
end)
app:Post("admin/announce", function(Player, Payload, req, res)
app:PushAll("admin.announce", { message = req.data.message, from = Player.Name })
res:Send({ ok = true })
end)
Friend Zone / Proximity
Server zone detection loop pushes enter/exit events reliably. Client fetches nearby players on demand.
local zones = { spawn = { center=Vector3.new(0,0,0), radius=30 } }
local playerZone = {}
app:Get("zone/:name/players", function(Player, Payload, req, res)
if not zones[req.params.name] then res:Status(404):Error("Unknown zone"); return end
local inside = {}
for uid, z in pairs(playerZone) do
if z == req.params.name then table.insert(inside, game.Players:GetPlayerByUserId(uid).Name) end
end
res:Send({ players = inside })
end)
task.spawn(function()
while true do
task.wait(2)
for _, player in ipairs(game.Players:GetPlayers()) do
local char = player.Character; if not char then continue end
local pos = char:GetPivot().Position
local prev = playerZone[player.UserId]; local curr = nil
for name, zone in pairs(zones) do
if (pos - zone.center).Magnitude <= zone.radius then curr = name; break end
end
if curr ~= prev then
playerZone[player.UserId] = curr
if curr then app:Push(player, "zone.enter", { zone=curr })
elseif prev then app:Push(player, "zone.exit", { zone=prev }) end
end
end
end
end)
Leaderboard
Compressed paginated fetch on join, live push on score change so clients stay current without polling.
app:Get("leaderboard/top", function(Player, Payload, req, res)
local limit = math.min(tonumber(req.query.limit) or 10, 100)
local offset = tonumber(req.query.offset) or 0
res:Send({ entries = slice(scores, offset, limit), total = #scores })
end, { compress = true })
app:Get("leaderboard/rank/:userId=number", function(Player, Payload, req, res)
local entry = findByUserId(scores, req.params.userId)
if not entry then res:Status(404):Error("Not ranked"); return end
res:Send(entry)
end)
app:Post("leaderboard/submit", function(Player, Payload, req, res)
upsertScore(Player, req.data.score)
rebuildRankings()
app:PushAll("leaderboard.update", { top10 = { table.unpack(scores,1,10) } })
res:Send({ rank = getRank(Player) })
end)
Player Data Save & Load
Push data on join so no client request is needed. Bridge notifies other server modules when data is ready.
game.Players.PlayerAdded:Connect(function(player)
local ok, data = pcall(store.GetAsync, store, tostring(player.UserId))
data = (ok and data) or defaultData()
app:Push(player, "data.loaded", data) -- push to client
bridge.Fire("playerDataReady", { player=player, data=data }) -- notify server modules
end)
app:Get("data/me", function(Player, Payload, req, res)
local ok, data = pcall(store.GetAsync, store, tostring(Player.UserId))
res:Send((ok and data) or defaultData())
end, { compress = true })
app:Post("data/save", function(Player, Payload, req, res)
local ok, err = pcall(store.SetAsync, store, tostring(Player.UserId), req.data)
if not ok then res:Status(500):Error(err); return end
res:Send({ saved = true })
end)
Matchmaking Queue
Join/leave via POST, position updates via push, PushTo fires the match-found
notification to exactly the right players. Bridge signals an internal match-ready event.
local MATCH_SIZE = 4; local queue = {}
local function pushPositions()
for i, p in ipairs(queue) do app:Push(p, "queue.position", { pos=i, total=#queue }) end
end
app:Post("queue/join", function(Player, Payload, req, res)
table.insert(queue, Player); pushPositions()
res:Send({ position = #queue })
if #queue >= MATCH_SIZE then
local match = {}
for i = 1, MATCH_SIZE do table.insert(match, table.remove(queue, 1)) end
local names = {}; for _, p in ipairs(match) do table.insert(names, p.Name) end
app:PushTo(match, "queue.matched", { players = names })
bridge.Fire("match.ready", { players = match }) -- notify server modules
pushPositions()
end
end)
app:Post("queue/leave", function(Player, Payload, req, res)
for i, p in ipairs(queue) do
if p == Player then table.remove(queue, i); pushPositions(); res:Send({ left=true }); return end
end
res:Status(404):Error("Not in queue")
end)
MVC Pattern with RoExpress
Model-View-Controller is a way of organising your game's server code so networking, data, and logic stay cleanly separated. RoExpress maps naturally onto MVC — routes are your Controllers, DataStore modules are your Models, and what you send to clients is your View.
The pattern
Without MVC — the common mess
-- everything in one script, no separation
app:Post("shop/buy", function(Player, Payload, req, res)
-- networking + data access + business logic all mixed together
local ok, data = pcall(store.GetAsync, store, Player.UserId)
if not ok then res:Status(500):Error("DB error"); return end
if data.coins < 100 then res:Status(400):Error("Poor"); return end
data.coins -= 100
data.inventory[req.data.itemId] = true
pcall(store.SetAsync, store, Player.UserId, data)
app:Push(Player, "shop.purchased", data.inventory)
res:Send({ ok = true })
end)
With MVC — clean separation
Model — PlayerModel.luau
-- pure data access, no networking knowledge
local PlayerModel = {}
local store = DataStoreService:GetDataStore("Players")
function PlayerModel.Get(userId: number)
local ok, data = pcall(store.GetAsync, store, userId)
return ok and data or { coins = 0, inventory = {} }
end
function PlayerModel.Save(userId: number, data: any)
return pcall(store.SetAsync, store, userId, data)
end
function PlayerModel.DeductCoins(data: any, amount: number): boolean
if data.coins < amount then return false end
data.coins -= amount
return true
end
return PlayerModel
Controller — ShopController.luau
-- handles routing only, delegates to model
local PlayerModel = require(script.Parent.PlayerModel)
local app = RoExpress("App")
app:Post("shop/buy", function(Player, Payload, req, res)
local data = PlayerModel.Get(Player.UserId)
if not PlayerModel.DeductCoins(data, 100) then
res:Status(400):Error("Insufficient funds")
return
end
data.inventory[req.data.itemId] = true
local ok = PlayerModel.Save(Player.UserId, data)
if not ok then
res:Status(500):Error("Save failed")
return
end
-- View: push the updated inventory to the client
app:Push(Player, "shop.purchased", {
inventory = data.inventory,
newBalance = data.coins,
})
res:Send({ ok = true })
end)
Wiring it together with Bridge
Bridge lets models notify other modules without knowing about networking. Instead of models importing App or Broadcast, they fire a Bridge event and the Controller decides what to push.
-- Model fires a Bridge event — no networking knowledge needed
function PlayerModel.Save(userId, data)
local ok = pcall(store.SetAsync, store, userId, data)
if ok then
bridge.Fire("player.saved", { userId = userId, data = data })
end
return ok
end
-- Controller listens and decides what to push
bridge.Bind("player.saved", function(payload)
local player = Players:GetPlayerByUserId(payload.userId)
if player then
app:Push(player, "data.synced", payload.data)
end
end)
Recommended folder structure
Middleware Guide
Middleware runs before every request in registration order. It's the right place for logging, auth, and cross-cutting concerns.
Blocking
app:Use("auth", function(Player, Payload)
if not Sessions[Player.UserId] then return false end -- 403
end)
Route-scoped guard
app:Use("admin-only", function(Player, Payload)
if Payload.route:match("^admin/") and not Admins[Player.UserId] then
return false
end
end)
Analytics preset
app:Use("analytics", function(Player, Payload)
print(string.format("[%s] %s %s %s",
os.date("%H:%M:%S"), Player.Name, Payload.method, Payload.route))
end)
YouTube Channel
Video tutorials, deep-dives, and build-alongs covering RoExpress and Roblox game development.
| Content | Description |
|---|---|
| RoExpress tutorials | Setup, routing, push, Bridge, compression |
| Example walkthroughs | Video breakdowns of every example in these docs |
| Roblox scripting | Luau, OOP, DataStore, services |
| Dev vlogs | Behind the scenes on new features |
Find me everywhere
Support the Project
RoExpress is free, MIT licensed, and always will be. If it's saved you time, a donation goes a long way toward keeping development active.
Send any amount directly. Every dollar funds new features and docs.
Already in Roblox? Visit the profile and send Robux directly.
Find me everywhere
Promise Guide
RoExpress ships a built-in Promise implementation for Network requests. Use
GetAsync and PostAsync instead of Get and Post when you
prefer chainable async code over callbacks.
Basic usage
local network = RoExpress("Network")
-- GetAsync returns requestId AND a chainable Promise
local id, promise = network:GetAsync("player/123")
promise
:Then(function(res)
print(res.data.coins)
end)
:Catch(function(err)
warn(err.status, err.message)
end)
:Finally(function()
UI:HideLoader()
end)
Chaining Then handlers
Returning a value from a Then handler passes it to the next Then. This lets you
transform data through the chain cleanly.
local _, promise = network:GetAsync("leaderboard/top")
promise
:Then(function(res)
return res.data.entries -- pass entries to next Then
end)
:Then(function(entries)
for _, entry in ipairs(entries) do
UI:AddRow(entry)
end
end)
:Catch(function(err)
UI:ShowError(err.message)
end)
Error handling
If a Then handler throws, the chain falls through to Catch. You never need to wrap
Then handlers in pcall.
local _, promise = network:PostAsync("stats/save", data)
promise
:Then(function(res)
if res.data.saved then
error("intentional error") -- falls through to Catch
end
end)
:Catch(function(err)
warn("caught:", err.message or err)
end)
Cancellation
-- GetAsync returns requestId so you can cancel by id or via the promise
local id, promise = network:GetAsync("feed/all")
promise:Then(function(res) UI:LoadFeed(res.data) end)
-- cancel by request id (from any thread)
network:Cancel(id)
-- or cancel via the promise directly
promise:Cancel()
State checks
promise:IsPending() -- true while waiting
promise:IsResolved() -- true after successful response
promise:IsRejected() -- true after error or timeout
Callback vs Promise — when to use which
| Use callbacks when | Use promises when |
|---|---|
| Simple one-off request | You need to chain transformations |
Blocking yield — you need requestId and response inline |
You want clean error separation |
| Legacy code | Multiple async steps in sequence |
TypeCoercer Guide
TypeCoercer is the shared serialisation utility that powers all route param coercion. Use it directly to convert RoExpress types to wire strings and back — useful when building route URLs dynamically or validating data outside of routes.
Getting it
local tc = RoExpress("TypeCoercer") -- available server and client
ToString — any type to wire string
tc.ToString(Vector3.new(10, 5, -20)) -- "10,5,-20"
tc.ToString(Color3.fromRGB(255, 0, 128)) -- "255,0,128"
tc.ToString(CFrame.new(0,5,0)) -- "0,5,0,0,0,0,1" (quaternion)
tc.ToString(Enum.Material.Grass) -- "Grass"
tc.ToString(workspace.Baseplate) -- "Part:Baseplate"
tc.ToString(true) -- "true"
tc.ToString(42) -- "42"
FromString — wire string to Lua value
local ok, val = tc.FromString("10,5,-20", "vector3")
-- ok = true, val = Vector3.new(10,5,-20)
local ok, val = tc.FromString("Running", "Enum.HumanoidStateType")
-- ok = true, val = Enum.HumanoidStateType.Running
local ok, val = tc.FromString("Part:Baseplate", "Instance")
-- ok = true, val = workspace.Baseplate (if registered root contains it)
Instance search roots
Register roots before using Instance coercion. Multiple roots are searched in registration order.
-- register once on startup
tc.RegisterInstanceRoot("Workspace", workspace)
tc.RegisterInstanceRoot("Players", game:GetService("Players"))
tc.RegisterInstanceRoot("Teams", game:GetService("Teams"))
-- now Instance params work in routes
app:Get("target/:t=Instance", function(Player, Payload, req, res)
print(req.params.t.Name) -- actual Instance
end)
Building route URLs dynamically
local pos = HRP.Position
local url = "spawn/" .. tc.ToString(pos)
-- "spawn/10.5,5,-20.3"
network:Post(url, data)
All supported types
| Type name | Wire format | Lua result |
|---|---|---|
string |
"hello" |
string |
boolean |
"true" / "1" |
boolean |
number |
"42.5" |
number |
int |
"42" (whole only) |
number (integer) |
vector2 |
"x,y" |
Vector2 |
vector3 |
"x,y,z" |
Vector3 |
color3 |
"r,g,b" (0-255 or 0-1) |
Color3 |
cframe |
"x,y,z,qX,qY,qZ,qW" |
CFrame (quaternion) |
Enum.TypeName |
"ValueName" or number |
EnumItem |
Instance |
"ClassName:Name" |
Instance |
FPS Stream Guide
A complete walkthrough for wiring Stream into an FPS game — from setup through lag-compensated hit registration.
Why Stream?
Traditional Roblox networking sends JSON over RemoteEvents. A single player state update in JSON is ~200 bytes. With 20 players at 60hz that's 240,000 bytes per second just for position data. Stream packs the same data into 21 bytes — a world broadcast for 20 players is 420 bytes, sent once per tick.
Step 1 — Server setup
-- ServerScript
local stream = RoExpress("Stream")
local tamper = RoExpress("Tamper")
local worldStates = {}
-- opt-in lag compensation (200ms window, 60hz)
stream.EnableLagCompensation({ windowMs = 200, tickRate = 60 })
-- store incoming player states
stream.OnState(function(player, state)
worldStates[player.UserId] = state
end)
-- validate hits
stream.OnHit(function(player, hit, valid)
if not valid then
tamper.Strike(player, "Invalid hit", "stream", hit)
return
end
local victim = Players:GetPlayerByUserId(hit.victimUserId)
if victim then
applyDamage(victim, hit.damage, hit.hitPart)
stream.ConfirmHit(victim, hit)
end
end)
-- broadcast world at 60hz
RunService.Heartbeat:Connect(function()
local states = {}
for _, state in pairs(worldStates) do
table.insert(states, state)
end
if #states > 0 then
stream.Broadcast(states)
end
end)
-- clean up on player leave
Players.PlayerRemoving:Connect(function(player)
worldStates[player.UserId] = nil
end)
Step 2 — Client setup
-- LocalScript
local stream = RoExpress("Stream")
local HRP = LocalPlayer.Character:WaitForChild("HumanoidRootPart")
-- send state at 60hz
RunService.Heartbeat:Connect(function()
if not LocalPlayer.Character then return end
stream.SendState({
userId = LocalPlayer.UserId,
position = HRP.Position,
rotY = math.deg(math.atan2(
HRP.CFrame.LookVector.X,
HRP.CFrame.LookVector.Z
)),
health = Humanoid.Health,
firing = isFiring,
ads = isADS,
jumping = Humanoid.FloorMaterial == Enum.Material.Air,
})
end)
-- interpolate other players
stream.OnWorld(function(states)
for _, state in ipairs(states) do
if state.userId ~= LocalPlayer.UserId then
interpolateRemoteCharacter(state)
end
end
end)
-- report a hit
local function onHit(victim, damage, hitPart)
stream.SendHit({
victimUserId = victim.UserId,
damage = damage,
hitPart = hitPart,
})
end
-- confirmed hit feedback
stream.OnHitConfirmed(function(hit)
UI:ShowHitMarker(hit.hitPart == stream.HitPart.HEAD)
end)
CFrame between the last two received
states works well for most FPS games.Ports Guide
When to use ports, how to structure them, and how to avoid common mistakes.
When to use ports
Ports solve one specific problem — traffic isolation. You need ports when two domains of traffic could realistically saturate a shared remote and affect each other.
| Good use case | Not needed |
|---|---|
| Combat + inventory in an MMO | A simple game with 5-10 routes |
| Admin routes isolated from gameplay | Routes that fire rarely |
| Chat isolated from game state | Routes that are already rate-limited |
Structure recommendation
local app = RoExpress("App")
-- global middleware — always runs first on every port
app:Use("version-check", function(Player, Payload) end)
app:Use("logger", function(Player, Payload) end)
-- default pipeline — low-frequency game logic
app:Get("player/:id=number", handler)
-- combat port — high rate limit, no extra middleware
app:Listen("combat", function(port)
port:Post("gun/fire/:damage=number", handler)
end, { Max = 60, Refill = 20 })
-- inventory port — auth middleware, default rate limit
app:Listen("inventory", function(port)
port:Use("session-auth", function(Player, Payload)
if not Sessions[Player.UserId] then return false end
end)
port:Get("items", handler)
port:Post("buy", handler)
end)
Remote limit warning
Exploit Detection Guide
Tamper hooks into App's pipeline passively. You don't wrap anything — just require it, subscribe, and optionally enable auto-kick.
Minimal setup
local tamper = RoExpress("Tamper")
tamper.AutoKick(10, "Exploiting detected")
tamper.On(function(report)
warn(string.format("[Tamper] %s — %s (strikes: %d)",
report.player.Name, report.reason, report.strikes))
end)
Manual strikes for business logic
app:Post("shop/buy", function(Player, Payload, req, res)
if req.data.amount < 0 then
tamper.Strike(Player, "Negative purchase", "shop/buy", req.data)
res:Status(400):Error("Invalid")
return
end
end)
Tuning thresholds
tamper.SetThresholds({
rateFloodWindow = 10, -- tighter window for faster detection
rateFloodCount = 3, -- lower threshold for stricter games
routeScanCount = 5, -- fewer unknown routes before pattern strike
paramFloodCount = 3,
})
Discord Server
The official RoExpress Discord — get help, share what you're building, report bugs, and follow development updates in real time.
| Channel | Purpose |
|---|---|
| #general | General discussion about RoExpress and Roblox dev |
| #help | Get help with implementation and debugging |
| #showcase | Share games and projects built with RoExpress |
| #bug-reports | Report issues with the framework |
| #updates | Announcements for new versions and changes |
GitHub
The RoExpress source code is fully open source on GitHub. Browse the code, open issues, submit pull requests, or star the repo to follow updates.
| How to contribute | Details |
|---|---|
| Star the repo | Helps with visibility and signals to other developers |
| Open an issue | Bug reports, feature requests, questions |
| Submit a PR | Fork, make your change, open a pull request against main |
| Share it | Post in Discord, DevForum, or anywhere Roblox devs hang out |
| CHANGELOG.md | Full version history in the repo |
| CONTRIBUTING.md | How to contribute code, style guide, PR process |
Twitter / X
Follow @tostadium on X for development updates, release announcements, and Roblox scripting content.
Website
The official developer portfolio and home base for all RoExpress-related content.
Library Comparison
A factual comparison of the main Roblox networking libraries. Feature claims are sourced from each library's official README or documentation.
At a glance
| RoExpress | BridgeNet2 | ByteNet | Zap | |
|---|---|---|---|---|
| Maintained | ✓ | ✓ | ✓ | ✓ (rewrite) |
| Pure Luau / no build step | ✓ | ✓ | ✓ | ✗ Rust CLI |
| Zero dependencies | ✓ | ✓ | ✓ | ✗ |
| Wally | ✓ | ✓ | ✓ | ✗ |
| Single remote | ✓ | ✓ | ✓ | ✓ |
| Request / response | ✓ | ✗ | ✗ | ✓ |
| Typed route params | ✓ | ✗ | ✗ | ✗ |
| Route middleware | ✓ | ✗ | ✗ | ✗ |
| Named ports | ✓ | ✗ | ✗ | ✗ |
| Server push | ✓ | ✓ | ✗ | ✓ |
| LZ77 compression | ✓ | ✗ | ✗ | ✗ |
| Binary protocol | ✗ | ✗ | ✓ | ✓ |
| Rate limiting | ✓ | ✗ | ✗ | ✗ |
| Exploit detection | ✓ | ✗ | ✗ | ✗ |
| Promise API | ✓ | ✗ | ✗ | ✗ |
| FPS streaming | ✓ | ✗ | ✗ | ✗ |
| Benchmarking | ✓ | ✗ | ✗ | ✗ |
Where RoExpress wins
Application-layer structure. RoExpress is the only library in this space that brings
Express.js-style routing to Roblox — typed path params (:id=number), wildcards, globs,
middleware chains, and named ports. Server code is organised, auditable, and testable in a way that
event-based libraries cannot match.
Security first. Built-in rate limiting, payload validation, version mismatch detection, and the Tamper module are part of the framework — not afterthoughts.
Complete client API. Callback, blocking yield, and Promise patterns. Cancellation by request ID. No other library in this group offers all three.
No build step. Pure Luau, zero external dependencies, one Wally line.
Where others win
Raw byte performance. ByteNet and Zap serialise into buffer objects
which are faster and smaller than Luau table serialisation. If you are sending thousands of small
fixed-schema packets per second, ByteNet or Zap will outperform RoExpress at the transport layer.
Code-generated types. Zap generates Luau bindings from a schema file, giving compile-time type guarantees. It requires a Rust CLI and a build step, but the type safety is stronger than anything you can express purely in Luau.
Simplest possible API. BridgeNet2's Fire / Connect surface
is familiar to anyone who has used vanilla RemoteEvents and has almost no learning curve.
When to use which
| Situation | Recommended |
|---|---|
| Structured server API — typed routes, middleware, security | RoExpress |
| High-frequency fixed-schema data (combat state, physics) | ByteNet |
| Guaranteed typed bindings from a build pipeline | Zap |
| Minimal migration from vanilla RemoteEvents | BridgeNet2 |
Design Consensus
Every major decision in RoExpress was made for a reason. This page records the reasoning — not just what was built, but why, what was rejected, and what tradeoffs were accepted. It exists so future contributors understand intent, not just code.
Overview timeline
- Every other Roblox networking library either creates one remote per event (BridgeNet approach) or requires explicit remote management. Both scatter state across the codebase.
- RoExpress uses exactly two remotes regardless of how many routes are registered. All request/response traffic shares the reliable remote. All broadcast traffic shares the unreliable remote.
- Tradeoff accepted: All traffic on one remote means a flood can theoretically saturate it. Solved in v2.1 with named Ports — opt-in isolation without changing the default.
- Rejected: One remote per route (BridgeNet model) — replication overhead on join scales with route count.
- Express.js is the most widely understood HTTP API in the world. Any developer who has touched web dev
understands
app.get(route, handler),req,res, and middleware chains. - Using this mental model means RoExpress has zero learning curve for developers who already know Express — and a very shallow one for those who don't.
- Tradeoff accepted: The metaphor isn't perfect. HTTP is stateless; Roblox games are stateful. GET/POST distinction is philosophically loose — nothing stops a developer from mutating state in a GET. This is intentional developer freedom, not an oversight.
- In v1.6, middleware had no return value. It ran and the pipeline continued regardless. A crash in auth middleware didn't stop the request — it silently continued. This made middleware useless for security.
- The fix: returning
falsesends 403 and stops the pipeline. Throwing sends 500 and stops the pipeline. Returning nothing continues. This mirrors how Express middleware works. - Why not pass a
next()function? The Luau style is return-based, not callback-based.return falseis cleaner thannext(false)and doesn't require an extra parameter in every middleware signature.
- v1.6 TokenBucket was a static table initialized at require-time. App and Broadcast shared it.
App:Destroy()wiped Broadcast's buckets too. Two App instances would fight over the same state. - Rewritten as
TokenBucket.New(settings). Each App and Broadcast instance owns its own bucket table and its own PlayerAdded/PlayerRemoving connections. - Additional fix: Buckets are now seeded for players already in the server on construction — previously a player who joined before the module loaded had no bucket until their first request.
- v1.6 routing was a plain array iterated top-to-bottom on every request. No typing, no wildcards, no priority, no patterns.
- Router.luau was extracted as its own module so it could be tested independently and extended without touching App. Routes are sorted by specificity on registration so declaration order never matters.
- Why not a trie? At typical Roblox route counts (10-50), a sorted array is fast enough and far simpler to debug. A trie would be premature optimization.
- TypeCoercer delegation: In v2.2, Router stopped maintaining its own coercer table and delegated entirely to TypeCoercer. One source of truth for all type logic.
- v1.6 had no reliable server-to-client path outside of responding to a request. Broadcast exists but it's unreliable — wrong for inventory updates, round state, or anything where delivery matters.
- Push uses the existing reliable RemoteEvent with
type = "push"in the packet. Listener filters by this field so Network response packets on the same remote are never intercepted. - Why not a third remote? The type field filter is sufficient. A third remote adds Instance replication overhead for every client join without meaningful benefit.
- Bridge solves module coupling. Without it, a DataService that needs to notify a LeaderboardService has to import it directly — tight coupling.
- Bridge is a singleton deliberately. The value of an event bus is that any module can fire and any module can listen without exchanging references. A per-instance model would require passing the instance around, defeating the purpose.
- Why no middleware on Bridge? Bridge is internal. Middleware on internal events adds latency and complexity with no security benefit — there's no untrusted caller to guard against.
- Why not use BindableEvents? BindableEvents create Roblox Instances, which have replication and lifecycle overhead. Bridge is a pure Luau table — zero Instance cost.
- Tamper hooks into App's pipeline via
Tamper.Signal()calls at each checkpoint. If Tamper is not required, those calls are instant no-ops. Zero overhead when unused. - Why not wrap App methods? Wrapping creates a fragile dependency chain. Passive signals keep App and Tamper independent — Tamper can be required at any point without changing App's behavior.
- Two tiers: Immediate strikes fire on first occurrence (version spoof, malformed payload). Pattern strikes accumulate and escalate (rate flood, route scan). Single-occurrence detection catches obvious exploits; pattern detection catches sustained attacks without punishing legitimate edge cases.
- The MMO objection: if combat and inventory share a remote, a combat flood slows inventory responses. Ports solve this with complete pipeline isolation.
app:Listen(name, callback, settings)mirrors Node'sserver.listen(port, callback)— instantly familiar. Each port gets its own RemoteEvent inReplicatedStorage/RoExpressPorts.- Global middleware inheritance: App middleware runs on all ports automatically. Port-local middleware runs after. You write auth once, it protects everything.
- Rejected: Route groups — syntactic convenience without isolation. Ports solve the real problem (traffic separation) rather than just organizational preference.
- At 60hz with 20 players, JSON over RemoteEvents sends ~240,000 bytes/second just for position data. Stream packs player state into 21 bytes. A world broadcast for 20 players is 420 bytes — one remote call.
- Two dedicated remotes: StreamState (unreliable, drop-tolerant at 60hz) and StreamEvent (reliable, for hits and weapon state that must arrive).
- Why not a single remote? Position drops are fine — next update is 16ms away. Hit drops are not fine — damage never registers. These have fundamentally different reliability requirements.
- Lag compensation is opt-in: Not all games need it. Forcing a snapshot ring buffer on every game that uses Stream would waste memory for games that don't need geometric hit validation.
- Callbacks are simple and direct. Promises are better for chained async operations and clean error separation. Both are valid — neither replaces the other.
GetAsync/PostAsyncreturn a Promise.Get/Postcontinue to accept callbacks. No migration required.- No external dependency: The Promise implementation is built in pure Luau. Adding a dependency like RbxUtil would create version coupling and installation complexity for Wally users.
- Then chaining passes values: Returning from a Then handler passes the result to the next Then. This enables transformation pipelines without nesting callbacks.
- shared.ROEXPRESS_VERSION: Should be a Version.luau module. Using Roblox's shared global works but is a footgun — any module can overwrite it. Known tech debt.
- Retry logic: Safe for GET, dangerous for POST without server-side idempotency. Deferred until a clean design for POST retry (deduplification tokens) is worked out.
- HTTP outbound: HttpService wrapper for external API calls — webhooks, Discord bots, external databases. Not bloat, but lower priority than stabilising the core framework.
- Plugin system: app:Plugin() was designed and abandoned. The MVC pattern with Bridge achieves the same decoupling without a formal plugin contract.
- Route groups: Syntactic convenience. Ports solve the underlying isolation problem better.
Per-module decisions
Click any module to expand its specific design rationale.
The Story
RoExpress didn't arrive fully formed. It came out of years of frustration, failure, circumstance, and stubbornness — built in pieces across very different seasons of life.
A post went up on the Roblox Developer Forum asking for feedback on an early idea for a remote framework. It never got listed. No traction, no replies worth counting. Most people would have moved on.
↗ View original DevForum postBack at it. A small standalone router module — basic route matching, params, nothing fancy. It worked but it didn't fit together as a framework. The pieces weren't talking to each other. The struggle was real: how do you make something that feels like one thing instead of a collection of parts?
That question didn't get answered in 2023. But the router module survived as a reference point — proof that the routing problem was solvable.
Joined a small development team. First time working in a real collaborative codebase. Saw how other people structured things — middleware patterns, request/response pipelines, module boundaries. Express.js wasn't just a reference anymore, it became the mental model.
The gap between "a Roblox game's networking" and "a structured API" became clear. And the gap was fixable.
2025 was hard. Homeless, but in school — studying engineering and working toward a welding certificate. The framework sat on the back burner. Life had other priorities. But the ideas kept accumulating: rate limiting that actually works, typed routes, a broadcast primitive that's honest about reliability.
Still showed up on the DevForum. Still talking to other developers, still part of the community. That continuity matters more than it sounds.
↗ DevForum — on homelessness as a Roblox developerSometimes the best thing that can happen to a project is that you can't touch it for a year. You come back knowing exactly what it needs.
The first attempt finally met the years of accumulated thinking. v1.6 shipped as the first real, complete version — one remote, a working request pipeline, rate limiting, middleware, broadcast. Something you could actually put in a game.
Then v2.0. The routing overhaul, typed params across seven Luau types, wildcards and globs, reliable server push, LZ77 compression over Roblox's buffer API, and Bridge — a full internal event bus with yieldable coroutine variants. The framework that was sketched in a DevForum post in 2021 finally became the thing it was always trying to be.
Updates
Full history of every change to RoExpress across all versions.
- TypeCoercer module — shared type serialisation singleton in both contexts
- TypeCoercer.ToString / FromString / RegisterInstanceRoot
- Promise module — chainable async API for Network
- network:GetAsync and network:PostAsync return a chainable Promise
- promise:Then, :Catch, :Finally, :Cancel
- Version module — single writer of shared.ROEXPRESS_VERSION
- Auto silent version check on server start — pings roexpress.dev then GitHub fallback
- Version.Check() — manual check with formatted output
- New Router param types via TypeCoercer: int, Enum.TypeName, Instance, cframe quaternion
- Router delegates all coercion to TypeCoercer — single source of truth
- network:Get and network:Post return requestId for cancellation
- Stream module — 60hz binary FPS streaming, raw buffer packing, up to 90% smaller than JSON
- Player state 21 bytes · Hit 10 bytes · Projectile 18 bytes · Weapon 3 bytes
- World broadcast batches all player states into one buffer — one remote call total
- Opt-in lag compensation — ring buffer snapshots + geometric hit validation
- BroadcastTo for zone-based streaming to player subsets
- Two dedicated remotes: StreamState (unreliable) + StreamEvent (reliable)
- Port module — named isolated pipelines via app:Listen()
- Each port owns its own RemoteEvent, Router, and TokenBucket
- Client connects via RoExpress("Network", "portName")
- Network callbacks are now optional — fire-and-forget without a callback
- network:Get and network:Post return requestId for cancellation
- Router module — typed params, wildcards, globs, inline pattern constraints
- Param types: string, boolean, number, vector2, vector3, color3, cframe
- Priority-sorted routes — most specific always wins, any declaration order
- req.captures — positional array for wildcard and glob segments
- Server push — app:Push, app:PushAll, app:PushTo over reliable remote
- Listener extended to receive reliable push alongside unreliable broadcast
- Codec module — LZ77 buffer compression, opt-in per route via { compress = true }
- Network auto-detects compressed responses and decompresses before callback
- Tamper module — server-only exploit detection singleton
- Eight detection reasons across two tiers: immediate and pattern
- Passive pipeline hooks in App — VERSION_SPOOF, MALFORMED_PAYLOAD, RATE_FLOOD, UNKNOWN_ROUTE, ROUTE_SCAN, INVALID_PARAM, PARAM_FLOOD
- tamper.Strike() for manual business-logic violations
- tamper.AutoKick() — opt-in auto-kick at configurable threshold
- tamper.SetThresholds() — override all pattern detection thresholds
- Per-player strike records with firstSeen / lastSeen timestamps
- Bridge module — shared internal event bus, server and client contexts
- Bridge.Bind, Bridge.Unbind, Bridge.UnbindAll, Bridge.Fire, Bridge.Has
- Bridge.Wait, Bridge.WaitUntil, Bridge.WaitFirst — yieldable coroutine variants
- TokenBucket rewritten as proper instantiable class — App and Broadcast own independent instances
- TokenBucket seeds existing players on construction — no missed joins in studio
- Middleware can return false to block (403) — previously a no-op
- Middleware crash now sends 500 and stops the request — previously continued silently
- Version constant moved to shared.ROEXPRESS_VERSION in init.luau — App and Network can never drift
- app:OnParamError() — custom handler for typed param coercion failures
- PushTo argument order fixed — (players, event, data)
- Module:Destroy() declaration restored in App (was orphaned)
- App, Network, Broadcast, Listener, Base64, TokenBucket
- Route params and query strings
- GET and POST with auto base64 encoding on POST responses
- Global middleware with Use/Unuse
- Token bucket rate limiting with Grant utilities
- Single reliable remote + single unreliable remote
- Version field on all payloads — 400 on mismatch
- res:Send, res:Error, res:Status with chainable API
- network:Cancel for client-side request abandonment
- Broadcast per-event and per-player rate limiting
- 64-event cap and 30s TTL on idle broadcast buckets
Research Papers
The academic foundations behind RoExpress's core techniques — compression, lag compensation, rate limiting, and game networking.
The original LZ77 paper. Defines the sliding-window dictionary compression algorithm that underpins RoExpress's Codec module — every route marked { compress = true } runs a direct implementation of this scheme. The algorithm finds repeated byte sequences within a look-back window and replaces them with length-offset pairs, achieving up to 90% size reduction on structured JSON payloads.
The canonical reference for server-side lag compensation in multiplayer games. Introduces the ring-buffer snapshot model — the server stores a rolling history of world state and rewinds to a player's perceived moment-in-time before validating hit registration. RoExpress's Stream module implements this directly: stream.EnableLagCompensation() maintains a configurable ring buffer of player states and applies geometric validation against the rewound snapshot.
A thorough breakdown of the client-server and peer-to-peer networking models used in real-time games — covering snapshot interpolation, client-side prediction, and the trade-offs between reliable and unreliable transports. Directly informs RoExpress's two-remote architecture (reliable remote for push and requests, unreliable remote for high-frequency state streaming) and the decision to expose StreamState as unreliable while keeping StreamEvent reliable.
↗ Read on Gaffer on GamesThe IETF specification that formalises the token bucket algorithm — the mechanism behind RoExpress's TokenBucket module. Tokens accumulate at a fixed rate up to a burst ceiling; each request consumes one token and is dropped when the bucket is empty. RoExpress extends the base model with per-player isolation, configurable refill intervals, and the Grant utility for predictable pre-allocation at join time.
↗ Read on IETFThe dissertation that defined REST and, by extension, the URL routing conventions Express.js is built on. Chapter 5 introduces Representational State Transfer — hierarchical resource paths, uniform method semantics (GET, POST), and stateless request-response — the exact pattern RoExpress brings to Roblox. Every typed route param (:id=number), wildcard, and middleware chain in RoExpress descends directly from the resource-oriented URL model formalised here.