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
| Action | Method | Swing Mode | Throw Force |
|---|---|---|---|
| Primary (left click) | startPrimary() | STRONG | asset.strongThrowForce |
| Secondary (right click) | startSecondary() | WEAK | asset.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
| Type | Component | Behavior |
|---|---|---|
| Explosive | Grenade | Fuse countdown, explosion on detonation, damage in radius, launch speed |
| Flashbang | Flashbang | Fuse countdown, screen flash, audio deafen |
| Distraction | Distraction | Attracts AI attention |
| Sticky | StickyGrenade | Attaches to surfaces |
| Impact | ImpactGrenade | Explodes 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:
- Fuse countdown:
Destroy(gameObject, fuseLength)on non-server clients; server waitsfuseLengthseconds before callingDamageTool.explode(). - Explosion effect:
EffectManager.triggerEffect()with the grenade's explosion effect GUID. - Damage:
DamageTool.explode(ExplosionParameters)with radius, damage values, and damage origin. - Launch force:
explosionLaunchSpeedapplies 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:
| List | Scope | Purpose |
|---|---|---|
foundInRadius | Local player only | All charges within 64m, refreshed on movement |
chargesInRadius | Local player only | Charges currently highlighted (owned by player) |
charges | Client + Server | Selected 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 indicatorSelection (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.hasOwnershipOnly charges owned by the player (or their group) can be selected or detonated.
Visual Feedback
| State | Visual |
|---|---|
| In range, not targeted | Charge has highlight (yellow glow) |
| Aiming at charge | Charge shows target indicator (red outline/crosshair) |
| Selected for detonation | Charge shows selection indicator |
| Detonation triggered | Explosion 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/untargetOn dequip(), all highlighted charges are unhighlighted.
Comparison: Throwable vs Detonator
| Aspect | UseableThrowable | UseableDetonator |
|---|---|---|
| Ammo | Consumed on throw (useStepA/B) | Battery/battery-less; not consumed |
| Effect timing | Fuse timer (asset.fuseLength) | Instant per-charge on simulate tick |
| Damage source | Grenade component | InteractableCharge.Detonate |
| Multi-target | Single throw | Up to ~512 charges |
| Trajectory | Physics rigidbody | N/A |
| Ownership | Killer set on Grenade | Ownership check per charge |
| Boost interaction | OLYMPIC: force * boostMultiplier | None |
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 * falloffThe 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, objectExplosion 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:
asset.explosionEffectGuid— explosion particle/audio effect.asset.explosion— legacy explosion ID (used if GUID is empty).- 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:
| Method | Caller | Purpose |
|---|---|---|
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 |
hasOwnership | UseableDetonator.startPrimary() | Ownership check for client-side |
owner / group | UseableDetonator.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(viaplayer.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 detonationThe UseableDetonator inherits from Useable which provides the equip/unequip lifecycle, but there is no ammo consumption.
Network Protocol — Throwable RPCs
Ownership and Replication
| RPC | Direction | Reliability | Payload | Purpose |
|---|---|---|---|---|
SendPlaySwing | Server → Client (excl. owner) | Unreliable | None | Play throw animation |
SendToss | Server → Client (excl. owner) | Unreliable | Vector3 origin, Vector3 force | Spawn throwable object |
Client-Side Prediction
The owning client:
- Calls
startAttack()→ plays animation locally. - In
tick(), computes origin and force (same formula as server). - Calls
toss()locally — spawns throwable withInstantiate. - Reports the throw raycast via
player.input.sendRaycast.
The server:
- Validates animation timing (
isThrowable) matches client. - Receives raycast input from client.
- Calls
toss()— spawns throwable server-side withInstantiate. - Broadcasts
SendTossto non-owning clients with server-authoritative origin and force.
Non-owning clients:
- Receive
SendPlaySwing— play throw animation. - On server completion: receive
SendToss— spawn throwable at server position.
Detonator RPCs
| RPC | Direction | Reliability | Payload | Purpose |
|---|---|---|---|---|
SendPlayPlunge | Server → Client (excl. owner) | Unreliable | None | Play 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.Countat 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
isDetonatablegate requiresrealtimeSinceStartup - 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:
- Visual: Fullscreen white flash with intensity based on distance and angle.
- Audio: Loud ringing sound that masks other audio.
- Duration: Scales with distance from detonation point.
- 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
fuseLengthexpires, 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
FixedJointto 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
IExplodableThrowableon first collision. - If no
IExplodableThrowableis found, falls back toLocallyPredictImpactDestroyThrowablefor client-side prediction. explodeOnImpactitems 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:
startAttack()— plays animation, does NOT consume yet.tick()→isThrowable→toss()→useStepA()— removes one item.simulate()→isUseable→useStepB()— 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
| Event | Signature | Phase |
|---|---|---|
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:
hasUsedflag prevents starting a second throw after the first animation finishes (public issue #4849 fix). - Client prediction: Local client spawns the throwable immediately (
channel.IsLocalPlayerpath) while also sending the serversendRaycast. Remote clients receiveSendTosswith server-authoritative origin and force. - Debris layer: Throwables are registered as debris (
EffectManager.RegisterDebris) for cleanup tracking. - Impact grenade layer override:
LayerMasks.TRAPallows 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.
