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.
- name
game-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
- Network prediction and reconciliation design — use multiplayer-netcode
- Engine-specific physics node setup in Godot — use godot-games
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.
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,FixedUpdateschedule). 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
| Object | Body type | Why |
|---|---|---|
| Player character in a platformer/shooter | Kinematic character controller | Precise, designer-tuned motion; slopes and steps handled explicitly |
| Crates, ragdolls, debris, vehicles | Dynamic rigid body | Emergent physical behavior |
| Moving platforms, doors, elevators | Kinematic (animated) body | Pushes dynamics, ignores forces |
| Triggers, pickups, hit volumes | Sensor/area (no response) | Detection only |
| Physics-driven player (ball, ragdoll game) | Dynamic with constraints | Feel 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Fast object passes through thin wall | Tunneling: moves more than half its thickness per step | Enable CCD/bullet flag, raycast or shape-cast the motion, thicken colliders, raise step rate |
| Movement speed varies with frame rate | Physics in variable-rate update, or delta applied twice | Move logic into fixed step; check velocity * delta passed to APIs that already integrate |
| Visual stutter, physics fine | No interpolation, camera updated before physics | Enable interpolation; update camera after physics (LateUpdate) |
| Stacks jitter or slowly sink | Too few solver iterations, sleeping disabled, huge mass ratios | Raise velocity/position iterations, enable sleep, keep mass ratios under about 10:1 |
| Objects explode apart on spawn | Spawned overlapping, or joint limits fighting | Spawn with clearance, check overlap first, soften joints |
| Joint chains stretch or wobble | Light body attached to heavy body | Reduce mass ratio, increase iterations or substeps |
| Character snags on tile seams | Internal edges between adjacent boxes | Merge colliders, use chain/edge shapes or edge smoothing |
| Bounces forever | Restitution 1.0 or combine mode max | Restitution below 1, check combine mode |
| Slides down shallow slopes | Gravity applied while grounded | Zero vertical velocity when grounded on walkable slope; snap to ground |
| Falls in slow motion | World scale too large | Rescale to meters or scale gravity consistently |
| Different results on two machines | Nondeterministic floats, iteration order, variable dt | See determinism section |
Collision detection basics
| Stage | Default choice |
|---|---|
| Broadphase, mostly static world | Static 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 shapes | Spheres/capsules analytic; boxes SAT; general convex GJK + EPA |
| Fast movers | Swept 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:
PlayervsWorld,Enemy,Pickup;PlayerBulletvsWorld,Enemyonly. - 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
Updateinstead 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
- Debug draw shows colliders matching visuals and contacts where expected.
- Recorded input replay produces the same end state (or state hash) across runs.
- 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.
- 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
# 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.