Game Physics skill for Claude Code

Fixed steps, no tunneling, bugs you can replay. Claude fixes physics bugs from the loop outward: it checks for a fixed timestep and render interpolation, then scale, mass ratios, collider shapes and layers, and only then tunes parameters. It knows when a character needs a kinematic controller, how to stop fast objects tunneling, and how to prove fixes with debug draw and recorded input replays.

  • namegame-physics
  • size166 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Diagnoses and builds game physics with fixed-timestep loops and interpolation, stable integration, collision detection (broadphase, SAT, swept tests, CCD), rigid body tuning, kinematic character controllers, collision layers, and determinism for replays and lockstep. Use when objects tunnel through walls, stacks jitter or explode, movement depends on frame rate, a character sticks to walls or slides on slopes, physics differs between machines, or when writing custom collision code or choosing between kinematic and dynamic bodies.

Try asking

  • Bullets go straight through thin walls when the game runs at high speed
  • Our physics feels different at 144 Hz than at 60 Hz, make it frame-rate independent
  • Write swept AABB collision for my custom 2D engine so the player never clips walls

Use it when

  • Objects tunnel, jitter, sink, or explode
  • Movement or physics changes with frame rate
  • Writing custom collision detection or a character controller
  • Replays or lockstep simulations drift apart

Skip it when

How to check the result

A recorded input replay ends in the same state hash every run, trajectories match at 30, 60 and 144 fps, and a stress test shows zero tunneling.

The common mistake it prevents

Running physics in the variable-rate frame update, which makes jump heights and speeds depend on the player's frame rate.

~/.claude/skills/game-physics/SKILL.md

