MIT Luau
Introduction

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.

v2.0 ships today. Typed route params, wildcard/glob matching, reliable server push, LZ77 buffer compression, instantiable TokenBuckets, and Bridge — a full internal event bus. All backwards-compatible.

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:

RoExpress ├── App server — routing, push, middleware │ ├── Router typed params, wildcards, globs ✦ v2 │ └── TokenBucket per-instance rate limiter ├── Network client — request/response ├── Broadcast server — unreliable fire-and-forget ├── Listener client — broadcast + reliable push ├── Bridge shared — internal event bus ✦ v2 ├── Codec shared — LZ77 buffer compression ✦ v2 └── Base64 shared — encode/decode utility

Two network channels

ChannelRemoteUse for
App / NetworkRemoteEvent (reliable)Data fetches, mutations, server push
Broadcast / ListenerUnreliableRemoteEventHUD pings, position hints, cosmetic events

Context access

CallContextReturns
RoExpress("App")Server onlyApp instance
RoExpress("Network")Client onlyNetwork instance
RoExpress("Broadcast")Server onlyBroadcast instance
RoExpress("Listener")Client onlyListener instance
RoExpress("Bridge")BothShared singleton event bus
RoExpress("Base64")BothBase64 utility
Setup

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
Calling a server-only module on the client (or vice versa) throws an assertion with a clear context message. Bridge and Base64 are the only modules available in both contexts.
Getting Started

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)
Internals

Request Pipeline

Every incoming request flows through a fixed sequence. Each step can terminate the request early with a specific status code.

1
Version check
→ 400 if mismatch
2
TokenBucket.Consume
→ 429 if empty
3
Payload validation
→ silent drop if malformed
4
Middleware chain
→ 403 if returns false · 500 if throws
5
Router.Match
→ 404 if no match
6
Typed param coercion
→ OnParamError / 400 on failure
7
handler(req, res)
→ your business logic

Status codes

CodeMeaning
200Success
400Version mismatch or invalid typed param
403Blocked by middleware returning false
404No route matched
408Client-side timeout
429Rate limited by TokenBucket
500Handler threw / middleware crashed
Server API

App

Handles all incoming client requests. Owns its own Router and TokenBucket instance — no shared global state.

local app = RoExpress("App")

Routing

GET app:Get(route, handler, options?)
POST app:Post(route, handler, options?)
ParameterTypeDescription
routestringSupports typed params, wildcards, globs, inline constraints. See Router.
handlerRouteHandler(Player, Payload, req, res) → ()
options.compressboolean?Enable LZ77 compression on the response. Default false.

Middleware

app:Use(id, fn)  ·  app:Unuse(id)

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

PUSH app:Push(player, event, data)
PUSH app:PushAll(event, data)
PUSH app:PushTo(players, event, data)

Reliable server-to-client events over the RemoteEvent. Received by listener:On(event). Guaranteed delivery.

req object

FieldTypeDescription
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.dataanyRaw payload from client.

res object

MethodDescription
res:Send(data)Send success response. Callable once.
res:Error(message)Send error response. Callable once.
res:Status(code)Set status code. Chainable.
app:OnParamError(fn)

Called when a typed route param fails coercion. If not registered, failures return 400 automatically.

Server API

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")
broadcast:Emit(event, player, data)
broadcast:EmitAll(event, data)
broadcast:EmitTo(event, targets, data)

Rate limits

LimitValueBehaviour on exceed
Per event20 tokens, refill 10/sWarn + drop
Per playerShared TokenBucketPlayer skipped individually
Data cap900 bytesWarn + drop
Bucket TTL30s idleAuto cleared
Max unique events64New events dropped
Unreliable = droppable. Use app:PushAll when delivery must be guaranteed.
Client API

Network

Fires requests to the server and resolves responses via callbacks. Client-only.

local network = RoExpress("Network")
GET network:Get(route, data?, callback, timeout?)
POST network:Post(route, data, callback, timeout?)
ParameterTypeDescription
routestringRoute path including params and query strings
dataany?Optional payload
callbackNetworkCallbackCalled with NetworkResponse on resolution
timeoutnumber?Seconds before 408. Default: 10

NetworkResponse

FieldDescription
res.type"response" or "error"
res.statusHTTP-style status code
res.dataPayload — decompressed automatically if server used Codec
res.messageError message (nil on success)
network:Cancel(requestId) → boolean
Cancel is client-only. The server still executes the handler — there is no mid-flight abort mechanism.
Client API

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")
listener:On(event, handler)

Persistent subscription. Fires every time the event arrives from either channel.

listener:Once(event, handler)

Fires once then auto-unsubscribes. Safe against race conditions — handler is removed before being called.

listener:Off(event)

Removes both On and Once handlers for the event.

listener:Use(id, fn)  ·  listener:Unuse(id)

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)
New in v2

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

SyntaxExampleDescription
Literalplayer/coinsExact match. Highest priority.
Plain param:nameAny single segment → string in req.params
Typed param:id=numberCoerced to declared type in req.params
Constrained:id(\d+)Raw string must match Lua pattern
Typed + constrained:id(\d+)=numberPattern then coerce
Wildcard*One segment → req.captures[n]
Glob**Zero-or-more segments → req.captures[n] as table

Supported param types

