Skip to content

UseableGun — The Weapon Firing System

Overview

UseableGun (6602 lines) at Unturned/Useable/UseableGun.cs is the runtime behavior for all ranged weapons. It manages the complete firing lifecycle: input handling, fire rate capping, ammo consumption, spread calculation, recoil patterns, ballistic simulation, projectile spawning, impact detection, damage processing with falloff, magazine reloading, chamber hammering, jamming, attachment swapping (sight, tactical, grip, barrel, magazine), aiming mechanics (zoom, scope overlays, holographic reticule, distance markers), minigun spin-up, and bayonet melee attacks.

Supporting types: EFiremode (SAFETY, SEMI, AUTO, BURST), BulletInfo, GunStateIndices, EAttackInputFlags.


Gun State Array Layout

The gun's item state is a byte[] indexed by GunStateIndices:

IndexFieldSizeDescription
0–1SIGHT_ID2 bytesAttached sight item ID (ushort)
2–3TACTICAL_ID2 bytesAttached tactical item ID
4–5GRIP_ID2 bytesAttached grip item ID
6–7BARREL_ID2 bytesAttached barrel item ID
8–9MAGAZINE_ID2 bytesAttached magazine item ID
10AMMO1 byteCurrent ammo count
11FIREMODE1 byteEFiremode as byte
12TACTICAL_ACTIVE1 byte0/1 toggle for laser/light/rangefinder
13SIGHT_QUALITY1 byteAttached sight item quality
14TACTICAL_QUALITY1 byteAttached tactical item quality
15GRIP_QUALITY1 byteAttached grip item quality
16BARREL_QUALITY1 byteAttached barrel item quality (doubles as durability)
17MAGAZINE_QUALITY1 byteAttached magazine item quality

Total: 18 bytes. Written/read via System.Buffer.BlockCopy for ID pairs and direct indexing for single bytes.


Fire Modes

EFiremode has four values: SAFETY, SEMI, AUTO, BURST.

Change Firemode Flow

ReceiveChangeFiremode(EFiremode newFiremode)
  → Validates not busy, not fired, not reloading/hammering/unjamming
  → Checks gun asset has the requested firemode (hasSafety, hasSemi, hasAuto, hasBurst)
  → Sets firemode, writes to state[11], calls sendUpdateState()
  → Triggers firemode effect

Client-side cycling in tick(): Each press of ControlsSettings.firemode cycles to the next valid firemode in a configurable order (SAFETY → SEMI → AUTO → BURST → ...).

Fire Rate

csharp
int fireRateTicks = equippedGunAsset.firerate;
fireRateTicks -= sight.FirerateOffset;
fireRateTicks -= tactical.FirerateOffset;
fireRateTicks -= grip.FirerateOffset;
fireRateTicks -= barrel.FirerateOffset;
fireRateTicks -= magazine.FirerateOffset;
fireRateTicks = Mathf.Max(fireRateTicks, 0);

Firing occurs when clock - lastFire > fireRateTicks. Each tick is PlayerInput.RATE (roughly 1/50s ≈ 20ms).

Fire Delay

Some weapons have a fireDelay tick count before the first shot. When startPrimary is called:

fireDelayCounter = equippedGunAsset.fireDelay

During tockShoot, each tick decrements fireDelayCounter. On reaching 1, the shot fires. A fire delay sound may play.

Semi-Automatic Mode

if (firemode == SEMI) isShooting = false;

Immediately after each shot, the shooting flag clears, requiring a fresh click per shot.

Burst Mode

if (firemode == BURST)
{
    isShooting = false;
    if (wantedToShoot) bursts += equippedGunAsset.bursts;
}

Sets a burst counter. Each tockShoot tick decrements bursts and fires. When bursts reaches 0, firing stops until the next trigger pull.


Ammo System

Consumption

csharp
if (!equippedGunAsset.infiniteAmmo)
{
    ammo -= equippedGunAsset.ammoPerShot;
    player.equipment.state[10] = ammo;
}

ammoPerShot is typically 1 but can be higher (e.g., dual-shot weapons). When ammo drops below ammoPerShot on the local client, a RELOAD message is shown.

