> ## Documentation Index
> Fetch the complete documentation index at: https://docs.summerengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Game feel skill for AI agents

> Add juice: hit-flash, trauma camera shake and audio ducking, one hit fires all three. A Summer Engine skill for AI agents.

`game-feel` is a skill from the [Summer skill library](/mcp/skills): a guide your AI agent reads when the task matches, so it builds the good version on the first try. Add juice: hit-flash, trauma camera shake and audio ducking, one hit fires all three.

| | |
| - | - |
| Status | Stable |
| Domains | Visual effects, Gameplay |
| Version | `1.0.2` |
| Engine version | `>=4.6` |
| Recommended set | Yes, installed by `--recommended` |

## When your agent uses it

* A Summer game feels flat, lacks impact, needs juice or punch
* The user asks for hit feedback, screen shake, camera shake, or audio ducking

## Install

`summer setup` installs every skill for your agent. To add only this one:

```bash theme={null}
npx -y summer-engine@latest skills install game-feel --agent claude-code
```

Swap `claude-code` for your agent, such as `codex` or `cursor`. See [Skills](/mcp/skills#quick-start) for every option.

## The skill

Particles do not make a game feel good. Screen shake alone does not either.
What makes a hit land is three systems firing on the same frame: a sub-frame
**hit flash** on the body that got hit, **trauma-based camera shake** with
quadratic falloff so big hits saturate, and **audio ducking** that briefly
squashes Music/Ambient so the impact SFX punches through. This skill installs
the canonical Summer Engine stack as three small systems wired to one signal.

## Step 1 — Ask what feels flat

Open with one focused question. Wait for the answer.

> What feels flat? Pick one to start: combat hits / pickups / explosions / spell casts / something else.

If the user says "all of it", anchor on **combat hits** — the hit-flash + shake + duck trio is the loudest perceived improvement per minute, and pickups/explosions reuse the same building blocks afterwards.

## Step 2 — Read the current scene state

Before adding anything, see what's there.

**Preferred (Summer MCP):**

```
summer_get_scene_tree
summer_inspect_node "./World/Enemy"   # the body that gets hit
summer_inspect_node "./World/Player/Camera3D"   # the camera to shake
```

You're looking for: a `MeshInstance3D` on the hurt body (for the flash), an existing `Camera3D` on the player (don't spawn a new one), and the names of the audio buses already present (Master + at least one of `Music`, `Ambient`, `SFX`).

If there's no Camera3D under the player, **stop and ask** before adding one — there may be a level camera elsewhere you'd be fighting.

If the only bus is `Master`, ducking will do nothing useful. See *Common mistakes* below.

**Fallback (no MCP):** ask the user to paste the relevant `.tscn` and the project's `default_bus_layout.tres` (or describe the bus list).

## Step 3 — Propose the bundle

Don't drip the systems in one at a time. The whole point is the trio fires together — install all three, wire one signal, see it land.

> I'm about to add: `HitFlash` on `./World/Enemy`, `CameraShake` script on `./World/Player/Camera3D`, `AudioDucker` autoload, and connect the `damaged` signal so one hit fires all three. About 130 lines of GDScript across 3 files. May I proceed?

## Step 4 — Install Section 1 (Hit Flash)

**Goal:** when a body takes damage, its mesh pulses to white for \~0.08s, then tweens back. Reads instantly even at 60 fps.

**Approach:** keep a reference to the original `albedo_color`. On `flash()`, write white directly to the mesh's `material_override` (or build a flash override the first time), then `create_tween()` back to the original over `duration` seconds. Tween, not `await` + sleep — tweens auto-cancel if `flash()` is re-called mid-flash, which matters during multi-hit combos.

**Node tree:**

```
World/
  Enemy (CharacterBody3D)
    Mesh (MeshInstance3D)        # the visible body
    HitFlash (Node)              # holds the script, no transform
```

**MCP path** (every scene-mutating call takes an explicit `scenePath`):

```
summer_add_node(scenePath="res://levels/world.tscn", parent="./World/Enemy", type="Node", name="HitFlash")
summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Enemy/HitFlash", key="script", value="res://scripts/vfx/hit_flash.gd")
```

Then write `scripts/vfx/hit_flash.gd` (via `summer_write_file`, which requires exactly
one of `create_only` or `expected_sha256`):

```gdscript theme={null}
class_name HitFlash
extends Node

@export var mesh: MeshInstance3D
@export var flash_color: Color = Color(1.0, 1.0, 1.0)
@export var duration: float = 0.08

var _orig_albedo: Color
var _flash_mat: StandardMaterial3D
var _tween: Tween

func _ready() -> void:
    if mesh == null:
        push_warning("HitFlash: mesh export not assigned")
        return
    # Build a dedicated override material once. Cloning the surface material
    # would also work, but a fresh StandardMaterial3D is simpler and free.
    _flash_mat = StandardMaterial3D.new()
    _flash_mat.albedo_color = flash_color
    _flash_mat.emission_enabled = true
    _flash_mat.emission = flash_color
    _flash_mat.emission_energy_multiplier = 2.0
    _orig_albedo = _flash_mat.albedo_color
    mesh.material_override = null  # leave original mesh material in place

func flash() -> void:
    if mesh == null:
        return
    if _tween and _tween.is_valid():
        _tween.kill()  # restart cleanly on rapid re-hits
    mesh.material_override = _flash_mat
    _flash_mat.albedo_color = flash_color
    _tween = create_tween()
    _tween.tween_interval(duration)
    _tween.tween_callback(func() -> void: mesh.material_override = null)
```

**Tunable knobs:** `duration` 0.06–0.12s — under 0.06 reads as a flicker, over 0.12 looks slow. `emission_energy_multiplier` 1.5–3.0.

> Note: `summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Enemy/Mesh", key="material_override", value="StandardMaterial3D")` creates an inline `StandardMaterial3D` sub\_resource that `summer_set_resource_property(scenePath="res://levels/world.tscn", nodePath="./World/Enemy/Mesh", resourceProperty="material_override", subProperty="albedo_color", value="Color(r, g, b, a)")` then edits and `summer_save_scene` persists — so the inline route, the script above, and a saved `materials/flash.tres` are all valid. What breaks is the value *shape*: a JSON object instead of a Godot literal string, a misspelled `key`/`subProperty`, or a wrong-typed value returns `ok:true` yet silently no-ops or destructively coerces on current engines (dict → material cleared; dict → `Color(0,0,0,1)`; newer engines reject these with `unknown_property` / `bad_value_shape` / `type_mismatch`). Always pass class names and `Color(...)`/`Vector3(...)` strings, and confirm the result in the saved `.tscn` or a snapshot diff — never from `ok` alone. See `summer_docs_read` id `reference/mcp-tools-reference`.

## Step 5 — Install Section 2 (Trauma Camera Shake)

**Goal:** any system can `add_trauma(amount)` to the camera. The camera accumulates trauma 0–1, decays it every frame, and shakes by `trauma² × random_offset`. The square is the trick: a 0.3 hit shakes very little (0.09), a 0.8 hit shakes a *lot* (0.64). Big hits feel huge, small hits don't pollute the screen.

