Files
Samuel LisonandClaude Opus 4.8 7a5aaac0df fix(combat): rate-limit bow shots + validate bow inputs
The bow release handler enforced no cooldown — the only client-driven action
in the engine without one (melee, harvesting and item use all rate-limit). Each
release also costs up to MaxRange/StepSize server raycasts to simulate the arc,
so the gate now runs BEFORE the arrow is spent and before the simulation.

- Combat.Bow.Cooldown (0.35s) added to CombatConfig + the EngineConfig schema,
  so it's tunable no-code in SurvivorCore Studio.
- onBowRelease honours def.weaponCooldown first, falling back to Bow.Cooldown.
  weaponCooldown was previously read only by the melee path, even though the
  authoring form offers it for every weapon — a bow cooldown was silently
  ignored. Its plugin label is now "Cooldown (s)" noting it covers both.
- A release with no matching draw is rejected (a real client fires BowDraw on
  press, BowRelease on release).
- BowDraw validates the sender is alive and holding a bow; it previously
  accepted anything from anyone.
- Aim points are checked for finiteness: a non-finite Vector3 defeats magnitude
  comparisons and would reach the raycast after the arrow was already spent.
- lastShot is cleared in PlayerRemoving alongside lastSwing/drawStart.

Affects v0.8.0 and earlier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:56:54 +10:00

6.6 KiB
Raw Permalink Blame History

Combat (melee + ranged)

📹 Demo: melee hits, bows & arrows, and creating weapons/ammo/mobs in the admin plugin

Combat (src/systems/Combat.luau) is server-authoritative and reuses the v0.4.0 client-input → RemoteEvent → server-validated-hit pipeline (the same shape as harvesting). The client only requests an attack; the server validates everything — equipped weapon, range, line-of-sight, cooldown — and decides the hit. It never trusts the client.

Targets are anything with a Humanoid: engine mobs (always damageable) and, when Config "Combat".FriendlyFire is on, other players. Damage flows through Humanoid:TakeDamage, so a player victim's survival Health HUD updates with no extra wiring and death/respawn is clean.

Weapons are items

A weapon is just an Items def with category = "weapon", a toolType (so the hotbar→Tool bridge equips it as a real Tool), and flat weapon* fields. Flat attributes keep weapons fully no-code-authorable (the admin plugin's Weapons editor writes exactly these):

SurvivorCore.Items.register({
    id = "wood_club",
    name = "Wooden Club",
    category = "weapon",
    toolType = "club",        -- lets the hotbar equip it
    weaponKind = "melee",     -- "melee" | "bow"
    weaponDamage = 20,
    weaponRange = 8,          -- melee reach (server-validated)
    weaponCooldown = 0.6,
})

SurvivorCore.Items.register({
    id = "short_bow",
    name = "Short Bow",
    category = "weapon",
    toolType = "bow",
    weaponKind = "bow",
    weaponDamage = 34,            -- base; multiplied by the arrow's ammoDamage and the draw
    weaponDrawTime = 1,           -- seconds to a full-power draw (the bow's "pullback")
    weaponProjectileSpeed = 200,
    weaponMaxRange = 260,         -- studs (fallback if the arrow sets no ammoRange)
    weaponAmmo = "arrow",         -- the arrow id consumed per shot ("" = no ammo)
})

Melee

Equip a melee weapon from the hotbar and click. The client (CombatInput) asks the server to swing; the server finds the nearest valid target within weaponRange (falling back to Combat.MeleeRange) with line-of-sight and applies weaponDamage. A registered swing animation (Assets "WeaponAnims" by toolType) plays client-side.

Ranged (bow) — the aiming flow

Bows use a TCE-style two-button aim (CombatInput):

  1. Hold right-click to aim — the camera shifts over-the-shoulder (Humanoid.CameraOffset) and zooms (FOV), and a crosshair + charge ring appear.
  2. Hold left-click to draw — the ring fills and greens up over weaponDrawTime.
  3. Release left-click to fire along the crosshair; release right-click exits aim.

The client sends the world point under the crosshair (a camera-centre ray); the server recomputes the true direction from the bow's own muzzle (killing shoulder-camera parallax and any spoofed direction), times the draw itself (anti-cheat), consumes one weaponAmmo arrow from the inventory (no arrows → no shot), and simulates the arrow authoritatively — a gravity arc stepped as short raycasts. It sends back the arc's path, and the client flies a cosmetic arrow along that exact curve. The melee and bow paths route by the equipped Tool's WeaponKind, so they coexist with harvesting without ever double-firing.

Arrows — configurable, weighted ammo

An arrow is its own item (category = "ammo"), authored in code or via the admin Arrows / Ammo editor — so a game can ship many arrow types with different ballistics. A shot combines the bow's pullback with the arrow's stats and the draw strength:

Arrow field Effect
ammoDamage × the bow's weaponDamage (e.g. 1.4 = +40%)
ammoDrop × gravity — the curve; heavier arrows (> 1) arc steeply and fall short
ammoRange caps the flight in studs (0 = use the bow's weaponMaxRange)
ammoSpeed × the bow's projectile speed (0 = use the bow's speed)
weight carry weight per arrow (the inventory cost of ammo)
SurvivorCore.Items.register({
    id = "heavy_arrow", name = "Heavy Arrow", category = "ammo",
    weight = 0.12, stack = 16,
    ammoDamage = 1.4,   -- hits harder
    ammoDrop = 2,       -- doubles gravity → a pronounced arc
    ammoSpeed = 0.85,   -- a touch slower
    ammoRange = 140,    -- short effective range
})

Final shot = damage = weaponDamage × ammoDamage × draw, speed = (bow speed × ammoSpeed) × draw, gravity = Combat.Bow.Gravity × ammoDrop, range = ammoRange or weaponMaxRange or Combat.Bow.MaxRange. Draw strength scales from Combat.Bow.MinDrawDamageMult (no draw) to full (full draw). Point a bow's weaponAmmo at any arrow id to set its ammo.

The kill-event schema (designed once)

Both paths fire the same schema, through Hooks (engine extension) and EventBridge (analytics / quests / achievements):

Event Payload
combat:hit { attacker, victim, weapon, source, damage, victimHpLeft }
combat:kill { attacker, victim, weapon, source }source = "melee" | "bow"

This replaces the scattered, content-specific kill events a survival game tends to grow (zombie_killed / animal_killed / pvp_kill / bow_kill / …) with one schema every consumer subscribes to. A mob's own death also fires the lifecycle mob:died (see mobs.md) — the two are complementary: combat:kill is attacker-attributed, mob:died is the creature lifecycle.

SurvivorCore.Hooks.on("combat:kill", function(ctx)
    if ctx.source == "bow" then awardArcheryXp(ctx.attacker) end
end)

Runtime & tuning

SurvivorCore.Combat boots with the engine (after Inventory + Mobs). Tuning:

Config.override("Combat", {
    MeleeRange = 8, MeleeCooldown = 0.6, RequireLineOfSight = true, FriendlyFire = false,
    Bow = {
        Gravity = 80, ProjectileSpeed = 180, MaxRange = 300, MinDrawDamageMult = 0.3, StepSize = 4,
        Cooldown = 0.35, -- seconds between accepted shots (fallback when a bow sets no weaponCooldown)
    },
})

Per-weapon weapon* values override these fallbacks — including weaponCooldown, which governs both melee swings and bow shots. Every shot is rate-limited server-side before any arrow is spent or any arc is simulated, so a client can't out-run its own fire rate. Out of scope: durability, blocking/parrying, and AoE are creator content via the hooks above; mob AI lives in mobs.md.