6.2 Character Controllers & Movement

Phase 6 · Gameplay Programming · Study time: 30–50 h

Building responsive player movement — ground checks, jumping, slopes, air control — and why so much of 'game feel' lives right here.

You already know the basics of a Unity scene: GameObjects, Transform, MonoBehaviour, the Update/FixedUpdate loop, and simple physics with Rigidbody and colliders. This chapter takes those pieces and builds the single most-touched system in almost any game you will ship: the thing that moves the player around. It sounds simple — read input, move the character — but the difference between a controller that feels like a HoYoverse or Nintendo platformer and one that feels like a student project lives entirely in the details covered here.

We will build it up in layers: pick a movement approach, make horizontal movement feel right, know when you are actually touching the ground, then get jumping right, including the two small timing tricks that every good platformer uses. By the end you will have a full, tunable player movement script and understand exactly what each number in it does.

1. What is a "character controller"?

A character controller is not one specific class — it is the general name for "the code that turns player input into the character's position and movement in the world." Every game with a player-controlled character has one, whether it is ten lines or two thousand. Its job list is always roughly the same:

In Unity there are two fundamentally different ways to build this. Both are used constantly in shipped games, and picking the right one for your game matters.

Approach A: Rigidbody Approach B: CharacterController ----------------------- -------------------------------- Physics engine (PhysX) moves YOUR CODE moves you directly by you by applying forces/velocity calling Move() every frame Reacts to other physics objects Ignores physics forces from others Can be pushed, can push things Does not get pushed by explosions, Gravity is automatic other rigidbodies, etc. (unless Movement can feel "physics-y" you code that yourself) (momentum, sliding, bouncing) Gravity is NOT automatic - you add it Movement is precise and predictable Built-in slope + step handling

Neither one is "the right answer" in general — they are two different tools. We will build a small working version of each, then spend the rest of the chapter on the tuning that makes movement feel good, most of which applies to both approaches equally.

2. Approach A: Rigidbody-based movement

A Rigidbody hands your character over to Unity's physics engine. You do not set the character's position directly; instead you set its velocity (or apply forces), and the physics engine moves it, checking collisions along the way. This is the same physics engine that drives crates, ragdolls, and vehicles, so a Rigidbody-controlled character naturally interacts with other physics objects.

Here is the simplest version that can move: read input in Update, apply it to the Rigidbody's velocity in FixedUpdate (physics always runs on its own fixed timestep, separate from the rendering frame rate — that is why movement code that touches physics belongs in FixedUpdate, not Update).

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class RigidbodyMoverNaive : MonoBehaviour
{
    public float moveSpeed = 7f;

    Rigidbody rb;
    Vector3 inputDir;   // world-space desired direction, length 0..1

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        rb.freezeRotation = true;   // don't let physics tip our capsule over
    }

    void Update()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        inputDir = new Vector3(x, 0f, z).normalized;
    }

    void FixedUpdate()
    {
        Vector3 target = inputDir * moveSpeed;
        // NAIVE: snaps straight to the target speed, every physics tick.
        rb.velocity = new Vector3(target.x, rb.velocity.y, target.z);
    }
}

Worked trace: suppose the physics step is a fixed 1/60s and the player is standing still, then slams the "right" key fully down.

tick (FixedUpdate #) inputDir.x rb.velocity.x 0 (key not pressed) 0.0 0.0 1 (key just pressed) 1.0 7.0 <-- jumps straight to max speed 2 1.0 7.0 ... (key released) 0.0 0.0 <-- jumps straight to zero

That is exactly the "instant, not floaty" problem the topic warns about, just the "instant" half of it: velocity teleports from 0 to 7 in a single physics tick, and back to 0 the instant the key lifts. It reads as robotic and stiff — real characters (and real platformer heroes) take a few frames to get going and a few frames to stop. We fix this in section 4; the fix is the same regardless of whether you use a Rigidbody or a CharacterController.

Tip Newer Unity versions (Unity 6 and later) renamed Rigidbody.velocity to Rigidbody.linearVelocity to make room for a separate angular velocity name. Both exist for a while for compatibility, but if you're on a recent Unity and the old name is greyed out as obsolete, use linearVelocity instead — the behavior is identical.
Common mistake Writing movement code that reads or writes physics state (rb.velocity, rb.AddForce, collision checks) inside Update instead of FixedUpdate. Update runs once per rendered frame, which is not a fixed length of time and can run faster or slower than the physics engine's own step. Reading input in Update is fine and normal (input should feel instantly responsive); applying that input to physics belongs in FixedUpdate.

3. Approach B: the kinematic CharacterController

Unity ships a built-in component literally called CharacterController. It is a capsule-shaped collider that you move by calling controller.Move(motion) yourself, once per frame. The word kinematic means it is not simulated by the physics engine at all — nothing pushes it, gravity does not pull it, other rigidbodies bounce off it but it never bounces back. You are 100% in charge of where it goes; the component's only job is to stop you from moving through walls and floors, and to give you a reliable isGrounded flag.

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class CharacterControllerMoverNaive : MonoBehaviour
{
    public float moveSpeed = 7f;
    public float gravity = -25f;   // CharacterController does NOT apply gravity for you

    CharacterController controller;
    float verticalVelocity;

    void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

    void Update()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        Vector3 inputDir = new Vector3(x, 0f, z).normalized;

        if (controller.isGrounded && verticalVelocity < 0f)
            verticalVelocity = -2f;      // small downward push so we stay "grounded"
        verticalVelocity += gravity * Time.deltaTime;

        Vector3 motion = inputDir * moveSpeed;
        motion.y = verticalVelocity;

        controller.Move(motion * Time.deltaTime);   // Move() takes an actual distance
    }
}

Two things about this code are easy to miss the first time:

The small downward push (verticalVelocity = -2f instead of 0f) when grounded looks odd but matters: if you let vertical velocity sit at exactly 0 while standing on flat ground, isGrounded can flicker false for a frame due to tiny floating point gaps, and on a downward slope you would slide off instead of sticking to it. A small constant push keeps the controller pressed firmly against the ground every frame.

Approach C: the kinematic Rigidbody

