Skip to content

UseableThrowable — Grenade and Explosive Deployment

Overview

UseableThrowable (306 lines) at Unturned/Useable/UseableThrowable.cs implements throwable items: grenades, flashbangs, distractions, smoke grenades, and impact-triggered explosives. It handles the throw trajectory, fuse timing, and spawning of the physics-driven throwable object. UseableDetonator (343 lines) at Unturned/Useable/UseableDetonator.cs implements the remote detonator that triggers InteractableCharge barricades in sequence.

Supporting types: ESwingMode (WEAK, STRONG), ItemThrowableAsset, ItemDetonatorAsset.


UseableThrowable Architecture

Throw Lifecycle

startPrimary() / startSecondary()
  → startAttack(swingMode)
    → isBusy = true, hasUsed = true
    → play("Use") animation
    → SendPlaySwing (broadcast to other clients)

tick() — when isSwinging && isThrowable:
  → calculate origin (wall avoidance)
  → calculate force (weak/strong + OLYMPIC boost)
  → toss(origin, force)
  → equipment.useStepA() (consume item)
  → isSwinging = false

simulate() — when isUsing && isUseable:
  → isBusy = false, isUsing = false
  → equipment.useStepB() (complete consumption)
  • isUseable: realtimeSinceStartup - startedUse > useTime (full animation).
  • isThrowable: realtimeSinceStartup - startedUse > useTime * 0.6 (release point at 60% of animation).

Primary vs Secondary

ActionMethodSwing ModeThrow Force
Primary (left click)startPrimary()STRONGasset.strongThrowForce
Secondary (right click)startSecondary()WEAKasset.weakThrowForce

