10.4 Integrating Physics with Gameplay

Phase 10 · Physics for Games · Study time: 15–30 h

Connecting physics to game logic cleanly — triggers, forces and impulses, and keeping the simulation deterministic where it matters.

You already know how to give an object a Rigidbody and a Collider and watch Unity make it fall, bounce, and stop against walls. This chapter is about the next step: wiring that physics simulation into actual gameplay -- picking up a coin, taking damage from lava, getting knocked back by an explosion, shooting a raycast down a hallway. Physics code that looks correct in isolation still causes some of the most confusing bugs in a Unity project -- jittery movement, missed pickups, frame-rate-dependent jump heights -- and almost all of them trace back to one of a handful of rules covered in this chapter.

1. Connecting the Physics Engine to Gameplay Code

Unity ships with a full physics engine (a separate system that simulates rigid bodies, gravity, and collisions -- Unity's default 3D engine is called PhysX) running underneath your scripts. It has its own internal clock and its own loop, completely separate from the rest of your game code. Your job is not to compute physics yourself -- it is to talk to the physics engine cleanly: tell it what you want (a force, a target position), and listen to what it reports back (a collision, an overlap, a new position).

Two components put a GameObject under the physics engine's control:

A Rigidbody can be one of three kinds, and mixing them up is a common source of confusion:

The cleanest way to think about the relationship: gameplay code sends intent to the physics engine (a force, an impulse, a target position), and the physics engine sends facts back (where things ended up, what touched what). Fighting this -- writing directly to transform.position on a dynamic Rigidbody every frame -- breaks the simulation, because now two systems think they own the same value.

Your gameplay code Physics engine (PhysX) ------------------- ----------------------- AddForce() / MovePosition() ----------> simulates every Rigidbody, (sent once, whenever needed) one fixed step at a time OnCollisionEnter/Exit() <---------- reports contacts OnTriggerEnter/Exit() <---------- reports overlaps rb.position, rb.linearVelocity <--------- reports resulting state Gameplay code never sets transform.position directly on a dynamic Rigidbody -- it sends forces or calls MovePosition, and reads the result back through events and rb properties.

Here is the mistake in code form, and the fix:


public class BadMover : MonoBehaviour
{
    public float speed = 5f;

    // BAD: this object also has a non-kinematic Rigidbody attached.
    // Overwriting transform.position every frame ignores the physics
    // engine completely -- the object can clip straight through walls,
    // and any collision that does happen looks jittery, because the
    // physics engine and this script are fighting over the same value.
    void Update()
    {
        transform.position += Vector3.forward * speed * Time.deltaTime;
    }
}

public class GoodMover : MonoBehaviour
{
    public float speed = 5f;
    private Rigidbody rb;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    // GOOD: MovePosition hands the move to the physics engine. It sweeps
    // the body through space between steps, so collisions along the way
    // are still detected correctly, and it belongs in FixedUpdate --
    // more on that split in Section 4.
    void FixedUpdate()
    {
        Vector3 target = rb.position + Vector3.forward * speed * Time.fixedDeltaTime;
        rb.MovePosition(target);
    }
}

Expected result: GoodMover moves forward at a steady speed and still stops correctly against a wall's collider, because the physics engine is the one performing the move and can detect the wall along the way. BadMover moves at the same speed on an empty floor, but drives straight through a thin wall if it is moving fast enough between frames, because it never asked the physics engine to check.

Tip A simple rule that covers most of this chapter: if a GameObject has a non-kinematic Rigidbody, never write to its transform.position or transform.rotation directly. Use AddForce, AddTorque, MovePosition, or linearVelocity instead, and let the physics engine own the Transform.

2. Solid Colliders vs Trigger Colliders

Every Collider has a checkbox in the Inspector called Is Trigger. It changes what kind of collider you have:

This is the tool for exactly the cases you would expect: a coin, a health pickup, a damage zone, an invisible "you entered this room" volume -- all things that should know something touched them without physically stopping it.

________________________ | | | Trigger Volume | Collider.isTrigger = true | (health pickup) | Player --> | | --> player keeps walking, walks in |________________________| nothing pushes back | v OnTriggerEnter(Collider other) fires once, the instant the two colliders start overlapping
Solid collider (isTrigger = false) Trigger collider (isTrigger = true) ----------------------------------- ------------------------------------ Player --> [ WALL ] Player --> [ ZONE ] --> keeps going physically stopped, cannot pass passes straight through, unblocked OnCollisionEnter fires OnTriggerEnter fires

For two colliders to generate any event at all (collision or trigger), at least one of the two objects needs a Rigidbody. A trigger volume that only has a Collider and no Rigidbody still detects a player who has a Rigidbody walking into it -- the reverse also works. Two plain colliders with no Rigidbody anywhere never send events to each other; the physics engine does not bother testing static-against-static pairs for events, since neither one can move.

Common mistake Forgetting the Rigidbody entirely. A trigger volume with only a Collider, placed against a player that also only has a Collider and no Rigidbody, never fires OnTriggerEnter. If nothing seems to detect the player, check that the player (or the trigger) actually has a Rigidbody attached.

When to use a solid collider: walls, floors, characters, crates -- anything that should physically stop or push other objects. When to use a trigger: pickups, damage zones, checkpoints, level-transition volumes, "is the player near this thing" sensors -- anything that should only know, not block.

3. Detecting Overlaps: OnTriggerEnter and OnTriggerExit

Unity calls three methods on a MonoBehaviour attached to a trigger collider (or to the other collider involved), automatically, whenever an overlap starts, continues, or ends:

A pickup only needs OnTriggerEnter:


using UnityEngine;

public class CoinPickup : MonoBehaviour
{
    public int value = 10;

    void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out PlayerScore score))
        {
            score.Add(value);
            Debug.Log("Coin collected, +" + value);
            Destroy(gameObject);
        }
    }
}