There is a third option that sits between the two: a Rigidbody with isKinematic = true. Turning that flag on tells the physics engine "stop simulating this body — do not apply gravity to it, do not let forces or other collisions move it." You then move it yourself with Rigidbody.MovePosition(targetPosition) inside FixedUpdate. It behaves a lot like a CharacterController (nothing pushes it, you are fully in charge), with one crucial difference: it has no built-in collision response at all. CharacterController.Move automatically stops at walls and slides along them; a kinematic Rigidbody will happily MovePosition straight through a wall unless your own code casts ahead and stops it.

[RequireComponent(typeof(Rigidbody))]
public class KinematicMover : MonoBehaviour
{
    public float moveSpeed = 7f;
    Rigidbody rb;
    Vector3 inputDir;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        rb.isKinematic = true;                 // physics will not move this body
        rb.interpolation = RigidbodyInterpolation.Interpolate; // smooth it (section 12)
    }

    void Update()   // read input where it is responsive
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        inputDir = new Vector3(x, 0f, z).normalized;
    }

    void FixedUpdate()   // move on the physics clock
    {
        Vector3 delta = inputDir * moveSpeed * Time.fixedDeltaTime;
        rb.MovePosition(rb.position + delta);   // MovePosition takes a TARGET position
    }
}

So why use this over simply writing transform.position += delta in Update? Because MovePosition keeps the character registered with the physics system: trigger volumes (OnTriggerEnter) fire reliably, other dynamic Rigidbodies get pushed correctly, and interpolation (section 12) can smooth the motion between physics ticks. Teleporting a Transform directly does none of that and is the usual cause of "the player walked through my trigger and nothing happened." The kinematic Rigidbody is the foundation most fully custom controllers are built on — you get correct physics bookkeeping while doing all the actual movement and collision yourself (via casts, sections 5 and 10).

Rigidbody vs CharacterController — how to choose

Dynamic Rigidbody Kinematic Rigidbody CharacterController (isKinematic = false) (isKinematic = true) (built-in capsule) ---------------------- ----------------------- ---------------------- physics moves you: YOU move it (MovePosition); YOU move it (Move); forces, gravity, physics does not. physics does not. momentum, bouncing no auto collision - you collide-and-slide, you CAN be pushed by cast ahead and stop isGrounded, slopeLimit, explosions and objects yourself stepOffset all built in best for: vehicles, nothing pushes it, but it nothing pushes it ragdolls, physics still fires triggers, best for: standard puzzles, momentum- pushes dynamics, and humanoid walk / run / driven characters interpolates jump with the least code best for: fully custom movers needing exact control + real events

Most 3D platformers, action games, and shooters (the genres HoYoverse and similar studios ship) use CharacterController or a fully custom kinematic mover for exactly this reason: total control over game feel. From here on, the tuning techniques (acceleration, ground checks, jump timing) are described using CharacterController-style code because it is the clearer teaching tool, but every one of them applies just as well to a Rigidbody mover — you would apply the same computed velocity to rb.velocity instead of passing it to Move().

4. Horizontal movement: acceleration and friction

Section 2 showed the problem: snapping straight to max speed and straight back to zero feels stiff. The opposite mistake is just as bad — smoothing so much that the character keeps sliding around after you let go of the stick, like it is standing on ice. That is the "floaty" failure mode. The fix for both is the same idea games have used forever: accelerate toward your target speed instead of teleporting to it, and decelerate (using friction) back to zero when there is no input, at a rate you control.

public float maxSpeed = 7f;         // top horizontal speed, units/second
public float acceleration = 60f;    // how fast we speed UP, units/second^2
public float friction = 45f;        // how fast we slow DOWN with no input

Vector3 horizontalVelocity;         // current x/z velocity, carried frame to frame

void UpdateHorizontalVelocity()
{
    float x = Input.GetAxisRaw("Horizontal");
    float z = Input.GetAxisRaw("Vertical");
    Vector3 wishDir = new Vector3(x, 0f, z).normalized;

    Vector3 targetVelocity = wishDir * maxSpeed;
    float rate = (wishDir.sqrMagnitude > 0.0001f) ? acceleration : friction;

    horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetVelocity, rate * Time.deltaTime);
}

Vector3.MoveTowards(current, target, maxDelta) moves current toward target by at most maxDelta units, and stops exactly at the target instead of overshooting — perfect for this. When there is input, we chase maxSpeed at the acceleration rate; when there is no input, the target becomes zero and we chase it at the (usually faster) friction rate, so stopping feels snappier than starting, which is what most action games want.

Worked trace at a fixed 60 FPS (Time.deltaTime = 1/60 ≈ 0.0167s), starting from a standstill, holding the move key: each frame the speed can change by at most acceleration * deltaTime = 60 * (1/60) = 1.0 unit/second.

frame: 0 1 2 3 4 5 6 7 speed (u/s): 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 <- reaches maxSpeed here key released at frame 7, friction = 45 -> step = 45/60 = 0.75 u/s per frame frame: 7 8 9 10 11 ... 17 speed (u/s): 7.0 6.25 5.50 4.75 4.00 ... 0.25 -> 0 soon after

Notice it takes 7 frames (about 0.12s) to reach full speed and about 10 frames (about 0.16s) to fully stop. Both are fast enough to feel responsive, but not instant — that gentle ramp is most of what separates "tight" controls from "stiff" ones.

speed ^ 7 | instant (bad): step function, snaps up and down | ____ | | |________ | | tuned (good): short ramp up, short ramp down | ___________ | / \ | / \___ | | floaty (bad): slow mushy curve, keeps drifting after input stops | ....................... | .. ....... 0 +----------------------------------------------> time
Tip Treat acceleration, friction, and maxSpeed as the first three knobs you tune for any character, before touching anything else. Doubling acceleration alone makes a character feel dramatically snappier without changing its top speed at all — this is one of the cheapest, highest-impact tuning changes in the whole system.
Common mistake Using Vector3.Lerp(current, target, t) with a fixed t each frame for this instead of MoveTowards. Lerp-with-fixed-t asymptotically approaches the target and mathematically never quite reaches it — it is exactly the "floaty, never settles" curve above, and its effective speed changes with frame rate in ways that are hard to reason about. MoveTowards with an explicit units/second^2 rate reaches the target cleanly and is frame-rate independent.

Ground vs air control: two sets of knobs

