Skip to content

WeaponKit

WeaponKit is server-validated hitscan weapons as a drop-in service. The client never reports a hit — it reports a claim: “I fired weapon 1 in this direction at this timestamp.” Everything after that is the server’s problem: identity, fire rate, ammo, lag compensation, hit detection, and damage all resolve server-side, and the claim dies in the fail-closed Intent.WK_Fire chain before any of it runs if a single check fails.

The defining design decision: with a Rewind instance attached, hit detection runs against rewound capsule hitboxes — where targets were at the client’s claimed timestamp — while world geometry blocks at its current state. Players get hits that match what they saw on their screen; walls don’t rewind.

One shot flows through five stages:

  1. Wire. The client fires the WK_Fire intent: (weaponId: NumberU8, direction: Vector3F24, timestamp: NumberF64). The channel’s token bucket allows 20 messages/second with a burst of 5 — above any legitimate weapon’s fire rate, so the bucket only bites floods.
  2. Validation — the kit’s gate on the fail-closed Intent.WK_Fire hook chain, priority 50. Unknown weapon id, degenerate direction, hot fire-rate window, or empty ammo each return false and the message never reaches a handler. Your game rules prepend at lower priority numbers.
  3. Lag-compensation claim check. With config.Rewind set, the handler anchors the muzzle to the shooter’s rewound position and runs Rewind:validateShot — a stale or future timestamp, missing history, or an origin too far from the rewound position rejects with a machine-readable reason on Weapon.ShotRejected.
  4. Hit detection. Rewind:castRay sweeps every tracked player’s rewound capsule (per-weapon size), then a world-only raycast confirms nothing currently blocks the path. Without Rewind, a plain raycast at current positions runs instead.
  5. Damage. The fail-open Weapon.CanDamage gate fires; unless a handler vetoes, the victim’s humanoid takes Damage and Weapon.Hit publishes.
Check Rule
Weapon identity weaponId must exist in config.Weapons
Direction sanity NaN components, Magnitude < 1e-4, and infinite magnitude all reject — NaN/Inf would silently pass a plain magnitude gate
Fire rate Rejects when os.clock() - WKLastShot < (1 / FireRate) * 0.92 — the 8% allowance absorbs replication jitter but is too small to macro past
Ammo With an Ammo adapter configured, Get(session, weaponId) <= 0 rejects

The validator normalizes the direction in place (context.Args[2] = Direction.Unit) and stashes the resolved weapon on the context, so downstream validators and the handler never re-derive them.

The muzzle anchors to where the shooter was at claim time: Rewind:getPositionAt(player, timestamp) + Vector3.new(0, 1.5, 0). Anchoring to the current root instead would reject honest high-ping shots on origin tolerance — by the time the claim arrives, the shooter has moved.

Rewind:validateShot then checks the claim itself. The reasons WeaponKit’s claims can reject with (published as Weapon.ShotRejected(player, reason)):

Reason Meaning
FutureTimestamp Claimed more than MaxFutureSeconds (default 0.2s) ahead of the server clock
StaleTimestamp Older than the rewind history window (HistorySeconds, default 1s)
NoShooterHistory No position samples for the shooter at that time (fresh spawn, just joined)
OriginMismatch Claimed origin further than OriginToleranceStuds (default 8) from the shooter’s rewound position

Hit detection is two casts on purpose. Rewind:castRay finds the nearest rewound capsule intersection — but it knows nothing about walls. A second, world-only raycast (every player character excluded from the filter) confirms the path is clear: live bodies must not block shots aimed at where targets used to be, but current walls must. A shot through a wall that existed at fire time and still exists is blocked; a target who ducked behind cover after the shooter fired is still hit.

