All tools
WeaponsAnimationRecoil

Weapon Animation Lab

Recoil, the action cycle and the reload — every moving part, in milliseconds.

Pistol

hold the stage to fire
Flash
Back
Hold
Forward
shot
eject
ready
0 ms158 ms · 9 frames @ 60 fps158 ms

The whole slide cycle is about 90 ms — shorter than the muzzle flash lingers in most players’ eyes. It reads not because you can see each phase but because the slide is caught at the rear for a beat before it returns.

Action cycle
6.2f
104 ms
Fire interval
9.5f
158 ms at 380 rpm
Sustained
380 rpm
trigger-limited
Damping ratio
0.68
overshoots past rest
Shots
0
since reset
Kick
0.0 px
ceiling 16
Overlap
no
cycle finishes first
Reload
0.96 s
press Reload

Weapons

Sidearm

Automatic

Manual

Exotic

Fire

380 rpm
1

A round leaves every 158 ms — 9.5f at 60 fps.

Recoil

10.00 px
3.00 px
8.00°
260
22
0.25
16 px

Damping ratio 0.68 — under critical, so the weapon overshoots past rest before settling. That dip is most of what reads as snappy.

Action cycle

Slide

10 ms
32 ms
18 ms
44 ms
16 px

The hold at the rear is what separates a mechanism from a slider. Set it to 0 and the same travel reads as the part bouncing rather than being worked. A manual action also caps the rate of fire — the weapon cannot fire until it is home.

Ejection & muzzle

44 ms
45 ms
20 px
150 px

A flash under about 30 ms is under two frames at 60 fps — it reads as a flicker on some displays and is missed entirely on others. Two to four frames is the safe range. The case should leave mid-travel, not at the shot.

Reload

280 ms
420 ms
260 ms

0.96 s total. Press Reload above to play it — the action cycles once at the end if a chamber time is set.

Camera

24 ms
0.20

1.4f at 60 fps, running at 20% speed.

0.20
7 px
0.70°
22 Hz
3.20 /s
1.2%

Shake is trauma squared, so it settles fast and small hits stay small. Trauma adds up across hits and clamps at 1 — that is what stops a burst from turning the screen into noise.

Your sprite sheet

Fire animation sheet
using System.Collections;
using UnityEngine;

/// <summary>
/// Pistol — recoil spring plus the action cycle.
/// Tuned in Weapon Animation Lab on burakkaan.dev.
///
/// Hierarchy this expects:
///   Weapon              (this component, holds the rest pose)
///     RecoilPivot       — driven by the spring, never keyframed
///       Body            — receiver, barrel, sprite
///       Action          — slide / bolt / forestock, driven by the cycle
///       Muzzle          — empty transform at the barrel tip
///       Magazine        — optional, driven by the reload
///
/// Recoil and the cycle are separate on purpose. Recoil is physics you feel;
/// the cycle is animation on fixed timings. Baking both into one clip means
/// you cannot change the rate of fire without re-authoring the animation.
/// </summary>
public class WeaponAnimator : MonoBehaviour
{
    public enum ActionType { Fixed, Slide, Bolt, Pump, BoltAction, Rotary }

    [Header("Fire")]
    public float roundsPerMinute = 380f;
    [Tooltip("0 = full auto while held.")]
    [Min(0)] public int roundsPerPull = 1;

    [Header("Recoil")]
    public float kickBack = 0.100f;   // world units
    public float kickUp = 0.030f;
    public float muzzleClimb = 8.0f;         // degrees
    public float stiffness = 260f;
    public float damping = 22f;
    [Range(0f, 1f)] public float accumulation = 0.25f;
    public float kickCeiling = 0.160f;

    [Header("Action cycle (seconds)")]
    public ActionType action = ActionType.Slide;
    public float cycleDelay = 0.010f;
    public float travelBack = 0.032f;
    [Tooltip("Held at the rear. Set this to 0 and the part reads as bouncing, not being worked.")]
    public float holdAtRear = 0.018f;
    public float travelForward = 0.044f;
    public float travelDistance = 0.160f;
    [Tooltip("The player works this action, so it gates the rate of fire.")]
    public bool manualAction = false;

    [Header("Rotary")]
    public float spinUp = 0.000f;
    public float spinDown = 0.000f;
    public float barrelRpm = 0f;