Using one acceleration and one friction everywhere hides a problem you feel the instant you jump: the same friction that snaps you to a stop on the ground also kills your horizontal speed in mid-air, so a running jump dies the moment you release the stick over a gap. Real controllers split these into two sets — grounded and airborne — because the air usually wants far less friction (so a jump keeps its momentum) and a little less acceleration (so you commit to a jump's arc but can still nudge it).

public float groundAccel    = 60f;
public float groundFriction = 45f;
public float airAccel    = 25f;   // weaker steering while airborne
public float airFriction = 6f;    // almost no drag: keep run momentum through a jump

void UpdateHorizontalVelocity()
{
    float x = Input.GetAxisRaw("Horizontal");
    float z = Input.GetAxisRaw("Vertical");
    Vector3 wishDir = new Vector3(x, 0f, z).normalized;

    Vector3 targetVelocity = wishDir * maxSpeed;
    bool moving = wishDir.sqrMagnitude > 0.0001f;

    float accel    = isGrounded ? groundAccel    : airAccel;
    float friction = isGrounded ? groundFriction : airFriction;
    float rate = moving ? accel : friction;

    horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetVelocity, rate * Time.deltaTime);
}

What the player feels: with airFriction near zero, sprinting off a ledge carries your full speed across the gap even if you let go of the stick — the signature feel of a precise platformer. With airAccel lower than groundAccel, you can still steer a little mid-jump but cannot instantly reverse in the air, which would look weightless and wrong. These are a design choice, not a law — a tight action-platformer like Celeste deliberately uses high air control. The point is that ground and air deserve their own numbers so you can tune each on its own.

5. Ground checks: raycasts and overlap spheres

Almost every decision in this chapter — can I jump, does gravity apply, do I stick to a slope — depends on one boolean question: is the character touching the ground right now? That sounds trivial and is actually one of the fiddlier parts of a character controller.

CharacterController.isGrounded exists and is a fine starting point, but it has a well-known quirk: it is only updated as a side effect of calling Move(), and it can report false for a stray frame on flat ground, or briefly report true for a frame right after you leave a ledge, because it is based on whether the last Move() call's collision touched something within the controller's tiny skin width (a small buffer, a few millimeters, that Unity keeps around the collider to avoid getting stuck). Most shipped controllers do not trust it alone — they add their own explicit ground check underneath the feet.

The two standard tools for this are a raycast (a thin invisible line you fire from a point in a direction, asking "what does this line first hit?") and an overlap sphere (a small invisible ball you place somewhere, asking "is anything touching this ball right now?"). Unity gives you both:

public Transform groundCheck;        // empty child GameObject, placed right at the feet
public float groundCheckRadius = 0.2f;
public LayerMask groundMask;         // set in the Inspector to ONLY the "Ground" layer

bool isGrounded;

void CheckGround()
{
    // Overlap sphere: "is anything on the Ground layer touching this little ball?"
    isGrounded = Physics.CheckSphere(groundCheck.position, groundCheckRadius, groundMask);
}

bool GroundedByRaycast(float castDistance)
{
    // Raycast: "what is directly below the character's center, within castDistance?"
    return Physics.Raycast(transform.position, Vector3.down, castDistance, groundMask);
}
character capsule +-----------------+ | | | o <- transform.position (capsule center) | | | | raycast, length = castDistance | v |_________________| o <- groundCheck transform, at the feet ( ) <- CheckSphere, radius = groundCheckRadius ========================================== ground collider, Layer "Ground"

A small sphere at the feet is usually more forgiving than a single thin raycast: standing right at the lip of a step or the edge of a platform, a single ray straight down the center can miss the ground entirely even though the character's feet are clearly still touching it. A sphere with a small radius covers that edge case. Many real controllers run both: a sphere check for the everyday "am I grounded" flag, plus a separate raycast when they specifically need the ground's surface normal (its "facing direction" — used for slopes in section 10).

Common mistake Putting the ground check layer mask on "Everything" instead of a dedicated "Ground" layer. If the player's own capsule collider, held items, or other characters are included in the mask, CheckSphere can return true because it is overlapping itself or a nearby character, not the floor — causing the classic bug where jumping seems to randomly work while standing next to a wall or another player. Always give ground a dedicated layer and mask to exactly that layer.
Tip Draw your ground check every frame with Debug.DrawRay or Gizmos while you tune it (Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius) inside OnDrawGizmosSelected). You cannot tune a value you cannot see — this is true of almost every number in this chapter.

6. Jump height from a desired apex

A beginner's first jump code usually looks like verticalVelocity = 8f; with the 8 picked by trial and error, then re-guessed every time gravity changes. There is a better way: decide how the jump should feel first — how high should it go, and how long should it take to get there — then compute the exact numbers that produce that feel.

Call the peak height h (how many units the character rises) and the time to reach that peak t (the apex — the top of the arc, where vertical velocity is momentarily zero). Basic constant-acceleration motion gives us two clean formulas:

initial upward velocity: v0 = 2h / t gravity (negative, pulls down): g = -2h / t^2

Where do these come from? At the apex the vertical velocity has decelerated to exactly 0, so v0 = -g * t. Plugging that into the standard rise formula h = v0*t + 0.5*g*t^2 and simplifying gives exactly the two formulas above. You do not need to re-derive this every time — just remember: pick a height and a time, plug them in.

public float jumpHeight = 2f;     // desired peak height, in units (meters)
public float timeToApex = 0.4f;   // desired seconds to reach that peak

float gravity;
float jumpVelocity;

void Awake()
{
    gravity      = -2f * jumpHeight / (timeToApex * timeToApex);
    jumpVelocity =  2f * jumpHeight / timeToApex;

    Debug.Log($"gravity = {gravity}, jumpVelocity = {jumpVelocity}");
}

Output (Console window):

gravity = -25, jumpVelocity = 10

Check it by hand: rising for 0.4s at an initial speed of 10 units/s while decelerating at 25 units/s^2, the character reaches 0 vertical speed exactly at t = 10 / 25 = 0.4s — matches. Height gained: 10*0.4 - 0.5*25*0.4^2 = 4 - 2 = 2 — matches jumpHeight exactly. Two numbers in, and the whole arc is exactly the shape you asked for.

