# Game Feel

> Tune how a game feels — input forgiveness, movement curves, hit stop, screen shake, squash and stretch, camera and audio feel — with concrete numbers.

- Skill: `kyh/game-feel` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kyh/game-feel`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kyh/game-feel/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: kyh (https://skillmd.com/u/kyh)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kyh/game-feel

---


# Game feel: the numbers

Game feel = real-time control + simulated space + polish (Swink). The polish
amplifies the simulation but never lies about it: if the screen shakes,
something must have hit. Small tweaks compound — Vlambeer's flat shooter
becomes Nuclear Throne via ~30 individually-invisible tricks. Apply them in
passes, then **play it**: feel is tuned in motion, never designed on paper.

All frame counts assume 60fps — which means **physics must be
delta-time-based**. Per-frame integration (`x += vx`) runs 2× fast on a 120Hz
display; this is the single most common feel bug in browser games. Multiply
all motion by `dt`, and convert the frame windows below to ms when your loop
isn't fixed-step.

- **Never derive a bounce/return angle from penetration depth** at the
  detection frame (`atan2(ball.y − paddle.y, …)`): depth ∈ (band − v·dt, band]
  is integration noise, so the same hit returns steep at 144 Hz and shallow at
  30 Hz. Use the tangential offset only (x-offset across the face → linear
  angle curve), or back-project to the band-entry point first.

## Input forgiveness (the invisible half of "tight controls")

Fudge everything slightly in the player's favor — players read forgiveness as
their own skill (Celeste).

- **Input buffer 4–10 frames**: a jump/attack pressed slightly early fires on
  the first legal frame. Celeste ~4, Smash 10. Start at 5 for precise games,
  8–10 for forgiving ones. Track a `bufferedAt` timestamp per action; consume
  in `update()`.
- **Coyote time 5–8 frames**: jump still works after walking off a ledge
  (Celeste ~5–6). Track `lastGroundedAt`.
- **Corner correction**: a jump that clips a ceiling corner by a few px gets
  nudged sideways; a dash that clips a ledge pops up onto it. Celeste's
  wall-jump reaches ~2px off the wall at 320×180 — scale to your resolution.
- **Never demand frame-perfection.** Any 1-frame window gets widened.
- **Player hurtbox smaller than the sprite** (Silksong, bullet hells): damage
  collision ~70–85% of the visual body. Near-misses that look like grazes
  should be grazes. Attack/interaction hitboxes go the other way — generous.
- **Touch: enlarge hitboxes, not visuals** — tappable region > sprite.
- Respond to core verbs within ~100ms total (Swink's correction cycle);
  never add startup delay to movement inputs.
- **Continuous/tracking inputs** (pointer, analog stick, webcam/hand): the
  forgiveness knob is smoothing-vs-latency. Lerp the controlled object toward
  the input (factor ~0.1–0.3 per 60fps frame) to kill jitter, but keep total
  lag under the ~100ms cycle; add a small dead zone, and fudge the
  _interaction_ hitboxes (paddle/catch zones) larger than the visuals.

## Movement curves

- **Reach max run speed in ~6 frames, stop in ~3** (Celeste). Stopping faster
  than starting reads "responsive"; the reverse reads "slippery".
- **Variable jump height**: releasing jump early cuts upward velocity.
- **Apex float**: while jump is held, halve gravity near the arc's peak
  (Celeste) — gives air-control where players need it.
- **Fall faster than you rise**: heavier gravity on descent + a fall-speed
  cap. Symmetric arcs feel floaty.
- **Keep platform momentum** for a few frames after leaving a moving platform.
- Phaser: implement as manual velocity writes in `update()` — Arcade `drag`
  alone can't express asymmetric accel or apex gravity.
- **Personality is a palette, not one recipe**: the numbers above tune
  "responsive", but a kit reads best through contrast — a tight, heavy base
  moveset plus ONE deliberately loose, momentum-carrying ability (a fling, a
  grapple, a slide) produces more joy than uniform tightness. Tune the outlier
  for arc and carry, not control.

## Movement depth & expression

More depth never means more buttons. Three ways a move can be triggered:

- **Button moves** — few, foundational (jump, dash, attack).
- **Context moves** — the same button does something new given environment
  state: wall touch → wall jump, airborne over a bounce surface → bounce,
  moving on landing → slide. This is where depth is cheapest.
- **Sequence moves** — input chains: jump at the end of a grapple → fling,
  dash into a roll → extended roll. Reward timing, don't require it.

Working method:

- **Audit dead moments**: anywhere the player is locked out of decisions
  (mid-grapple, mid-dash, mid-knockback), ask "what context or sequence could
  give them an option here?" Each option multiplies expression.
- **Directional choices follow facing** — fling/bounce direction = the way
  you face. Doubles as leniency: no separate aim input to fumble.
- **Consistency beats physics**: the same fling should work off floor,
  wall, and ceiling. Cross-move consistency is what lets players compose
  moves you never planned.
- **Emergent chains appearing uninvited** (roll → corner-fling → wall-bounce
  you never designed) are the success signal — keep them, build levels
  around them.
- **Playtest-kill confusing moves** even if you love them; keep a powerful
  move balanced by shrinking its effective range/window, not by weakening
  its feel.

## Impact

- **Hit stop**: freeze attacker AND victim 2–5 frames on light hits, 6–12 on
  kills, hard cap ~20–30 (Smash ≈ `damage × 0.65 + 6` frames). Scale with
  importance. Pause tweens/anims for the parties, not the whole world, when
  multiple fights overlap. In non-Phaser loops (rAF/`useFrame`), implement as
  a freeze timer that skips integration for the frozen entities while
  rendering continues.
- **Hit window ≠ animation length.** Keep the hit early (~100ms into the
  cycle) for responsiveness and let the swing _read_ slow; never tie the hit
  to a "make it look slower" multiplier. Pin the hit to the measured contact
  frame, compress windup, hold follow-through. Derive damage from duration
  (`dmg = round(dur × k)`) so slow kits hit harder and the DPS band stays
  level.
- **Knockback both ways**: victim recoils from hits; shooter kicks back 1–2px
  on fire. Suspend AI steering ~150ms so the shove reads.
- **Hit flash**: solid white tint 1–3 frames.
- **Particles at every contact** (muzzle, impact, dust on land), lifetimes
  0.2–0.5s.
- **Permanence**: corpses, craters, shell casings stay (Nijman's
  highest-leverage trick). Stamp into a texture or cheap static images, not
  live bodies.
- **Bigger projectiles, lower accuracy, higher fire rate** — perfect accuracy
  reads sterile.

## Animation principles (Disney, applied)

- **Squash & stretch, volume conserved**: scale Y 0.7 on landing → scale X
  ~1.3. Stretch along velocity at speed; squash on impact. Typical: jump
  stretch 1.1–1.25, land squash 1.2–1.4, recover over 100–200ms ease-out.
- **Anticipation on big actions only**: 2–6 frame wind-up before a boss leap
  or charged shot. Never on core movement — that's input lag.
- **Overshoot and settle**: `Back.easeOut`/`Elastic.easeOut` on everything
  that appears or changes; stagger group entrances 20–50ms apart.
- **Nothing pops**: scale-in on spawn, shrink-and-fade on death.

## Camera

- **Lerp-follow with look-ahead** in the facing/aim direction. Phaser:
  `startFollow(player, true, 0.08–0.15)` + manual look-ahead offset.
- **Trauma shake** (Eiserloh): events add 0.2–0.5 to a 0–1 trauma
  value; decay to 0 over 0.5–1s; offset = maxOffset × trauma², sampled from
  smooth noise per axis. Add rotational shake (max ~5–10°) — cheapest way to
  make 2D shake feel violent. Maxima ~5–10% of screen size. Trauma stacks
  naturally where fixed-magnitude `camera.shake()` calls fight each other.
- **Directional camera kick**: punch a few px opposite the shot / into the
  hit, ease back. Distinct from omni shake.
- **Keep shakes short** (<0.5s). Offer reduce-motion for sustained shakers.

## Audio feel

- **Every interaction sounds** — fire, hit, kill, land, pickup, UI. Audio is
  half of juice and the cheapest half.
- **Randomize pitch ±5–15%** per play: `sound.play({ rate: 0.9 + Math.random() * 0.2 })`.
- **Lower pitch = heavier.** Fatten kills/explosions with a bass layer; put a
  distinct confirm sound on kills above the routine hit sound.
- **Duck or sting music** on death/level-clear; silence after noise is itself
  an effect.
- Browser: unlock audio on first gesture; pool instances to avoid GC hitches.
- **Web Audio double-stop throws**: a source with `stop(t)` scheduled at
  creation throws on a second `stop()` — a forced release only `disconnect()`s.
- **Face/pose-only players never fire the unlock gesture.** Show a sound-hint
  pill while the context is locked; silence reads as a broken game.

## Tuning session

Numbers above are starting points, not answers. Wire the 5–6 feel constants
(accel frames, buffer, coyote, hit-stop, shake trauma) to a debug panel or
query params, play 2 minutes, adjust, repeat. If you can't decide between two
values, the larger forgiveness / smaller effect is usually right. When a
target feel exists (e.g. "like Celeste", "like Downwell"), run the reference
game side-by-side and A/B the same maneuver — match cadence and stop
distances by eye and SFX timing by ear; memory of feel is unreliable.

Related skills: `game-playbook` (build order + basic craft pass), `phaser`
(engine APIs), `onboarding` (difficulty feel), `design-lenses` (is it fun at
all).

