# 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