void Update()
{
    if (isGrounded && Input.GetButtonDown("Jump"))
        verticalVelocity = jumpVelocity;

    verticalVelocity += gravity * Time.deltaTime;
    // ... feed verticalVelocity into controller.Move() as in section 3
}
Tip Tune by feel, not by formula intuition. Most action platformers use a fairly short timeToApex (0.25–0.45s) — a long, slow rise reads as "floaty" even with correct math, because real-world jumps feel snappy. Small timeToApex changes are one of the highest-impact single-number tweaks in this whole chapter.

7. Variable jump height: short hop vs full jump

With a single fixed gravity, every jump — whether you tap the button for a frame or hold it the whole way up — reaches the exact same height. Compare that to games like Mario or Celeste, where tapping jump gives a small hop and holding it gives a full jump. That single feature makes platforming feel far more controllable, because the player can commit to a jump's height mid-air by choosing when to let go.

The trick: apply extra gravity in two specific situations — while falling (always, for a snappy, non-floaty descent), and while still rising if the jump button has already been released (this is what cuts a "short hop" off early):

public float fallMultiplier = 2.5f;     // extra gravity while falling
public float lowJumpMultiplier = 2f;    // extra gravity while rising but button released

void ApplyGravity()
{
    bool jumpHeld = Input.GetButton("Jump");

    if (verticalVelocity < 0f)
    {
        // Falling: always fall faster than we rose. Feels weighty, not floaty.
        verticalVelocity += gravity * (fallMultiplier - 1f) * Time.deltaTime;
    }
    else if (verticalVelocity > 0f && !jumpHeld)
    {
        // Rising but the button let go early: cut the jump short.
        verticalVelocity += gravity * (lowJumpMultiplier - 1f) * Time.deltaTime;
    }

    verticalVelocity += gravity * Time.deltaTime;   // the normal gravity from section 6
}

Worked trace using the numbers from section 6 (gravity = -25, jumpVelocity = 10), comparing a full-hold jump against tapping the button and releasing immediately at takeoff (lowJumpMultiplier = 2):

full jump (button held the whole way up): effective gravity while rising = -25 (normal) time to apex = 10 / 25 = 0.4s peak height = 10^2 / (2*25) = 2.0 units short hop (button released immediately at takeoff): effective gravity while rising = -25 * 2 = -50 (low jump multiplier applied) time to apex = 10 / 50 = 0.2s peak height = 10^2 / (2*50) = 1.0 units same initial launch speed (10 u/s), HALF the peak height, because the player let go of the button — the player controls height mid-air.

Notice both jumps start with the exact same push off the ground (jumpVelocity = 10) — what changes is purely how hard gravity pulls afterward, and that pull depends on player input during the jump, not just at takeoff. That is the whole trick.

Common mistake Checking Input.GetButtonUp("Jump") once and permanently switching to a smaller gravity value. That only cuts the jump if the player releases before the apex; if they release on the way down it does nothing, and worse, a value that gets permanently changed can leak into the next jump. Check Input.GetButton("Jump") (the held state) fresh every single frame instead, as in the code above — it naturally does the right thing whether the player releases early, late, or not at all.

Apex hang: a moment of float at the very top

One more piece of jump feel that pros tune and beginners miss: the apex modifier, or "apex hang." A plain parabola already moves slowest at its peak, but many great platformers exaggerate that on purpose — near the top of the jump, where vertical velocity is close to zero, they briefly reduce gravity and often add a small horizontal speed boost. The result is a subtle hang at the peak that gives the player extra time to line up a landing, and makes the jump read as expressive instead of a rigid arc.

public float apexThreshold   = 3f;    // |verticalVelocity| under this = "near the apex"
public float apexGravityMult = 0.5f;  // soften gravity to half strength at the apex
public float apexSpeedBonus  = 1.15f; // small horizontal speed boost at the apex

bool NearApex()
{
    return !isGrounded && Mathf.Abs(verticalVelocity) < apexThreshold;
}

// scale the normal gravity term while hanging near the top:
float g = gravity;
if (NearApex()) g *= apexGravityMult;
verticalVelocity += g * Time.deltaTime;

// and let horizontal top speed rise a touch at the apex:
float speed = NearApex() ? maxSpeed * apexSpeedBonus : maxSpeed;

Why Mathf.Abs? The hang should cover both the last of the rise and the first of the fall — the whole slow-moving region around the peak — so you test the magnitude of vertical velocity, not its sign. Keep the values gentle: a threshold of a few units per second and half-strength gravity is plenty. Overdo it and the character seems to catch on an invisible ledge at the top of every jump. Done right, players never notice the mechanism — they just feel that jumps "reach."

8. Coyote time: forgiving a jump just after leaving a ledge

Named after the cartoon coyote who runs off a cliff and does not fall until he looks down: coyote time is a short grace window (typically 80–150 milliseconds) after the character walks off a ledge where a jump input still works, exactly as if they were still standing on solid ground. Without it, a player who presses jump one frame too late — completely plausible human timing, not a mistake — just falls, and it feels unfair because visually they were still "basically on the platform."

grounded ============================| | (character walks off the edge here, t = 0.00s) time -> 0.00s 0.05s 0.10s 0.20s |<--- coyote window (0.10s) -->| (jump still allowed in here, as if still grounded) jump pressed at 0.05s -> INSIDE the window -> jump succeeds (a "coyote save") jump pressed at 0.15s -> OUTSIDE the window -> too late, character just falls
public float coyoteTime = 0.1f;   // seconds of grace after leaving the ground
float coyoteTimeCounter;

void Update()
{
    if (isGrounded)
        coyoteTimeCounter = coyoteTime;      // reset the grace window while grounded
    else
        coyoteTimeCounter -= Time.deltaTime; // count down while airborne

    bool canJump = coyoteTimeCounter > 0f;

    if (canJump && Input.GetButtonDown("Jump"))
    {
        verticalVelocity = jumpVelocity;
        coyoteTimeCounter = 0f;              // used it up, no double-dipping
    }
}

The counter resets to coyoteTime every single frame the character is grounded, and only starts ticking down the instant it becomes airborne — so it does not matter why the character left the ground (walked off an edge, or the floor moved out from under them); the window always behaves the same way. Setting coyoteTimeCounter = 0f after a successful jump stops the player from also jumping again mid-air by pure luck.

Tip 100ms (0.1s) is a common starting value — long enough that it fixes the "unfair" near-miss, short enough that players never notice a genuine mid-air jump is happening. Most players who benefit from coyote time never consciously realize it exists; they just feel like the controls are "fair."