Expected output: the instant the player's collider touches the coin's trigger collider, the console prints Coin collected, +10 and the coin disappears. If something without a PlayerScore component clips the trigger instead (a stray enemy, say), nothing happens -- the TryGetComponent check quietly skips it.

A damage zone needs continuous detection, so it uses OnTriggerStay instead:


using UnityEngine;

public class LavaPit : MonoBehaviour
{
    public float damagePerSecond = 10f;

    void OnTriggerStay(Collider other)
    {
        if (other.TryGetComponent(out Health health))
        {
            // OnTriggerStay fires once per physics step, so scale the
            // damage by the fixed step's length to get a steady
            // per-second rate no matter how often the step runs.
            float damageThisStep = damagePerSecond * Time.fixedDeltaTime;
            health.TakeDamage(damageThisStep);
        }
    }

    void OnTriggerExit(Collider other)
    {
        if (other.TryGetComponent(out Health health))
        {
            Debug.Log(other.name + " left the lava");
        }
    }
}

Expected output: while the player stands in the lava, Health.TakeDamage is called on every physics step with a small fractional amount, adding up to damagePerSecond hit points lost per real second. The moment the player steps out, OnTriggerExit prints Player left the lava once, and the steady damage stops.

Tip TryGetComponent is the safer habit over GetComponent followed by a null check -- it returns a plain bool and reads naturally inside an if, and it avoids the small overhead of comparing a returned reference against null separately.
Common mistake Doing expensive work (spawning particles, playing sounds, running AI checks) inside OnTriggerStay without noticing it fires every single physics step, potentially 50 times a second, for every object still overlapping. A small per-step calculation like the damage tick above is fine; instantiating a new particle system every step is not.

4. The FixedUpdate / Update Split

Unity's game loop calls two very different kinds of "once per X" methods, and mixing them up is one of the most common physics bugs beginners write:

Rendered frames (variable rate, Time.deltaTime): |---frame1---|-frame2-|-----frame3-----|-frame4-|---frame5---| Fixed physics steps (constant rate, Time.fixedDeltaTime = 0.02s): |-step-|-step-|-step-|-step-|-step-|-step-|-step-|-step-|-step-| Update() -> runs exactly once per rendered frame FixedUpdate() -> runs 0, 1, or more times per rendered frame, always exactly fixedDeltaTime apart, no matter how fast or slow the game is rendering

The rule that follows from this: read input in Update (a fast frame rate should never make you miss a key press -- Input.GetKeyDown is designed to be polled once per rendered frame), and apply forces, impulses, or velocity changes in FixedUpdate (so the physics engine always receives exactly the amount you intend, once per physics step, regardless of frame rate).

Here is the bug in its most common shape -- a thruster that pushes a ship forward while a key is held:


public class BuggyThruster : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 20f;

    // BUG: AddForce is called once per Update -- once per RENDERED
    // frame. At 240 fps, Update runs about 5 times for every single
    // FixedUpdate (at the default 50 Hz). That means roughly 5 times
    // as much force gets queued up per physics step as at 48 fps,
    // where Update barely keeps up with FixedUpdate. Thrust ends up
    // depending on the player's frame rate, not on the thrust value.
    void Update()
    {
        if (Input.GetKey(KeyCode.W))
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}

public class FixedThruster : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 20f;
    private bool thrusting;

    // Only READ input here. Polling once per rendered frame is exactly
    // right for input -- it is cheap, and GetKey/GetKeyDown are built
    // to be checked this often.
    void Update()
    {
        thrusting = Input.GetKey(KeyCode.W);
    }

    // Only APPLY physics here. This runs at a fixed rate, so the force
    // added per physics step is now the same no matter how fast the
    // game is rendering.
    void FixedUpdate()
    {
        if (thrusting)
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}

Expected result: FixedThruster reaches the same top speed after the same number of real-world seconds whether the game runs at 30 fps or 300 fps. BuggyThruster visibly accelerates faster on a high-end machine than a low-end one, running the exact same script.

Common mistake Reading input inside FixedUpdate instead of Update. At a low frame rate, FixedUpdate can run zero times in a rendered frame or several times in a row to catch up, so Input.GetKeyDown (true for exactly one call) can be checked on the wrong call and miss the press, or be checked twice and double-fire. Poll input in Update, store it in a field, and read that field in FixedUpdate.
Tip Inside FixedUpdate, Time.deltaTime is automatically equal to Time.fixedDeltaTime -- Unity sets it for you during that call. Using Time.fixedDeltaTime explicitly, as in the lava example, is still good habit, since it makes the intent obvious to anyone reading the code later.

5. Layer-Based Collision Matrix

Every GameObject has a layer (a numbered category, up to 32 of them, named in Project Settings > Tags and Layers -- things like Player, Enemy, EnemyBullet, Scenery). Layers alone do nothing by themselves, but two systems read them: rendering (which camera sees which layer) and physics.

Project Settings > Physics > Layer Collision Matrix is a big checkbox grid: one row and one column per layer. A checked box means colliders on those two layers are allowed to generate collision or trigger events with each other; an unchecked box means the physics engine skips testing that pair entirely -- not just ignoring the result, but never even doing the work.

Player Enemy EnemyBullet PlayerBullet Scenery Player - X X . X Enemy X . . X X EnemyBullet X . . . X PlayerBullet . X . . X Scenery X X X X - X = layers collide / send trigger events with each other . = physics engine ignores this pair completely (no events, no collision response, and no time spent testing them) Read this matrix as: PlayerBullet vs Player is "." -- your own bullets never hit you. EnemyBullet vs Enemy is "." -- no friendly fire between enemies.

Setting this up once in Project Settings is usually the right call -- it is a one-time, whole-project rule. Two extra tools help from code:


using UnityEngine;

