Squash & Stretch

Deform the character sprite or model to emphasize motion. Stretch when moving fast, squash on impact. The oldest animation principle, applied to videogames.

Practice - what you do

The Feel

Squash and stretch adds life and weight. A rigid character feels robotic. A squashing/stretching character feels organic, even when it's a geometric shape.

It communicates physics through shape: "I'm compressing because I hit something" or "I'm elongating because I'm moving fast."

Exposed Variables

Variable Type Default What it does
stretchAmount float 0.2 Maximum stretch (1.2 = 20% taller)
squashAmount float 0.2 Maximum squash (0.8 = 20% shorter)
returnSpeed float 10.0 How fast to return to normal
velocityThreshold float 5.0 Velocity needed to trigger stretch

Tuning Guide

For subtle realism:

  • stretchAmount = 0.05 - 0.1
  • squashAmount = 0.05 - 0.1
  • Barely noticeable but adds life

Reference: Celeste (subtle character deformation)

For cartoony feel:

  • stretchAmount = 0.2 - 0.4
  • squashAmount = 0.2 - 0.4
  • Obvious, playful, bouncy

Reference: Hollow Knight, Ori

For extreme stylization:

  • stretchAmount = 0.5+
  • squashAmount = 0.4+
  • Very exaggerated, full cartoon

Reference: Cuphead, classic Disney

Pseudocode

Pseudocode
Every Frame:
  - Get vertical velocity
  - If falling fast: stretch vertically, compress horizontally
  - If rising fast: stretch vertically, compress horizontally
  - If grounded: return to normal scale

On Land:
  - Apply squash (compress vertically, expand horizontally)
  - Lerp back to normal over time

Key: Preserve volume - if you stretch Y, compress X proportionally

Unity Setup (2D)

  1. Setup Visual Hierarchy:
    • Your player object has Rigidbody2D and Collider2D
    • Create a child object called "Visual" or "Sprite"
    • Move your SpriteRenderer to this child
    • The script scales the child, not the physics object
  2. Add the Script:
    • Add SquashStretch2D.cs to the player (parent object)
    • Assign the visual child to visualTransform
    • The physics collider stays unaffected
  3. Create Ground Check (optional for landing squash):
    • Create empty child "GroundCheck" at player's feet
    • Assign to script's groundCheck field
    • Configure ground layer mask
  4. Tune the Feel:
    • Subtle: stretchAmount/squashAmount = 0.05-0.1
    • Cartoony: stretchAmount/squashAmount = 0.2-0.4
    • Extreme: stretchAmount/squashAmount = 0.5+

The Script (2D)

SquashStretch2D.cs
// ============================================================
// SquashStretch2D.cs
// Deforms a visual sprite based on velocity and landing.
// Attach to player, assign child visual transform.
// Physics collider is NOT affected - only visuals.
// Unity 6.x
// ============================================================

using UnityEngine;

[RequireComponent(typeof(Rigidbody2D))]
public class SquashStretch2D : MonoBehaviour
{
    // ========================================================
    // REFERENCES - Assign in Inspector
    // ========================================================

    [Header("Visual Transform")]
    [Tooltip("The child transform to scale (NOT the physics object!)")]
    [SerializeField] private Transform visualTransform;

    [Header("Ground Detection (for landing squash)")]
    [Tooltip("Empty transform at player's feet")]
    [SerializeField] private Transform groundCheck;

    [Tooltip("Radius of ground check")]
    [SerializeField] [Range(0.05f, 0.5f)] private float groundCheckRadius = 0.1f;

    [Tooltip("Which layers count as ground")]
    [SerializeField] private LayerMask groundLayer;

    // ========================================================
    // SQUASH & STRETCH PARAMETERS
    // ========================================================

    [Header("Stretch (during fast vertical movement)")]
    [Tooltip("Maximum vertical stretch (0.2 = 20% taller at max velocity)")]
    [SerializeField] [Range(0f, 1f)] private float stretchAmount = 0.2f;

    [Tooltip("Velocity needed to reach full stretch")]
    [SerializeField] [Range(1f, 30f)] private float stretchVelocityMax = 15f;

    [Header("Squash (on landing)")]
    [Tooltip("Maximum squash on landing (0.3 = 30% shorter)")]
    [SerializeField] [Range(0f, 0.5f)] private float squashAmount = 0.25f;

    [Tooltip("Fall velocity needed for full squash")]
    [SerializeField] [Range(1f, 30f)] private float squashVelocityMax = 12f;

