From summer
Installs a three-system game-feel stack (hit-flash, trauma camera shake, audio ducking) wired to a single signal in Godot 4.5. Triggered by phrases like "feels flat" or "needs juice".
How this skill is triggered — by the user, by Claude, or both
Slash command
/summer:game-feel**/*.gd**/*.tscn**/*.tresThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Particles don't make a game feel good. Screen shake alone doesn't either. What makes a hit *land in the body* 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. Skip any one and the gam...
Particles don't make a game feel good. Screen shake alone doesn't either. What makes a hit land in the body 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. Skip any one and the game drops back to "tech demo". This skill installs the canonical Godot 4.5 stack — three small, self-contained systems wired to a single signal — so one hit() call fires all three.
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.
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).
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:
HitFlashon./World/Enemy,CameraShakescript on./World/Player/Camera3D,AudioDuckerautoload, and connect thedamagedsignal so one hit fires all three. About 130 lines of GDScript across 3 files. May I proceed?
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:
summer_add_node(parent="./World/Enemy", type="Node", name="HitFlash")
Then write scripts/vfx/hit_flash.gd:
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.
CRITICAL: do NOT inline a
StandardMaterial3Das asub_resourceviasummer_set_prop. Build it in script (as above) or save it tomaterials/flash.tres. Inline sub_resources breaksummer_set_resource_propertysilently — seereferences/mcp-tools-reference.md§ "Trap".
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 "./World/Player/Camera3D"
# attach scripts/vfx/camera_shake.gd
scripts/vfx/camera_shake.gd:
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 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:
$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.
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.Node tree: none — register the script as an autoload.
scripts/vfx/audio_ducker.gd:
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
var seq := create_tween()
seq.tween_property(AudioServer, "bus_volume_db", target_db, attack).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)\
.from(base_db)
# NOTE: AudioServer doesn't expose bus volume as an animatable
# property — use a method tween instead. See actual call below.
# (The simpler, real implementation:)
_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:
Project Settings → Autoload → Add scripts/vfx/audio_ducker.gd as "AudioDucker"
Calling it:
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.
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.
# 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.
var trauma := clamp(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 := clamp(0.3 + amount * 0.04, 0.3, 1.0)
AudioDucker.duck(duck_amount, 0.45)
Add the camera to a group so any actor can find it:
summer_add_node(...) # ./World/Player/Camera3D already exists
summer_set_prop(path="./World/Player/Camera3D", key="groups", value='["player_camera"]')
Or wire the signal explicitly via MCP if you prefer:
summer_connect_signal(from="./World/Enemy", signal="damaged", to="./World/Enemy/HitFlash", method="flash")
The trio's whole magic is that 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.
summer_save_scene
summer_get_script_errors
summer_play
# user reproduces a hit
summer_stop
Tune one knob at a time:
emission_energy_multiplier 1.5 → 3.0, or check that the mesh actually has a material to override.max_shake_angle_deg, or check yaw_factor is ≤ 0.5._initial_volumes got populated (autoload running before the first hit?), and that Music/Ambient bus names actually match your default_bus_layout.tres.amount argument. Weights are per-project; amount is per-hit.| 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 |
Inline StandardMaterial3D sub_resource via summer_set_prop | Build in script or save standalone .tres | Inline sub_resources silently break summer_set_resource_property |
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 references/collaborative-protocol.md.
Camera2D rewrite (offset instead of rotation). Use this skill as a reference, not a drop-in.Music / Ambient / SFX first via the audio-direction skill, then come back.hit_visual_received signal, not the authoritative damage signal.references/mcp-tools-reference.md — full MCP tool list, especially the inline-sub_resource trapreferences/godot-version.md — Godot 4.5 API notesreferences/collaborative-protocol.md — "May I write" patternreferences/gd-style.md — typed GDScript conventionsaudio/audio-direction/SKILL.md — bus layout setup (prerequisite for ducking)post-processing/screen-shake/SKILL.md — deeper trauma variants (Perlin noise, stacked sources)visual-effects/gpuparticles-3d-basics/SKILL.md — particles to layer on top of the triovisual-effects/hit-impact-flashes/SKILL.md — flash material variations beyond white pulsenpx claudepluginhub summerengine/summer-engine-agent --plugin summerProvides Godot 4 GDScript patterns for architecture, signals, scenes, state machines, and optimization. Useful for building games, game systems, and best practices.
Generates short SFX one-shots (footsteps, weapon swings, UI clicks, hit impacts) and wires them as AudioStreamPlayer nodes that auto-free on finish. Use when needing a concrete sound effect prompt.
Constructs Godot scenes from patterns like platformer characters, top-down chars, UI screens, projectiles, pickups, tilemaps with required companion nodes (e.g., CollisionShape2D).