    [Header("Ejection & muzzle")]
    public bool ejectShells = true;
    public float ejectAt = 0.044f;
    public GameObject shellPrefab;
    public Transform ejectionPort;
    public ParticleSystem muzzleFlash;

    [Header("Reload (seconds)")]
    public float magOut = 0.280f;
    public float magIn = 0.420f;
    public float chamber = 0.260f;

    [Header("Refs")]
    public Transform recoilPivot;
    public Transform actionPart;
    public Transform barrelGroup;
    public Transform magazine;
    public Transform muzzle;
    public CombatImpact impact;

    // spring state
    float _back, _backVel, _up, _upVel, _roll, _rollVel;
    // cycle state
    float _nextShotTime;
    int _burstLeft;
    bool _prevHeld;
    float _spin;
    Vector3 _pivotRest, _actionRest, _magRest;
    Coroutine _cycle, _reload;

    /// <summary>Seconds between rounds the trigger allows.</summary>
    public float FireInterval => 60f / Mathf.Max(1f, roundsPerMinute);
    /// <summary>Seconds the action needs from the shot to back in battery.</summary>
    public float CycleTime =>
        action == ActionType.Fixed || action == ActionType.Rotary
            ? 0f
            : cycleDelay + travelBack + holdAtRear + travelForward;
    /// <summary>What the weapon can actually sustain: 380 rpm as tuned.</summary>
    public float Gate => manualAction ? Mathf.Max(FireInterval, CycleTime) : FireInterval;

    public bool Reloading => _reload != null;
    public bool SpunUp => action != ActionType.Rotary || _spin >= 1f;

    void Awake()
    {
        if (recoilPivot == null) recoilPivot = transform;
        _pivotRest = recoilPivot.localPosition;
        if (actionPart != null) _actionRest = actionPart.localPosition;
        if (magazine != null) _magRest = magazine.localPosition;
    }

    /// <summary>Drive this from input every frame.</summary>
    public void SetTrigger(bool held)
    {
        if (roundsPerPull > 0)
        {
            if (held && !_prevHeld) _burstLeft = roundsPerPull;   // needs a fresh pull
            if (!held) _burstLeft = 0;
        }
        _prevHeld = held;

        // Rotary barrels have to reach speed before anything leaves them: the
        // animation gates the shooting here, not the other way round.
        if (action == ActionType.Rotary)
        {
            float rate = held && !Reloading
                ? Time.deltaTime / Mathf.Max(0.001f, spinUp)
                : -Time.deltaTime / Mathf.Max(0.001f, spinDown);
            _spin = Mathf.Clamp01(_spin + rate);
            if (barrelGroup != null)
                barrelGroup.Rotate(0f, 0f, _spin * barrelRpm * 6f * Time.deltaTime);
        }

        bool canFire = (roundsPerPull > 0 ? _burstLeft > 0 : held) && SpunUp && !Reloading;
        if (canFire && Time.time >= _nextShotTime)
        {
            Fire();
            _nextShotTime = Time.time + Gate;
            if (roundsPerPull > 0) _burstLeft--;
        }
    }

    public void Fire()
    {
        _back = Mathf.Min(kickCeiling, _back * accumulation + kickBack);
        _up = Mathf.Min(kickCeiling, _up * accumulation + kickUp);
        _roll = Mathf.Min(kickCeiling * 150f, _roll * accumulation + muzzleClimb);

        if (muzzleFlash != null) muzzleFlash.Play();
        if (impact != null) impact.AddTrauma(impact.traumaPerHit);

        SpawnRound();

        // Restarting rather than stacking: at a high rate of fire the previous
        // cycle is often still running, and two coroutines fighting over one
        // transform is what makes an action jitter.
        if (_cycle != null) StopCoroutine(_cycle);
        _cycle = StartCoroutine(CycleAction());
    }

    void SpawnRound()
    {
        // Your projectile or raycast goes here.
        if (muzzle != null) Debug.DrawRay(muzzle.position, muzzle.right * 5f, Color.yellow, 0.3f);
    }