From the GunArena starter template:

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Rewind = require(Root.AntiExploit.Rewind)
local TeamKit = require(Root.Kits.TeamKit)
local WeaponKit = require(Root.Kits.WeaponKit)
local Lag = Rewind.attach(Kernel)
-- Seed ammo on join; point the adapter at session.Profile.Data to persist it
Kernel:onSession(function(session)
session.Data.Ammo = 120
end)
Kernel:registerService(WeaponKit.service({
Rewind = Lag,
Weapons = {
[1] = { Name = "Rifle", Damage = 25, FireRate = 6, MaxRange = 250 },
[2] = { Name = "Sidearm", Damage = 12, FireRate = 10, MaxRange = 120 },
},
Ammo = { -- optional gate; point Get/Take at session.Profile.Data to persist
Get = function(session, weaponId)
return session.Data.Ammo or 0
end,
Take = function(session, weaponId)
session.Data.Ammo -= 1
end,
},
}))
-- Friendly-fire vetoes register on Weapon.CanDamage automatically
TeamKit.attach(Kernel, {
Teams = {
Red = { Color = Color3.fromRGB(200, 60, 60), Capacity = 8 },
Blue = { Color = Color3.fromRGB(60, 60, 200), Capacity = 8 },
},
SyncRoblox = true,
})
-- A hit that leaves the victim dead credits the shooter
Kernel.Bus:subscribe("Weapon.Hit", function(_, session, victim)
local Character = victim.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if Humanoid and Humanoid.Health <= 0 then
session.Data.Kills = (session.Data.Kills or 0) + 1
end
end)

WeaponKit.service(config) takes a KitConfig:

Field Type Default Description
Weapons { [number]: WeaponConfig } required Weapon definitions keyed by wire id (1–255, sent as NumberU8)
Rewind Rewind? nil An attached Rewind instance. With it, claims validate and hits detect against rewound positions; without it, plain raycasts at current positions
Ammo { Get, Take }? nil Ammo adapter. Get(session, weaponId) -> number gates firing at zero; Take(session, weaponId) runs once per accepted shot

Each WeaponConfig:

Field Type Default Description
Name string required Display name; not used in validation
Damage number required Applied via Humanoid:TakeDamage on an accepted hit
FireRate number required Shots per second, enforced server-side per session
MaxRange number required Studs; bounds both the claim check and the hit cast
Hitbox { Radius: number?, HalfHeight: number? }? Radius 1.6, HalfHeight 2.4 The rewound capsule around each target’s root, per weapon. Defaults approximate an R15 body

There is no reload machinery in the kit — ammo is deliberately just the two-function adapter. A reload is your code writing the backing field (and your own intent, if the client initiates it); pointing Get/Take at session.Profile.Data persists ammo through the DataDriver for free.

Channel Schema Rate limit Direction
WK_Fire { "NumberU8", "Vector3F24", "NumberF64" } — weaponId, direction, timestamp 20/s, burst 5 client → server, fail-closed

Vector3F24 quantizes the direction to 3 bytes per axis — plenty for an aim vector, and it keeps a full fire intent at 18 payload bytes (1 + 9 + 8).

Point Mode Context Fired
Intent.WK_Fire fail-closed { Session, Player, Args = { weaponId, direction, timestamp } }; the kit’s gate adds Weapon and normalizes Args[2] Every fire intent, before the handler. Kit gate at priority 50
Weapon.CanDamage fail-open { Session, Attacker, Victim, WeaponId, Weapon } After a confirmed hit, before damage. TeamKit registers friendly-fire vetoes here when FriendlyFire = false

Returning false from a Weapon.CanDamage handler vetoes the damage; the shot still happened (Weapon.Fired already published) but no Weapon.Hit follows.

Topic Payload When
Weapon.Fired session, weaponId, origin, direction Shot accepted and ammo taken — before hit detection. Subscribe for tracers and sound replication
Weapon.Hit session, victimPlayer, weaponId, position Damage landed (post-CanDamage). Subscribe for kill credit, hitmarkers, QuestKit objectives
Weapon.ShotRejected player, reason The lag-comp claim check failed. Reasons are machine-readable (table above) so subscribers can apply per-reason strike policies