# 5. Physics 2D - Worked Example: Kinematic Platformer with Slide
Part of [[Physics 2D]]. Previous: [[4. Physics 2D Effectors and Joints]]. Next: [[6. Physics 2D Pitfalls and LowLevelPhysics2D]].
**Goal:** a precise, metrics-driven 2D platformer character. Kinematic body (nothing but our code changes its velocity), moved with `Rigidbody2D.Slide`, tuned from a ScriptableObject that a designer reads as a **metrics sheet** - jump height in tiles, time to apex, run speed - exactly Rogers' Level 5 "gameplay metrics" ([[Level Up! (Review)]]).
> [!note] Verification status
> `Rigidbody2D.Slide(Vector2, float, SlideMovement)`, `SlideMovement` fields (`maxIterations`, `surfaceSlideAngle`, `gravitySlipAngle`, `surfaceUp`, `surfaceAnchor`, `gravity`, `useSimulationMove`, `SetLayerMask`) and `SlideResults` (`position`, `remainingVelocity`, `slideHit`, `surfaceHit`, `iterationsUsed`) checked against the 6000.5 reference on 2026-11-07. **Not compiled or play-tested in the Editor.** Treat the tuning numbers as starting points.
## 5.1 Why Slide?
`Rigidbody2D.Slide` (present in 6000.5; it predates Unity 6, but I could not confirm the exact version it was introduced in) does the "move and slide" loop that kinematic controllers traditionally hand-roll: cast, move to contact, project the remaining velocity along the surface, repeat up to `maxIterations`, optionally stick to the ground with `surfaceAnchor`. The docs state it is *mainly intended for Kinematic* bodies.
We supply our own gravity (in the velocity) rather than Slide's `gravity` field so that jump arcs come straight from the metrics math below.
## 5.2 The metrics math
Given **jump height** `h` and **time to apex** `t`:
- gravity `g = 2h / t^2`
- jump velocity `v0 = g * t`
- horizontal **jump distance** at run speed `s` with symmetric gravity is `s * 2t` (longer if fall gravity is lower, shorter if higher).
Designers think in *tiles*, so the asset stores tiles and seconds, and derives physics values. Change one number and every gap in the metrics gym (5.6) tells you if the level still works.
## 5.3 Metrics asset
```csharp
using UnityEngine;
[CreateAssetMenu(menuName = "Gameplay/Platformer Metrics 2D", fileName = "PlatformerMetrics2D")]
public class PlatformerMetrics2D : ScriptableObject
{
[Header("World")]
[Tooltip("World units per tile (usually 1 with a 1-unit grid).")]
public float tileSize = 1f;
[Header("Run")]
[Tooltip("Top run speed in tiles per second.")]
public float runSpeedTiles = 8f;
[Tooltip("Seconds from standstill to full speed on the ground.")]
public float groundAccelTime = 0.08f;
[Tooltip("Seconds from full speed to standstill on the ground.")]
public float groundDecelTime = 0.06f;
[Range(0f, 1f)] public float airControl = 0.65f;
[Header("Jump")]
[Tooltip("Apex height of a full jump, in tiles.")]
public float jumpHeightTiles = 3.2f;
[Tooltip("Seconds to reach the apex of a full jump.")]
public float timeToApex = 0.38f;
[Tooltip("Gravity multiplier while falling (>1 = snappier landings).")]
public float fallGravityMultiplier = 1.8f;
[Tooltip("Gravity multiplier after jump is released early (variable jump height).")]
public float jumpCutGravityMultiplier = 2.5f;
[Tooltip("Terminal fall speed in tiles per second.")]
public float maxFallTiles = 22f;
[Header("Forgiveness")]
public float coyoteTime = 0.1f;
public float jumpBufferTime = 0.12f;
// Derived values (world units)
public float RunSpeed => runSpeedTiles * tileSize;
public float Gravity => 2f * jumpHeightTiles * tileSize / (timeToApex * timeToApex);
public float JumpVelocity => Gravity * timeToApex;
public float MaxFallSpeed => maxFallTiles * tileSize;
/// Approximate full-speed jump distance in tiles back to take-off height.
public float JumpDistanceTiles
{
get
{
float tUp = timeToApex;
float tDown = Mathf.Sqrt(2f * jumpHeightTiles * tileSize / (Gravity * fallGravityMultiplier));
return runSpeedTiles * (tUp + tDown);
}
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled or play-tested.
Create it: **Assets > Create > Gameplay > Platformer Metrics 2D**. With the defaults the derived jump distance is about 8 x (0.38 + 0.28) = ~5.3 tiles - write that number on the whiteboard next to the level diagram. (More on metrics-as-data in [[ScriptableObject Architecture]] and [[1.Stat Definitions and Base vs Derived Stats]].)
## 5.4 Scene setup
1. Layers: `Player`, `Ground`, `OneWay` (Part 3 matrix).
2. Gray-box with a Tilemap + Tilemap Collider 2D (Composite Operation **Merge**) + Composite Collider 2D (**Outlines**) + Rigidbody 2D **Static**, layer `Ground` ([[2. Physics 2D Colliders Composite and Materials]], [[Tilemaps and Tilesets]]).
3. One-way ledges on a second Tilemap on layer `OneWay` (no effector needed for this controller).
4. Player: sprite + **Capsule Collider 2D** (0.8 x 1.8) + **Rigidbody 2D** -> Body Type **Kinematic**, **Interpolate**, Freeze Rotation. Turn on **Use Full Kinematic Contacts** if you want collision callbacks with level geometry.
5. Input Actions: `Move` (Vector2; WASD/stick) and `Jump` (Button) - see [[Input System]].
6. Add `KinematicPlatformer2D` (below), assign the metrics asset and masks.
## 5.5 Controller
```csharp
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody2D), typeof(CapsuleCollider2D))]
public class KinematicPlatformer2D : MonoBehaviour
{
[SerializeField] PlatformerMetrics2D metrics;
[SerializeField] LayerMask groundMask; // solid ground and walls
[SerializeField] LayerMask oneWayMask; // jump-through ledges
[SerializeField] InputActionReference move;
[SerializeField] InputActionReference jump;
[SerializeField] float groundProbe = 0.06f;
[SerializeField, Range(0f, 89f)] float maxSlopeDeg = 50f;
Rigidbody2D _rb;
Rigidbody2D.SlideMovement _slide;
ContactFilter2D _groundFilter;
readonly List<RaycastHit2D> _hits = new List<RaycastHit2D>(4);
Vector2 _velocity;
float _moveX;
bool _jumpHeld;
float _lastGroundedTime = -999f;
float _lastJumpPressedTime = -999f;
bool _isJumping;
bool _dropThrough;
float _dropUntil;
public bool Grounded { get; private set; }
public Vector2 Velocity => _velocity;
void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_rb.bodyType = RigidbodyType2D.Kinematic;
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
_slide = new Rigidbody2D.SlideMovement
{
maxIterations = 4,
surfaceSlideAngle = maxSlopeDeg,
gravitySlipAngle = maxSlopeDeg,
surfaceUp = Vector2.up,
surfaceAnchor = new Vector2(0f, -0.1f),
gravity = Vector2.zero, // we integrate gravity ourselves
useSimulationMove = true
};
_groundFilter = new ContactFilter2D { useTriggers = false };
_groundFilter.SetNormalAngle(90f - maxSlopeDeg, 90f + maxSlopeDeg);
}
void OnEnable()
{
move.action.Enable();
jump.action.Enable();
jump.action.started += OnJumpStarted;
}
void OnDisable()
{
jump.action.started -= OnJumpStarted;
}
void OnJumpStarted(InputAction.CallbackContext _)
{
if (move.action.ReadValue<Vector2>().y < -0.5f && Grounded)
{
_dropThrough = true; // down + jump = drop through one-way
_dropUntil = Time.time + 0.25f;
return;
}
_lastJumpPressedTime = Time.time; // buffered
}
void Update()
{
_moveX = move.action.ReadValue<Vector2>().x;
_jumpHeld = jump.action.IsPressed();
if (_dropThrough && Time.time > _dropUntil) _dropThrough = false;
}
void FixedUpdate()
{
float dt = Time.fixedDeltaTime;
// 1. Which layers are solid this step? One-way ledges only while falling and not dropping.
LayerMask solid = groundMask;
if (_velocity.y <= 0f && !_dropThrough) solid |= oneWayMask;
_slide.SetLayerMask(solid);
_groundFilter.SetLayerMask(solid);
// 2. Ground probe (cast the whole capsule down a hair).
bool wasGrounded = Grounded;
Grounded = _velocity.y <= 0.01f && _rb.Cast(Vector2.down, _groundFilter, _hits, groundProbe) > 0;
if (Grounded)
{
_lastGroundedTime = Time.time;
_isJumping = false;
if (_velocity.y < 0f) _velocity.y = 0f;
}
// 3. Horizontal: accelerate toward target speed.
float target = _moveX * metrics.RunSpeed;
float accelTime = Mathf.Abs(target) > 0.01f ? metrics.groundAccelTime : metrics.groundDecelTime;
float maxDelta = metrics.RunSpeed / Mathf.Max(accelTime, 0.0001f) * dt;
if (!Grounded) maxDelta *= metrics.airControl;
_velocity.x = Mathf.MoveTowards(_velocity.x, target, maxDelta);
// 4. Jump with coyote time + buffer.
bool buffered = Time.time - _lastJumpPressedTime <= metrics.jumpBufferTime;
bool coyote = Time.time - _lastGroundedTime <= metrics.coyoteTime;
if (buffered && coyote && !_isJumping)
{
_velocity.y = metrics.JumpVelocity;
_isJumping = true;
Grounded = false;
_lastJumpPressedTime = -999f;
_lastGroundedTime = -999f;
}
// 5. Gravity with fall / jump-cut multipliers.
if (!Grounded)
{
float g = metrics.Gravity;
if (_velocity.y < 0f) g *= metrics.fallGravityMultiplier;
else if (_isJumping && !_jumpHeld) g *= metrics.jumpCutGravityMultiplier;
_velocity.y = Mathf.Max(_velocity.y - g * dt, -metrics.MaxFallSpeed);
}
// 6. Ground snapping only when grounded and not launching.
_slide.surfaceAnchor = Grounded ? new Vector2(0f, -0.1f) : Vector2.zero;
// 7. Move.
Rigidbody2D.SlideResults r = _rb.Slide(_velocity, dt, _slide);
// 8. React to what we hit: ceilings kill upward speed, walls kill horizontal speed.
if (r.slideHit.collider != null)
{
Vector2 n = r.slideHit.normal;
if (n.y < -0.5f && _velocity.y > 0f) _velocity.y = 0f;
if (Mathf.Abs(n.x) > 0.9f) _velocity.x = 0f;
}
if (!wasGrounded && Grounded) OnLanded();
}
void OnLanded()
{
// Hook: landing dust, camera impulse, footstep/land audio (see Audio notes).
}
void OnDrawGizmosSelected()
{
if (metrics == null) return;
// Draw the full-jump arc from the current position as a teaching aid.
Gizmos.color = Color.yellow;
Vector3 p = transform.position;
float vx = metrics.RunSpeed, vy = metrics.JumpVelocity, g = metrics.Gravity;
Vector3 prev = p;
for (float t = 0f; t < 2.5f; t += 0.02f)
{
float gEff = (vy - g * t) < 0f ? g * metrics.fallGravityMultiplier : g;
float tApex = metrics.timeToApex;
float y = t <= tApex
? vy * t - 0.5f * g * t * t
: metrics.jumpHeightTiles * metrics.tileSize - 0.5f * gEff * (t - tApex) * (t - tApex);
Vector3 q = p + new Vector3(vx * t, y, 0f);
Gizmos.DrawLine(prev, q);
prev = q;
if (y < -1f) break;
}
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled or play-tested. Known assumptions to check in the Editor: (1) how `Slide` behaves if the capsule *starts* overlapping a one-way ledge when the mask switches on - if it pops up, add a small "only collide with one-way if feet were above its top last step" check; (2) the `RaycastHit2D.collider` null-check pattern for "no hit"; (3) moving platforms - Kinematic bodies are not carried by friction, so add the platform's velocity yourself (Part 1's `KinematicPlatform2D` can expose its velocity).
### What each forgiveness feature is for (teach these by name)
| Feature | Player-facing problem | Level Up! link |
|---|---|---|
| **Coyote time** | "I pressed jump at the edge and fell!" | Level 7 controls: "as the button is pressed, the action should happen" |
| **Jump buffer** | Jump pressed 80 ms before landing is ignored | same - responsiveness |
| **Variable jump (jump cut)** | Only one jump height = hard to do small hops | Level 5 metrics: short hop and full jump are *two* metrics |
| **Fall gravity multiplier** | Floaty landings | feel / juice |
## 5.6 2D metrics gym
Build one Tilemap scene that **tests the metrics sheet**, not a level:
1. A flat runway 30 tiles long with tick marks every tile (a second Tilemap with a ruler tile) - verify `runSpeedTiles` with a stopwatch.
2. Pillars of height 1, 2, 3, 4 tiles - which can the player clear? With `jumpHeightTiles = 3.2` the answer must be "3 yes, 4 no".
3. Gaps at **80%, 100% and 120%** of `JumpDistanceTiles` (round to whole tiles) - Rogers' classroom exercise.
4. A one-way ledge stack spaced at jump height - tests drop-through and the one-way mask.
5. A 1-tile-high corridor - crouch/slide metrics if you add them.
6. Ramps at 30, 45 and 55 degrees - the last should be unwalkable at `maxSlopeDeg = 50`.
Put a sign (TextMeshPro or a UI Toolkit label - [[UI Toolkit and Diegetic UI]]) on each test piece with the metric it validates. When a designer changes the asset, replay the gym - this is your regression test for feel. The 3D version of this gym is in [[Character Controllers]].
## Docs
- `Rigidbody2D.Slide`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Rigidbody2D.Slide.html
- `Rigidbody2D.SlideMovement`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Rigidbody2D.SlideMovement.html
- `Rigidbody2D.SlideResults`: https://docs.unity.com/en-us/engine/6000.5/script-reference/unityengine/rigidbody2d/slideresults
- `Rigidbody2D.Cast`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Rigidbody2D.Cast.html
- `ContactFilter2D.SetNormalAngle`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ContactFilter2D.html