Skip to content

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.

One shot passes five stages:

  1. 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.
  2. The kit’s validator on the fail-closed Intent.WK_Fire hook chain runs at priority 50. A dead shooter, an unknown weapon, a bad direction, a hot fire-rate window or an empty magazine returns false, and no handler runs. Game rules go in validators with lower priority numbers, which run first.
  3. With Rewind set, the handler anchors the muzzle to the shooter’s rewound position and checks the claim with Rewind:validateShot. A refused claim publishes Weapon.ShotRejected with a machine-readable reason.
  4. The handler looks for a victim. With Rewind, Rewind:castRay finds 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.
  5. The fail-open Weapon.CanDamage hook fires. Unless a handler returns false, the victim’s Humanoid takes Damage and Weapon.Hit publishes.
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.

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:

src/Server/Bootstrap.luau
--!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
src/Server/Game/Services/CombatService.luau
--!strict
-- CombatService: seeds ammo and credits kills from WeaponKit hits.
local CombatService = {
Name = "CombatService",
}
function CombatService.init(self: any, kernel: any)
self.Kernel = kernel
end
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()
end
end
return CombatService

Without 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.

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.

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.

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.