    [Header("Recovery")]
    [Tooltip("How fast to return to normal scale")]
    [SerializeField] [Range(1f, 30f)] private float returnSpeed = 12f;

    [Header("Horizontal Stretch (for dashing)")]
    [Tooltip("Enable horizontal stretch based on horizontal velocity")]
    [SerializeField] private bool enableHorizontalStretch = true;

    [Tooltip("Maximum horizontal stretch")]
    [SerializeField] [Range(0f, 0.5f)] private float horizontalStretchAmount = 0.15f;

    [Tooltip("Horizontal velocity for full stretch")]
    [SerializeField] [Range(1f, 50f)] private float horizontalStretchVelocityMax = 20f;

    [Header("Debug")]
    [Tooltip("Show debug info")]
    [SerializeField] private bool debugMode = false;

    // ========================================================
    // INTERNAL STATE
    // ========================================================

    private Rigidbody2D rb;
    private Vector3 targetScale = Vector3.one;
    private Vector3 baseScale = Vector3.one;
    private bool isGrounded = false;
    private bool wasGrounded = false;
    private float lastFallVelocity = 0f;

    // ========================================================
    // UNITY LIFECYCLE
    // ========================================================

    void Awake()
    {
        rb = GetComponent<Rigidbody2D>();

        // Store the original scale of the visual
        if (visualTransform != null)
        {
            baseScale = visualTransform.localScale;
        }
    }

    void Update()
    {
        if (visualTransform == null) return;

        // Check ground state
        CheckGroundState();

        // Calculate target scale based on current state
        CalculateTargetScale();

        // Smoothly interpolate to target scale
        visualTransform.localScale = Vector3.Lerp(
            visualTransform.localScale,
            targetScale,
            returnSpeed * Time.deltaTime
        );
    }

    // ========================================================
    // SCALE CALCULATION
    // ========================================================

    /// <summary>
    /// Calculate what the scale should be based on velocity
    /// </summary>
    private void CalculateTargetScale()
    {
        float scaleX = baseScale.x;
        float scaleY = baseScale.y;

        float velocityY = rb.linearVelocity.y;
        float velocityX = Mathf.Abs(rb.linearVelocity.x);

        // ----------------------------------------
        // VERTICAL STRETCH (rising or falling fast)
        // ----------------------------------------
        if (!isGrounded && Mathf.Abs(velocityY) > 0.5f)
        {
            // Normalize velocity to 0-1 range
            float stretchFactor = Mathf.Clamp01(
                Mathf.Abs(velocityY) / stretchVelocityMax
            );

            // Apply stretch: taller vertically
            float stretchY = 1f + (stretchAmount * stretchFactor);

            // Preserve volume: compress horizontally
            // If Y grows by 20%, X shrinks to 1/1.2 = 0.833
            float stretchX = 1f / stretchY;

            scaleY = baseScale.y * stretchY;
            scaleX = baseScale.x * stretchX;

            if (debugMode && stretchFactor > 0.1f)
            {
                Debug.Log($"Vertical stretch: {stretchFactor:F2} (vel: {velocityY:F1})");
            }
        }

        // ----------------------------------------
        // HORIZONTAL STRETCH (dashing/running fast)
        // ----------------------------------------
        if (enableHorizontalStretch && velocityX > 1f)
        {
            float hStretchFactor = Mathf.Clamp01(
                velocityX / horizontalStretchVelocityMax
            );

            // Apply horizontal stretch: wider horizontally
            float hStretchX = 1f + (horizontalStretchAmount * hStretchFactor);

            // Preserve volume: compress vertically
            float hStretchY = 1f / hStretchX;

            // Blend with vertical stretch (take the more extreme)
            scaleX = Mathf.Max(scaleX, baseScale.x * hStretchX);
            scaleY = Mathf.Min(scaleY, baseScale.y * hStretchY);
        }

        // Track fall velocity for landing squash
        if (velocityY < 0)
        {
            lastFallVelocity = Mathf.Min(lastFallVelocity, velocityY);
        }

        targetScale = new Vector3(scaleX, scaleY, baseScale.z);
    }

    // ========================================================
    // GROUND DETECTION + LANDING SQUASH
    // ========================================================

