Skip to content

Weapons and combat

A complete weapon: pick it up, aim, fire, reload, put it down.

How the pieces fit

PieceWhere it livesWhy there
The keyself:on_action("fire", { key = "mouse1" })an action belongs to the world
Recoil, rate of fire, coneplayer:set_weapon("fire", …)a weapon belongs to whoever picked it up
The shot itselfinside the handlerit is your game's rule

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

luau
--!strict
local 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.magazine
local reserve = WEAPON.reserve
local 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 = false
end)

-- ── 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
    end
end)

-- ── 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

luau
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 — 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

luau
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

luau
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,
    })
end

facing lays the disc flat against whatever was hit — floor, wall, slope, 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:

luau
local marks = {}
-- after each spawn
table.insert(marks, id)
while #marks > 16 do
    world:despawn(table.remove(marks, 1))
end

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

luau
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
    end
end)

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.

luau
local players = require("players")

-- haste, for six seconds
p:set_movement({ speed = 1.5 })
self:on_timer(6, function() p:clear_movement() end)

-- a stun: rooted, and no jumping out of it
p: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:

luau
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 })
end

That 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

Hungrit scripting documentation.