Hungrit Docs

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

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#

Luau
--!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#

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, 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#

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, 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:

Luau
local marks = {}-- after each spawntable.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    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.

Luau
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:

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#

Updated 04 September 2026