public class SetupLayers : MonoBehaviour
{
    void Awake()
    {
        // Same effect as unchecking a box in the matrix, done at runtime.
        int enemyLayer = LayerMask.NameToLayer("Enemy");
        int enemyBulletLayer = LayerMask.NameToLayer("EnemyBullet");
        Physics.IgnoreLayerCollision(enemyLayer, enemyBulletLayer, true);

        // Assigning a layer to a spawned object, in code.
        gameObject.layer = LayerMask.NameToLayer("EnemyBullet");
    }
}

When to use it: any time two categories of object should never physically interact, or should never even be checked for events -- friendly fire off, bullets ignoring their own shooter, ghosts walking through walls, a "detection only" layer used purely for raycasts. This also directly reduces the physics engine's workload (Section 12 comes back to this), since ignored pairs are never even tested.

Tip The Layer Collision Matrix affects OnCollisionEnter/OnTriggerEnter pairs. It does not automatically affect raycasts or overlap queries -- those need their own LayerMask parameter, covered next.

6. Applying Forces: AddForce and Force Modes

Rigidbody.AddForce(Vector3 force, ForceMode mode) is the main tool for pushing a dynamic Rigidbody. The second argument, ForceMode, changes what the numbers mean -- mixing these up is a common source of "why is my force too weak or too strong" confusion:

ForceMode.Force continuous, uses mass, like a rocket engine (apply every FixedUpdate while thrusting) ForceMode.Acceleration continuous, ignores mass, same acceleration for a feather or a boulder ForceMode.Impulse instant, uses mass, like a bullet impact (apply once) ForceMode.VelocityChange instant, ignores mass, exact speed change (apply once)

"Uses mass" means a heavier Rigidbody (higher rb.mass) needs more force to move by the same amount -- matching real-world intuition (a shove barely moves a truck but sends a shopping cart flying). "Ignores mass" means every object gets the exact same acceleration or speed change, useful when you want predictable, tunable game feel rather than physical realism.

A continuous thruster uses ForceMode.Force, applied every FixedUpdate while active (the same pattern from Section 4):


void FixedUpdate()
{
    if (thrusting)
    {
        rb.AddForce(transform.forward * thrust, ForceMode.Force);
    }
}

Expected result: the ship accelerates smoothly, gaining speed the longer thrusting stays true, and a heavier ship (rb.mass increased) accelerates more slowly under the exact same thrust value, because ForceMode.Force respects mass -- just like a real rocket engine on a heavier rocket.

Tip AddForce with no second argument defaults to ForceMode.Force. Writing it explicitly, as above, saves the next reader (often you, in six months) from having to look up the default.

7. Raycasts and Overlap Queries: Shooting and Detection

Not every physics question needs a Rigidbody moving through the world. Sometimes you just want to ask the physics engine a question right now: "what is directly in front of this gun?" or "what is standing near this explosion?" These one-shot questions are called queries, and they read the current state of every collider in the scene without simulating anything.

A raycast (firing an invisible, infinitely thin line through the scene and asking what it hits first) is the standard way to implement hitscan shooting -- a gun that hits instantly, with no visible bullet travel time:


using UnityEngine;

public class Gun : MonoBehaviour
{
    public float range = 100f;
    public int damage = 25;
    public LayerMask hittableLayers; // set in the Inspector: Enemy + Scenery

    public void Fire()
    {
        Ray ray = new Ray(transform.position, transform.forward);

        // Physics.Raycast returns true the instant it finds something,
        // and fills "hit" with details -- what it hit, where, how far.
        if (Physics.Raycast(ray, out RaycastHit hit, range, hittableLayers))
        {
            Debug.Log("Hit " + hit.collider.name + " at distance " + hit.distance);

            if (hit.collider.TryGetComponent(out Health targetHealth))
            {
                targetHealth.TakeDamage(damage);
            }
        }
        else
        {
            Debug.Log("Shot missed everything");
        }
    }
}

Expected output: aiming at an enemy 12 units away and calling Fire() prints Hit Enemy at distance 12 and reduces that enemy's health by 25. Aiming at open sky prints Shot missed everything. Passing hittableLayers (excluding the Player layer) means the raycast physically cannot report hitting the player who fired it, no matter where the ray starts.