Empty Magazine Deletion

After each shot, if ammo == 0 and shouldDeleteEmptyMagazines:

csharp
player.equipment.state[MAGAZINE_ID] = 0;       // clear ID
player.equipment.state[MAGAZINE_ID + 1] = 0;   // clear second byte
player.equipment.state[AMMO] = 0;
player.equipment.sendUpdateState();

Bolt/Pump Action (String Action)

For EAction.String weapons (bows, crossbows):

  • Ammo is consumed per shot.
  • Magazine durability (state[MAGAZINE_QUALITY]) decreases by magazineAsset.stuck per shot.
  • When durability runs out, the magazine is destroyed and the ammo/ID state clears.
  • dropID, dropAmount, dropQuality track the spent magazine for item dropping on hit.

Ballistic Simulation

Overview

The ballistics() method runs every tock (simulation tick) on both client and server. It progresses each in-flight BulletInfo by one step.

Client-Side Simulation

For each bullet:

  1. Raycast: DamageTool.raycast(ray, travelLength, DAMAGE_CLIENT) where:

    • With ballistics enabled: travelLength = bullet.velocity.magnitude * BALLISTICS_DELTA_TIME (0.02s).
    • Without ballistics: travelLength = equippedGunAsset.range (instant hit scan).
  2. Hitmarker Display: Checks raycast info against players, zombies, animals, barricades, structures, vehicles, resources, and objects. Each check:

    • Validates entity exists and is damageable.
    • Shows EPlayerHit.ENTITIY, EPlayerHit.CRITICAL (skull), EPlayerHit.BUILD, or EPlayerHit.GHOST (bullet-resistant zombie).
    • Shotgun pellets > 1 suppress duplicate hitmarkers (only the first pellet's hit per bullet group creates a visible marker).
  3. Tracer: If no hit registered, emits a tracer particle effect at the bullet's position.

  4. Bullet Propagation: If the raycast missed:

    bullet.position += bullet.velocity * BALLISTICS_DELTA_TIME
    bullet.velocity.y += gravity * bulletGravityMultiplier * BALLISTICS_DELTA_TIME

    On hit, bullet.steps is set to 254 (termination) and the raycast info is sent via player.input.sendRaycast.

  5. Step Counting: Each tock increments bullet.steps. When steps >= ballisticSteps, the bullet is removed (max range reached).

Bullet Direction

During fire() on the local client:

csharp
Quaternion aimRotation = player.look.aim.rotation;
if (perspective == FIRST)
    aimRotation *= Quaternion.Euler(recoilViewmodelCameraRotation);
float spread = CalculateSpreadAngleRadians(quality, aimAlpha);
Vector3 direction = aimRotation * RandomEx.GetRandomForwardVectorInCone(spread);
bullet.velocity = direction * muzzleVelocity;

The spread cone is calculated by CalculateSpreadAngleRadians which multiplies base spread by stance modifiers (sprint, crouch, prone, swim, midair), stance multipliers, aiming alpha, skill reductions (Sharpshooter), and attachment multipliers (sight, tactical, grip, barrel, magazine) in sequence.

Server-Side Hit Processing

The server polls player.input.getInput() for each bullet:

  1. Validates the hit point is within range of the bullet's current position.
  2. Calls onBulletHit plugin event.
  3. Spawns impact effects (bullet impact or magazine-specific effect).
  4. Computes damage multiplier:
    times = getBulletDamageMultiplier(bullet)
    falloffAlpha = InverseLerp(range * falloffStart, range * falloffMax, distance)
    times *= Lerp(1.0, falloffMultiplier, falloffAlpha)
  5. Applies damage through DamageTool with entity-specific multipliers and armor calculations.
  6. Handles explosive magazines via DetonateExplosiveMagazine.
  7. Drops spent magazine items (for bow-type weapons).
  8. Awards XP for Horde mode kills or standard kill XP.

Ballistic Gravity

csharp
float CalculateBulletGravityMultiplier()
{
    float multiplier = equippedGunAsset.bulletGravityMultiplier;
    multiplier *= barrel.BallisticGravityMultiplier;
    multiplier *= tactical.BallisticGravityMultiplier;
    multiplier *= sight.BallisticGravityMultiplier;
    multiplier *= magazine.BallisticGravityMultiplier;
    multiplier *= grip.BallisticGravityMultiplier;
    return multiplier;
}

Resulting gravity acceleration: Physics.gravity.y * bulletGravityMultiplier.

Scope Distance Markers

For scopes with distanceMarkers configured:

  • Markers are rendered as LineRenderer segments with optional TextMeshPro labels.
  • Position is calculated via SleekScopeOverlay.CalcAngle(speed, distance, gravity).
  • Vertical FOV percentage determines marker Y position.
  • Markers outside visible range (−0.01 to −0.9 of FOV) are hidden.

Recoil System

Per-Shot Calculation (clientside fire())

csharp
float recoil_x = Random.Range(equippedGunAsset.recoilMin_x, equippedGunAsset.recoilMax_x);
float recoil_y = Random.Range(equippedGunAsset.recoilMin_y, equippedGunAsset.recoilMax_y);

Multiplied by:

  • Quality: if quality < 50%, recoil increases: 1f + (1f - (quality * 2f)).
  • Sharpshooter skill: skillMultiplier = 1.0 - skill.NormalizeLevel(level) * 0.4.
  • Aiming: recoil *= aimingRecoilMultiplier (typically 0.5–0.8).
  • All attachments: Each attachment (sight, tactical, grip, barrel, magazine) applies its own recoil_x/y multiplier.
  • Stance: sprint/crouch/prone/swim/midair multipliers.
  • Perspective: FirstPerson_RecoilMultiplier and FirstPerson_AimingRecoilMultiplier (1P), ThirdPerson_RecoilMultiplier (3P). Scope zoom applies additional FirstPerson_AimingZoomRecoilReduction.

Applied to camera via:

csharp
player.look.recoil(recoil_x, recoil_y, recover_x, recover_y);
player.animator.AddRecoilViewmodelCameraOffset(shake_x, shake_y, shake_z);
player.animator.AddRecoilViewmodelCameraRotation(recoil_x, recoil_y);

Shake

Separate from recoil, camera shake (shake_x/y/z) is computed from asset values, multiplied by the same attachment/skill/perspective chain, and applied as viewmodel position offset. Shake decays independently with different recovery rates.


Projectile Weapons (Rockets/Grenades)

When equippedGunAsset.projectile != null:

  1. The project() method spawns a GameObject from projectile prefab (or magazineAsset.ProjectilePrefabOverride).
  2. A Rigidbody with continuous collision detection receives force: AddForce(direction * ballisticForce * forceMultiplier).
  3. A Rocket component is added with killer, range, damage values, explosion radius, and effect GUIDs.
  4. If a Grenade component is present on the prefab, its killer is set to match for ownership.
  5. The projectile is destroyed after projectileLifespan seconds.
  6. Server sends SendPlayProject to clients (origin, direction, barrelId, magazineId).

For projectile weapons, ballistic simulation (ballistics()) is skipped entirely (if (projectile != null) return).


Bayonet/Jab Attack

Tactical attachments with isMelee enable a bayonet attack (jab()):

  1. Rate-limited to 25 tocks (lastJab check).
  2. Client plays audio (MeleeAttack_01.mp3), adds viewmodel camera offset.
  3. Raycasts DAMAGE_CLIENT up to meleeRange (default 2.0m, from MeleeProperties.MeleeRange).
  4. Sends raycast info via ERaycastInfoUsage.Bayonet.
  5. Server applies damage using MeleeProperties damage multipliers per entity type.
  6. Triggers AlertTool.alert(8m).

Reloading

Magazine Reload Flow

  1. Client presses ControlsSettings.reload.
  2. tick() searches inventory for compatible magazines via FindAttachmentsByCaliber(EItemType.MAGAZINE, magazineCalibers).
  3. Picks the magazine with the highest ammo count.
  4. Calls SendAttachMagazine(page, x, y, hash).
  5. Server ReceiveAttachMagazine:
    • Validates not busy, fired, reloading, etc.
    • Verifies caliber compatibility (cross-checks asset.calibers with gunAsset.magazineCalibers).
    • Determines shouldHammer based on RechamberAfterMagazineAttached:
      • IfAmmoWasEmpty: hammer only if ammo was 0.
      • Never: never hammer.
      • Always: always hammer.
    • Updates ammo, writes state (ID, ammo, quality), removes old magazine from inventory and returns it (with ammo preserved).
    • Broadcasts SendPlayReload(shouldHammer).

Reload Animation Timing

reloadTime = GetAnimationLength("Reload")
reloadTime = Max(reloadTime, asset.reloadTime / speed)

Speed is influenced by Dexterity skill (1.0 + mastery * 0.5) and magazine speed multiplier.

Animation Sequence

  1. needsUnplace: After reloadTime * unplace fraction, the magazine model disappears.
  2. needsReplace: After reloadTime * replace fraction, the magazine model reappears.
  3. needsUnload: After EjectAfterReloadDelay, shell casings are emitted (if CasingEjectCountAfterReload > 0).
  4. On completion: if needsHammer is true, hammer() is called; otherwise the equipment is un-busied.

Hammering

csharp
void hammer()
{
    isHammering = true;
    speed = 1.0 + Dexterity_mastery * 0.5;
    speed *= magazineAsset.speed;
    hammerTime = Max(animationLength, asset.hammerTime / speed);
    playSound(asset.hammer, speed, 0.0f pitchDeviation);
    setAnimationSpeed("Hammer", speed);
    // ... fires OnChamberingStarted events
}

Chamber Jamming

When canEverJam is true and quality is below jamQualityThreshold:

jamAlpha = 1.0 - (quality / jamQualityThreshold)
jamChance = Lerp(0.0f, jamMaxChance, jamAlpha)

If a jam occurs, SendPlayChamberJammed(correctedAmmo) is broadcast to fix client prediction and play the unjam animation.


Attachment System

Swapping Attachments

All five attachment slots (sight, tactical, grip, barrel, magazine) follow the same ReceiveAttach* pattern:

  1. Validates the gun supports the attachment type.
  2. Creates an Item for the currently attached attachment (preserving quality).
  3. If swapping to a new item (page != 255):
    • Identifies inventory item at (page, x, y).
    • Validates asset exists, hash matches (if shouldVerifyHash), and caliber compatibility.
    • Fires change*Requested plugin event.
    • Writes new ID/quality to state via BlockCopy.
    • Removes new item from inventory, forces old item back.
    • Calls sendUpdateState().
  4. If removing (page == 255):
    • Fires plugin event.
    • Returns current attachment item to inventory.
    • Clears the state bytes.

Caliber Compatibility

csharp
foreach (byte assetCaliber in asset.calibers)
    foreach (byte gunCaliber in gunAsset.attachmentCalibers)
        if (assetCaliber == gunCaliber) compatible = true;

If an attachment specifies zero calibers, it is universally compatible (unless requiresNonZeroAttachmentCaliber is set on the gun).

Tactical Interact

The tactical toggle (laser on/off, light on/off, rangefinder display) is handled by askInteractGun():

  • For isMelee tactical: triggers jab attack (if not sprinting, not in safezone, not on safety).
  • For other types: toggles interact bool and writes state[12].

Attach Mode

Pressing ControlsSettings.attach enters attachment mode (isAttaching = true), which spawns clickable UI buttons over each attachment hook. The buttons auto-position based on the firstAttachments hook transforms in viewport space.


Aiming (ADS)

Start Aim

csharp
startAim():
    viewmodelSwayMultiplier = 0.1f
    viewmodelOffsetPreferenceMultiplier = 0
    playSound(asset.aim)
    play("Aim_Start")
    enableZoom(zoomFactor)
    UpdateScopeOverlay()
    UpdateCrosshairEnabled()
    UpdateHolographicReticulePosition()
    // ... fires OnAimingStarted events

Aim Accuracy

_aimAccuracy increments from 0 to maxAimingAccuracy (≈ aimInDuration * 50). As accuracy increases:

  • Spread converges toward spreadAim.
  • Holographic reticule converges toward true aim point.
  • Scope alpha converges toward 1.0.

Aiming Interpolation

csharp
float GetInterpolatedAimAlpha()
{
    double deltaTime = now - fixedTime;
    float timeAlpha = deltaTime / fixedDeltaTime;
    if (isAiming)
        return 1 - Square(1 - SmootherStep((accuracy * reciprocal) + (timeAlpha * reciprocal)));
    else
        return 1 - Square(1 - SmootherStep((accuracy * reciprocal) - (timeAlpha * reciprocal)));
}

This decouples visual aim smoothness from the physics update rate, providing 60+ fps interpolation over 50-tick simulation.

Steady Breathing

When aiming with a zoom > 2× scope, holding breath (inputSteady):

  • steadyAccuracy increases up to 4.
  • Oxygen decreases: 5 - (Diving_level / 2) per tick.
  • At 0 oxygen, canSteady becomes false until breath recovers.

Crosshair Management

Crosshair is hidden during:

  • ADS (non-minigun, non-turret, first person).
  • Attachment mode.
  • Third person while in a vehicle.

Minigun Support

Minigun-type weapons (EAction.Minigun) have a spin-up system:

csharp
// In Update():
if (isMinigunSpinning)
    minigunSpeed = Lerp(minigunSpeed, 1.0f, 8.0f * delta);
else
    minigunSpeed = Lerp(minigunSpeed, 0.0f, 2.0f * delta);
minigunDistance += minigunSpeed * 720.0f * delta;

The barrel model rotates on Y-axis. A whir audio source fades volume with spin speed.


Sprint Integration

During tick():

  • If sprinting and moving (or safety mode), isSprinting is set and Sprint_Start animation plays.
  • When sprint stops, Sprint_Stop plays (unless already aiming).
  • canAimDuringSprint gate: if false, aiming is blocked during sprint.
  • isSprinting gates startPrimary and startSecondary.

Third-Person Aim Correction

In third-person perspective during fire(), the client adjusts the aim rotation towards the third-person camera's forward:

csharp
RaycastHit target;
Physics.Raycast(MainCamera.instance.transform.position, MainCamera.instance.transform.forward, out target, 512, DAMAGE_CLIENT);
if (target.transform != null && Dot(target.point - aim.position, camera.forward) > 0)
    look.aim.rotation = LookAt(target.point - aim.position);

This prevents third-person camera clipping from causing shots to hit walls in front of the player.


Weapon Quality Degradation

Each shot has a chance (Random.value < durability) to reduce quality:

csharp
if (quality > wear) quality -= wear;
else quality = 0;

Config-controlled by ShouldWeaponTakeDamage.


Alarm Triggering

Each shot triggers AlertTool.alert(transform.position, equippedGunAsset.alertRadius). Suppressed when a functional silencer is attached (barrelAsset.isSilenced && barrel quality > 0).


Plugin Events

EventParametersPhase
onChangeSightRequested(equipment, gun, oldItem, newItemJar, ref shouldAllow)Pre-attachment change
onChangeTacticalRequestedsamePre-attachment change
onChangeGripRequestedsamePre-attachment change
onChangeBarrelRequestedsamePre-attachment change
onChangeMagazineRequestedsamePre-attachment change
onBulletSpawned(gun, bulletInfo)On bullet creation (server)
onBulletHit(gun, bulletInfo, inputInfo, ref shouldAllow)On bullet hit (server)
onProjectileSpawned(gun, projectileObject)On rocket/grenade spawn (server)
OnReloading_Global(gun)On reload start
OnAimingChanged_Global(gun)On aim start/stop

Key State Machine

States: isShooting, isAiming, isReloading, isHammering, isUnjamming,
        isAttaching, isSprinting, isFired, isJabbing, isMinigunSpinning,
        needsRechamber, needsEject, needsUnload, needsUnplace, needsReplace

Busy check: equipment.isBusy gates most actions
  → set true on: fire(), reload, hammer, unjam
  → set false on: isFired timer (150ms), reload complete, hammer complete, unjam complete

Mutual exclusion:
  Shooting   × {reload, hammer, unjam, attach}
  Aiming     × {reload, hammer, unjam, attach}
  Sprinting  × {aim, shoot, reload, hammer, unjam, attach} (unless canAimDuringSprint)