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.
Mental model
Section titled “Mental model”One shot flows through five stages:
- Wire. The client fires the
WK_Fireintent:(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. - Validation — the kit’s gate on the fail-closed
Intent.WK_Firehook chain, priority 50. Unknown weapon id, degenerate direction, hot fire-rate window, or empty ammo each returnfalseand the message never reaches a handler. Your game rules prepend at lower priority numbers. - Lag-compensation claim check. With
config.Rewindset, the handler anchors the muzzle to the shooter’s rewound position and runsRewind:validateShot— a stale or future timestamp, missing history, or an origin too far from the rewound position rejects with a machine-readable reason onWeapon.ShotRejected. - Hit detection.
Rewind:castRaysweeps 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. - Damage. The fail-open
Weapon.CanDamagegate fires; unless a handler vetoes, the victim’s humanoid takesDamageandWeapon.Hitpublishes.
What the validator rejects
Section titled “What the validator rejects”| 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.
How the handler resolves a hit
Section titled “How the handler resolves a hit”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 itKernel:onSession(function(session) session.Data.Ammo = 120end)
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 automaticallyTeamKit.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 shooterKernel.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 endend)local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)local NetClient = require(ChloeKernel.Net.Client)
local Net = NetClient.new()local Fire = Net:intent("WK_Fire", { "NumberU8", "Vector3F24", "NumberF64" })
local Input = InputDriver.attach(Kernel)Input:bindAction("Fire", { Keyboard = Enum.KeyCode.F, Handler = function(state: Enum.UserInputState) if state ~= Enum.UserInputState.Begin then return end local Camera = workspace.CurrentCamera if Camera then -- The timestamp is the lag-compensation claim time — the shared -- server clock, not os.clock() Fire:Fire(1, Camera.CFrame.LookVector, workspace:GetServerTimeNow()) end end,})Configuration
Section titled “Configuration”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.
Wire channel
Section titled “Wire channel”| 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.
Bus topics
Section titled “Bus topics”| 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 |