Introduction

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.

v2.2 is here. Stream, named ports, optional callbacks. Fully backwards-compatible.

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:

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.0 ├── Tamper server — exploit detection ✦ v2.0 ├── Port server — isolated named pipelines ✦ v2.1 ├── Stream shared — 60hz binary FPS streaming ✦ v2.1 ├── TypeCoercer shared — type serialisation utility ✦ v2.2 ├── Promise client — chainable async Network API ✦ v2.2 ├── Codec shared — LZ77 buffer compression ✦ v2 └── Base64 shared — encode/decode utility

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
Setup

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.

⬇ Creator Store ⌥ GitHub 📦 Wally

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

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

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

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

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
Unreliable = droppable. Use app:PushAll when delivery must be guaranteed.
Client API

Network

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

local network = RoExpress("Network")
GET network:Get(route, data?, callback, timeout?) → requestId  non-blocking
GET network:Get(route, data?, nil, timeout?) → requestId, response  blocking
POST network:Post(route, data, callback, timeout?) → requestId  non-blocking
POST network:Post(route, data, nil, timeout?) → requestId, response  blocking
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)
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)
Client API

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.

Benchmark shares your Network connection. Pass the instance you already have — it does not open a second remote.
local network   = RoExpress("Network")
local Benchmark = require(game.ReplicatedStorage.RoExpress.Benchmark)

local bench = Benchmark.New(network)

Run

bench:Run(route, config?) → Results

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 fieldTypeDefaultDescription
method"GET" | "POST""GET"HTTP method
dataany?nilRequest payload
iterationsnumber?20Measured requests
warmupnumber?3Discarded warm-up requests
intervalnumber?0Seconds between requests
timeoutnumber?10Per-request timeout (seconds)
labelstring?routeDisplay name in Print output

Results

FieldTypeDescription
minnumberFastest request (ms)
avgnumberMean RTT (ms)
mediannumberp50 (ms)
p95number95th percentile (ms)
p99number99th percentile (ms)
maxnumberSlowest request (ms)
errorsnumberFailed request count
errorCodes{ [number]: number }Error count per status code
throughputnumberRequests per second (wall-clock)
totalnumberTotal wall time for all iterations (ms)

Print

bench:Print(results)

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

bench:RunAndPrint(route, config?) → Results

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

bench:Compare({ results, ... })

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" }),
})
Rate limiting. The default TokenBucket allows 10 tokens at 2/s refill. With 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.
bench:Destroy()

Drops the internal reference. Does not destroy the Network instance — the caller owns it.

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

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

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.

Ports are optional. If your game has a single traffic domain you will never need them. Use ports when you have genuinely separate traffic that could choke each other — combat vs inventory, chat vs position, admin vs game logic.

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

app:Listen(name: string, callback: (port) → (), settings?: { Max: number?, Refill: number? })

Creates a named port. Each port gets its own RemoteEvent in ReplicatedStorage/RoExpressPorts. The callback receives the port instance for route registration.

app:GetPort(name: string) → Port?

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
New in v2.1

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.

This is RoExpress's answer to ByteNet. Buffer serialization built directly into the framework, with routing, lag compensation, hit validation, and exploit detection included.

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

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.

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  -- (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
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

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

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

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

Your Game (Server) ├── Controllers app:Get / app:Post handlers — receive requests, call models ├── Models DataStore, game state — pure data, no networking └── Views res:Send / app:Push — what you send back to clients

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

ServerScriptService └── Game ├── Controllers │ ├── ShopController.luau app:Post("shop/...") │ ├── PlayerController.luau app:Get("player/...") │ └── AdminController.luau app:Listen("admin", ...) ├── Models │ ├── PlayerModel.luau DataStore access │ └── ShopModel.luau catalogue, stock, pricing └── Services └── BridgeService.luau bridge.Bind() registrations
Controllers should be thin. If a controller is doing DataStore calls, validation logic, and game calculations all in one function, it belongs in a Model. Controllers route — they don't think.
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
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

YouTube
@UNOFFICIAL_ROBLOX_TUTOR
𝕏
Twitter / X
@tostadium
Discord
Community Server
GitHub
unofficialrobloxtutor
Roblox
DeathToTheStadium
🌐
Website
unofficialrobloxtutor.com
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.

Find me everywhere

YouTube
@UNOFFICIAL_ROBLOX_TUTOR
𝕏
Twitter / X
@tostadium
Discord
Community Server
GitHub
unofficialrobloxtutor
Roblox
DeathToTheStadium
🌐
Website
unofficialrobloxtutor.com
Guide

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
Guide

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
Guide

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)
Interpolation is not included. Stream provides the data — how you interpolate remote characters is up to you. A simple lerp on CFrame between the last two received states works well for most FPS games.
Guide

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

