RoExpress — structured networking for Roblox
A type-safe, rate-limited, Express.js-style RPC framework. One reliable remote handles all request/response traffic. One unreliable remote handles broadcasts. Every request is versioned, rate-limited, and routed automatically.
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("Base64") | Both | Base64 utility |
Installation
Drop the RoExpress folder into ReplicatedStorage. RoExpress creates its own remotes automatically — you don't touch them.
game.ReplicatedStorage
└── Modules
└── Libraries
└── RoExpress ← root ModuleScript (init.luau)
├── App ← ModuleScript
├── Network ← ModuleScript
├── Broadcast ← ModuleScript
├── Listener ← ModuleScript
├── Router ← ModuleScript
├── Codec ← ModuleScript
├── Bridge ← ModuleScript
├── 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. 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 |
timeout | number? | Seconds before 408. Default: 10 |
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)
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.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 -- returns boolean?
type BroadcastHandler = RoExpress.BroadcastHandler
type NetworkCallback = RoExpress.NetworkCallback
-- modules
type App = RoExpress.App
type Network = RoExpress.Network
type Broadcast = RoExpress.Broadcast
type Listener = RoExpress.Listener
type Router = RoExpress.Router
type Codec = RoExpress.Codec
type Bridge = RoExpress.Bridge
-- 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)
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 |
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.
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.
- 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
- 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