--- name: game-physics description: Diagnoses and builds game physics with fixed-timestep loops and interpolation, stable integration, collision detection (broadphase, SAT,… license: MIT ---

Done well means: simulation advances in fixed steps independent of frame rate, rendering interpolates between steps, nothing moves far enough per step to skip a collider, bodies are in sensible units and mass ratios, and every bug fix is proven with debug draw or a recorded input replay rather than “looks better now”.

Ground rules

  • Identify the physics backend first (Box2D, Jolt, PhysX/Chaos, Havok, Rapier, Godot physics, Arcade/Matter, custom). Fix bugs with the engine’s own tools before writing custom collision code.
  • All physics mutations happen in the fixed step (FixedUpdate, _physics_process, FixedUpdate schedule). Input is sampled per frame and consumed per step.
  • Never teleport simulated bodies by writing their transform every frame; use velocity, forces, impulses, or the engine’s kinematic move API.
  • Use SI units: 1 unit = 1 m, kg, seconds. Engines are tuned for moving objects roughly 0.1 to 10 m in size. A 500-unit character falls in slow motion.
  • Turn on the physics debug draw before diagnosing anything.

Workflow

- [ ] 1. Reproduce with debug draw on; record the inputs if possible
- [ ] 2. Check the loop: fixed step? render interpolation? step count clamped?
- [ ] 3. Check scale, mass ratios, collider shapes vs visuals, layers/masks
- [ ] 4. Match the symptom in the bug table; change one parameter at a time
- [ ] 5. Re-run the same recorded input; compare positions or a state hash
- [ ] 6. Test at 30, 60, 144 fps and with a forced frame hitch

The fixed-timestep loop

const STEP = 1 / 60;           // simulation rate
const MAX_STEPS = 5;           // spiral-of-death guard
let acc = 0, prev = performance.now() / 1000;

function frame(nowMs: number) {
  const now = nowMs / 1000;
  acc += Math.min(now - prev, 0.25);   // clamp huge hitches (tab switch, breakpoint)
  prev = now;
  let steps = 0;
  while (acc >= STEP && steps < MAX_STEPS) {
    snapshotPrevious();        // copy current state to previous
    simulate(STEP);            // physics + gameplay that affects physics
    acc -= STEP;
    steps++;
  }
  if (steps === MAX_STEPS) acc = 0;    // drop time rather than spiral
  const alpha = acc / STEP;
  render(alpha);               // draw lerp(previous, current, alpha)
  requestAnimationFrame(frame);
}
  • Rendering without interpolation shows stutter whenever display rate is not a multiple of the step rate (e.g. 144 Hz display, 60 Hz physics). Engines provide it: Unity Rigidbody Interpolate, Godot physics interpolation, Unreal sub-stepping/smoothing.
  • Integrator for custom code: semi-implicit (symplectic) Euler, i.e. update velocity first, then position with the new velocity. Explicit Euler gains energy; orbits and springs blow up.
vel.y += gravity * dt;          // 1. velocity
pos.x += vel.x * dt;            // 2. position with the new velocity
pos.y += vel.y * dt;

Kinematic or dynamic

ObjectBody typeWhy
Player character in a platformer/shooterKinematic character controllerPrecise, designer-tuned motion; slopes and steps handled explicitly
Crates, ragdolls, debris, vehiclesDynamic rigid bodyEmergent physical behavior
Moving platforms, doors, elevatorsKinematic (animated) bodyPushes dynamics, ignores forces
Triggers, pickups, hit volumesSensor/area (no response)Detection only
Physics-driven player (ball, ragdoll game)Dynamic with constraintsFeel comes from simulation

Character controller essentials: shape cast or ray for ground detection, slope limit (e.g. 45 degrees), step offset, snap-to-ground when walking down slopes, zero-friction material on the sides so the character does not stick to walls, coyote time (about 0.1 s) and jump buffering (about 0.1 s) for responsive jumps.

Symptom table

SymptomLikely causeFix
Fast object passes through thin wallTunneling: moves more than half its thickness per stepEnable CCD/bullet flag, raycast or shape-cast the motion, thicken colliders, raise step rate
Movement speed varies with frame ratePhysics in variable-rate update, or delta applied twiceMove logic into fixed step; check velocity * delta passed to APIs that already integrate
Visual stutter, physics fineNo interpolation, camera updated before physicsEnable interpolation; update camera after physics (LateUpdate)
Stacks jitter or slowly sinkToo few solver iterations, sleeping disabled, huge mass ratiosRaise velocity/position iterations, enable sleep, keep mass ratios under about 10:1
Objects explode apart on spawnSpawned overlapping, or joint limits fightingSpawn with clearance, check overlap first, soften joints
Joint chains stretch or wobbleLight body attached to heavy bodyReduce mass ratio, increase iterations or substeps
Character snags on tile seamsInternal edges between adjacent boxesMerge colliders, use chain/edge shapes or edge smoothing
Bounces foreverRestitution 1.0 or combine mode maxRestitution below 1, check combine mode
Slides down shallow slopesGravity applied while groundedZero vertical velocity when grounded on walkable slope; snap to ground
Falls in slow motionWorld scale too largeRescale to meters or scale gravity consistently
Different results on two machinesNondeterministic floats, iteration order, variable dtSee determinism section

Collision detection basics

StageDefault choice
Broadphase, mostly static worldStatic BVH or grid for static, dynamic AABB tree for movers
Broadphase, uniform small objects (bullets, particles)Uniform spatial hash grid, cell size about 2x object size
Narrowphase, convex shapesSpheres/capsules analytic; boxes SAT; general convex GJK + EPA
Fast moversSwept tests (ray/shape cast) or engine CCD

Prefer primitive colliders (sphere, capsule, box) over mesh colliders; use convex hulls for dynamic objects, triangle meshes only for static geometry.

For algorithms with code (swept AABB, circle vs AABB, SAT for oriented boxes, spatial hash), read references/collision-detection.md only when writing custom collision code or debugging a hand-rolled implementation.

Collision layers

  • Define a layer matrix in a table and keep it in the repo: Player vs World, Enemy, Pickup; PlayerBullet vs World, Enemy only.
  • Bullets should never test against other bullets; pickups never against world.
  • In Godot: layer = what I am, mask = what I detect. In Unity: Physics Layer Collision Matrix. In Box2D: category bits and mask bits.

Determinism

Needed for lockstep multiplayer, rollback netcode, and input-replay testing.

  • Fixed timestep only; never feed wall-clock delta into simulation.
  • Same build, same platform is the easy case: avoid iterating hash maps or sets with unstable order, use a seeded RNG owned by the simulation, process entities in a stable order (sort by ID).
  • Cross-platform/cross-compiler determinism is hard with floats (FMA contraction, SIMD width, transcendental function implementations). Options: fixed-point math for the simulation, or an engine that advertises cross-platform determinism in a specific build configuration (check the docs of Box2D v3, Jolt, or Rapier for their current guarantees and flags).
  • Verify with a per-step state hash (positions and velocities quantized) logged on each peer; first diverging step identifies the cause.

Gotchas

  • Scaling a parent transform with a non-uniform scale distorts child colliders in many engines; scale the mesh, not the physics object.
  • Changing mass or collider size at runtime without waking the body: it stays asleep.
  • Raycasts in the same step as a teleport may hit the old position until the broadphase updates; sync transforms or query next step.
  • Applying continuous force in Update instead of the fixed step makes thrust frame-rate dependent; impulses for one-off events, forces for continuous effects.
  • Very small (under 1 cm) or very thin colliders are unstable; inflate them and hide it in the visuals.

Verification

  1. Debug draw shows colliders matching visuals and contacts where expected.
  2. Recorded input replay produces the same end state (or state hash) across runs.
  3. Same scenario at 30, 60, and 144 fps renders identical trajectories; a forced 200 ms hitch does not cause tunneling or a spiral of death.
  4. Tunneling test: fire the fastest projectile at the thinnest wall 1000 times; zero pass-throughs.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/collision-detection.md 192 lines · 7.6 KB
# Collision detection reference

Hand-rolled collision code for 2D games and custom engines. Examples are TypeScript but
translate directly to C#, C++, GDScript, or Rust. All boxes are axis-aligned unless noted.

```ts
type Vec2 = { x: number; y: number };
type AABB = { minX: number; minY: number; maxX: number; maxY: number };
```

## Overlap tests (discrete)

```ts
const aabbOverlap = (a: AABB, b: AABB) =>
  a.minX < b.maxX && a.maxX > b.minX && a.minY < b.maxY && a.maxY > b.minY;

function circleVsAabb(c: Vec2, r: number, b: AABB): boolean {
  const px = Math.max(b.minX, Math.min(c.x, b.maxX));   // closest point on box
  const py = Math.max(b.minY, Math.min(c.y, b.maxY));
  const dx = c.x - px, dy = c.y - py;
  return dx * dx + dy * dy < r * r;                     // compare squared, no sqrt
}
```

Use strict `<` so touching boxes do not count as overlapping; otherwise a character
resting exactly on the floor registers a collision every frame.

## Minimum translation vector for AABBs

```ts
function aabbMtv(a: AABB, b: AABB): Vec2 | null {
  const ox = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
  const oy = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
  if (ox <= 0 || oy <= 0) return null;
  const ac = (a.minX + a.maxX) / 2 - (b.minX + b.maxX) / 2;
  const bc = (a.minY + a.maxY) / 2 - (b.minY + b.maxY) / 2;
  return ox < oy ? { x: ac < 0 ? -ox : ox, y: 0 } : { x: 0, y: bc < 0 ? -oy : oy };
}
```

Push out along the smaller overlap axis. Resolving X and Y in separate passes (move X,
resolve X; move Y, resolve Y) avoids the classic "snag on the corner between two floor
tiles" bug that single-pass MTV resolution produces on tilemaps.

## Swept AABB (continuous, prevents tunneling)

Treat the moving box as a point by expanding the static box by the mover's half-size
(Minkowski sum), then cast a ray from the mover's center along its displacement using
the slab method.

```ts
type Hit = { t: number; normal: Vec2 };

function sweptAabb(mover: AABB, delta: Vec2, target: AABB): Hit | null {
  const hw = (mover.maxX - mover.minX) / 2, hh = (mover.maxY - mover.minY) / 2;
  const ox = mover.minX + hw, oy = mover.minY + hh;
  const minX = target.minX - hw, maxX = target.maxX + hw;
  const minY = target.minY - hh, maxY = target.maxY + hh;

  let tEnterX: number, tExitX: number, tEnterY: number, tExitY: number;
  if (delta.x === 0) {
    if (ox <= minX || ox >= maxX) return null;
    tEnterX = -Infinity; tExitX = Infinity;
  } else {
    const t1 = (minX - ox) / delta.x, t2 = (maxX - ox) / delta.x;
    tEnterX = Math.min(t1, t2); tExitX = Math.max(t1, t2);
  }
  if (delta.y === 0) {
    if (oy <= minY || oy >= maxY) return null;
    tEnterY = -Infinity; tExitY = Infinity;
  } else {
    const t1 = (minY - oy) / delta.y, t2 = (maxY - oy) / delta.y;
    tEnterY = Math.min(t1, t2); tExitY = Math.max(t1, t2);
  }

  const tEnter = Math.max(tEnterX, tEnterY);
  const tExit = Math.min(tExitX, tExitY);
  if (tEnter > tExit || tEnter < 0 || tEnter > 1) return null;   // no hit this step

  const normal = tEnterX > tEnterY
    ? { x: delta.x > 0 ? -1 : 1, y: 0 }
    : { x: 0, y: delta.y > 0 ? -1 : 1 };
  return { t: tEnter, normal };
}
```

Resolution with sliding (run up to 3 iterations per step):

1. Find the earliest hit among candidate colliders (broadphase over the swept bounds:
   union of start and end boxes).
2. Move by `delta * t` minus a small skin (e.g. 0.001 units) so you never end touching.
3. Remove the velocity component into the normal: `v -= dot(v, n) * n`.
4. Repeat with the remaining displacement `delta * (1 - t)` projected the same way.

If `tEnter < 0` and the boxes already overlap, fall back to the MTV push-out; that case
means a previous step left them interpenetrating.

## Ray vs circle

```ts
function rayCircle(o: Vec2, d: Vec2, c: Vec2, r: number): number | null {
  const mx = o.x - c.x, my = o.y - c.y;
  const a = d.x * d.x + d.y * d.y;
  const b = mx * d.x + my * d.y;
  const cc = mx * mx + my * my - r * r;
  const disc = b * b - a * cc;
  if (disc < 0) return null;
  const t = (-b - Math.sqrt(disc)) / a;   // nearest root; d need not be normalized
  return t >= 0 && t <= 1 ? t : null;     // t in units of d (one step's displacement)
}
```

For bullets, cast this per step from the previous to the next position instead of
testing overlap at the end position.

## SAT for oriented boxes and convex polygons (2D)

Two convex shapes are disjoint if and only if some axis separates their projections.
For polygons, the candidate axes are the edge normals of both shapes (a box contributes 2
unique normals).

```ts
function project(pts: Vec2[], ax: Vec2): [number, number] {
  let min = Infinity, max = -Infinity;
  for (const p of pts) { const s = p.x * ax.x + p.y * ax.y; if (s < min) min = s; if (s > max) max = s; }
  return [min, max];
}

function satMtv(a: Vec2[], b: Vec2[]): { axis: Vec2; depth: number } | null {
  let best = { axis: { x: 0, y: 0 }, depth: Infinity };
  for (const poly of [a, b]) {
    for (let i = 0; i < poly.length; i++) {
      const p = poly[i], q = poly[(i + 1) % poly.length];
      const len = Math.hypot(q.y - p.y, p.x - q.x);
      const ax = { x: (q.y - p.y) / len, y: (p.x - q.x) / len };   // edge normal
      const [a0, a1] = project(a, ax), [b0, b1] = project(b, ax);
      const overlap = Math.min(a1, b1) - Math.max(a0, b0);
      if (overlap <= 0) return null;                              // separating axis found
      if (overlap < best.depth) best = { axis: ax, depth: overlap };
    }
  }
  // orient the axis to push a away from b
  const ca = centroid(a), cb = centroid(b);
  if ((ca.x - cb.x) * best.axis.x + (ca.y - cb.y) * best.axis.y < 0) best.axis = { x: -best.axis.x, y: -best.axis.y };
  return best;
}
const centroid = (p: Vec2[]) => p.reduce((s, v) => ({ x: s.x + v.x / p.length, y: s.y + v.y / p.length }), { x: 0, y: 0 });
```

In 3D, SAT for boxes needs 15 axes (3 + 3 face normals plus 9 edge cross products); use
GJK/EPA or the engine instead of hand-writing it.

## Uniform spatial hash (broadphase)

```ts
class SpatialHash<T> {
  private cells = new Map<number, T[]>();
  constructor(private size: number) {}
  private key(cx: number, cy: number) { return ((cx & 0xffff) << 16) | (cy & 0xffff); }
  clear() { this.cells.clear(); }
  insert(item: T, b: AABB) {
    for (let cx = Math.floor(b.minX / this.size); cx <= Math.floor(b.maxX / this.size); cx++)
      for (let cy = Math.floor(b.minY / this.size); cy <= Math.floor(b.maxY / this.size); cy++) {
        const k = this.key(cx, cy);
        (this.cells.get(k) ?? this.cells.set(k, []).get(k)!).push(item);
      }
  }
  query(b: AABB, out: Set<T>) {
    for (let cx = Math.floor(b.minX / this.size); cx <= Math.floor(b.maxX / this.size); cx++)
      for (let cy = Math.floor(b.minY / this.size); cy <= Math.floor(b.maxY / this.size); cy++)
        for (const it of this.cells.get(this.key(cx, cy)) ?? []) out.add(it);
  }
}
```

- Cell size around twice the typical object size. Objects much larger than a cell land in
  many cells; keep them in a separate list or a second, coarser grid.
- Rebuild every step for moving objects (cheap) or update only movers.
- The 16-bit key packing wraps at 65,536 cells per axis; use a string or BigInt key for
  larger worlds.
- Deduplicate pairs (`a.id < b.id`) when generating pair lists.

## Choosing narrowphase shapes

| Shape pair | Test |
|---|---|
| Circle-circle, sphere-sphere | Distance squared vs (r1 + r2) squared |
| Capsule-capsule | Closest points between two segments, then radius test |
| AABB-AABB | Interval overlap per axis |
| OBB/polygon (2D) | SAT |
| Convex-convex (3D) | GJK for distance/overlap, EPA for penetration depth |
| Anything vs triangle mesh | BVH over triangles, then per-triangle test |

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc