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).
1---2name: game-feel3description: Tune how a game feels — input forgiveness, movement curves, hit stop, screen shake, squash and stretch, camera and audio feel — with concrete numbers.4---56# Game feel: the numbers78Game feel = real-time control + simulated space + polish (Swink). The polish9amplifies the simulation but never lies about it: if the screen shakes,10something must have hit. Small tweaks compound — Vlambeer's flat shooter11becomes Nuclear Throne via ~30 individually-invisible tricks. Apply them in12passes, then **play it**: feel is tuned in motion, never designed on paper.1314All frame counts assume 60fps — which means **physics must be15delta-time-based**. Per-frame integration (`x += vx`) runs 2× fast on a 120Hz16display; this is the single most common feel bug in browser games. Multiply17all motion by `dt`, and convert the frame windows below to ms when your loop18isn't fixed-step.1920- **Never derive a bounce/return angle from penetration depth** at the21 detection frame (`atan2(ball.y − paddle.y, …)`): depth ∈ (band − v·dt, band]22 is integration noise, so the same hit returns steep at 144 Hz and shallow at23 30 Hz. Use the tangential offset only (x-offset across the face → linear24 angle curve), or back-project to the band-entry point first.2526## Input forgiveness (the invisible half of "tight controls")2728Fudge everything slightly in the player's favor — players read forgiveness as29their own skill (Celeste).3031- **Input buffer 4–10 frames**: a jump/attack pressed slightly early fires on32 the first legal frame. Celeste ~4, Smash 10. Start at 5 for precise games,33 8–10 for forgiving ones. Track a `bufferedAt` timestamp per action; consume34 in `update()`.35- **Coyote time 5–8 frames**: jump still works after walking off a ledge36 (Celeste ~5–6). Track `lastGroundedAt`.37- **Corner correction**: a jump that clips a ceiling corner by a few px gets38 nudged sideways; a dash that clips a ledge pops up onto it. Celeste's39 wall-jump reaches ~2px off the wall at 320×180 — scale to your resolution.40- **Never demand frame-perfection.** Any 1-frame window gets widened.41- **Player hurtbox smaller than the sprite** (Silksong, bullet hells): damage42 collision ~70–85% of the visual body. Near-misses that look like grazes43 should be grazes. Attack/interaction hitboxes go the other way — generous.44- **Touch: enlarge hitboxes, not visuals** — tappable region > sprite.45- Respond to core verbs within ~100ms total (Swink's correction cycle);46 never add startup delay to movement inputs.47- **Continuous/tracking inputs** (pointer, analog stick, webcam/hand): the48 forgiveness knob is smoothing-vs-latency. Lerp the controlled object toward49 the input (factor ~0.1–0.3 per 60fps frame) to kill jitter, but keep total50 lag under the ~100ms cycle; add a small dead zone, and fudge the51 _interaction_ hitboxes (paddle/catch zones) larger than the visuals.5253## Movement curves5455- **Reach max run speed in ~6 frames, stop in ~3** (Celeste). Stopping faster56 than starting reads "responsive"; the reverse reads "slippery".57- **Variable jump height**: releasing jump early cuts upward velocity.58- **Apex float**: while jump is held, halve gravity near the arc's peak59 (Celeste) — gives air-control where players need it.60- **Fall faster than you rise**: heavier gravity on descent + a fall-speed61 cap. Symmetric arcs feel floaty.62- **Keep platform momentum** for a few frames after leaving a moving platform.63- Phaser: implement as manual velocity writes in `update()` — Arcade `drag`64 alone can't express asymmetric accel or apex gravity.65- **Personality is a palette, not one recipe**: the numbers above tune66 "responsive", but a kit reads best through contrast — a tight, heavy base67 moveset plus ONE deliberately loose, momentum-carrying ability (a fling, a68 grapple, a slide) produces more joy than uniform tightness. Tune the outlier69 for arc and carry, not control.7071## Movement depth & expression7273More depth never means more buttons. Three ways a move can be triggered:7475- **Button moves** — few, foundational (jump, dash, attack).76- **Context moves** — the same button does something new given environment77 state: wall touch → wall jump, airborne over a bounce surface → bounce,78 moving on landing → slide. This is where depth is cheapest.79- **Sequence moves** — input chains: jump at the end of a grapple → fling,80 dash into a roll → extended roll. Reward timing, don't require it.8182Working method:8384- **Audit dead moments**: anywhere the player is locked out of decisions85 (mid-grapple, mid-dash, mid-knockback), ask "what context or sequence could86 give them an option here?" Each option multiplies expression.87- **Directional choices follow facing** — fling/bounce direction = the way88 you face. Doubles as leniency: no separate aim input to fumble.89- **Consistency beats physics**: the same fling should work off floor,90 wall, and ceiling. Cross-move consistency is what lets players compose91 moves you never planned.92- **Emergent chains appearing uninvited** (roll → corner-fling → wall-bounce93 you never designed) are the success signal — keep them, build levels94 around them.95- **Playtest-kill confusing moves** even if you love them; keep a powerful96 move balanced by shrinking its effective range/window, not by weakening97 its feel.9899## Impact100101- **Hit stop**: freeze attacker AND victim 2–5 frames on light hits, 6–12 on102 kills, hard cap ~20–30 (Smash ≈ `damage × 0.65 + 6` frames). Scale with103 importance. Pause tweens/anims for the parties, not the whole world, when104 multiple fights overlap. In non-Phaser loops (rAF/`useFrame`), implement as105 a freeze timer that skips integration for the frozen entities while106 rendering continues.107- **Hit window ≠ animation length.** Keep the hit early (~100ms into the108 cycle) for responsiveness and let the swing _read_ slow; never tie the hit109 to a "make it look slower" multiplier. Pin the hit to the measured contact110 frame, compress windup, hold follow-through. Derive damage from duration111 (`dmg = round(dur × k)`) so slow kits hit harder and the DPS band stays112 level.113- **Knockback both ways**: victim recoils from hits; shooter kicks back 1–2px114 on fire. Suspend AI steering ~150ms so the shove reads.115- **Hit flash**: solid white tint 1–3 frames.116- **Particles at every contact** (muzzle, impact, dust on land), lifetimes117 0.2–0.5s.118- **Permanence**: corpses, craters, shell casings stay (Nijman's119 highest-leverage trick). Stamp into a texture or cheap static images, not120 live bodies.121- **Bigger projectiles, lower accuracy, higher fire rate** — perfect accuracy122 reads sterile.123124## Animation principles (Disney, applied)125126- **Squash & stretch, volume conserved**: scale Y 0.7 on landing → scale X127 ~1.3. Stretch along velocity at speed; squash on impact. Typical: jump128 stretch 1.1–1.25, land squash 1.2–1.4, recover over 100–200ms ease-out.129- **Anticipation on big actions only**: 2–6 frame wind-up before a boss leap130 or charged shot. Never on core movement — that's input lag.131- **Overshoot and settle**: `Back.easeOut`/`Elastic.easeOut` on everything132 that appears or changes; stagger group entrances 20–50ms apart.133- **Nothing pops**: scale-in on spawn, shrink-and-fade on death.134135## Camera136137- **Lerp-follow with look-ahead** in the facing/aim direction. Phaser:138 `startFollow(player, true, 0.08–0.15)` + manual look-ahead offset.139- **Trauma shake** (Eiserloh): events add 0.2–0.5 to a 0–1 trauma140 value; decay to 0 over 0.5–1s; offset = maxOffset × trauma², sampled from141 smooth noise per axis. Add rotational shake (max ~5–10°) — cheapest way to142 make 2D shake feel violent. Maxima ~5–10% of screen size. Trauma stacks143 naturally where fixed-magnitude `camera.shake()` calls fight each other.144- **Directional camera kick**: punch a few px opposite the shot / into the145 hit, ease back. Distinct from omni shake.146- **Keep shakes short** (<0.5s). Offer reduce-motion for sustained shakers.147148## Audio feel149150- **Every interaction sounds** — fire, hit, kill, land, pickup, UI. Audio is151 half of juice and the cheapest half.152- **Randomize pitch ±5–15%** per play: `sound.play({ rate: 0.9 + Math.random() * 0.2 })`.153- **Lower pitch = heavier.** Fatten kills/explosions with a bass layer; put a154 distinct confirm sound on kills above the routine hit sound.155- **Duck or sting music** on death/level-clear; silence after noise is itself156 an effect.157- Browser: unlock audio on first gesture; pool instances to avoid GC hitches.158- **Web Audio double-stop throws**: a source with `stop(t)` scheduled at159 creation throws on a second `stop()` — a forced release only `disconnect()`s.160- **Face/pose-only players never fire the unlock gesture.** Show a sound-hint161 pill while the context is locked; silence reads as a broken game.162163## Tuning session164165Numbers above are starting points, not answers. Wire the 5–6 feel constants166(accel frames, buffer, coyote, hit-stop, shake trauma) to a debug panel or167query params, play 2 minutes, adjust, repeat. If you can't decide between two168values, the larger forgiveness / smaller effect is usually right. When a169target feel exists (e.g. "like Celeste", "like Downwell"), run the reference170game side-by-side and A/B the same maneuver — match cadence and stop171distances by eye and SFX timing by ear; memory of feel is unreliable.172173Related skills: `game-playbook` (build order + basic craft pass), `phaser`174(engine APIs), `onboarding` (difficulty feel), `design-lenses` (is it fun at175all).