Each port creates one RemoteEvent. Four ports = four additional remotes in ReplicatedStorage. All replicate to every client on join. Keep port count reasonable — four to six is fine, twenty is worth questioning.
Guide

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,
})
Don't auto-kick on first strike. Set the threshold to at least 5-10. False positives happen — a laggy player can hit the rate limit legitimately. Log first, kick later.
Community

Discord Server

The official RoExpress Discord — get help, share what you're building, report bugs, and follow development updates in real time.

RoExpress Community
Help · Showcase · Bug reports · Dev updates
Join the Server
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
Community

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.

unofficialrobloxtutor/RoExpress
MIT Licensed · Open source · Pull requests welcome
View on GitHub
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
Community

Twitter / X

Follow @tostadium on X for development updates, release announcements, and Roblox scripting content.

@tostadium
RoExpress updates · Roblox dev · Scripting
Follow on X
Community

Website

The official developer portfolio and home base for all RoExpress-related content.

🌐
unofficialrobloxtutor.com
Portfolio · Projects · Contact
Visit Website
Reference

Library Comparison

A factual comparison of the main Roblox networking libraries. Feature claims are sourced from each library's official README or documentation.

Red and Knit are archived. Red was archived December 2025, Knit in July 2024. Neither is maintained. New projects should not depend on them.

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

SituationRecommended
Structured server API — typed routes, middleware, securityRoExpress
High-frequency fixed-schema data (combat state, physics)ByteNet
Guaranteed typed bindings from a build pipelineZap
Minimal migration from vanilla RemoteEventsBridgeNet2
Reference

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

One remote, not many
The founding decision
  • 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 API surface
Why Express.js and not something Roblox-native
  • 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.
Middleware blocks by returning false
v2.0 — fixing the silent continue bug
  • 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 false sends 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 false is cleaner than next(false) and doesn't require an extra parameter in every middleware signature.
TokenBucket as an instance, not a singleton
v2.0 — fixing shared global state
  • 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.
Router as a separate module
v2.0 — extracting route matching from App
  • 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.
Server push over the reliable remote
v2.0 — closing the missing primitive gap
  • 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 as a singleton
v2.0 — the internal event bus
  • 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 as passive hooks
v2.0 — exploit detection without wrapping
  • 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.
Named ports via app:Listen
v2.1 — traffic isolation
  • 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's server.listen(port, callback) — instantly familiar. Each port gets its own RemoteEvent in ReplicatedStorage/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.
Stream raw buffer format
v2.1 — the FPS answer
  • 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.
Promise alongside callbacks
v2.2 — async API without replacing existing patterns
  • Callbacks are simple and direct. Promises are better for chained async operations and clean error separation. Both are valid — neither replaces the other.
  • GetAsync / PostAsync return a Promise. Get / Post continue 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.
Decisions deferred
Deliberately not built yet
  • 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.

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.2 30 May 2026
TypeCoercer, Promise, Version module, new param types
  • 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
v2.1 30 May 2026
Stream, Ports, optional Network callbacks
  • 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
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
  • 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)
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
Reference

Research Papers

The academic foundations behind RoExpress's core techniques — compression, lag compensation, rate limiting, and game networking.

Data Compression · 1977
A Universal Algorithm for Sequential Data Compression
Jacob Ziv & Abraham Lempel — IEEE Transactions on Information Theory, 23(3), 337–343

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.

↗ Read on IEEE
Codec LZ77 Sliding window
Game Networking · 2001
Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization
Yahn W. Bernier — Valve Software, Game Developers Conference 2001

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.

↗ Read on Valve Dev Wiki
Stream Lag compensation Ring buffer Hit validation
Game Networking · 2010
What Every Programmer Needs to Know About Game Networking
Glenn Fiedler — Gaffer on Games

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 Games
Stream App Reliable vs unreliable Snapshot interpolation
Rate Limiting · 1999
RFC 2697 — A Single Rate Three Color Marker
J. Heinanen & R. Guerin — IETF, September 1999

The 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 IETF
TokenBucket Rate limiting Token bucket algorithm
REST & URL Design · 2000
Architectural Styles and the Design of Network-based Software Architectures
Roy T. Fielding — Doctoral Dissertation, University of California, Irvine, 2000

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

↗ Read Fielding's Dissertation
Router App REST URL design Express.js