HasUsed flag prevents double-throw (startAttack returns false if hasUsed is true, fixing public issue #4849).


Trajectory Calculation

csharp
Vector3 origin = player.look.aim.position;
Vector3 direction = player.look.aim.forward;

// Wall avoidance: if a wall blocks the aim within 1.5m, spawn 0.5m in front of the wall
RaycastHit hit;
if (Physics.Raycast(new Ray(origin, direction), out hit, 1.5f, DAMAGE_SERVER))
    origin += direction * (hit.distance - 0.5f);
else
    origin += direction;

// Force magnitude based on swing mode
float forceMagnitude = swingMode == STRONG ? strongThrowForce : weakThrowForce;

// OLYMPIC boost multiplier
if (player.skills.boost == EPlayerBoost.OLYMPIC)
    forceMagnitude *= asset.boostForceMultiplier;

Vector3 force = direction * forceMagnitude;
toss(origin, force);

No ballistic arc simulation on the server — the physics engine handles trajectory once the Rigidbody is launched. The initial velocity is purely directional with no vertical component; gravity takes over from the first physics step.


Throwable Spawning (toss)

csharp
void toss(Vector3 origin, Vector3 force)
{
    Quaternion rotation = Quaternion.LookRotation(force);
    Transform throwable = Instantiate(asset.throwable, origin, rotation).transform;
    throwable.name = "Throwable";
    EffectManager.RegisterDebris(throwable.gameObject);

    Rigidbody rb = throwable.GetComponent<Rigidbody>();
    rb.AddForce(force);
    rb.collisionDetectionMode = Continuous;

    // Type-specific components
    if (isExplosive):
        Grenade grenade = throwable.AddComponent<Grenade>();
        grenade.killer = owner.steamID;
        grenade.range = asset.range;
        grenade.playerDamage = asset.playerDamageMultiplier.damage;
        grenade.fuseLength = asset.fuseLength;
        // ... damage fields, explosion effect

    else if (isFlash):
        Flashbang flash = throwable.AddComponent<Flashbang>();
        flash.fuseLength = asset.fuseLength;

    else: // distraction
        throwable.AddComponent<Distraction>();
        Destroy(throwable, fuseLength);

    if (isSticky):
        StickyGrenade sticky = throwable.AddComponent<StickyGrenade>();
        sticky.ignoreTransform = transform;

    if (explodeOnImpact):
        throwable.SetLayerRecursively(TRAP); // passes through vehicles
        ImpactGrenade impact = throwable.AddComponent<ImpactGrenade>();
        // ...
}

Throwable Types

TypeComponentBehavior
ExplosiveGrenadeFuse countdown, explosion on detonation, damage in radius, launch speed
FlashbangFlashbangFuse countdown, screen flash, audio deafen
DistractionDistractionAttracts AI attention
StickyStickyGrenadeAttaches to surfaces
ImpactImpactGrenadeExplodes on contact with any surface

Components can combine: a grenade can be both explosive and sticky and impact-triggered.

Server-Side Damage Assignment

When Provider.isServer:

csharp
// Grenade damage per type from ItemThrowableAsset or magazine overrides
rocket.playerDamage = asset.playerDamageMultiplier.damage * damageMultiplier;
// ... zombie, animal, barricade, structure, vehicle, resource, object
rocket.explosionLaunchSpeed = asset.explosionLaunchSpeed;
rocket.explosionEffectGuid = asset.explosionEffectGuid;

Ragdoll Effect

csharp
rocket.ragdollEffect = player.equipment.getUseableRagdollEffect();

This uses the mythical skin's ragdoll effect if the player has cosmetics active.


Explosive Grenade Mechanics

The Grenade component (not in available source, but referenced) handles:

  1. Fuse countdown: Destroy(gameObject, fuseLength) on non-server clients; server waits fuseLength seconds before calling DamageTool.explode().
  2. Explosion effect: EffectManager.triggerEffect() with the grenade's explosion effect GUID.
  3. Damage: DamageTool.explode(ExplosionParameters) with radius, damage values, and damage origin.
  4. Launch force: explosionLaunchSpeed applies outward force to affected entities.

Client-Side Fuse

On clients (non-server), the throwable object is destroyed after fuseLength seconds without spawning damage. Damage is server-authoritative.


UseableDetonator — Remote Charge System

Architecture

The detonator interacts with InteractableCharge barricades (C4 charges, explosives). It manages three lists:

ListScopePurpose
foundInRadiusLocal player onlyAll charges within 64m, refreshed on movement
chargesInRadiusLocal player onlyCharges currently highlighted (owned by player)
chargesClient + ServerSelected charges to detonate

Equipment Flow

equip():
    useTime = GetAnimationLength("Use")
    charges = new List<InteractableCharge>()  // both client and server

tick() (local player only):
    if moved > 1m from last chargePoint:
        chargePoint = current position
        PowerTool.checkInteractables(position, 64m, foundInRadius)
        unhighlight charges that left radius
        highlight and add new owned charges to chargesInRadius

    Find best charge to target:
        for each charge in chargesInRadius:
            dot = Dot((charge.pos - camera.pos).normalized, camera.forward)
            if dot > bestDot: bestCharge = charge, bestDot = dot
        Update visual target indicator

Selection (Secondary Action)

csharp
override bool startSecondary()
{
    if (target != null)
    {
        if (target.isSelected)
        {
            target.deselect();
            charges.Remove(target);
        }
        else
        {
            target.select();
            charges.Add(target);
            // Cap at MAX_CLIENTSIDE_INPUTS (~512) to prevent overflow
            if (charges.Count > MAX_CLIENTSIDE_INPUTS)
            {
                charges[0].deselect();
                charges.RemoveAt(0);
            }
        }
    }
}

Detonation (Primary Action)

csharp
override bool startPrimary()
{
    // Client: send all selected charge transforms as raycast inputs
    for each charge in charges:
        RaycastInfo info = new RaycastInfo(charge.transform);
        player.input.sendRaycast(info, ERaycastInfoUsage.Detonator);
    charges.Clear();

    // Server: validate each input → InteractableCharge
    for each input:
        if (info.type == BARRICADE && info.transform.tag == "Barricade"):
            InteractableCharge charge = info.transform.GetComponent<InteractableCharge>();
            if (OwnershipTool.checkToggle(owner, charge.owner, group, charge.group))
                charges.Add(charge);

    isBusy = true;
    isDetonating = true;
    plunge(); // play animation + sound + alert
    SendPlayPlunge to other clients
}

Sequential Detonation

csharp
override void simulate(uint simulation, bool inputSteady)
{
    if (isDetonating && isDetonatable)
    {
        if (charges.Count > 0)
        {
            if (simulation - lastExplosion > 1)
            {
                lastExplosion = simulation;
                InteractableCharge charge = charges[0];
                charges.RemoveAt(0);
                charge?.Detonate(player);
            }
        }
        else
            isDetonating = false;
    }

    if (isUsing && isUseable && charges.Count == 0)
    {
        isBusy = false;
        isUsing = false;
    }
}

Charges detonate one per simulation tick (simulation - lastExplosion > 1). This creates a rapid cascade of explosions rather than all at once, spreading the CPU load and creating visual chain-reaction effects.

Ownership Check

csharp
DedicatedServer:
    OwnershipTool.checkToggle(owner, charge.owner, group, charge.group)
Non-Dedicated:
    charge.hasOwnership

Only charges owned by the player (or their group) can be selected or detonated.


Visual Feedback

StateVisual
In range, not targetedCharge has highlight (yellow glow)
Aiming at chargeCharge shows target indicator (red outline/crosshair)
Selected for detonationCharge shows selection indicator
Detonation triggeredExplosion effects from InteractableCharge.Detonate

Update Loop

csharp
tick() runs every frame for local player:
1. Movement check → refresh charge list (64m radius via PowerTool.checkInteractables)
2. Best charge targeting → dot product with camera forward (threshold: 0.98)
3. State transitions: highlight/unhighlight, target/untarget

On dequip(), all highlighted charges are unhighlighted.


Comparison: Throwable vs Detonator

AspectUseableThrowableUseableDetonator
AmmoConsumed on throw (useStepA/B)Battery/battery-less; not consumed
Effect timingFuse timer (asset.fuseLength)Instant per-charge on simulate tick
Damage sourceGrenade componentInteractableCharge.Detonate
Multi-targetSingle throwUp to ~512 charges
TrajectoryPhysics rigidbodyN/A
OwnershipKiller set on GrenadeOwnership check per charge
Boost interactionOLYMPIC: force * boostMultiplierNone

Explosive Damage Calculation (via Grenade Component)

When an explosive Grenade detonates (source not in available files, but inferred from UseableThrowable assignment and DamageTool.explode):

Damage Falloff

distance = |target - explosionCenter|
if distance > range: no damage
falloff = 1 - (distance / range)
damage = baseDamage * falloff

The Grenade component is assigned all per-type damage values from ItemThrowableAsset:

csharp
grenade.playerDamage = asset.playerDamageMultiplier.damage;
grenade.zombieDamage = asset.zombieDamageMultiplier.damage;
grenade.barricadeDamage = asset.barricadeDamage;
grenade.structureDamage = asset.structureDamage;
// ... vehicle, resource, object

Explosion Launch Force

csharp
grenade.explosionLaunchSpeed = asset.explosionLaunchSpeed;

This applies blast force to rigidbodies and players within the explosion radius, proportional to distance. Ragdolls receive the force for physics-driven death animations.

Explosion Effects

The explosion effect is resolved from:

  1. asset.explosionEffectGuid — explosion particle/audio effect.
  2. asset.explosion — legacy explosion ID (used if GUID is empty).
  3. Magazine override: if a magazine asset is attached, its explosion effect GUID takes priority.

Penetration

csharp
grenade.penetrateBuildables = asset.ExplosionPenetratesBuildables;

When true, explosion damage passes through barricade/structure HP rather than being absorbed by the first hit.


Detonator — Charge Interaction Protocol

InteractableCharge Interface

InteractableCharge is a barricade component (not in available source) with these responsibilities:

MethodCallerPurpose
Detonate(Player instigator)UseableDetonator.simulate()Triggers the charge explosion
select()UseableDetonator.startSecondary()Adds visual selection indicator
deselect()UseableDetonator.startSecondary()Removes visual selection indicator
highlight()UseableDetonator.tick()Shows charge is in range
unhighlight()UseableDetonator.tick()Removes range highlight
target()UseableDetonator.tick()Shows charge is currently aimed at
untarget()UseableDetonator.tick()Removes aim target indicator
hasOwnershipUseableDetonator.startPrimary()Ownership check for client-side
owner / groupUseableDetonator.startPrimary()Ownership check for server-side

Visual State Machine

 State       Visual        Set By
─────────   ───────────   ──────────────────
 Idle       Normal        (default)
 InRange    Yellow glow   highlight() on tick() move-detection
 Targeted   Red outline   target() on tick() aim-detection
 Selected   Checkmark     select() on startSecondary()
 Detonating Flash/Fade    Detonate() on simulate()

Detonator Range

64 meters radius, scanned via PowerTool.checkInteractables(position, 64f, foundInRadius). Only charges within this range are interactable. Range re-scans occur every 1 meter of player movement.


Ownership System — Server Validation

On the server, charge ownership is verified before detonation:

csharp
bool hasOwnership = Dedicator.IsDedicatedServer
    ? OwnershipTool.checkToggle(owner.steamID, charge.owner, group, charge.group)
    : charge.hasOwnership;

OwnershipTool.checkToggle returns true if:

  • The player's Steam ID matches charge.owner, OR
  • The player is in the same group as charge.group (via player.quests.groupID).

This prevents players from detonating charges placed by enemies. The ownership check is performed per-charge during startPrimary() server-side processing.


Detonator — Reload Mechanics

The detonator does not consume ammo or durability with each use:

csharp
// No ammo tracking in UseableDetonator
// No useStepA/useStepB calls on detonation

The UseableDetonator inherits from Useable which provides the equip/unequip lifecycle, but there is no ammo consumption.


Network Protocol — Throwable RPCs

Ownership and Replication

RPCDirectionReliabilityPayloadPurpose
SendPlaySwingServer → Client (excl. owner)UnreliableNonePlay throw animation
SendTossServer → Client (excl. owner)UnreliableVector3 origin, Vector3 forceSpawn throwable object

Client-Side Prediction

The owning client:

  1. Calls startAttack() → plays animation locally.
  2. In tick(), computes origin and force (same formula as server).
  3. Calls toss() locally — spawns throwable with Instantiate.
  4. Reports the throw raycast via player.input.sendRaycast.

The server:

  1. Validates animation timing (isThrowable) matches client.
  2. Receives raycast input from client.
  3. Calls toss() — spawns throwable server-side with Instantiate.
  4. Broadcasts SendToss to non-owning clients with server-authoritative origin and force.

Non-owning clients:

  1. Receive SendPlaySwing — play throw animation.
  2. On server completion: receive SendToss — spawn throwable at server position.

Detonator RPCs

RPCDirectionReliabilityPayloadPurpose
SendPlayPlungeServer → Client (excl. owner)UnreliableNonePlay detonate animation

The detonator uses player.input.sendRaycast(info, ERaycastInfoUsage.Detonator) for each selected charge. The server collects all charge inputs during startPrimary() and processes them in simulate().


Detonator — Cascading Explosion Timing

The cascading explosion in simulate():

csharp
if (isDetonating && isDetonatable)
{
    if (charges.Count > 0)
    {
        if (simulation - lastExplosion > 1)
        {
            lastExplosion = simulation;
            InteractableCharge charge = charges[0];
            charges.RemoveAt(0);
            charge?.Detonate(player);
        }
    }
    else
        isDetonating = false; // All charges detonated
}
  • One charge detonates per simulation tick (50 Hz).
  • With charges.Count at maximum (~512), the full cascade takes ~10 seconds.
  • Each charge's Detonate() explosion is processed server-side and replicated to clients as a normal explosion.
  • The isDetonatable gate requires realtimeSinceStartup - startedUse > useTime * 0.33, adding a brief delay before the cascade starts.

Flashbang Mechanics

When asset.isFlash is true:

csharp
Flashbang flash = throwable.gameObject.AddComponent<Flashbang>();
flash.fuseLength = equippedThrowableAsset.fuseLength;

The Flashbang component (not in available source) handles:

  1. Visual: Fullscreen white flash with intensity based on distance and angle.
  2. Audio: Loud ringing sound that masks other audio.
  3. Duration: Scales with distance from detonation point.
  4. Client-only: On dedicated servers, Destroy(throwable, fuseLength) is called instead of creating the Flashbang component.

Distraction Mechanics

When the throwable is not explosive or flash:

csharp
throwable.gameObject.AddComponent<Distraction>();
Destroy(throwable.gameObject, equippedThrowableAsset.fuseLength);

The Distraction component:

  • Emits noise at regular intervals (banging, hissing).
  • Attracts zombie AI to the noise source.
  • Lasts until fuseLength expires, then the object is destroyed.
  • Used for items like fireworks, noisemakers, and smoke grenades.

Sticky Grenade Mechanics

csharp
if (equippedThrowableAsset.isSticky)
{
    StickyGrenade sticky = throwable.gameObject.AddComponent<StickyGrenade>();
    sticky.ignoreTransform = transform;
}

The StickyGrenade component:

  • Uses a FixedJoint to attach to the first surface it contacts.
  • Ignores collision with the thrower (ignoreTransform = transform).
  • Maintains its fuse timer while attached.
  • Detonates or functions after fuse expiration.

Explode on Impact (Contact Grenade)

csharp
if (equippedThrowableAsset.explodeOnImpact)
{
    throwable.gameObject.SetLayerRecursively(LayerMasks.TRAP);
    ImpactGrenade impact = throwable.gameObject.AddComponent<ImpactGrenade>();
    impact.explodable = throwable.GetComponent<IExplodableThrowable>();
    impact.ignoreTransform = transform;
}

The layer change to TRAP ensures the impact grenade can collide with vehicles (standard debris layer passes through vehicles). The ImpactGrenade component:

  • Detonates IExplodableThrowable on first collision.
  • If no IExplodableThrowable is found, falls back to LocallyPredictImpactDestroyThrowable for client-side prediction.
  • explodeOnImpact items with destructive power (RPGs, impact grenades).

Item Consumption — Use Step System

UseableThrowable uses the two-step item consumption pattern:

csharp
// Step A: Remove one from stack (tick, after toss)
player.equipment.useStepA();

// Step B: Complete dequip if stack is empty (simulate, after animation)
player.equipment.useStepB();

The steps in UseableThrowable:

  1. startAttack() — plays animation, does NOT consume yet.
  2. tick()isThrowabletoss()useStepA() — removes one item.
  3. simulate()isUseableuseStepB() — dequips if the stack is exhausted.

This means the item is consumed at the throwable release point (60% animation), not at the start or end of the animation.


Plugin Events

EventSignaturePhase
onThrowableSpawned(UseableThrowable, GameObject throwable)After instantiation, before physics step

Edge Cases and Safeguards

  • Wall clipping prevention: If a wall is within 1.5m in front of the aim, the throwable spawns 0.5m in front of the wall surface, preventing the grenade from spawning inside geometry.
  • Double-throw prevention: hasUsed flag prevents starting a second throw after the first animation finishes (public issue #4849 fix).
  • Client prediction: Local client spawns the throwable immediately (channel.IsLocalPlayer path) while also sending the server sendRaycast. Remote clients receive SendToss with server-authoritative origin and force.
  • Debris layer: Throwables are registered as debris (EffectManager.RegisterDebris) for cleanup tracking.
  • Impact grenade layer override: LayerMasks.TRAP allows impact grenades to collide with vehicles (Debris layer normally passes through vehicles).
  • Dedicated server optimization: Smoke particles are destroyed on dedicated servers to save rendering resources.
  • Detonator capacity: Capped at MAX_CLIENTSIDE_INPUTS (512) to prevent client network packet overflow.
  • Detonator ownership: Server re-validates charge ownership before detonation, preventing stolen charge triggering.