An overlap query asks a different question: "what colliders are currently inside this shape?" -- useful for area detection, like an alarm that notices the player nearby:


using UnityEngine;

public class ProximityAlarm : MonoBehaviour
{
    public float radius = 5f;
    public LayerMask playerLayer;

    void FixedUpdate()
    {
        // Physics.OverlapSphere returns every collider touching the
        // sphere -- this object does not even need a Collider itself.
        Collider[] nearby = Physics.OverlapSphere(transform.position, radius, playerLayer);

        if (nearby.Length > 0)
        {
            Debug.Log("Player detected, colliders in range: " + nearby.Length);
        }
    }
}

Expected output: the console prints Player detected, colliders in range: 1 every physics step the player stays within 5 units, and stops printing once they walk away.

Tip Always pass a LayerMask to raycasts and overlap queries when you know what you are looking for. Without one, they test against every collider in the scene, including ones you never meant to hit, and the results are noisier and slower to filter afterward.
Common mistake Forgetting that OnTriggerEnter/OnCollisionEnter and queries like Physics.Raycast answer different questions at different times. Trigger and collision events tell you about things that are about to happen or just happened during a physics step. A raycast tells you about the scene's state right now, this instant, whenever you call it -- useful for an action the player triggers on demand, like firing a gun.

8. Impulses, Knockback, and Explosions

A single, instant push -- a melee hit, a bullet impact, a bounce pad -- uses ForceMode.Impulse instead of the continuous ForceMode.Force from Section 6, applied exactly once:


void ApplyKnockback(Rigidbody target, Vector3 direction, float strength)
{
    // Impulse: an instant velocity change that still respects mass,
    // so a heavy enemy flies back less than a light one for the
    // same strength value -- feels right without extra tuning.
    target.AddForce(direction.normalized * strength, ForceMode.Impulse);
}

An explosion needs to push every nearby Rigidbody at once, with force that fades by distance. Rigidbody.AddExplosionForce handles that falloff for you, combined with the Physics.OverlapSphere query from Section 7 to find everything in range:


using UnityEngine;

public class Grenade : MonoBehaviour
{
    public float explosionForce = 700f;
    public float explosionRadius = 5f;
    public float upwardsModifier = 0.5f;
    public LayerMask affectedLayers;

    public void Explode()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, explosionRadius, affectedLayers);

        foreach (Collider hit in hits)
        {
            if (hit.attachedRigidbody != null)
            {
                hit.attachedRigidbody.AddExplosionForce(
                    explosionForce,
                    transform.position,
                    explosionRadius,
                    upwardsModifier,
                    ForceMode.Impulse);
            }
        }

        Destroy(gameObject);
    }
}

The parameters, in order: explosionForce (the strength at the very center of the blast), explosionPosition (where the blast happens -- objects farther from this point get less force, down to zero at explosionRadius), explosionRadius (how far the blast reaches), and upwardsModifier (an artificial extra push straight up, which makes debris and ragdolls fly up and outward instead of skimming flat along the ground -- real explosions do this too, but the parameter lets you exaggerate it for a better-looking effect).

Expected result: calling Explode() near three crates finds all three with OverlapSphere, and each one flies outward and slightly upward, with a crate right next to the grenade flying much farther than one near the edge of explosionRadius -- the falloff happens automatically inside AddExplosionForce, you never compute distance yourself.

Tip Set affectedLayers to exclude Scenery if walls and terrain have Rigidbodies you never want flung around by a nearby explosion, or to exclude Player if explosions should only ever damage enemies. This is the Layer Collision Matrix idea from Section 5 applied to a query instead of a collision pair.

9. Velocity vs Force: The Jump Case Study

