WeaponKit
Server-validated hitscan weapons with a fail-closed fire intent, per-weapon fire rate and ammo gates, and lag-compensated hits against rewound capsules.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .WeaponKit - Since
- v0.1.0
- Source
- Kits/WeaponKit.luau
WeaponKit is hitscan weapons as a kernel service. The client never reports a hit. It sends a claim: “I fired weapon 1 in this direction at this server time.” Identity, fire rate, ammo, lag compensation, hit detection and damage all resolve on the server, and a claim that fails a check dies in the fail-closed Intent.WK_Fire chain before the handler runs. It is a Kit.service kit, registered like any other service. MeleeKit covers close range and SpellKit covers cast abilities.
The decision that defines it: with a Rewind instance attached, hits test against capsules placed where targets were at the claimed timestamp, while walls block at their current state. Players hit what they saw on screen. Geometry does not rewind.
Mental model
Section titled “Mental model”One shot passes five stages:
- The client fires
WK_Fire(weaponId: NumberU8, direction: Vector3F24, timestamp: NumberF64). The channel’s token bucket allows 20 messages per second with a burst of 5. That is above any real weapon’s fire rate, so the bucket only stops floods. - The kit’s validator on the fail-closed
Intent.WK_Firehook chain runs at priority 50. A dead shooter, an unknown weapon, a bad direction, a hot fire-rate window or an empty magazine returnsfalse, and no handler runs. Game rules go in validators with lower priority numbers, which run first. - With
Rewindset, the handler anchors the muzzle to the shooter’s rewound position and checks the claim withRewind:validateShot. A refused claim publishesWeapon.ShotRejectedwith a machine-readable reason. - The handler looks for a victim. With Rewind,
Rewind:castRayfinds the nearest rewound capsule, then one world ray confirms no colliding geometry stands in front of it now. Without Rewind, a world ray finds the first wall and a body ray finds the first player part before it. - The fail-open
Weapon.CanDamagehook fires. Unless a handler returnsfalse, the victim’s Humanoid takesDamageandWeapon.Hitpublishes.
What the validator rejects
Section titled “What the validator rejects”| Check | Rule |
|---|---|
| Shooter | The session is active, Player.Character exists, and its Humanoid has Health > 0. The handler repeats this check, so a shooter who dies between validation and dispatch does nothing. |
| Weapon | weaponId must be a key in config.Weapons. |
| Direction | NaN components, a magnitude below 1e-4 and an infinite magnitude all reject. NaN and infinity would otherwise pass a plain magnitude comparison. |
| Fire rate | Rejects when os.clock() - WKLastShot < (1 / FireRate) * 0.92. The 8% allowance absorbs replication jitter and is too small to macro past. |
| Ammo | With an Ammo adapter, Get(session, weaponId) <= 0 rejects. |
The validator normalizes the direction in place (context.Args[2] = Direction.Unit) and stores the weapon’s config on context.Weapon. The handler receives the normalized direction, and later validators can read both.
WKLastShot is stamped in the handler once the shot is accepted: after every validator has passed and, with Rewind, after the claim check. A shot that your own validator rejects, or that Rewind refuses, does not spend the player’s fire-rate window. Before 0.9.1 the stamp came before the claim check, so a shot Rewind refused still spent it.
How the handler resolves a hit
Section titled “How the handler resolves a hit”The muzzle sits 1.5 studs above where the shooter was at claim time: Rewind:getPositionAt(player, timestamp) + Vector3.new(0, 1.5, 0). Using the current root instead would reject honest high-ping shots, because the shooter has moved by the time the claim arrives. Without Rewind, or when Rewind has no sample for the shooter, the muzzle is the current root plus the same offset.
Rewind:validateShot then checks the claim. These are the reasons a WeaponKit claim can produce:
| Reason | Meaning |
|---|---|
InvalidTimestamp, InvalidOrigin, InvalidDirection |
A non-finite timestamp, origin or direction. |
FutureTimestamp |
More than MaxFutureSeconds (default 0.2 s) ahead of the server clock. |
StaleTimestamp |
Older than the shooter’s round trip plus InterpolationSeconds (0.15 s) and PastSlackSeconds (0.1 s), and never older than HistorySeconds (1 s). A low-ping shooter cannot backdate a shot to the edge of the history window. |
NoShooterHistory |
Rewind has no sample for the shooter at that time: a fresh spawn, a dead character, or a buffer reset by AntiExploit.Forgive. |
OriginMismatch |
The origin is more than OriginToleranceStuds (default 8) from the rewound position. WeaponKit derives its origin from that same position, 1.5 studs away, so this reason does not occur at the default tolerance. |
WeaponKit passes no target or hit position in the claim, so the target-side reasons (OutOfRange, DirectionMismatch, HitMismatch) never apply to it.
Hit detection with Rewind uses two queries on purpose. Rewind:castRay tests the rewound capsule of every tracked player except the shooter and returns the nearest intersection. It knows nothing about walls. A second ray from the muzzle to that distance runs against the world with every player character excluded and RespectCanCollide = true. Live bodies must not block a shot aimed at where a target used to be, and current walls must. A target who ducked behind cover after the shooter fired is still hit. A target behind a wall that exists now is not.
Without Rewind, the kit casts a world ray to find the first colliding wall, then a body ray up to that distance that includes only player characters minus the shooter. The body ray does not respect CanCollide, because limbs do not collide by default and a shot to the arm must still count.
Both ray parameter objects are created once in init and reused for every shot. The character exclusion list is rebuilt only on the next shot after a join, a leave, a respawn or a character removal. start() tracks those events; without it, every shot rebuilds the list.
With lag compensation, WeaponKit needs a Rewind instance, and Rewind needs the kernel. Register the service where the kernel is in hand, before the game layer registers its own services:
--!strict-- Server bootstrap: attaches lag compensation and WeaponKit, then loads the game layer.
local ServerScriptService = game:GetService("ServerScriptService")
local Rewind = require(ServerScriptService.ChloeKernelServer.AntiExploit.Rewind)local WeaponKit = require(ServerScriptService.ChloeKernelServer.Kits.WeaponKit)
return function(kernel: any) kernel:registerService(WeaponKit.service({ Rewind = Rewind.attach(kernel), Weapons = { [1] = { Name = "Rifle", Damage = 25, FireRate = 6, MaxRange = 250 }, [2] = { Name = "Sidearm", Damage = 12, FireRate = 10, MaxRange = 120, Hitbox = { Radius = 1.4 } }, }, Ammo = { Get = function(session: any, _weaponId: number): number return session.Data.Ammo or 0 end, Take = function(session: any, _weaponId: number) session.Data.Ammo -= 1 end, }, })) require(script.Parent.Game)(kernel)end--!strict-- CombatService: seeds ammo and credits kills from WeaponKit hits.
local CombatService = { Name = "CombatService",}
function CombatService.init(self: any, kernel: any) self.Kernel = kernelend
function CombatService.start(self: any) self.SessionConnection = self.Kernel:onSession(function(session: any) session.Data.Ammo = 120 end) self.HitConnection = self.Kernel.Bus:subscribe("Weapon.Hit", function(_topic: string, session: any, victim: Player) local Character = victim.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid and Humanoid.Health <= 0 then self.Kernel.Bus:publish("Combat.Kill", session, victim) end end)end
function CombatService.stop(self: any) if self.SessionConnection then self.SessionConnection:disconnect() end if self.HitConnection then self.HitConnection:disconnect() endend
return CombatService--!strict-- WeaponController: fires the rifle toward the camera look direction.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local WeaponController = { Name = "WeaponController",}
function WeaponController.init(self: any, kernel: any) self.Kernel = kernelend
function WeaponController.start(self: any) -- Kit channels are defined by the kit on the server; the schema must match it exactly local Fire = NetClient.new():intent("WK_Fire", { "NumberU8", "Vector3F24", "NumberF64" }) self.Input = InputDriver.attach(self.Kernel) self.Input:bindAction("Fire", { Keyboard = Enum.KeyCode.F, Gamepad = Enum.KeyCode.ButtonR2, TouchButton = true, Handler = function(state: Enum.UserInputState) local Camera = workspace.CurrentCamera if state == Enum.UserInputState.Begin and Camera then -- Rewind samples on the shared server clock, so the claim time must use it too Fire.fire(1, Camera.CFrame.LookVector, workspace:GetServerTimeNow()) end end, })end
function WeaponController.stop(self: any) if self.Input then self.Input:destroy() self.Input = nil endend
return WeaponControllerWithout lag compensation, the kit needs no kernel at construction. Put the config in Game/Config and add WeaponKit.service(require(script.Parent.Config.Weapons)) to src/Server/Game/Registry.luau.
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 to 255, sent as NumberU8). |
Rewind |
Rewind? |
nil |
An attached Rewind instance. With it, claims validate and hits test against rewound capsules. Without it, rays test current positions. |
Ammo |
{ Get, Take }? |
nil |
Ammo adapter. Get(session, weaponId) -> number rejects the shot at zero. Take(session, weaponId) runs once per shot that passes the claim check. |
Each WeaponConfig:
| Field | Type | Default | Description |
|---|---|---|---|
Name |
string |
required | Display name. Validation does not read it. |
Damage |
number |
required | Applied with Humanoid:TakeDamage on an accepted hit. |
FireRate |
number |
required | Shots per second, enforced per session. |
MaxRange |
number |
required | Studs. Bounds the claim check and both rays. Must be in (0, 15000]. |
Hitbox |
{ Radius: number?, HalfHeight: number? }? |
Radius 1.6, HalfHeight 2.4 |
The rewound capsule around each target’s root for this weapon. The defaults approximate an R15 body. Used only with Rewind. |
WeaponKit.service checks every MaxRange when it is called and errors unless the value is a number greater than 0 and at most 15,000. WorldRoot:Raycast stops at 15,000 studs, so a longer range would silently shorten every shot.
The kit has no reload machinery. Ammo is the two-function adapter and nothing else. A reload is your code writing the backing field, through your own channel if the client starts it. Point Get and Take at session.Profile.Data to persist ammo through the DataDriver.
| Member | Description |
|---|---|
WeaponKit.service(config) -> ServiceDefinition |
Validates every MaxRange and returns a service named "WeaponKit" for kernel:registerService or the game Registry.luau. |
service:init(kernel) |
Kernel-called. Defines WK_Fire and the Weapon.CanDamage point, installs the validator at priority 50 and the handler, and creates the cached ray parameters. |
service:start() |
Kernel-called. Subscribes to sessions and character changes so the character exclusion list rebuilds only after the roster changes. |
service:stop() |
Kernel-called at close and shutdown. Unhooks the fire validator, disconnects the session listener and makes the handler inert. |
After stop() the WK_Fire channel has no kit validator. A channel with no validator rejects every payload, so shots stop. If a game validator still keeps the channel open, the handler returns without doing anything.
Wire channel
Section titled “Wire channel”| Channel | Schema | Rate limit | Direction |
|---|---|---|---|
WK_Fire |
{ "NumberU8", "Vector3F24", "NumberF64" }: weaponId, direction, timestamp |
20 per second, burst 5 | client to server, fail-closed |
| Point | Mode | Context | Fired |
|---|---|---|---|
Intent.WK_Fire |
fail-closed | { Name, Session, Player, Args = { weaponId, direction, timestamp } }. The kit’s validator adds Weapon and normalizes Args[2]. |
Every fire intent, before the handler. Kit validator at priority 50. |
Weapon.CanDamage |
fail-open | { Session, Attacker, Victim, WeaponId, Weapon } |
After a confirmed hit on a living victim, before damage. TeamKit registers its friendly-fire veto here at priority 40 when FriendlyFire = false. |
A Weapon.CanDamage handler that returns false cancels the damage. The shot still happened (Weapon.Fired already published), but no Weapon.Hit follows. After the hook, the handler checks again that the shooter is alive and that the victim still has the same living character, since a handler may yield.
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Weapon.Fired |
session, weaponId, origin, direction |
The claim passed and ammo was taken, before hit detection. Tracers and sound replication subscribe here. |
Weapon.Hit |
session, victimPlayer, weaponId, position |
Damage landed after Weapon.CanDamage. Kill credit, hit markers and QuestKit objectives subscribe here. |
Weapon.ShotRejected |
player, reason |
The Rewind claim check failed. The reason strings are the table above, so a subscriber can apply a strike policy per reason. |
WeaponKit does not publish Combat.Kill. A game that counts kills publishes it from a Weapon.Hit subscriber, as CombatService does above.