Screen shake, hit-stop and squash are easy to add one at a time and surprisingly easy to break once several play together. Three hits in one frame shake the camera three times as hard, two squashes leave the sprite permanently squished, and a shake that sets camera.offset wipes out the offset your own code set. Here’s the approach I settled on, in plain GDScript, in case it helps anyone polishing a 2D game.
1. Apply the difference, not the value
Never write camera.offset = shake. Keep what you added last frame and swap it for this frame’s amount:
cam.offset += new_offset - applied_offset
applied_offset = new_offset
Your own code can keep moving the camera freely, and when the shake ends the offset is exactly what you left it at. The same trick works for scale: divide out last frame’s squash factor and multiply in the new one (n.scale = n.scale / applied * f). Three squashes on one sprite then settle back to its exact original scale, even if your code changed the scale in the middle.
2. Shake with trauma, not with “add an offset per hit”
Each hit adds “trauma” (0 to 1), trauma fades over time, and the camera moves by trauma squared times a maximum offset, sampled from noise rather than random numbers so it moves smoothly:
trauma = clampf(trauma + amount, 0.0, 1.0) # on hit
trauma = maxf(trauma - decay * delta, 0.0) # every frame
var amt := trauma * trauma * max_offset
offset = Vector2(noise.get_noise_2d(t, 0.0), noise.get_noise_2d(0.0, t + 100.0)) * amt
Squaring is what makes it feel right: small hits stay subtle, a big hit is a real jolt, and 300 enemies dying in one frame clamp to 1 instead of throwing the camera across the map. (Credit to Squirrel Eiserloh’s GDC talk on camera shake for the idea.)
3. Hit-stop counts down in real time
Hit-stop means Engine.time_scale = 0 for a few frames. The catch: if you time it with delta, delta is now 0 and it never ends. So measure with Time.get_ticks_usec() instead, and run the node with PROCESS_MODE_ALWAYS. Two more details:
- Overlapping hit-stops don’t add up; the lowest time scale wins and each one expires on its own.
- Save the game’s own
time_scalebefore you touch it and restore that, so a slow-motion power-up isn’t reset to 1.
4. One pass per frame for everything
All effects write into per-frame totals (camera offset, zoom, time scale, screen flash color), and one node with a high process_priority applies them once, after your game code has moved things. Effects that finish instantly (a shake, a damage number, some sparks) run on one reused object instead of allocating, and damage numbers, sparks and rings are each drawn by a single _draw() call, so a crowd taking hits is still cheap.
5. Limit per frame
Give each effect a “most starts per frame” setting: one for shakes and hit-stops, two or three for sounds. That single setting is what stops an area attack hitting 80 enemies from sounding like a wall of noise.
I’ve packaged all of this as Impact, a Godot 4 add-on with 22 effects (shake, hit-stop, flashes, squash, knockback, damage numbers, sparks, rings, slow motion and more) combined into 14 ready-made recipes you play with one line, plus player settings for an options menu (shake, flashes, hit-stop, motion):
There’s also a free Impact Lite (shake, hit-stop, flash and damage numbers) if you just want the basics.
Happy to answer questions about any of it.