    /// <summary>
    /// Check ground and trigger squash on landing
    /// </summary>
    private void CheckGroundState()
    {
        wasGrounded = isGrounded;

        if (groundCheck != null)
        {
            isGrounded = Physics2D.OverlapCircle(
                groundCheck.position,
                groundCheckRadius,
                groundLayer
            );
        }

        // Just landed - trigger squash!
        if (isGrounded && !wasGrounded)
        {
            ApplyLandingSquash();
        }

        // Reset fall velocity tracking when grounded
        if (isGrounded)
        {
            lastFallVelocity = 0f;
        }
    }

    /// <summary>
    /// Apply instant squash when landing
    /// </summary>
    private void ApplyLandingSquash()
    {
        // Calculate squash based on fall velocity
        float squashFactor = Mathf.Clamp01(
            Mathf.Abs(lastFallVelocity) / squashVelocityMax
        );

        // Apply squash: shorter vertically, wider horizontally
        float squashY = 1f - (squashAmount * squashFactor);
        float squashX = 1f / squashY; // Preserve volume

        // Apply immediately (then returnSpeed lerps back to normal)
        visualTransform.localScale = new Vector3(
            baseScale.x * squashX,
            baseScale.y * squashY,
            baseScale.z
        );

        if (debugMode)
        {
            Debug.Log($"Landing squash: {squashFactor:F2} (fall vel: {lastFallVelocity:F1})");
        }
    }

    // ========================================================
    // PUBLIC METHODS - Call from other scripts
    // ========================================================

    /// <summary>
    /// Manually trigger a squash (e.g., on hit, on jump anticipation)
    /// </summary>
    /// <param name="intensity">0-1, how much to squash</param>
    public void TriggerSquash(float intensity = 1f)
    {
        float squashY = 1f - (squashAmount * intensity);
        float squashX = 1f / squashY;

        visualTransform.localScale = new Vector3(
            baseScale.x * squashX,
            baseScale.y * squashY,
            baseScale.z
        );
    }

    /// <summary>
    /// Manually trigger a vertical stretch (e.g., on jump launch)
    /// </summary>
    /// <param name="intensity">0-1, how much to stretch</param>
    public void TriggerStretch(float intensity = 1f)
    {
        float stretchY = 1f + (stretchAmount * intensity);
        float stretchX = 1f / stretchY;

        visualTransform.localScale = new Vector3(
            baseScale.x * stretchX,
            baseScale.y * stretchY,
            baseScale.z
        );
    }

    /// <summary>
    /// Immediately reset to normal scale
    /// </summary>
    public void ResetScale()
    {
        if (visualTransform != null)
        {
            visualTransform.localScale = baseScale;
            targetScale = baseScale;
        }
    }

    // ========================================================
    // DEBUG VISUALIZATION
    // ========================================================

    void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

// ============================================================
// USAGE NOTES:
//
// 1. IMPORTANT: Only scale the VISUAL, not the physics object!
//    Structure your player like this:
//    - Player (has Rigidbody2D, Collider2D, SquashStretch2D)
//      - Visual (child with SpriteRenderer - this gets scaled)
//
// 2. Volume preservation formula:
//    If scaleY = 1.2 (20% taller), then scaleX = 1/1.2 = 0.833
//    This keeps the visual "area" roughly constant
//
// 3. Call TriggerSquash() manually for:
//    - Jump anticipation (brief squash before launch)
//    - Taking damage
//    - Charging up an attack
//
// 4. Call TriggerStretch() manually for:
//    - Jump launch moment
//    - Dash start
//    - Attack follow-through
//
// 5. Combine with other juice:
//    void OnJump()
//    {
//        squashStretch.TriggerSquash(0.5f); // Anticipation
//        // Small delay, then...
//        squashStretch.TriggerStretch(0.8f); // Launch
//    }
//
// 6. For 3D: Same principles apply, but scale on local Y axis
//    and compensate on X and Z to preserve volume
// ============================================================

Common Issues

"It looks wrong/distorted"
You're probably not preserving volume. When you stretch on Y, compress on X by the inverse amount. Total area should stay roughly constant.

"It happens too suddenly"
Use lerp/smoothing for the return to normal. The squash can be instant, but recovery should ease out.

"It doesn't match the movement"
Make sure stretch is based on velocity, not just "jumping" state. A slow rise shouldn't stretch as much as a fast dash.

"Colliders are getting messed up"
Only deform the visual, not the physics collider. Keep your hitbox at constant size.

When to Apply

Moment Deformation
Fast falling Stretch vertically
Landing Squash on impact
Jump anticipation Brief squash before launch
At jump apex Return to normal briefly
Dash/fast horizontal Stretch horizontally
Hit/damage Squash in hit direction

Combine With


Related

Glossary Terms