9. Jump buffering: remembering a jump pressed a moment early

Jump buffering is coyote time's mirror image. Instead of forgiving a jump pressed slightly late (after leaving the ground), it forgives a jump pressed slightly early (before landing on the ground). A player mashing jump right before their character lands — again, completely normal human timing when trying to chain a jump immediately off a landing — should not have that press silently dropped just because the character was still a frame or two above the floor.

time -> ... 0.40s 0.50s (character lands) |<------- buffer window (0.15s) ------->| jump pressed here (still airborne) buffered jump fires HERE press at 0.40s, lands at 0.50s: gap = 0.10s < 0.15s buffer -> jump fires on landing press at 0.25s, lands at 0.50s: gap = 0.25s > 0.15s buffer -> buffer expired, dropped
public float jumpBufferTime = 0.15f;   // seconds a jump press is remembered
float jumpBufferCounter;

void Update()
{
    if (Input.GetButtonDown("Jump"))
        jumpBufferCounter = jumpBufferTime;   // remember this press
    else
        jumpBufferCounter -= Time.deltaTime;  // memory fades over time

    bool wantsToJump = jumpBufferCounter > 0f;

    if (isGrounded && wantsToJump)
    {
        verticalVelocity = jumpVelocity;
        jumpBufferCounter = 0f;               // consumed, don't fire twice
    }
}

The pattern is symmetric with coyote time on purpose: coyote time asks "was I grounded recently enough?", jump buffering asks "did I press jump recently enough?" Both are just a countdown timer that resets on the triggering event and is checked against zero. In a real controller you combine both checks into a single jump condition:

bool canJump = (isGrounded || coyoteTimeCounter > 0f);
bool wantsToJump = jumpBufferCounter > 0f;

if (canJump && wantsToJump)
{
    verticalVelocity = jumpVelocity;
    coyoteTimeCounter = 0f;
    jumpBufferCounter = 0f;
}
Tip Coyote time and jump buffering cost almost nothing to implement — two floats and two if statements — and are two of the highest ratio of "game feel improvement" to "lines of code" in this entire chapter. Nearly every well-reviewed platformer uses both, even if the values never appear in any marketing material.

10. Slopes and steps

Flat ground is the easy case. Real levels have ramps and small ledges, and a character controller needs rules for both, or the character will slide down gentle slopes it should walk up fine, get stuck launching off the top of ramps, or stop dead at a curb-height bump that should not even slow it down.

Slopes: the ground's normal vector tells you the angle

Every surface has a normal — a vector pointing straight out from it, at 90 degrees to the surface. Flat ground has a normal pointing straight up (Vector3.up). A slope's normal is tilted; the angle between the normal and straight-up is the slope's steepness.

normal vector (n) ^ \ \ ground surface \ / theta -> \ / ______angle__\/________________ theta = angle between n and Vector3.up theta <= slopeLimit -> walkable: character sticks to the surface theta > slopeLimit -> too steep: treated like a wall, character can't climb it

CharacterController has this built in: set the slopeLimit field (in degrees, 45 by default) in the Inspector or in code, and the component automatically lets the character walk up anything shallower and blocks anything steeper, sliding the character back down slopes that are too steep. If you are writing a fully custom mover (no CharacterController, pure raycasts and manual position changes), you do the same check yourself:

public float maxSlopeAngle = 45f;

bool TryGetGroundNormal(out Vector3 normal)
{
    if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 1.2f, groundMask))
    {
        normal = hit.normal;
        float angle = Vector3.Angle(normal, Vector3.up);
        return angle <= maxSlopeAngle;   // true only if it's walkable
    }
    normal = Vector3.up;
    return false;
}

Vector3 AdjustMoveForSlope(Vector3 moveDir, Vector3 groundNormal)
{
    // Re-aim the move direction to lie flat along the slope's surface,
    // instead of driving into it (or lifting off it) on an angle.
    return Vector3.ProjectOnPlane(moveDir, groundNormal).normalized;
}

Vector3.ProjectOnPlane(vector, planeNormal) takes a direction and flattens it onto a surface described by that surface's normal — exactly what "walk along this ramp instead of into it" means mathematically. Without this step, a naive mover would try to walk horizontally on a ramp, which either drives it slightly into the ramp (the collider then shoves it back out, causing jitter) or leaves a small gap.

Steps: small height changes you should just walk over

A "step" is a small vertical bump — a curb, a stair, a tree root — short enough that a real person would not even notice stepping over it, but tall enough that, geometrically, it counts as a wall to a capsule collider sliding along the ground. CharacterController again has this built in: the stepOffset field (in units, commonly 0.2–0.4) tells it the maximum height it should automatically climb without any jump input at all.

void Awake()
{
    controller = GetComponent<CharacterController>();
    controller.slopeLimit = 45f;
    controller.stepOffset = 0.3f;   // auto-climbs bumps up to 0.3 units tall
}

If you are writing a fully custom mover without CharacterController, the manual version casts two rays forward: a low one at "shin height" and a higher one at "step height." If the low ray hits something but the high ray does not, whatever is in front is short enough to just step up onto, so you nudge the character upward before moving it forward.

Tip This is a strong reason many teams reach for CharacterController over a fully custom Rigidbody mover: slope limits and step offsets that would otherwise be a page of raycasting code are two Inspector fields.

Moving platforms: standing on things that move

Put a CharacterController on a platform that slides sideways and watch the platform glide out from under it — the character stays put in world space while the platform leaves. The reason is simple: controller.Move moves the character relative to the world, and nothing tells it that "the ground" is itself moving. The character has to be carried along deliberately. The robust way is to add the platform's own per-frame movement to the character's move every frame it stands on it.

public float castDistance = 0.4f;   // how far below the feet to look for a platform
Transform activePlatform;
Vector3 lastPlatformPos;
Vector3 platformVelocity;           // world units/second the platform is moving

void TrackPlatform()
{
    // Cast a small sphere down to find what we stand on (and its collider).
    if (Physics.SphereCast(transform.position, groundCheckRadius, Vector3.down,
                           out RaycastHit hit, castDistance, groundMask)
        && hit.transform.CompareTag("MovingPlatform"))
    {
        if (hit.transform == activePlatform)
            platformVelocity = (hit.transform.position - lastPlatformPos) / Time.deltaTime;
        else
            platformVelocity = Vector3.zero;   // just stepped on: no delta to measure yet

        activePlatform  = hit.transform;
        lastPlatformPos = hit.transform.position;
    }
    else
    {
        activePlatform  = null;
        platformVelocity = Vector3.zero;
    }
}

