Weapons and combat#
A complete weapon: pick it up, aim, fire, reload, put it down.
How the pieces fit#
| Piece | Where it lives | Why there |
|---|---|---|
| The key | self:on_action("fire", { key = "mouse1" }) | an action belongs to the world |
| Recoil, rate of fire, cone | player:set_weapon("fire", …) | a weapon belongs to whoever picked it up |
| The shot itself | inside the handler | it is your game's rule |
Careful
Do not put recoil on the action. "fire" is one bit of the region: two objects declaring it are one trigger, which is right, because a player has one finger. So a recoil on the binding is a recoil everybody shares. A rifle and a sidearm fold into whichever was seen last, and somebody holding nothing still gets kicked when they click.
A working weapon#
--!strictlocal self = require("self")local world = require("world")local players = require("players")local objects = require("objects")local events = require("events") local WEAPON = { name = "Service rifle", damage = { head = 110, body = 36, legs = 27 }, magazine = 30, reserve = 90, reload = 2.4, range = 80, grip = { pos = { x = 0.06, y = -0.02, z = -0.14 }, rot = { x = 0, y = 90, z = 0 }, }, recoil = { up = 1.05, side = 0.55, climb = 0.11, max = 12, recover = 0.16, reset = 0.4 }, spread = { base = 0.18, moving = 4.2, air = 10.0, crouch = 0.72, per_shot = 0.30, recover = 0.30, move_min = 0.34, max = 14.0 }, sight = { length = 7, thickness = 2, gap = 5, outline = 1, dynamic = true },} local rounds = WEAPON.magazinelocal reserve = WEAPON.reservelocal reloading = false local function in_hand(): boolean return self:held_at() == "right_hand"end -- ── picking it up ──────────────────────────────────────────────────────── self:on_touch(function(p) self:hold({ player = p.id, at = "right_hand", pos = WEAPON.grip.pos, rot = WEAPON.grip.rot, settle = true, })end) self:on_hold(function(p) if not in_hand() then return end local who = players:get(p.id) if not who then return end who:show_crosshair(WEAPON.sight) who:set_weapon("fire", { recoil = WEAPON.recoil, spread = WEAPON.spread, auto = 0.1, cycle = 0.1, })end) self:on_drop(function(p) local who = players:get(p.id) if who then who:hide_crosshair() who:clear_weapon("fire") end reloading = falseend) -- ── firing ─────────────────────────────────────────────────────────────── self:on_action("fire", { key = "mouse1" }, function(p, shot) if self:held_by() ~= p.id or not in_hand() then return end if reloading or rounds <= 0 then return end rounds -= 1 self:play_sound("shot", { pitch = { 0.96, 1.04 }, range = 200, near = 6 }) local hit = world:raycast({ from = shot.eye, dir = shot.dir, max = WEAPON.range, ignore_player = p.id, }) if not hit then return end if hit.what == "player" then local zone = hit.zone or "body" local amount = WEAPON.damage[zone] or WEAPON.damage.body events:emit("combat:hit", { target = hit.player, by = p.id, amount = amount, zone = zone, }) elseif hit.what == "object" then local obj = objects:get(hit.object) if obj then obj:damage(30, { by = p.id }) end endend) -- ── reloading ──────────────────────────────────────────────────────────── self:on_action("reload", { key = "r" }, function(p) if self:held_by() ~= p.id or not in_hand() or reloading then return end if rounds >= WEAPON.magazine or reserve <= 0 then return end reloading = true self:play_sound("reload", { volume = 0.9, range = 25, near = 2 }) self:after(WEAPON.reload, function() if not reloading then return end -- dropped mid-reload reloading = false local wanted = WEAPON.magazine - rounds local taken = math.min(wanted, reserve) rounds += taken reserve -= taken end)end) -- ── aiming ─────────────────────────────────────────────────────────────── self:on_action("aim", { key = "mouse2", camera = { distance = 0, height = 1.62, fov = 48, smooth = 0.09 },}, function() end)The parts worth understanding#
Cast from the eye, never the feet#
world:raycast({ from = shot.eye, dir = shot.dir, ... })shot.eye is where their eyes were for that stance. A constant added to the feet is right standing up and wrong crouching, because the eye drops about 55 centimetres and your constant drops nothing, so every crouched shot leaves half a metre above the crosshair.
shot.dir already is the bullet#
The recoil and the shot cone are applied before your handler runs. You do not add them, and you must not: shot.dir is the final direction.
ignore_player is not optional#
A shot from eye height starts inside the shooter's own capsule. Without it, every weapon hits its owner at point-blank range.
Check the hand every time#
if self:held_by() ~= p.id or not in_hand() then return end"fire" is one bit of the region, so every weapon script in the world wakes up when anyone clicks, including the one on this player's back. held_by alone is not enough; a weapon slung over a shoulder is still theirs.
auto and cycle#
auto is the rate while the trigger is held. cycle is the shortest gap between two shots however the trigger is worked.
Without cycle, a weapon claiming ten rounds a second gives fifteen to somebody who clicks quickly, which makes clicking better than holding, exactly backwards from what a recoil pattern rewards. Leave it out on an automatic (auto stands in); set it on anything semi-automatic.
The cone is what the crosshair shows#
With dynamic = true on the crosshair, the arms open by exactly the cone that deviates the bullet, so the picture cannot lie.
move_min is the number that makes the game: below that fraction of full speed, moving costs nothing. Without it there is no such thing as having stopped enough, so nobody ever lets go of a movement key.
Marking what you hit#
if hit and hit.what ~= "player" then world:spawn({ shape = "cylinder", pos = hit.pos + hit.normal * 0.006, facing = hit.normal, rot = { x = 0, y = math.random(0, 359), z = 0 }, scale = { x = 0.035, y = 0.01, z = 0.035 }, color = "#12100e", collide = false, life = 25, })endfacing lays the disc flat against whatever was hit, whether floor, wall, slope or imported mesh, without you converting a normal into angles.
Keep a pool. The region allows 256 script-made objects in total, so a weapon with no ceiling eats everybody's budget:
local marks = {}-- after each spawntable.insert(marks, id)while #marks > 16 do world:despawn(table.remove(marks, 1))endDamage on the receiving end#
The weapon reports; something else decides what a hit costs. That keeps scoring, armour and respawning in one place instead of in every weapon.
events:listen("combat:hit", function(e) local hp = health[e.target] or 100 hp -= e.amount if hp <= 0 then health[e.target] = 100 players:teleport(e.target, SPAWN) else health[e.target] = hp endend)Buffs: speed, jump, and being unable to move#
A combat system is mostly numbers going up and down on one person. set_movement is where the ones about their body live, as multipliers on the world's own movement.
local players = require("players") -- haste, for six secondsp:set_movement({ speed = 1.5 })self:on_timer(6, function() p:clear_movement() end) -- a stun: rooted, and no jumping out of itp:set_movement({ can_move = false, can_jump = false })can_move = false is a stun and not a freeze: they still fall, still ride a platform, and are still pushed by anything that pushes bodies.
One set per player. A second call replaces the first, so your system holds its own numbers and sends the product:
local buffs = {} -- [player] = { haste = 1.5, armour = 0.8 } local function apply(who) local speed = 1 for _, v in pairs(buffs[who] or {}) do speed *= v end players:set_movement(who, { speed = speed })endThat is deliberate rather than a gap. An engine that added up contributions would have to decide what happens when two of them disagree, when one stops, and in what order they combine, and the right answer is different in every game. Your rules are the only thing that knows.
Buffs die with the script that granted them, so a re-saved potion cannot leave somebody permanently fast.
See set_movement for every field, and for why can_jump is a state rather than an event you cancel.
See also#
- set_movement covers speed, jump and stuns
- set_weapon covers recoil, cone and cycle
- self:on_action · world:raycast
Updated 04 September 2026