**Approach:** put the shake on the existing Camera3D. Two variables — `_trauma` (0–1 accumulator) and `_shake_offset` (last frame's applied rotation). Every frame, **subtract last frame's offset first** so the next offset doesn't drift, then apply the new one. This is the non-obvious trick.

**Node tree:** the script attaches to the existing `./World/Player/Camera3D`. No new nodes.

```
summer_inspect_node(path="./World/Player/Camera3D")
summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Player/Camera3D", key="script", value="res://scripts/vfx/camera_shake.gd")
```

`scripts/vfx/camera_shake.gd`:

```gdscript theme={null}
class_name CameraShake
extends Camera3D

@export var trauma_decay: float = 3.0       # how fast trauma bleeds off (per second)
@export var max_shake_angle_deg: float = 2.5 # max pitch at trauma = 1.0
@export var yaw_factor: float = 0.5          # less yaw than pitch (eye anatomy)

var _trauma: float = 0.0
var _shake_offset: Vector3 = Vector3.ZERO

func _ready() -> void:
    # Node has no `groups` property, so there is nothing to set from the outside.
    # Joining the group here is how any actor finds this camera later.
    add_to_group(&"player_camera")

func add_trauma(amount: float) -> void:
    _trauma = clamp(_trauma + amount, 0.0, 1.0)

func _process(delta: float) -> void:
    # Always undo last frame's offset BEFORE deciding the next.
    # Without this, rotation drifts every frame trauma is non-zero.
    if _shake_offset != Vector3.ZERO:
        rotation -= _shake_offset
        _shake_offset = Vector3.ZERO

    if _trauma <= 0.0:
        return

    var shake := _trauma * _trauma   # quadratic — small hits barely shake
    var max_angle := deg_to_rad(max_shake_angle_deg) * shake

    _shake_offset = Vector3(
        randf_range(-max_angle, max_angle),
        randf_range(-max_angle, max_angle) * yaw_factor,
        0.0
    )
    rotation += _shake_offset
    _trauma = max(0.0, _trauma - trauma_decay * delta)
```

**Calling it:**

```gdscript theme={null}
$Camera3D.add_trauma(0.3)   # light hit (parry, glancing blow)
$Camera3D.add_trauma(0.5)   # standard combat hit
$Camera3D.add_trauma(0.8)   # heavy / explosion
```

**Tunable knobs:** `trauma_decay` 3.0 (long-tail, cinematic) ↔ 6.0 (snappy, twitch-shooter feel). `max_shake_angle_deg` 2.0 (subtle) ↔ 4.0 (action-game). Keep `yaw_factor` ≤ 0.5 — equal pitch/yaw shake feels uniform and wrong; eyes are far more sensitive to vertical motion.

## Step 6 — Install Section 3 (Audio Ducking)

This is the system most projects skip and the one that makes the biggest perceived difference. On a hit, briefly attenuate Music + Ambient + Announcer (whatever non-impact buses you have) so the SFX bus stabs through the mix. Then smoothly tween volumes back. **Crucially: store original bus volumes once at startup, and apply ducking as an offset.** Snapping volumes to absolute values destroys the user's volume preferences.

**Approach:** an autoload (`AudioDucker`) so any system can call `AudioDucker.duck(0.5, 0.4)` from anywhere. Internally:

* `_initial_volumes: Dictionary[StringName, float]` captured once in `_ready()` from `AudioServer.get_bus_volume_db()` for each named bus.
* `duck(amount, duration)` lowers each non-impact bus by `amount × per_bus_weight`, scheduled via Tween, and tweens back over `duration`.
* Per-bus weights let Music duck more than Ambient (Music 0.9 = -90% intensity, Ambient 0.7, Announcer 0.5).
* Kill any in-flight tween before starting a new one so rapid-fire hits don't compound the duck and silence the mix.

**Node tree:** none — register the script as an autoload.

`scripts/vfx/audio_ducker.gd`:

```gdscript theme={null}
extends Node

# Bus name -> duck weight (1.0 = full duck, 0.0 = never duck this bus).
# "SFX" / "UI" / "Voice" should NOT be ducked — those are the buses you want
# to punch through. Adjust to match your project's default_bus_layout.tres.
const BUS_WEIGHTS := {
    &"Music":     0.9,
    &"Ambient":   0.7,
    &"Announcer": 0.5,
}

# How loud the bus is "when not ducked", captured once. NEVER overwrite —
# this is the user's preference and mixer baseline.
var _initial_volumes: Dictionary = {}
var _tween: Tween

func _ready() -> void:
    for bus_name in BUS_WEIGHTS.keys():
        var idx := AudioServer.get_bus_index(bus_name)
        if idx == -1:
            push_warning("AudioDucker: bus '%s' not found in bus layout" % bus_name)
            continue
        _initial_volumes[bus_name] = AudioServer.get_bus_volume_db(idx)

# amount: 0.0–1.0 (0.5 = noticeable, 1.0 = near-silence on weighted buses)
# duration: total duck-and-restore time, seconds. 0.4–0.6 feels right for hits.
func duck(amount: float, duration: float = 0.5) -> void:
    if _initial_volumes.is_empty():
        return
    if _tween and _tween.is_valid():
        _tween.kill()
    _tween = create_tween().set_parallel(true)

    var attack := duration * 0.15  # fast duck-down, slow restore
    var release := duration * 0.85

    for bus_name in _initial_volumes.keys():
        var idx := AudioServer.get_bus_index(bus_name)
        if idx == -1:
            continue
        var base_db: float = _initial_volumes[bus_name]
        var weight: float = BUS_WEIGHTS.get(bus_name, 0.0)
        # Convert "amount" (0–1) into a dB drop. -24 dB at amount=1, weight=1
        # is "almost gone" without being absolute silence.
        var drop_db := -24.0 * amount * weight
        var target_db := base_db + drop_db

        # AudioServer exposes bus volume through a method, not an animatable
        # property, so tween_method is the only form that works here.
        # `tween_property(AudioServer, "bus_volume_db", ...)` errors at runtime.
        _tween.tween_method(_set_bus_db.bind(idx), base_db, target_db, attack)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
        _tween.tween_method(_set_bus_db.bind(idx), target_db, base_db, release)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
            .set_delay(attack)

func _set_bus_db(idx: int, db: float) -> void:
    AudioServer.set_bus_volume_db(idx, db)
```

Register it as an autoload:

```
summer_project_setting(...)   # autoload/AudioDucker = "*res://scripts/vfx/audio_ducker.gd"
```

(Equivalently, by hand: Project Settings → Autoload → Add `scripts/vfx/audio_ducker.gd`
as `AudioDucker`.)

**Calling it:**

```gdscript theme={null}
AudioDucker.duck(0.5, 0.4)   # standard hit
AudioDucker.duck(0.8, 0.6)   # heavy / boss hit
AudioDucker.duck(1.0, 1.2)   # cinematic — explosion or boss intro
```

**Why offset-not-absolute matters:** if the user has Music slider at -10 dB in your settings menu, and ducking writes `set_bus_volume_db("Music", -24.0)` directly, the *restore* tween will pull Music up to -24's "neutral" which is louder than the user wanted. Capturing `_initial_volumes` at autoload `_ready()` and only ever offsetting from those values preserves the user's mix forever.

## Step 7 — Wire the trio to one signal

The whole point: one hit, all three fire on the same frame. Keep this in the hurt body's damage handler — *not* on a global event bus, because you want the trauma amount and flash to scale per-hit.

```gdscript theme={null}
# scripts/enemy.gd (or wherever you handle damage on the body that got hit)
extends CharacterBody3D

signal damaged(amount: float)

@onready var _hit_flash: HitFlash = $HitFlash
@onready var _player_camera: CameraShake = get_tree().get_first_node_in_group("player_camera")

func take_damage(amount: float) -> void:
    # ... your existing health subtract / death check ...
    damaged.emit(amount)
    _on_damaged(amount)

func _on_damaged(amount: float) -> void:
    # 1. Hit flash on this body.
    _hit_flash.flash()
    # 2. Camera shake on the player's camera, scaled by hit weight.
    # clampf(), not clamp(): clamp() returns Variant, and inferring a variable from
    # a Variant is an error under Godot's default warning settings.
    var trauma := clampf(0.2 + amount * 0.05, 0.2, 0.9)
    if _player_camera:
        _player_camera.add_trauma(trauma)
    # 3. Audio duck, scaled by hit weight.
    var duck_amount := clampf(0.3 + amount * 0.04, 0.3, 1.0)
    AudioDucker.duck(duck_amount, 0.45)
```

The camera joins `player_camera` from its own `_ready()` (Step 5). `Node` has no
`groups` property, so `summer_set_prop(key="groups", ...)` cannot do this — and
`summer_set_prop` only accepts a string, number, or boolean anyway, never an array.
The two ways that do work are the `add_to_group()` call in `_ready()` or a `groups = ["player_camera"]` line written into the `.tscn` node header.

Or wire the signal explicitly via MCP if you prefer (`emitter`/`receiver`, not
`from`/`to`, and `scenePath` is required):

```
summer_connect_signal(scenePath="res://levels/world.tscn", emitter="./World/Enemy", signal="damaged", receiver="./World/Enemy/HitFlash", method="flash")
```

## Step 8 — Scaling intensity per hit weight

The same code makes a glancing parry feel different from a critical hit. Calibrate the curve once:

| Hit type | `damage` (HP) | trauma | duck amount |
| - | - | - | - |
| Glance / parry | 1–3 | 0.20 | 0.30 |
| Standard hit | 5–10 | 0.35 | 0.50 |
| Heavy hit | 15–25 | 0.55 | 0.70 |
| Critical / boss | 30–50 | 0.75 | 0.85 |
| Explosion / death | 50+ | 0.90 | 1.00 |

Because trauma is squared inside the camera, the *felt* difference between 0.35 (→ 0.12 shake) and 0.75 (→ 0.56 shake) is roughly 5×. That's the whole point — small hits stay clean, big hits saturate the screen.

## Step 9 — Verify

```
summer_save_scene(scenePath="res://levels/world.tscn")
summer_get_script_errors(path="res://scripts/vfx/hit_flash.gd")   # repeat for each of the three scripts
summer_play
# user reproduces a hit
summer_get_debugger_errors        # runtime errors from the tweens / bus lookups
summer_stop
```

Tune one knob at a time:

* Flash invisible → `emission_energy_multiplier` 1.5 → 3.0, or check that the mesh actually has a material to override.
* Shake feels jittery → reduce `max_shake_angle_deg`, or check `yaw_factor` is ≤ 0.5.
* Ducking inaudible → check `_initial_volumes` got populated (autoload running before the first hit?), and that `Music`/`Ambient` bus names actually match your `default_bus_layout.tres`.
* Ducking *too loud* → reduce per-bus weight, not the `amount` argument. Weights are per-project; amount is per-hit.

## Common mistakes

| Don't | Do | Why |
| - | - | - |
| `AudioServer.set_bus_volume_db("Music", -24)` directly | Capture `_initial_volumes` once, apply ducking as an offset | Absolute writes destroy the user's volume preferences |
| Linear shake (`shake = trauma`) | Quadratic (`shake = trauma * trauma`) | Linear feels uniform, twitchy. Quadratic separates small hits from big hits |
| Apply new shake offset without subtracting last frame's | Subtract `_shake_offset`, zero it, then write the new one | Otherwise rotation drifts every frame trauma > 0 |
| Hit flash with `await get_tree().create_timer(...)` | `create_tween().tween_callback(...)` | A timer can't be cancelled mid-flight on rapid re-hits, leaving albedo broken white |
| Only one bus ("Master") in the project | Add at least `Music`, `Ambient`, `SFX` and route SFX through SFX bus | Ducking does nothing if the impact SFX is on the same bus you're ducking |
| Duck for 1.5+ seconds | 0.4–0.6 second total duck-and-restore | Long ducks make the music feel broken, not punchy |
| Equal pitch/yaw shake | `yaw_factor ≤ 0.5` | Eyes are sensitive to vertical motion; equal axes feel mechanical |
| `Engine.time_scale` for every hit | Reserve hit-stop for big events only (boss hits, deaths) | Time-scale dips on every hit feel laggy, not impactful |
| A JSON object (or a misspelled key) as the `summer_set_prop` / `summer_set_resource_property` value — e.g. `value={"type": "StandardMaterial3D", ...}` through `summer_batch`, which forwards ops verbatim | `value="StandardMaterial3D"` to create the inline material, then `summer_set_resource_property(... value="Color(r, g, b, a)")` per field; confirm in the saved `.tscn` | Current engines return `ok:true` and silently no-op or coerce destructively — dict → material cleared, dict → `Color(0,0,0,1)`. The inline sub\_resource itself is fine |
| `summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Player/Camera3D", key="groups", value='["player_camera"]')` | `add_to_group(&"player_camera")` in `_ready()`, or a `groups = [...]` line in the `.tscn` | `Node` has no `groups` property, and `summer_set_prop` takes only string/number/boolean — never an array |
| `BaseMaterial3D.new()` | `StandardMaterial3D.new()` | `BaseMaterial3D` is abstract; constructing it is a parse error that kills the whole script |

## Collaborative protocol

This skill writes 3 GDScript files and adds 1 node + 1 autoload entry. Always ask before each section is applied. Group related writes into one ask: "I'm about to add HitFlash + CameraShake + AudioDucker autoload, wire one signal. OK?" See `summer_docs_read` id `reference/collaborative-protocol`.

## When NOT to use this skill

* **2D games** — the trauma curve and dB ducking still apply, but the camera-shake script needs a `Camera2D` rewrite (`offset` instead of `rotation`). Use this skill as a reference, not a drop-in.
* **Turn-based / strategy** — game-feel here lives in UI animation, not body-hit feedback. Skip the trio; reach for tween-based UI polish instead.
* **No audio buses configured** — ducking with one Master bus is a no-op. Set up `Music` / `Ambient` / `SFX` first via the audio-direction skill, then come back.
* **Networked games where the hurt body is remote** — the flash should run client-side on visual hit confirm, not on damage authority. Hook to a `hit_visual_received` signal, not the authoritative damage signal.

## See also

* `summer_docs_read` id `reference/mcp-tools-reference` — full MCP tool list, especially `summer_set_resource_property` on inline sub\_resources and which failures are loud vs. silent
* `summer_docs_read` id `reference/godot-version` — Summer compatibility and
  version-sensitive API notes
* `summer_docs_read` id `reference/collaborative-protocol` — "May I write" pattern
* `summer_docs_read` id `reference/gd-style` — typed GDScript conventions
* `audio-direction` — bus layout setup (prerequisite for ducking)
* `vfx-hit-spark` — particles to layer on top of the trio
* `debugging-game-feel` — when the trio is installed and it still feels wrong

## From a shell

No Summer MCP tools in this session? Run each one as a command; when `summer --version` is missing or below 4.0, use `npx -y summer-engine@latest` in place of `summer`. Fields are flags in kebab-case (`--scene-path res://main.tscn`) or one `--input '<json>'`, and `summer ops describe <op>` prints the exact input. The `summer-cli` skill has the rest.

| In this skill | Command |
| - | - |
| `summer_docs_read` | `summer docs read` |
| `summer_add_node` | `summer engine add-node` |
| `summer_batch` | `summer engine batch` |
| `summer_connect_signal` | `summer engine connect-signal` |
| `summer_get_debugger_errors` | `summer engine get-debugger-errors` |
| `summer_get_scene_tree` | `summer engine get-scene-tree` |
| `summer_get_script_errors` | `summer engine get-script-errors` |
| `summer_inspect_node` | `summer engine inspect-node` |
| `summer_inspect_resource` | `summer engine inspect-resource` |
| `summer_play` | `summer engine play` |
| `summer_project_setting` | `summer engine project-setting` |
| `summer_read_file` | `summer engine read-file` |
| `summer_save_scene` | `summer engine save-scene` |
| `summer_set_prop` | `summer engine set-prop` |
| `summer_set_resource_property` | `summer engine set-resource-property` |
| `summer_stop` | `summer engine stop` |
| `summer_write_file` | `summer engine write-file` |

## Related skills

* [`audio-direction`](/skills/audio-direction): Define the game's sound (music style, SFX vocabulary, dynamic music) as .summer/audio-bible.md.

***

Source: [`library/skills/game-feel/SKILL.md`](https://github.com/SummerEngine/summer/blob/main/library/skills/game-feel/SKILL.md), rendered from `summer-engine@4.3.0` on npm.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.