// fold it into the normal move so the character rides along:
Vector3 motion = horizontalVelocity + platformVelocity;
motion.y = verticalVelocity;
controller.Move(motion * Time.deltaTime);

Because platformVelocity is measured from the platform's real movement, the same code handles a sideways conveyor, a rising elevator, and a circular platform with no special cases. It also sets up velocity inheritance on jump cleanly. While grounded the carry only lasts as long as you keep touching the platform, so the frame you press jump you fold that speed into your own velocity:

horizontalVelocity += new Vector3(platformVelocity.x, 0f, platformVelocity.z);

Now jumping straight up off a platform sliding quickly to the right throws you to the right, exactly like jumping off a moving train. Without that one line the inherited speed vanishes the instant you leave the platform and the jump feels oddly dead.

Common mistake Carrying the player by parenting (transform.SetParent(platform)) to a platform that has a non-uniform or animated scale. Parenting inherits the parent's scale, so the character visibly stretches or shrinks while riding it. Parenting works fine for uniformly-scaled platforms, but the add-the-delta approach above avoids the whole class of scale and rotation surprises.

11. Putting it together, and why game feel lives here

Here is a full controller combining every piece from this chapter: acceleration/friction movement (section 4), a dedicated ground check (section 5), jump height from an apex (section 6), variable jump height (section 7), coyote time (section 8), and jump buffering (section 9). Slopes and steps (section 10) are handled by the two CharacterController fields set in Awake.

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Horizontal movement")]
    public float maxSpeed = 7f;
    public float acceleration = 60f;
    public float friction = 45f;

    [Header("Jump shape")]
    public float jumpHeight = 2f;
    public float timeToApex = 0.4f;
    public float fallMultiplier = 2.5f;
    public float lowJumpMultiplier = 2f;

    [Header("Game feel windows")]
    public float coyoteTime = 0.1f;
    public float jumpBufferTime = 0.15f;

    [Header("Ground check")]
    public Transform groundCheck;
    public float groundCheckRadius = 0.2f;
    public LayerMask groundMask;

    CharacterController controller;
    Vector3 horizontalVelocity;
    float verticalVelocity;
    float gravity;
    float jumpVelocity;
    float coyoteTimeCounter;
    float jumpBufferCounter;
    bool isGrounded;

    void Awake()
    {
        controller = GetComponent<CharacterController>();
        controller.slopeLimit = 45f;
        controller.stepOffset = 0.3f;

        gravity      = -2f * jumpHeight / (timeToApex * timeToApex);
        jumpVelocity =  2f * jumpHeight / timeToApex;
    }

    void Update()
    {
        isGrounded = Physics.CheckSphere(groundCheck.position, groundCheckRadius, groundMask);

        UpdateHorizontalVelocity();
        UpdateJumpTimers();
        ApplyGravityAndJump();

        Vector3 motion = horizontalVelocity;
        motion.y = verticalVelocity;
        controller.Move(motion * Time.deltaTime);
    }

    void UpdateHorizontalVelocity()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        Vector3 wishDir = new Vector3(x, 0f, z).normalized;

        Vector3 targetVelocity = wishDir * maxSpeed;
        float rate = (wishDir.sqrMagnitude > 0.0001f) ? acceleration : friction;
        horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetVelocity, rate * Time.deltaTime);
    }

    void UpdateJumpTimers()
    {
        coyoteTimeCounter = isGrounded ? coyoteTime : coyoteTimeCounter - Time.deltaTime;

        if (Input.GetButtonDown("Jump"))
            jumpBufferCounter = jumpBufferTime;
        else
            jumpBufferCounter -= Time.deltaTime;
    }

    void ApplyGravityAndJump()
    {
        bool jumpHeld = Input.GetButton("Jump");
        bool canJump = isGrounded || coyoteTimeCounter > 0f;
        bool wantsToJump = jumpBufferCounter > 0f;

        if (isGrounded && verticalVelocity < 0f)
            verticalVelocity = -2f;   // stay pressed to the ground / slopes

        if (canJump && wantsToJump)
        {
            verticalVelocity = jumpVelocity;
            coyoteTimeCounter = 0f;
            jumpBufferCounter = 0f;
        }
        else if (verticalVelocity < 0f)
        {
            verticalVelocity += gravity * (fallMultiplier - 1f) * Time.deltaTime;
        }
        else if (verticalVelocity > 0f && !jumpHeld)
        {
            verticalVelocity += gravity * (lowJumpMultiplier - 1f) * Time.deltaTime;
        }

        verticalVelocity += gravity * Time.deltaTime;
    }
}

This is deliberately the core loop, not the ceiling. Each optional refinement from this chapter bolts straight onto it without restructuring: ground-vs-air control (section 4) just swaps two constants inside UpdateHorizontalVelocity; apex hang (section 7) scales the gravity term when Mathf.Abs(verticalVelocity) is small; carrying the player on a moving platform (section 10) adds the platform's per-frame velocity to the final Move. That the same skeleton absorbs all of them without a rewrite is exactly why each idea lives in its own small method.

Nothing in this script is individually complicated — it is nine small, named ideas stacked on top of each other, each one solving exactly one problem. That stacking is the real lesson of this chapter: "movement" in a shipped game is never one clever trick, it is a pile of small, deliberate decisions, and every single one of them is a number or a rule you can feel with your own hands by changing it and playing.

This is also why so much of a game's "feel" lives specifically in this system, more than almost anywhere else in the codebase. The player's hands are on the input device every single second of play, and the character controller is the only piece of code that touches every one of those seconds. A bug in your inventory system might annoy a player for one menu visit. A stiff jump arc, or 50ms of missing coyote time, annoys them every single jump, for the entire game. Small changes here are felt disproportionately:

Tip When a controller feels "off" and you cannot say exactly why, change one number at a time and play the same test jump or run repeatedly. Movement tuning is not something you get right by reading formulas once — it is something you feel your way into, the same way a sound designer tunes a mix by ear.

12. The fixed timestep, FixedUpdate, and interpolation

Section 2 said physics runs on its own clock and that is why physics code belongs in FixedUpdate. It is worth understanding that clock properly, because a surprising number of movement bugs are really timing bugs in disguise. Unity has two update loops running at once: Update fires once per rendered frame (30, 60, 144 times a second — whatever the machine can manage, and it varies frame to frame), while FixedUpdate fires on a fixed timestep, by default every 0.02 seconds (50 times a second), readable as Time.fixedDeltaTime and set in Project Settings > Time.

Those two clocks almost never line up. Unity keeps a running bank of elapsed time and spends it in fixed-size chunks:

fixedDeltaTime = 0.02s (50 Hz physics) Running at 144 fps -> each frame is ~0.0069s of real time: frame: 1 2 3 4 5 6 7 ... time banked: .007 .014 .021 .001 .008 .015 .022 ... physics runs: 0 0 1 0 0 0 1 ... (~50/sec) Running at 30 fps -> each frame is ~0.0333s of real time: frame: 1 2 3 ... time banked: .033 .026 .019 ... (0.02 spent, remainder carried over) physics runs: 1 1 1 ... occasionally 2 (still ~50/sec)

So in a single rendered frame, FixedUpdate can run zero times (when rendering outpaces physics), once, or several times (after a hitch, to catch up). Two rules fall straight out of this:

[RequireComponent(typeof(Rigidbody))]
public class TimestepAwareMover : MonoBehaviour
{
    public float jumpVelocity = 10f;
    Rigidbody rb;
    bool jumpQueued;

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

    void Update()   // variable frame clock: read input here
    {
        if (Input.GetButtonDown("Jump"))
            jumpQueued = true;
    }

    void FixedUpdate()   // fixed 50 Hz clock: change physics here
    {
        if (jumpQueued)
        {
            Vector3 v = rb.velocity;
            v.y = jumpVelocity;
            rb.velocity = v;
            jumpQueued = false;   // consume exactly one press
        }
    }
}

But now a new problem appears at high refresh rates: if physics only moves the body 50 times a second while you render 144 frames a second, the body visibly jumps in discrete steps — smooth logic, stuttery picture. The fix is interpolation: set rb.interpolation = RigidbodyInterpolation.Interpolate and Unity smoothly slides the rendered Transform between the two most recent physics positions, hiding the 50 Hz steps entirely. (Extrapolate instead guesses ahead using current velocity — smoother in free flight, but it overshoots and visibly snaps back when the body hits something, so Interpolate is the safer default.) Turn it on only for objects you actually watch closely, like the player, since it has a small per-body cost.

Common mistake "Fixing" physics stutter by dragging Fixed Timestep down to something tiny like 0.005s. That just runs the whole simulation 200 times a second and burns CPU for no visual gain — the stutter was a rendering smoothness problem, and interpolation solves it for almost nothing. Leave Fixed Timestep at 0.02 unless you have a specific simulation-accuracy reason to change it.
Tip A CharacterController sidesteps this entire issue: you call controller.Move from Update, so it moves the Transform every rendered frame already and never shows the 50 Hz stutter — one more reason it feels smooth out of the box and needs no interpolation setting. The trade is that you own gravity and timing yourself, multiplying by the variable Time.deltaTime, as every CharacterController example in this chapter does.

13. Common bugs and how to fix them

Every one of these has bitten every character-controller programmer at least once. Learning to recognize the symptom and jump straight to the mechanism is most of what makes debugging movement fast instead of miserable.

The character jitters or vibrates

Usually one of three things. (1) A Rigidbody moved in Update or without interpolation — move it in FixedUpdate and set Interpolate (section 12). (2) Two systems writing position in the same frame and fighting — for example your move code plus a parented moving platform both pushing the Transform; make exactly one piece of code own position. (3) On a slope, driving horizontally into the surface so the collider shoves you back out every frame — project your movement onto the slope (section 10) and keep the small grounded down-force (section 3) instead of letting vertical velocity sit at zero.

A phantom double jump (or infinite jump)

The frame you press jump, vertical velocity shoots up to +10, but the character has not physically left the ground yet, so the ground-check sphere still overlaps the floor for a frame or two. If your jump condition sees isGrounded == true again while a buffered press is still alive, it fires a second jump. Two fixes, used together: consume the timers the instant you jump (coyoteTimeCounter = 0; jumpBufferCounter = 0;, as in section 9), and do not count as grounded while moving upward — gate it on verticalVelocity <= 0f, or add a tiny post-jump lockout (ignore the ground check for ~0.05s after takeoff) so the first airborne frames cannot re-trigger.

Sticking to walls in mid-air

Two separate causes wear the same symptom. On a Rigidbody, the wall's physics material friction is grabbing you — assign a PhysicsMaterial with zero friction and Minimum friction-combine to walls so you slide down them instead of freezing. On a custom mover, you are pushing your full velocity straight into the wall and it has nowhere to go; remove the into-wall part by projecting onto the wall's face: velocity = Vector3.ProjectOnPlane(velocity, wallNormal), which lets you slide along the wall instead of pinning to it. A CharacterController does this collide-and-slide for you, which is why the bug is mostly a Rigidbody-mover problem.

Sinking into the floor, or buzzing on slopes

The tell is a character that slowly settles into the ground or vibrates while standing on a ramp. The cause is gravity accumulating: if you keep adding gravity every frame while grounded, verticalVelocity grows to a large negative number and each Move tries to drive the capsule deep into the floor; the controller's penetration recovery shoves it back, and the fight shows up as a buzz. Reset vertical velocity to a small negative value (-2f) the moment you are grounded (section 3) rather than letting it grow, project movement along slopes so the horizontal part never digs in (section 10), and make sure the ground mask excludes the player's own collider (section 5).

Falling through thin floors at speed (tunneling)

A fast body can move farther in one physics step than its own thickness, so the discrete collision test never finds an overlap and the body passes clean through the floor. The first fix is not smaller steps — it is continuous collision detection: set the Rigidbody's collisionDetectionMode to ContinuousDynamic (or Continuous) so the solver sweeps the body's path instead of only testing its endpoints. Making thin platforms thicker than the fastest per-step travel is the belt-and-braces version. The same reasoning explains why a CharacterController moving very fast can skip a paper-thin trigger.