You now have two different tools that can make something move: adding a force or impulse, or setting rb.linearVelocity directly (overwriting the object's current velocity outright -- this property was called velocity in older Unity versions). A jump is the clearest example of when each one fits, because both look "correct" in a quick test and only reveal their difference in an edge case.


public class JumpWithForce : MonoBehaviour
{
    public Rigidbody rb;
    public float jumpForce = 8f;

    public void Jump()
    {
        // Adds ON TOP of whatever vertical velocity already exists.
        rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
    }
}

public class JumpWithVelocity : MonoBehaviour
{
    public Rigidbody rb;
    public float jumpSpeed = 8f;

    public void Jump()
    {
        // OVERWRITES vertical velocity outright.
        Vector3 v = rb.linearVelocity;
        v.y = jumpSpeed;
        rb.linearVelocity = v;
    }
}

Try both while the character is already falling off a ledge (moving downward, rb.linearVelocity.y is negative). JumpWithForce adds the jump impulse to that negative value, so the jump height depends on how fast the character was already falling -- sometimes barely leaving the ground, sometimes launching unexpectedly high. JumpWithVelocity ignores whatever the character was doing and always sets y to exactly jumpSpeed -- the jump reaches the same height every single time.

Standing still, then jump: AddForce(Impulse): 0 --> jumpForce (predictable here) Set linearVelocity: 0 --> jumpSpeed (predictable here) Already falling (y = -6), then jump: AddForce(Impulse): -6 --> -6 + jumpForce (inconsistent height!) Set linearVelocity: -6 --> jumpSpeed (still exactly the same)

When to add force or impulse: anything that should look and feel physically real, where varying with mass and existing motion is exactly the point -- explosions, knockback, ragdolls, environmental pushes, a boulder rolling downhill and picking up speed.

When to set velocity directly: tightly controlled, player-facing actions where consistency matters more than realism -- a platformer jump that must reach the same height every time, a dash move with an exact fixed speed, clamping a Rigidbody so it never exceeds a max speed. Most responsive, "tight-feeling" arcade-style character controllers lean heavily on setting velocity directly rather than accumulating forces.

Common mistake Using AddForce for player movement and then wondering why acceleration feels sluggish or inconsistent, especially at low mass or while already moving. If the goal is "move at exactly this speed, right now," set linearVelocity. Save AddForce for cases where gradual, mass-aware acceleration is the actual goal.

10. Interpolation: Smoothing Physics-Driven Visuals

Recall from Section 4 that FixedUpdate runs at a fixed rate (50 Hz by default) while rendering can run much faster (144 Hz, 240 Hz, or uncapped). A Rigidbody's Transform only actually changes on a physics step. If the screen redraws more often than that, the same position gets drawn several frames in a row, then jumps to the next physics position -- visible as a slight stutter, especially on fast-moving objects.

Physics steps (50/sec): P0 ---------- P1 ---------- P2 Render frames (144/sec): r r r r r r r r r r r r Without interpolation: the rendered object sits at P0 for several frames, then snaps to P1, then sits again -- visible stutter at high frame rates. With interpolation: Unity blends the rendered position smoothly between P0 and P1 across every frame in between, so the object visually glides even though physics only updates 50 times/sec.

The fix is the Rigidbody.interpolation setting, either in the Inspector or in code:


void Awake()
{
    Rigidbody rb = GetComponent<Rigidbody>();
    rb.interpolation = RigidbodyInterpolation.Interpolate;
}

Expected result: a ball rolling across the floor with Interpolate set looks perfectly smooth at 144 fps even though it is still only physically simulated 50 times a second. The same ball with None set visibly steps between positions on a very fast monitor, especially noticeable on slow camera pans.

Tip Set interpolation on the player and any camera-followed object. Leave it off (None) on background clutter, debris, or anything off-screen -- interpolation costs a small amount of extra CPU per Rigidbody, and objects nobody is closely watching do not need it.

11. Determinism: Fixed Step, Fixed Order

A simulation is deterministic when the exact same starting state and the exact same sequence of inputs always produce the exact same result, every single time it runs. Two features depend on this directly:

Two requirements make this possible, and both connect directly to earlier sections:


[System.Serializable]
public struct InputFrame
{
    public int tick;
    public float horizontal;
    public bool jumpPressed;
}

public class ReplayRecorder : MonoBehaviour
{
    private List<InputFrame> recordedFrames = new List<InputFrame>();
    private int currentTick;

    void FixedUpdate()
    {
        InputFrame frame = new InputFrame
        {
            tick = currentTick,
            horizontal = Input.GetAxisRaw("Horizontal"),
            jumpPressed = Input.GetButtonDown("Jump")
        };
        recordedFrames.Add(frame);

        ApplyInput(frame);
        currentTick++;
    }

    void ApplyInput(InputFrame frame)
    {
        // The SAME method drives live play and replay playback. As long
        // as this only ever runs from FixedUpdate (fixed step, fixed
        // order), replaying the same recordedFrames list reproduces the
        // exact same run.
    }
}
Live play: input each fixed tick --> ApplyInput() --> physics step (also saved to a list, one InputFrame per tick) Replay: saved list, tick by tick --> same ApplyInput() --> same physics step, same fixed order, same fixed dt = identical run, every time it plays back
Common mistake Using an unseeded random number generator during simulation. Random.Range with no fixed seed picks a different sequence every run, which breaks determinism immediately -- a replay or a lockstep peer that used a different random sequence produces a different result from the same inputs. Seed the RNG explicitly (Random.InitState(seed)) with a value that is itself part of the recorded or synced state.

One honest limitation worth knowing: Unity's built-in PhysX engine is not guaranteed to produce bit-for-bit identical results across different CPU architectures or platforms, because floating-point rounding can differ at that level. A single-machine replay system (the case above) is reliable, since the same machine runs both the recording and the playback. True cross-platform lockstep multiplayer with full 3D physics is hard enough that many competitive RTS and fighting games either restrict lockstep to simpler, custom fixed-point simulations, or only rely on determinism within one platform, rather than trusting a general-purpose physics engine across every machine.

12. Performance: Too Many Active Bodies

Every physics step, the engine has to do real work for every awake Rigidbody: find which pairs of colliders might be touching (broadphase), check those candidate pairs precisely (narrowphase), and resolve any actual contacts (the solver). FixedUpdate has a fixed amount of real time to finish its work before the next step is due -- too many active bodies and the physics step itself starts taking longer than the time budget allows, which drags the whole game down even if nothing looks visually complex.

10 active Rigidbodies -> physics step finishes fast, budget to spare 2,000 active Rigidbodies -> physics step alone can exceed the entire per-frame time budget -> frame rate drops, even though the scene looks the same

A few concrete ways to keep the count and the cost down:


using System.Collections.Generic;
using UnityEngine;

public class DebrisSpawner : MonoBehaviour
{
    public GameObject debrisPrefab;
    public int maxActiveDebris = 30;

    private readonly Queue<GameObject> activeDebris = new Queue<GameObject>();

    public void SpawnDebris(Vector3 position)
    {
        if (activeDebris.Count >= maxActiveDebris)
        {
            // Cap the number of active physics bodies instead of letting
            // every explosion add more debris forever.
            GameObject oldest = activeDebris.Dequeue();
            Destroy(oldest);
        }

        GameObject piece = Instantiate(debrisPrefab, position, Quaternion.identity);
        activeDebris.Enqueue(piece);
    }
}

Expected result: repeated explosions keep spawning debris chunks, but the active Rigidbody count never grows past maxActiveDebris -- the oldest piece is removed before a new one is added, keeping the physics step's workload bounded no matter how long the player keeps blowing things up.

Tip Project Settings > Physics also exposes Solver Iterations (how many passes the solver makes resolving contacts -- higher is more accurate and more expensive) and Max Allowed Timestep (a safety cap so a huge frame-rate stall does not force physics to try to simulate a giant leap in time all at once). Both are worth knowing about, but only worth tuning after you have actually measured a physics-related slowdown with the Profiler.

13. Glossary

14. Exercises

Exercise 1 -- Fix the Frame-Rate Bug The script below is meant to make a hovercraft accelerate forward while W is held. It "works" in a quick test, but its top speed after 5 seconds turns out different on a fast machine than on a slow one. Explain why, using what Section 4 taught about Update and FixedUpdate, then rewrite it correctly.

public class Hovercraft : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 15f;

    void Update()
    {
        if (Input.GetKey(KeyCode.W))
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}
Show answer

AddForce is called from Update, which runs once per rendered frame -- a variable rate. On a fast machine, Update runs many more times per second than the fixed physics rate (50 Hz by default), so far more force gets queued up per physics step than on a slow machine, where Update barely outpaces FixedUpdate. The hovercraft ends up accelerating faster purely because the game is rendering faster, which has nothing to do with the thrust value. The fix is to only read input in Update and only touch the Rigidbody in FixedUpdate:


public class Hovercraft : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 15f;
    private bool thrustHeld;

    void Update()
    {
        thrustHeld = Input.GetKey(KeyCode.W);
    }

    void FixedUpdate()
    {
        if (thrustHeld)
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}

Now AddForce is called at most once per fixed physics step, no matter how many times Update runs in between, so the hovercraft reaches the same speed after the same number of real-world seconds regardless of frame rate.

Exercise 2 -- Trigger or Solid? You are building a level with a stone wall, a healing potion pickup, and a lava pit that should damage the player steadily while they stand in it. For each object, decide whether its collider should have Is Trigger checked, and explain why in one sentence. Then write the HealingPotion script: it should heal the player 20 HP, log a message, and destroy itself the moment the player touches it.
Show answer

The wall should be a solid collider (isTrigger = false) -- it needs to physically stop the player from walking through it. The healing potion should be a trigger (isTrigger = true) -- it needs to detect the player without blocking their movement. The lava pit should also be a trigger (isTrigger = true) -- the player needs to be able to walk into it (even if that is a bad idea) so the game can detect the overlap and apply damage; a solid collider there would just act like an invisible wall and never let the player experience the lava at all.


using UnityEngine;

public class HealingPotion : MonoBehaviour
{
    public int healAmount = 20;

    void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out Health health))
        {
            health.Heal(healAmount);
            Debug.Log("Healing potion used, +" + healAmount + " HP");
            Destroy(gameObject);
        }
    }
}

This follows the same shape as the CoinPickup example from Section 3: check for the right component with TryGetComponent, apply the effect, then destroy the pickup so it cannot be collected twice.

Exercise 3 -- Layer-Filtered Explosion Write a Grenade script whose Explode() method finds every Rigidbody within a 6-unit radius using an overlap query, but should only affect objects on an Enemies layer (never the player or scenery), knocking each one back with AddExplosionForce using a force of 500 and an upwards modifier of 0.3. Expose the affected layer as a LayerMask field so it can be set in the Inspector.
Show answer

using UnityEngine;

public class Grenade : MonoBehaviour
{
    public float explosionForce = 500f;
    public float explosionRadius = 6f;
    public float upwardsModifier = 0.3f;
    public LayerMask enemyLayer; // set to "Enemies" only in the Inspector

    public void Explode()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, explosionRadius, enemyLayer);

        foreach (Collider hit in hits)
        {
            if (hit.attachedRigidbody != null)
            {
                hit.attachedRigidbody.AddExplosionForce(
                    explosionForce,
                    transform.position,
                    explosionRadius,
                    upwardsModifier,
                    ForceMode.Impulse);
            }
        }

        Destroy(gameObject);
    }
}

The enemyLayer mask does the filtering work: Physics.OverlapSphere only returns colliders on that layer in the first place, so the player and scenery are never even in the hits array -- there is no need for an extra tag check afterward. This is the same technique from Section 8, combined with the layer filtering from Section 5, applied together.

← Back to all chapters