TypeWire formatLua 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
New in v2

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)
RemoteReliable RemoteEventUnreliableRemoteEvent
DeliveryGuaranteedMay drop under load
Rate limitedNo (server-initiated)Yes — per-event + per-player
Use forInventory, state sync, round eventsHUD 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)
Push packets carry type = "push" internally. Listener filters them so Network response packets on the same remote are never intercepted.
New in v2

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
Measure before enabling. LZ77 has overhead — on small payloads (<500 bytes) the output may be larger than the input. Best on large repetitive tables (>2kb).
New in v2

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.

Bridge is not a network primitive. It doesn't cross the client/server boundary. Use 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

bridge.Bind(name: string, handler: (data: any) → ())

Register a handler on a named channel. Multiple handlers per channel are supported — all fire in registration order.

bridge.Unbind(name: string, handler)

Remove a specific handler by reference. Logs a warning if not found.

bridge.UnbindAll(name?: string)

Clear all handlers on one channel, or every channel if no name is given.

bridge.Fire(name: string, data?: any)

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.

bridge.Has(name: string) → boolean

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.

bridge.Wait(name: string, timeout?: number) → data | nil

Yields until the named channel fires once. Returns the data, or nil on timeout. Default timeout: 10 seconds.

bridge.WaitUntil(name: string, predicate: (data) → boolean, timeout?: number) → data | nil

Yields until the channel fires AND the predicate returns true. Non-matching fires are silently skipped — the coroutine stays yielded.

bridge.WaitFirst(names: {string}, timeout?: number) → (channelName | nil, data | nil)

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
Destroy does not resume yielded coroutines. Any coroutine mid-Wait when bridge.Destroy() is called will stay yielded permanently. Call Destroy only on full shutdown or test teardown.
Shared

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

MethodDescription
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.

Shared

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
Reference

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
Server API

Tamper NEW

Exploit detection and tamper reporting. Passively hooks into App's request pipeline and surfaces suspicious activity as structured reports. Server-only singleton.

Tamper is passive. It receives signals from App automatically — you don't wrap or replace anything. Just require it, subscribe, and optionally enable auto-kick.
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

ReasonTierTrigger
VERSION_SPOOFimmediateClient version doesn't match server
MALFORMED_PAYLOADimmediatePayload fails structure validation
INVALID_PARAMimmediateTyped param coercion fails
UNKNOWN_ROUTEimmediateRoute does not exist on the server
RATE_FLOODpatternRepeated 429s within a short window
ROUTE_SCANpatternMany distinct unknown routes fired
PARAM_FLOODpatternRepeated param failures on same route
MANUALimmediateDeveloper called tamper.Strike() directly

Report object

FieldTypeDescription
report.playerPlayerThe player who triggered the detection
report.reasonReasonNamed reason string (see table above)
report.severity"immediate" | "pattern"Detection tier
report.routestring?Route involved, if applicable
report.evidenceany?Raw context — payload, values, counts
report.strikesnumberTotal accumulated strikes for this player
report.firstSeennumbertick() of first strike
report.lastSeennumbertick() 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

tamper.On(handler: (report) → ())

Register the detection handler. One handler supported — calling again replaces the previous.

tamper.AutoKick(threshold: number, reason?: string)

Enable auto-kick when a player reaches the strike threshold. Fires after the handler so you can log first.

tamper.Strike(player, reason?, route?, evidence?)

Manually issue an immediate strike from your own validation logic.

tamper.GetReport(player) → PlayerRecord?

Returns the full accumulated record for a player, or nil if no strikes on record.

tamper.GetStrikes(player) → number

Returns the current strike count. Returns 0 if no record exists.

tamper.ClearStrikes(player)

Reset a player's record. Useful after a false positive or manual review.

tamper.ClearAll()

Reset all player records. Useful on round transitions.

tamper.SetThresholds(config)
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)
Example

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)
Example

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)
Example

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)
Example

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)
Example

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)
Example

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)
Example

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)
Example

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)
Reference

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)
Community

YouTube Channel

Video tutorials, deep-dives, and build-alongs covering RoExpress and Roblox game development.

UNOFFICIAL ROBLOX TUTOR
Roblox scripting, frameworks, and dev guides
▶ Visit Channel
ContentDescription
RoExpress tutorialsSetup, routing, push, Bridge, compression
Example walkthroughsVideo breakdowns of every example in these docs
Roblox scriptingLuau, OOP, DataStore, services
Dev vlogsBehind the scenes on new features
Community

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.

Cash App
One-time donation

Send any amount directly. Every dollar funds new features and docs.

$robloxtutor25
Open Cash App →
Robux
Donate in-platform

Already in Roblox? Visit the profile and send Robux directly.

unofficialrobloxtutor
View Roblox Profile →
No pressure, ever. If you can't donate, sharing the project or starring the repo helps just as much.
Origin

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.

2021
The first post

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 post
Original idea, 2021 The core problem was already right: Roblox games scatter RemoteEvents everywhere with no structure and no safety. One remote, one pipeline. That instinct never changed — everything since has been figuring out how to execute it properly.
2023
A small router module

Back 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.

2024
Joining a team, learning new paradigms

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
The hardest year

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 developer

Sometimes 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.

2026
v1.6 → v2.0

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.

9
modules
~1800
lines of Luau
5yr
in the making
DeathToTheStadium — the framework is MIT licensed and free to use. If it helps your game ship faster, that's the whole point.
Reference

Updates

Full history of every change to RoExpress across all versions.

v2.0 30 May 2026
Major release — routing overhaul, push, compression, Bridge
  • 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)
v1.6 19 May 2026
Initial public release
  • 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