Movement feels different at different frame rates

If the jump is higher on a 30 fps machine than a 144 fps one, something time-dependent is not being scaled correctly — commonly a jump applied from FixedUpdate while input was also read in FixedUpdate (dropped or doubled presses, section 12), or a per-frame velocity change that forgot its Time.deltaTime. Keep every acceleration and gravity value in units-per-second and multiply by the matching delta (Time.deltaTime in Update, Time.fixedDeltaTime in FixedUpdate), buffer input in Update, and prefer MoveTowards over frame-count-dependent Lerp (section 4).

14. Glossary

15. Exercises

Exercise 1 A character has acceleration = 40 units/s^2, maxSpeed = 6 units/s, and the game runs at a fixed 50 FPS, so Time.deltaTime = 0.02s exactly. The player holds the move key from a complete standstill. Using Vector3.MoveTowards as in section 4, write the horizontal speed after each of the first 6 frames. Then say on which frame the character first reaches maxSpeed.
Show answer

Each frame the speed can change by at most acceleration * deltaTime = 40 * 0.02 = 0.8 units/s.

frame:  1    2    3    4    5    6
speed:  0.8  1.6  2.4  3.2  4.0  4.8

Continuing the same pattern: frame 7 gives 5.6, and frame 8 would give 6.4, but MoveTowards never overshoots the target, so it clamps to exactly 6.0. Max speed is first reached on frame 8, about 0.16 seconds after the key was pressed.

Exercise 2 You want a jump that peaks at exactly 3.24 meters, 0.6 seconds after leaving the ground. Using the formulas from section 6 (v0 = 2h/t and g = -2h/t^2), compute the jumpVelocity and gravity values you would put in the Inspector. Then, separately: if you apply a fallMultiplier greater than 1 to this jump, does the character spend more time rising or more time falling, and why is that usually what you want?
Show answer

With h = 3.24 and t = 0.6:

jumpVelocity = 2 * 3.24 / 0.6 = 10.8
gravity      = -2 * 3.24 / (0.6 * 0.6) = -6.48 / 0.36 = -18.0

Check: time to apex = 10.8 / 18.0 = 0.6s (matches). Height gained = 10.8*0.6 - 0.5*18*0.6^2 = 6.48 - 3.24 = 3.24 (matches).

A fallMultiplier > 1 only adds extra gravity while verticalVelocity < 0 (the falling half of the arc) — it never touches the rising half. So the character rises at the normal rate but falls faster than it rose, meaning it spends more time rising than falling. This is usually what you want: a snappy, deliberate launch off the ground followed by a quick, weighty landing, instead of a symmetric, floaty parabola where the character seems to hang in the air on the way down.

Exercise 3 A character walks off a ledge (stops being grounded) at t = 0.00s. coyoteTime = 0.10s and jumpBufferTime = 0.15s. If nothing interferes with gravity, the character would naturally land on lower ground at t = 0.50s. Consider three separate playthroughs of this exact same drop. In each one the player presses the jump button exactly once, at a different time. For each press time below, say whether a jump fires, and if so, at what time and because of which mechanism (coyote time or jump buffering).
  • (a) jump pressed at t = 0.05s
  • (b) jump pressed at t = 0.25s
  • (c) jump pressed at t = 0.40s
Show answer

(a) t = 0.05s: jumps immediately, at t = 0.05s, via coyote time. The coyote window lasts until 0.00 + 0.10 = 0.10s. The press at 0.05s falls inside that window, so canJump is true even though the character is airborne, and the jump fires the instant the button is pressed.

(b) t = 0.25s: no jump. It is far too late for coyote time (window closed at 0.10s). It starts a jump-buffer countdown that lasts until 0.25 + 0.15 = 0.40s, but the character does not land until t = 0.50s — by then the buffer has already expired, so wantsToJump is false at the moment landing happens. The press is silently dropped.

(c) t = 0.40s: jumps at t = 0.50s (the moment of landing), via jump buffering. The buffer lasts until 0.40 + 0.15 = 0.55s. Landing happens at 0.50s, which is inside that window, so the moment isGrounded becomes true, wantsToJump is still true too, and the jump fires right on landing — reading to the player as a perfectly timed jump, even though the button was technically pressed while still in the air.

Exercise 4 A game renders at a steady 120 fps and Fixed Timestep is left at the default 0.02s. (a) Over one real second, how many times does Update run and how many times does FixedUpdate run? (b) On average, how many FixedUpdate calls happen per rendered frame, and why is that not a whole number? (c) Why is reading Input.GetButtonDown("Jump") only inside FixedUpdate a bug here?
Show answer

(a) Update runs once per rendered frame, so about 120 times. FixedUpdate runs on the fixed clock, 1 / 0.02 = 50 times.

(b) 50 / 120 ≈ 0.42 FixedUpdate calls per rendered frame — less than one. It is not a whole number because the two clocks are independent: Unity banks each frame's real time and spends it in 0.02s chunks, so most frames run zero physics steps and roughly every third frame runs one. Over a whole second it still averages out to 50.

(c) GetButtonDown is true for exactly one rendered frame. On the many frames where FixedUpdate runs zero times, that press is never seen inside FixedUpdate and the jump is silently dropped; on a frame where FixedUpdate runs twice it could be read twice. Read it in Update, set a flag, and consume the flag in FixedUpdate.

Exercise 5 A player stands still (their own horizontalVelocity is zero) on a platform moving in world space at (4, 0, 0) units/s, using the moving-platform code from section 10. (a) What motion does controller.Move receive this frame, before multiplying by Time.deltaTime? (b) The player now jumps. With the velocity-inheritance line included, roughly where does the jump carry them horizontally, and what real-world behavior is this reproducing?
Show answer

(a) motion = horizontalVelocity + platformVelocity = (0,0,0) + (4,0,0) = (4,0,0), then motion.y is set to verticalVelocity. So Move receives (4, verticalVelocity, 0) * Time.deltaTime — the player is carried sideways with the platform even though their own velocity is zero.

(b) The jump adds the platform's velocity into the player's own: horizontalVelocity += (4, 0, 0). Now airborne with near-zero airFriction, they keep about 4 units/s of rightward speed for the whole jump and land well to the right of where they left. This reproduces real momentum inheritance — jumping off a moving train or platform throws you in its direction of travel.

← Back to all chapters