The previous chapter built a player movement script: read input, turn it into a velocity, hand that velocity to CharacterController.Move(), and let fields like isGrounded, slopeLimit, and stepOffset quietly handle the rest. That was enough to make a controller feel good. It is not enough to understand what is actually happening the moment that capsule touches a wall, a ramp, a staircase, or a platform that is itself moving. This chapter opens that box.
Everything here is the physics layer underneath any character controller, whether it is Unity's built-in CharacterController or a fully custom one you write yourself. We will build it up the same way as before: why a plain Rigidbody is usually the wrong tool for a player character, why almost every mover uses a capsule shape, the single algorithm — collide-and-slide — that makes a capsule move smoothly through a world full of walls and floors, then ground detection, slopes, steps, gravity, moving platforms, and finally a clear-eyed comparison of kinematic vs dynamic movers and Unity's built-in controller vs a custom one.
Chapter 6.2 mentioned that a Rigidbody-driven character can feel "physics-y" — momentum, sliding, bouncing. For crates, ragdolls, and vehicles that is exactly what you want. For the character the player is directly steering, it is usually a problem, and it is worth understanding precisely why, not just accepting it as a rule of thumb.
A physics engine like PhysX (the one built into Unity) is solving a much harder problem than "move this one capsule." Every physics step, it looks at every rigid body in the scene, finds every pair that overlaps, and computes a set of impulses (instant changes in velocity) that push them apart just enough to stop overlapping, while also trying to keep the whole scene numerically stable. That is a solver — an algorithm that iterates toward an approximate answer rather than computing an exact one in one pass. It is built for realism and stability across an entire scene, not for making one specific object feel perfectly crisp and predictable under direct player control.
Three concrete symptoms fall directly out of that design:
rb.freezeRotation = true to stop the capsule tipping over, contact with an edge at a shallow angle can still impart a small bounce, because the solver is resolving a generic collision, not specifically "this is a player, never bounce it."The fix used by almost every shipped character controller, Unity's built-in one included, is to stop asking the physics engine to simulate the character at all. Instead: take full manual control of the character's position every frame, use a specialized shape (the capsule) and a specialized algorithm (collide-and-slide) written specifically for "move this one object as far as it is allowed to go, and nothing more, nothing less" — no solver, no impulses, no bounce, unless you explicitly code it in. That is what the rest of this chapter builds.
A capsule is a cylinder with a hemisphere (half a sphere) capping each end. You can describe one completely with just three numbers: two points marking the ends of the straight line running through its middle (call them point0 and point1, together called the capsule's axis), and a radius — the same radius for the cylindrical body and both rounded caps.
Why this shape specifically, instead of a box (which is what a person's silhouette from above more closely resembles) or a plain cylinder?
Physics.CapsuleCast, Physics.CheckCapsule) are fast enough to run every single frame.That "closest point on the axis, then compare to radius" test is worth seeing in real numbers, because it is the exact math running inside every capsule collision check the engine does for you. Say the capsule's axis runs straight up from point0 = (0, 1, 0) to point1 = (0, 2, 0), with radius = 0.5, and we want to know whether the point (0.3, 1.5, 0.4) is inside it:
using System;
class CapsuleCheck
{
static void Main()
{
// capsule axis
float ax = 0f, ay = 1f, az = 0f; // point0
float bx = 0f, by = 2f, bz = 0f; // point1
float radius = 0.5f;
// test point
float px = 0.3f, py = 1.5f, pz = 0.4f;
// closest point on the segment [point0, point1] to the test point.
// since this axis only varies in y, t is just how far along in y.
float t = (py - ay) / (by - ay);
t = Math.Max(0f, Math.Min(1f, t)); // clamp to the segment, not the infinite line
float cx = ax, cy = ay + t * (by - ay), cz = az;
float dx = px - cx, dy = py - cy, dz = pz - cz;
float dist = (float)Math.Sqrt(dx * dx + dy * dy + dz * dz);
Console.WriteLine($"t = {t}");
Console.WriteLine($"closest point on axis = ({cx}, {cy}, {cz})");
Console.WriteLine($"distance to axis = {dist}");
Console.WriteLine($"inside capsule (radius {radius})? {dist <= radius}");
}
}
Output:
t = 0.5
closest point on axis = (0, 1.5, 0)
distance to axis = 0.5
inside capsule (radius 0.5)? True
Check it by hand: the test point is 0.3 units off in x and 0.4 units off in z from the axis, and 0.3-4-0.5 is a 3-4-5 right triangle scaled by 0.1, so the straight-line distance is exactly 0.5 — precisely on the capsule's surface. Notice how little work that was: no per-face checks, no corners, just one line-segment projection and one square root. That is the entire trick that makes capsules the default shape for character physics, in Unity and in essentially every other engine.
t to the [0, 1] range. Without the clamp, the formula treats the axis as an infinitely long line instead of a finite segment, so a point far below the capsule's feet or far above its head could incorrectly compute as "close" to the axis and register as a collision.Suppose the character wants to move 1 unit forward this frame, and there happens to be a wall 0.6 units away, angled diagonally across the character's path. Two naive approaches both fail:
Collide-and-slide is the algorithm that does the right thing in both cases. In plain words: try to make the full move; if something blocks part of it, do as much of the move as is actually clear, then take whatever distance is left over and redirect it so it runs along the surface you hit instead of into it, then repeat with that redirected leftover amount, in case it immediately hits something else too.
The redirect step — turning the leftover motion into motion "along the surface" — is exactly the projection you already used for slopes in chapter 6.2: take the leftover move vector, and flatten it onto the plane defined by the surface's normal (the vector pointing straight out from it) using Vector3.ProjectOnPlane. That single operation removes the "into the wall" part of the motion and keeps the "along the wall" part.
Written as an algorithm, ignoring language-specific detail:
function CollideAndSlide(position, moveVector, depth):
if depth >= MAX_BOUNCES:
return position // safety net, see below
hit = CapsuleCast(position, moveVector.direction, moveVector.length)
if hit is nothing:
return position + moveVector // path is clear, do the whole move
// move up to just short of the hit, leaving a tiny gap (the "skin width")
allowedDistance = hit.distance - SKIN_WIDTH
newPosition = position + moveVector.direction * allowedDistance
// whatever distance we did not get to use yet
leftoverDistance = moveVector.length - allowedDistance
leftoverVector = moveVector.direction * leftoverDistance
// redirect it to run along the surface instead of into it
slideVector = ProjectOnPlane(leftoverVector, hit.normal)
return CollideAndSlide(newPosition, slideVector, depth + 1)
Two details matter as much as the main idea:
CharacterController.Move() — you just do not see the loop, because it happens inside the engine's native code. Understanding it is what lets you predict why a character controller behaves the way it does in a corner or on a glancing wall hit, instead of treating it as a black box.Here is the algorithm from section 3 as an actual Unity component, using Physics.CapsuleCast to test the capsule's path before committing to it. This is the foundation the rest of the chapter builds on top of.
using UnityEngine;
[RequireComponent(typeof(CapsuleCollider))]
public class CapsuleMover : MonoBehaviour
{
public float skinWidth = 0.02f;
public int maxSlideIterations = 4;
public LayerMask collisionMask;
CapsuleCollider capsule;
void Awake()
{
capsule = GetComponent<CapsuleCollider>();
}
// Moves the transform by moveVector this frame, sliding along
// anything it touches along the way. Call this once per frame.
public void Move(Vector3 moveVector)
{
Vector3 remaining = moveVector;
for (int i = 0; i < maxSlideIterations && remaining.sqrMagnitude > 0.0001f; i++)
{
GetCapsulePoints(out Vector3 point0, out Vector3 point1);
float distance = remaining.magnitude;
Vector3 direction = remaining.normalized;
bool didHit = Physics.CapsuleCast(point0, point1, capsule.radius,
direction, out RaycastHit hit, distance, collisionMask);
if (didHit)
{
float allowedDistance = Mathf.Max(0f, hit.distance - skinWidth);
transform.position += direction * allowedDistance;
float usedFraction = (distance > 0f) ? allowedDistance / distance : 0f;
Vector3 leftover = remaining * (1f - usedFraction);
remaining = Vector3.ProjectOnPlane(leftover, hit.normal);
}
else
{
transform.position += remaining;
remaining = Vector3.zero;
}
}
}
void GetCapsulePoints(out Vector3 point0, out Vector3 point1)
{
float halfSegment = Mathf.Max(0f, capsule.height * 0.5f - capsule.radius);
Vector3 center = transform.position + capsule.center;
point0 = center + Vector3.up * halfSegment;
point1 = center - Vector3.up * halfSegment;
}
}
The one piece of math worth checking by hand is the redirect step: Vector3.ProjectOnPlane(leftover, hit.normal). Say, after the first blocked attempt, the leftover vector is (0.42, 0, 0) — 0.42 units still to travel, straight along x — and the wall it hit is angled so its normal is approximately (-0.7071, 0, 0.7071) (a 45-degree wall, since 0.7071 ≈ 1/√2):
using System;
class SlideMath
{
static void Main()
{
float vx = 0.42f, vy = 0f, vz = 0f; // leftover move vector
float nx = -0.7071f, ny = 0f, nz = 0.7071f; // wall's surface normal
float dot = vx * nx + vy * ny + vz * nz;
float px = vx - dot * nx;
float py = vy - dot * ny;
float pz = vz - dot * nz;
Console.WriteLine($"dot(v,n) = {dot:F4}");
Console.WriteLine($"slide vector = ({px:F4}, {py:F4}, {pz:F4})");
Console.WriteLine($"slide length = {Math.Sqrt(px * px + py * py + pz * pz):F4}");
}
}
Output:
dot(v,n) = -0.2970
slide vector = (0.2100, 0.0000, 0.2100)
slide length = 0.2970
The original 0.42 units of "straight into the wall" motion turns into 0.297 units of motion split evenly between x and z — sliding diagonally along the wall's face instead of pushing into it. That is collide-and-slide's entire job, expressed as one dot product and one subtraction, repeated up to maxSlideIterations times per frame.
Chapter 6.2 covered basic ground checks with a raycast or CheckSphere. A capsule mover built on collide-and-slide benefits from one more piece: a spherecast — a sphere swept along a direction, asking "what is the first thing this moving ball would touch?" — which behaves like the overlap sphere from 6.2 but also tells you the distance and the surface normal of whatever it hit, which slopes and steps both need.
public float groundCheckDistance = 0.3f;
public float groundSnapDistance = 0.3f;
public LayerMask groundMask;
bool isGrounded;
Vector3 groundNormal = Vector3.up;
bool CheckGround(out RaycastHit hit)
{
GetCapsulePoints(out Vector3 point0, out Vector3 point1);
// sweep the bottom sphere of the capsule straight down a short distance
bool hitSomething = Physics.SphereCast(point1, capsule.radius, Vector3.down,
out hit, groundCheckDistance, groundMask);
return hitSomething;
}
Now for a subtler problem. Picture the character walking down a staircase at a steady horizontal speed. Each frame, collide-and-slide moves it forward; gravity is also pulling it down, but only a little bit each frame, since it just reset to a small value the moment it was last grounded (chapter 6.2, section 3). If a stair step drops away faster than gravity can pull the character down onto it within a single frame, the character sails a few centimeters past the edge of each step before gravity catches up — over an entire staircase, this reads as the character visibly hopping down each stair instead of walking down them smoothly.
The fix: after the normal collide-and-slide move for the frame, if the character was grounded last frame, do one extra short spherecast straight down — further than the tiny per-frame gravity drop would reach, but still short enough that it only catches a nearby stair or slope, not a genuine ledge the character should fall off. If it hits, snap the position straight down onto that surface immediately, instead of waiting for several frames of accumulating gravity to close the gap.
void SnapToGround()
{
if (!wasGroundedLastFrame) return; // only snap if we were already on ground
GetCapsulePoints(out Vector3 point0, out Vector3 point1);
bool hit = Physics.SphereCast(point1, capsule.radius, Vector3.down,
out RaycastHit groundHit, groundSnapDistance, groundMask);
if (hit)
{
transform.position += Vector3.down * groundHit.distance;
isGrounded = true;
groundNormal = groundHit.normal;
}
}
Every surface has a normal: a vector pointing straight out from it, at 90 degrees to the surface. Flat ground has a normal of exactly Vector3.up. A ramp's normal tilts away from straight up by the ramp's angle, and that angle is exactly what a slopeLimit value compares against, using Vector3.Angle(normal, Vector3.up).
A slope inside the limit is walkable: the character should move smoothly along its surface, not stick out at a horizontal angle and clip into it or hover above it. That reuses Vector3.ProjectOnPlane again — flatten the intended horizontal move onto the slope's plane before applying it, exactly as in chapter 6.2's AdjustMoveForSlope.
A slope past the limit is not walkable, and needs different handling: instead of letting the character climb it, treat it like collide-and-slide treats a wall — except the "slide direction" here is not the player's input direction, it is straight down, redirected along the slope's face. That produces exactly the sliding-off-a-steep-surface behavior players expect.
public float slopeLimit = 45f;
public float slideSpeed = 8f;
Vector3 ComputeSlideVelocity(Vector3 normal)
{
// "straight down," flattened onto the slope's own surface
return Vector3.ProjectOnPlane(Vector3.down, normal).normalized * slideSpeed;
}
bool IsWalkable(Vector3 normal)
{
return Vector3.Angle(normal, Vector3.up) <= slopeLimit;
}
Worked example: a slope whose surface normal is (0.8, 0.6, 0) (a unit vector — check: 0.8² + 0.6² = 0.64 + 0.36 = 1.0, correct). The angle from straight up is arccos(0.6) ≈ 53.13°, which is steeper than a 45° slopeLimit, so it counts as unwalkable. What direction does the character slide?
using System;
class SlopeSlide
{
static void Main()
{
float nx = 0.8f, ny = 0.6f, nz = 0f; // slope's surface normal
float dx = 0f, dy = -1f, dz = 0f; // straight down
float dot = dx * nx + dy * ny + dz * nz;
float sx = dx - dot * nx;
float sy = dy - dot * ny;
float sz = dz - dot * nz;
float len = (float)Math.Sqrt(sx * sx + sy * sy + sz * sz);
Console.WriteLine($"dot(down,n) = {dot}");
Console.WriteLine($"slide direction (unnormalized) = ({sx}, {sy}, {sz})");
Console.WriteLine($"length = {len}");
Console.WriteLine($"normalized slide direction = ({sx / len}, {sy / len}, {sz / len})");
}
}
Output:
dot(down,n) = -0.6
slide direction (unnormalized) = (0.48, -0.64, 0)
length = 0.8
normalized slide direction = (0.6, -0.8, 0)
The character slides mostly downward and slightly sideways, exactly the direction gravity would pull something resting flush against that slope's face — not straight down through the slope, and not sideways off of it. Multiply that normalized direction by slideSpeed and feed it into the same Move() from section 4, and the capsule slides down the steep surface using the very same collide-and-slide code that handles everything else.
A step — a curb, a stair, a tree root — is short enough that it should not slow the character down at all, but tall enough that, geometrically, collide-and-slide sees it as a wall: the capsule's bottom hemisphere hits the vertical face of the step and slides to a stop right in front of it, exactly like hitting any other wall.
stepOffset is the height, in units, below which the mover should just climb over a bump automatically instead of treating it as a wall. The trick behind it does not require any new physics — it reuses the exact same Move() from section 4, called three times in sequence:
public float stepOffset = 0.3f;
// Called when the normal horizontal move was blocked by something low
// enough that it might just be a step. Returns true if it climbed it.
bool TryStepUp(Vector3 horizontalMove)
{
Vector3 startPosition = transform.position;
// 1) lift up by stepOffset, stopping early if something blocks even that
Move(Vector3.up * stepOffset);
// 2) try the original horizontal move again from up here
Move(horizontalMove);
// 3) drop back down onto whatever is below, up to stepOffset + a margin
GetCapsulePoints(out Vector3 point0, out Vector3 point1);
bool grounded = Physics.SphereCast(point1, capsule.radius, Vector3.down,
out RaycastHit hit, stepOffset + 0.1f, collisionMask);
if (grounded)
{
transform.position += Vector3.down * hit.distance;
return true;
}
// nothing solid found below - this wasn't a valid step, undo everything
transform.position = startPosition;
return false;
}
The important design detail is the very last branch: if lifting up and moving forward does not land back on solid ground within roughly stepOffset, the attempt is rolled back completely. That is what keeps a tall wall a wall — a wall is, from this function's point of view, just "a step higher than stepOffset," and the drop-back-down check is what tells the two apart.
CharacterController.stepOffset field does for you internally, in native engine code rather than a script. Setting it is two Inspector fields; writing it yourself, as above, is roughly 15 lines — a strong reason many teams just use the built-in component for step handling even when they write a custom mover for everything else.Chapter 6.2 covered gravity as a value that accumulates into verticalVelocity every frame while airborne. One piece it did not cover: real free-falling objects do not accelerate forever — air resistance eventually balances gravity, and the object stops speeding up. That balancing speed is called terminal velocity. Character movers do not simulate air resistance, but they copy the visible result: clamp the falling speed to a maximum, instead of letting it grow without limit.
There are two very practical reasons to bother with this, beyond realism:
public float gravity = -36f; // units/s^2
public float terminalVelocity = -24f; // most negative allowed fall speed, units/s
float verticalVelocity;
void ApplyGravity()
{
verticalVelocity += gravity * Time.deltaTime;
verticalVelocity = Mathf.Max(verticalVelocity, terminalVelocity);
}
Worked trace at a fixed 30 FPS (Time.deltaTime = 1/30s exactly), starting from verticalVelocity = 0 at the top of a fall: each frame subtracts 36 * (1/30) = 1.2 units/s, until the clamp stops it at -24.
Frame 20 lands exactly on -24.0 because 24 / 1.2 = 20 evenly — a coincidence of these particular numbers, chosen to make the trace land on a clean value, but the shape of the curve (a straight ramp down, then a flat line) is exactly what happens with any gravity and terminal velocity pair.
terminalVelocity so extreme (or forgetting to set it at all) that it never actually gets hit during normal gameplay falls. The clamp only helps once falls are long enough to reach it — if the tallest fall in your level only reaches -18 units/s and terminalVelocity is -200, you still have the exact same tunneling risk you were trying to fix. Tune it against your actual level geometry: the fastest fall speed you want to allow before the character's per-frame movement distance risks skipping over your thinnest floor collider.Everything so far assumes the ground is not moving. An elevator platform breaks that assumption, and it breaks it in a specific way: the character's own Move() only ever reasons about the character's own intended motion (input plus gravity). If the platform underneath slides out from under the character between one frame and the next, nothing in sections 1–8 knows that happened — from the mover's point of view, the ground simply teleported a little, and the character is left behind, standing on empty air where the platform used to be, or sliding backward relative to it.
The fix: track how far the platform itself moved this frame (its delta), and if the character is currently grounded on that specific platform, add that same delta to the character's position before doing its own Move() for the frame.
using UnityEngine;
public class MovingPlatform : MonoBehaviour
{
Vector3 lastPosition;
public Vector3 DeltaPosition { get; private set; }
void Awake()
{
lastPosition = transform.position;
}
void FixedUpdate()
{
DeltaPosition = transform.position - lastPosition;
lastPosition = transform.position;
}
}
// inside the character mover, once per frame, BEFORE its own Move() call:
void ApplyPlatformMotion(RaycastHit groundHit)
{
MovingPlatform platform = groundHit.collider.GetComponent<MovingPlatform>();
if (platform != null && isGrounded)
{
transform.position += platform.DeltaPosition;
}
}
One more detail that trips people up the first time: when the character jumps off a moving platform, should it keep sailing sideways along with the platform's motion, or drop straight down as if the platform had never been moving? Real physical intuition, and most action games, want the former — the character should keep the platform's horizontal velocity at the exact instant it leaves, exactly the way jumping off a moving skateboard carries your sideways speed with you. This is called velocity inheritance: at the moment of leaving the platform (jumping, or just walking off its edge), add the platform's current velocity (its delta position divided by delta time) into the character's own horizontal velocity once, rather than continuing to snap position every frame.
CharacterController does not do any of this automatically — it only knows about static and kinematic colliders it directly touched during its last Move() call. Moving-platform support, in both the built-in component and a fully custom mover, is always something you add yourself, using exactly the delta-tracking pattern above.Section 1 explained why a raw Rigidbody usually feels wrong for a player character. It is worth laying the full tradeoff out clearly now that you have seen what the kinematic alternative actually costs in code, so the choice is a real engineering decision and not just "always avoid Rigidbody."
Most player-controlled characters in shipped 3D action games, platformers, and shooters use the kinematic approach, precisely because the things that make a controller feel deliberately tuned — consistent jump arcs, no unexpected bounce, ground snapping that never misfires — all depend on the exact determinism a solver does not give you. It is common, though, to see a hybrid: a kinematic mover for the player, with a small amount of explicit code (an OnControllerColliderHit-style callback, covered next) that applies a push force to any dynamic Rigidbody the player's capsule touches, so crates still get shoved around even though the player itself is not simulated by the solver.
Everything built across sections 3–9 — capsule casting, collide-and-slide, ground snapping, slope handling, step offset, gravity, moving platforms — is, in essence, a from-scratch reimplementation of what Unity's built-in CharacterController component already does for you, in fast native engine code, exposed through a much smaller surface: mostly just Move(), isGrounded, slopeLimit, and stepOffset.
So why would anyone write a custom one? Because the built-in component's small surface is also its limitation — several things are baked in and not tunable from script:
maxSlideIterations-equivalent, its skin width, and the exact order it resolves slopes vs steps vs walls are all fixed inside the engine. If your game needs unusual movement — wall-running, ledge grabs, a slide move that deliberately ignores the slope limit for two seconds — you are fighting a black box instead of adjusting a number.OnControllerColliderHit(ControllerColliderHit hit) callback, which fires once per surface it touched that Move() call) but does not apply any force to what it hit — you must call something like hit.rigidbody.AddForce(...) yourself, exactly like the hybrid approach mentioned in section 10.Physics.CapsuleCast calls and vector math shown in this chapter, is code you own completely and can make deterministic on purpose.A practical rule of thumb: start with the built-in CharacterController. It is correct, fast, and covers the large majority of games, including most of what HoYoverse-style third-person action games need. Reach for a fully custom mover, built the way sections 3–9 of this chapter describe, only once you hit a specific, concrete limitation of the built-in one — an ability the black box cannot express, or a determinism requirement it cannot guarantee — rather than writing one up front "just in case."
CharacterController's occasional weird behavior — a snag on a specific piece of level geometry, a slope that will not let go, a step that sometimes gets climbed and sometimes does not — instead of guessing at Inspector values at random.point0 to point1.point0 = (2, 0, 0) to point1 = (2, 3, 0), with radius = 0.6. Using the method from section 2 (clamp t to [0, 1], find the closest point on the axis, then compare distance to radius), determine whether the point (2.45, 1.5, 0.6) is inside the capsule. Show t, the closest point, and the distance.The axis only varies in y, so t = (py - 0) / (3 - 0) = 1.5 / 3 = 0.5, already inside [0, 1], no clamping needed.
Closest point on the axis: (2, 0 + 0.5 * 3, 0) = (2, 1.5, 0).
Offset from that point to the test point: dx = 2.45 - 2 = 0.45, dz = 0.6 - 0 = 0.6, dy = 0.
distance = sqrt(0.45^2 + 0.6^2) = sqrt(0.2025 + 0.36) = sqrt(0.5625) = 0.75
0.75 > radius (0.6), so the point is outside the capsule, by 0.75 - 0.6 = 0.15 units.
v = (0, 0, 0.6) (0.6 units still to travel, straight along z) and the wall it hit has surface normal n = (0.6, 0, -0.8) (a unit vector: check 0.6² + 0.8² = 1). Using slide = v - (v · n) * n as in section 4, compute the slide vector and its length.Dot product: v · n = 0*0.6 + 0*0 + 0.6*(-0.8) = -0.48.
slide.x = 0 - (-0.48)*0.6 = 0 + 0.288 = 0.288
slide.y = 0 - (-0.48)*0 = 0
slide.z = 0.6 - (-0.48)*(-0.8) = 0.6 - 0.384 = 0.216
length = sqrt(0.288^2 + 0.216^2) = sqrt(0.082944 + 0.046656) = sqrt(0.1296) = 0.36
The slide vector is (0.288, 0, 0.216), length 0.36. Notice the length shrank from the original 0.6 to 0.36 — projecting onto the wall's plane always keeps the same or less length than the original vector, since it throws away the component that was pointed straight into the wall.
verticalVelocity = 0. gravity = -45 units/s^2, terminalVelocity = -27 units/s, and the game runs at a fixed 20 FPS (Time.deltaTime = 0.05s exactly). Using the clamp pattern from section 8, what is verticalVelocity after 5 frames? After how many frames does it first reach terminalVelocity exactly, and what is its value there?Each frame subtracts 45 * 0.05 = 2.25 units/s, until the clamp at -27 stops it.
frame: 0 1 2 3 4 5
velocity: 0.0 -2.25 -4.5 -6.75 -9.0 -11.25
After 5 frames, verticalVelocity = -11.25 units/s.
It reaches the clamp when 2.25 * frames = 27, i.e. frames = 27 / 2.25 = 12. At frame 12, verticalVelocity first reaches exactly -27.0 (the terminal velocity), and stays there on every later frame since the clamp keeps re-applying.