Guide
Combat System
A complete gun framework using Ports for request isolation, Stream for player movement, Broadcast for visual effects, and TokenBucket rate limiting that matches weapon fire rates.
Architecture overview
| Channel | Module | What it carries |
|---|---|---|
| Main App | App | Game logic, inventory, player data |
| Combat Port | Port | Shoot, reload, weapon switch | isolated rate limits |
| Movement Stream | Stream | CFrame + velocity at 20 Hz, binary delta-compressed |
| Hit FX | Broadcast | Particle/sound events | unreliable, lossy OK |
1. Combat Port setup (server)
Isolating combat requests on a Port means a rate-flooding cheater can't starve inventory requests. The cost and refill model the weapon's fire rate.
-- Server: CombatService.luau
local RoExpress = require(game.ReplicatedStorage.RoExpress)
local app = RoExpress.GetApp()
local broadcast = RoExpress("Broadcast")
app:Listen("combat", function(port)
-- Shoot: max 10 shots burst, refills at 8/s (semi-auto rifle)
port:Post("shoot", function(req, res)
local origin = req.data.origin -- CFrame
local targetId = req.data.targetId -- number?
local hitResult = validateShot(req.player, origin, targetId)
if hitResult.hit then
applyDamage(req.player, hitResult.victim, hitResult.damage)
broadcast:EmitAll("hitFX", { pos = hitResult.pos })
end
res:Send({ hit = hitResult.hit, damage = hitResult.damage })
end)
port:Post("reload", function(req, res)
startReload(req.player)
res:Send()
end)
end, { max = 10, refill = 8, cost = 1 })
2. Movement Stream (shared schema)
Define the schema once in a shared module. Server and client both require it | no duplication.
-- Shared: Schemas/Movement.luau
local Stream = RoExpress.Stream
return Stream:Schema({
cframe = "CFrame",
velocity = "Vector3",
health = "uint8",
flags = "uint8", -- bit 0: sprinting, bit 1: crouching
})
-- Client: sends at 20 Hz using unreliable channel
local MovSchema = require(game.ReplicatedStorage.Schemas.Movement)
local channel = RoExpress.Stream:Channel("movement", MovSchema)
RunService.Heartbeat:Connect(function(dt)
accum += dt
if accum < 0.05 then return end -- 20 Hz
accum = 0
channel:Send({
cframe = char.HumanoidRootPart.CFrame,
velocity = char.HumanoidRootPart.AssemblyLinearVelocity,
health = humanoid.Health,
flags = getFlags(humanoid),
})
end)
3. Hit FX on the client
-- Client: receive broadcast hit events
local listener = RoExpress("Listener")
listener:On("hitFX", function(data)
-- Dropped packets are fine | nobody notices a missing particle
spawnBloodParticle(data.pos)
end)
4. Rate limiting that fits your weapon
| Weapon type | max | refill | Effect |
|---|---|---|---|
| Pistol (10 rps) | 10 | 10 | Sustained 10 shots/sec, burst 10 |
| Semi-auto rifle (8 rps) | 10 | 8 | Burst 10, sustained 8 |
| Shotgun (2 rps) | 4 | 2 | Can double-tap, then 1 per 500ms |
| Sniper (1 rps) | 2 | 1 | One shot immediately, then 1/sec |
5. Server-side hit validation
Never trust the client's target ID alone. Always validate on the server:
local function validateShot(shooter, origin, targetId)
local target = game.Players:GetPlayerByUserId(targetId)
if not target or not target.Character then
return { hit = false }
end
local dist = (origin.Position - target.Character.HumanoidRootPart.Position).Magnitude
if dist > MAX_RANGE then
-- Flag as suspicious for Tamper
return { hit = false }
end
return { hit = true, victim = target, damage = 25, pos = origin.Position }
end
See also
Ports | isolated pipelines · Stream | binary channels · Broadcast | unreliable events · TokenBucket | rate limiting · Tamper | exploit detection · Full Gun Example