    IEnumerator CycleAction()
    {
        if (ejectShells) StartCoroutine(EjectAfter(ejectAt));
        if (actionPart == null || CycleTime <= 0f) yield break;

        if (cycleDelay > 0f) yield return new WaitForSeconds(cycleDelay);

        // Out is eased Out and back is eased In: the part decelerates into the
        // rear stop against its spring, then accelerates out of it. Swapping the
        // two is what makes a cycle look like a slider being dragged.
        yield return Travel(0f, travelDistance, travelBack, true);
        if (holdAtRear > 0f) yield return new WaitForSeconds(holdAtRear);
        yield return Travel(travelDistance, 0f, travelForward, false);

        actionPart.localPosition = _actionRest;
        _cycle = null;
    }

    IEnumerator Travel(float from, float to, float duration, bool easeOut)
    {
        float t = 0f;
        while (t < duration)
        {
            t += Time.deltaTime;
            float k = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
            k = easeOut ? 1f - (1f - k) * (1f - k) : k * k;
            actionPart.localPosition = _actionRest + Vector3.left * Mathf.Lerp(from, to, k);
            yield return null;
        }
    }

    IEnumerator EjectAfter(float delay)
    {
        if (delay > 0f) yield return new WaitForSeconds(delay);
        if (shellPrefab == null || ejectionPort == null) yield break;

        var shell = Instantiate(shellPrefab, ejectionPort.position, ejectionPort.rotation);
        var rb = shell.GetComponent<Rigidbody2D>();
        if (rb != null)
        {
            rb.linearVelocity = ejectionPort.up * Random.Range(2.2f, 3.4f)
                              + ejectionPort.right * Random.Range(0.4f, 1.1f);
            rb.angularVelocity = Random.Range(-720f, 720f);
        }
    }

    public void Reload()
    {
        if (Reloading) return;
        _reload = StartCoroutine(ReloadRoutine());
    }

    IEnumerator ReloadRoutine()
    {
        if (magazine != null && magOut > 0f)
        {
            float t = 0f;
            while (t < magOut)
            {
                t += Time.deltaTime;
                float k = Mathf.Clamp01(t / magOut);
                magazine.localPosition = _magRest + Vector3.down * (k * 0.34f);
                yield return null;
            }
            magazine.gameObject.SetActive(false);
        }

        if (magazine != null && magIn > 0f)
        {
            magazine.gameObject.SetActive(true);
            float t = 0f;
            while (t < magIn)
            {
                t += Time.deltaTime;
                float k = Mathf.Clamp01(t / magIn);
                magazine.localPosition = _magRest + Vector3.down * ((1f - k) * 0.34f);
                yield return null;
            }
            magazine.localPosition = _magRest;
        }

        // Working the action after the magazine seats — skip it if the weapon
        // never went to slide lock.
        if (chamber > 0f && actionPart != null)
        {
            yield return Travel(0f, travelDistance, chamber * 0.45f, true);
            yield return Travel(travelDistance, 0f, chamber * 0.55f, false);
        }

        _reload = null;
    }

    void LateUpdate()
    {
        float dt = Time.deltaTime;
        Step(ref _back, ref _backVel, stiffness, damping, dt);
        Step(ref _up, ref _upVel, stiffness, damping, dt);
        Step(ref _roll, ref _rollVel, stiffness * 0.85f, damping, dt);

        recoilPivot.localPosition = _pivotRest + new Vector3(-_back, _up, 0f);
        recoilPivot.localRotation = Quaternion.Euler(0f, 0f, _roll);
    }

    /// <summary>
    /// Substepped at 4 ms. A stiff spring integrated once per frame diverges on a
    /// long frame — that is the "recoil launched the gun off screen" bug.
    /// </summary>
    static void Step(ref float value, ref float velocity, float k, float c, float dt)
    {
        int steps = Mathf.Clamp(Mathf.CeilToInt(dt / 0.004f), 1, 8);
        float h = dt / steps;
        for (int i = 0; i < steps; i++)
        {
            float accel = -value * k - velocity * c;
            velocity += accel * h;
            value += velocity * h;
        }
    }

    void OnDrawGizmosSelected()
    {
        if (muzzle == null) return;
        Gizmos.color = new Color(1.000f, 0.824f, 0.478f, 0.600f);
        Gizmos.DrawWireSphere(muzzle.position, 0.08f);
    }
}

Recoil and the action cycle are driven separately: the spring runs every frame in LateUpdate, the cycle is a coroutine on fixed timings. Mixing them into one tween is what makes a fast weapon look like it is sliding rather than working.