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:
| Index | Field | Size | Description |
|---|---|---|---|
| 0–1 | SIGHT_ID | 2 bytes | Attached sight item ID (ushort) |
| 2–3 | TACTICAL_ID | 2 bytes | Attached tactical item ID |
| 4–5 | GRIP_ID | 2 bytes | Attached grip item ID |
| 6–7 | BARREL_ID | 2 bytes | Attached barrel item ID |
| 8–9 | MAGAZINE_ID | 2 bytes | Attached magazine item ID |
| 10 | AMMO | 1 byte | Current ammo count |
| 11 | FIREMODE | 1 byte | EFiremode as byte |
| 12 | TACTICAL_ACTIVE | 1 byte | 0/1 toggle for laser/light/rangefinder |
| 13 | SIGHT_QUALITY | 1 byte | Attached sight item quality |
| 14 | TACTICAL_QUALITY | 1 byte | Attached tactical item quality |
| 15 | GRIP_QUALITY | 1 byte | Attached grip item quality |
| 16 | BARREL_QUALITY | 1 byte | Attached barrel item quality (doubles as durability) |
| 17 | MAGAZINE_QUALITY | 1 byte | Attached 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 effectClient-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.fireDelayDuring 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 bymagazineAsset.stuckper shot. - When durability runs out, the magazine is destroyed and the ammo/ID state clears.
dropID,dropAmount,dropQualitytrack 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:
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).
- With ballistics enabled:
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, orEPlayerHit.GHOST(bullet-resistant zombie). - Shotgun pellets > 1 suppress duplicate hitmarkers (only the first pellet's hit per bullet group creates a visible marker).
Tracer: If no hit registered, emits a tracer particle effect at the bullet's position.
Bullet Propagation: If the raycast missed:
bullet.position += bullet.velocity * BALLISTICS_DELTA_TIME bullet.velocity.y += gravity * bulletGravityMultiplier * BALLISTICS_DELTA_TIMEOn hit,
bullet.stepsis set to 254 (termination) and the raycast info is sent viaplayer.input.sendRaycast.Step Counting: Each tock increments
bullet.steps. Whensteps >= 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:
- Validates the hit point is within range of the bullet's current position.
- Calls
onBulletHitplugin event. - Spawns impact effects (bullet impact or magazine-specific effect).
- Computes damage multiplier:
times = getBulletDamageMultiplier(bullet) falloffAlpha = InverseLerp(range * falloffStart, range * falloffMax, distance) times *= Lerp(1.0, falloffMultiplier, falloffAlpha) - Applies damage through
DamageToolwith entity-specific multipliers and armor calculations. - Handles explosive magazines via
DetonateExplosiveMagazine. - Drops spent magazine items (for bow-type weapons).
- 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
LineRenderersegments with optionalTextMeshProlabels. - 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/ymultiplier. - Stance: sprint/crouch/prone/swim/midair multipliers.
- Perspective:
FirstPerson_RecoilMultiplierandFirstPerson_AimingRecoilMultiplier(1P),ThirdPerson_RecoilMultiplier(3P). Scope zoom applies additionalFirstPerson_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:
- The
project()method spawns aGameObjectfromprojectileprefab (ormagazineAsset.ProjectilePrefabOverride). - A
Rigidbodywith continuous collision detection receives force:AddForce(direction * ballisticForce * forceMultiplier). - A
Rocketcomponent is added with killer, range, damage values, explosion radius, and effect GUIDs. - If a
Grenadecomponent is present on the prefab, itskilleris set to match for ownership. - The projectile is destroyed after
projectileLifespanseconds. - Server sends
SendPlayProjectto 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()):
- Rate-limited to 25 tocks (
lastJabcheck). - Client plays audio (
MeleeAttack_01.mp3), adds viewmodel camera offset. - Raycasts
DAMAGE_CLIENTup tomeleeRange(default 2.0m, fromMeleeProperties.MeleeRange). - Sends raycast info via
ERaycastInfoUsage.Bayonet. - Server applies damage using
MeleePropertiesdamage multipliers per entity type. - Triggers
AlertTool.alert(8m).
Reloading
Magazine Reload Flow
- Client presses
ControlsSettings.reload. tick()searches inventory for compatible magazines viaFindAttachmentsByCaliber(EItemType.MAGAZINE, magazineCalibers).- Picks the magazine with the highest ammo count.
- Calls
SendAttachMagazine(page, x, y, hash). - Server
ReceiveAttachMagazine:- Validates not busy, fired, reloading, etc.
- Verifies caliber compatibility (cross-checks asset.calibers with gunAsset.magazineCalibers).
- Determines
shouldHammerbased onRechamberAfterMagazineAttached: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
needsUnplace: AfterreloadTime * unplacefraction, the magazine model disappears.needsReplace: AfterreloadTime * replacefraction, the magazine model reappears.needsUnload: AfterEjectAfterReloadDelay, shell casings are emitted (if CasingEjectCountAfterReload > 0).- On completion: if
needsHammeris 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:
- Validates the gun supports the attachment type.
- Creates an
Itemfor the currently attached attachment (preserving quality). - 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*Requestedplugin event. - Writes new ID/quality to state via
BlockCopy. - Removes new item from inventory, forces old item back.
- Calls
sendUpdateState().
- 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
isMeleetactical: triggers jab attack (if not sprinting, not in safezone, not on safety). - For other types: toggles
interactbool and writesstate[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 eventsAim 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):
steadyAccuracyincreases up to 4.- Oxygen decreases:
5 - (Diving_level / 2)per tick. - At 0 oxygen,
canSteadybecomes 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),
isSprintingis set andSprint_Startanimation plays. - When sprint stops,
Sprint_Stopplays (unless already aiming). canAimDuringSprintgate: if false, aiming is blocked during sprint.isSprintinggatesstartPrimaryandstartSecondary.
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
| Event | Parameters | Phase |
|---|---|---|
onChangeSightRequested | (equipment, gun, oldItem, newItemJar, ref shouldAllow) | Pre-attachment change |
onChangeTacticalRequested | same | Pre-attachment change |
onChangeGripRequested | same | Pre-attachment change |
onChangeBarrelRequested | same | Pre-attachment change |
onChangeMagazineRequested | same | Pre-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)