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

ChannelModuleWhat it carries
Main AppAppGame logic, inventory, player data
Combat PortPortShoot, reload, weapon switch | isolated rate limits
Movement StreamStreamCFrame + velocity at 20 Hz, binary delta-compressed
Hit FXBroadcastParticle/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 typemaxrefillEffect
Pistol (10 rps)1010Sustained 10 shots/sec, burst 10
Semi-auto rifle (8 rps)108Burst 10, sustained 8
Shotgun (2 rps)42Can double-tap, then 1 per 500ms
Sniper (1 rps)21One 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