Game Feel · Blog
Game controller close up on a dark surface
Photo: Furkan Salihoğlu / Pexels
Contents7
  1. Why the Naive Approach Breaks Down
  2. Why It Works
  3. The Recipe
  4. The Code
  5. Tuning Tips & Common Mistakes
  6. Famous Examples
  7. FAQ

Screen shake is the single fastest way to make your game feel alive. Without it, bullets feel like pokes, explosions feel like firecrackers, and deaths feel like a timeout screen. With it, every impact has weight. Here is how to wire up the trauma method in Godot 4 — the most flexible approach and the one used behind the scenes in countless indie hits.

trauma²why small hits stay subtle
0.8 / sstarting trauma decay
80 × 60 pxmax offset at full trauma
15 minto add it to your game
How much the camera moves: linear vs. squared trauma (% of max)
LinearSquared (trauma²)
Trauma 0.2
20%
4%
Trauma 0.4
40%
16%
Trauma 0.6
60%
36%
Trauma 0.8
80%
64%
Trauma 1.0
100%
100%
trauma² curve used in the code below
Starting add_trauma() value per event
UI button press
0.05
Light hit / bullet
0.25
Heavy hit / melee
0.4
Nearby explosion
0.7
Massive explosion / death
1
Starting values from the table below (upper end of each range)
Arcade game cabinet lit up in a dark room
Arcade game cabinet lit up in a dark room — Photo: Dan Butler / Pexels

Why the Naive Approach Breaks Down

The first screen shake most devs write looks like this: on impact, pick a random offset, apply it for a few frames, done. The result is a choppy, jittery flicker that reads as a visual glitch rather than impact. Two problems:

  1. Frame-to-frame random values create jitter, not motion. The camera teleports instead of swinging.
  2. A flat amplitude means every hit feels the same. A fist and a grenade get the same nudge.

The trauma method solves both.

Why It Works

The trauma model, popularized in the indie community through Jan Willem Nijman's (Vlambeer) 2013 talk "The Art of Screenshake" at INDIGO Classes (YouTube, 2013), works on three ideas:

  • Accumulation. Trauma is a 0–1 value that adds up. Fire three shots and the shake compounds; it does not reset each time.
  • Squaring. The actual shake displacement is trauma² (or trauma³). At 0.3 trauma the camera barely moves (0.09 of max); at 1.0 it hits full force. Small hits stay subtle; big hits are violent.
  • Smooth noise. Instead of randf() each frame, the camera samples a FastNoiseLite texture along a moving axis. Consecutive frames are correlated — the camera swings, not twitches.

The result scales naturally: a bullet hit adds 0.2 trauma, an explosion adds 0.7, a screen-clearing bomb adds 1.0. Each event earns its own drama.

The Recipe

Parameters table

ParameterStarting valueUseful rangeWhat it changes
decay0.80.5 – 2.5Trauma lost per second; higher = snappier fade
max_offsetVector2(80, 60)30–150 pxMaximum pixel displacement at full trauma
max_roll0.08 rad (~4.5°)0.0 – 0.15Rotation at full trauma; keep low or it nauseates
trauma_power22 – 3Exponent; 3 makes light hits almost invisible
noise.frequency0.50.3 – 1.5Lower = slower, lazier wobble; higher = nervous energy

How much trauma to add per event

Eventadd_trauma() value
UI button press0.05
Light hit / bullet impact0.15 – 0.25
Heavy hit / melee0.3 – 0.4
Explosion nearby0.5 – 0.7
Massive explosion / death0.8 – 1.0

The Code

Attach this script to your Camera2D node. It needs a FastNoiseLite resource (created at runtime) and nothing else — no additional nodes required.

# TraumaCamera2D.gd
# Attach to a Camera2D node.
# Call add_trauma(amount) from anywhere in your code on impact.

extends Camera2D

## How fast trauma decays per second (0 = never, 2.5 = very quick).
@export var decay: float = 0.8

## Maximum pixel displacement at full trauma.
@export var max_offset: Vector2 = Vector2(80.0, 60.0)

## Maximum rotation at full trauma (~4.5 degrees). Keep below 0.15.
@export var max_roll: float = 0.08

## Exponent applied to trauma before displacing. 2 = quadratic, 3 = cubic.
@export_range(2, 3, 1) var trauma_power: int = 2

# Current trauma value, 0.0 (calm) to 1.0 (full shake).
var trauma: float = 0.0

# Noise position advances each frame to get a continuous wobble.
var _noise_t: float = 0.0

@onready var _noise: FastNoiseLite = _make_noise()


func _make_noise() -> FastNoiseLite:
    var n := FastNoiseLite.new()
    n.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    n.seed = randi()
    n.frequency = 0.5
    return n


## Call this on every impact. Amount is 0.0–1.0.
## Values stack: calling twice with 0.3 results in 0.6 trauma (capped at 1.0).
func add_trauma(amount: float) -> void:
    trauma = minf(trauma + amount, 1.0)


func _process(delta: float) -> void:
    if trauma <= 0.0:
        return

    # Decay trauma linearly over time.
    trauma = maxf(trauma - decay * delta, 0.0)

    # Raise to power so small trauma stays subtle.
    var t: float = pow(trauma, trauma_power)

    # Advance noise position (speed is arbitrary; 50 gives a nice pace).
    _noise_t += delta * 50.0

    # Sample three independent noise channels for x, y, and rotation.
    # Multiplying the seed offsets ensures channels are uncorrelated.
    rotation    = max_roll    * t * _noise.get_noise_2d(0.0,          _noise_t)
    offset.x    = max_offset.x * t * _noise.get_noise_2d(1000.0,       _noise_t)
    offset.y    = max_offset.y * t * _noise.get_noise_2d(2000.0,       _noise_t)

    # Reset when fully decayed.
    if trauma <= 0.0:
        offset   = Vector2.ZERO
        rotation = 0.0

Where to put it

  1. Select your Camera2D node in the scene tree.
  2. Click Attach Script and paste the code above (or save it as TraumaCamera2D.gd).
  3. That's it — no autoload, no singleton required.

Triggering shake from other scripts

Export a reference or use a group to reach the camera:

# Option A: direct reference (simplest)
@onready var camera: TraumaCamera2D = $Camera2D

func _on_enemy_hit() -> void:
    camera.add_trauma(0.25)

# Option B: via group (useful when the camera lives in a different scene)
# In _ready(): add_to_group("trauma_camera")
# From anywhere:
func _on_explosion() -> void:
    for cam in get_tree().get_nodes_in_group("trauma_camera"):
        cam.add_trauma(0.7)
Game developer coding at a monitor with code on screen
Game developer coding at a monitor with code on screen — Photo: Lee Campbell / Pexels

Tuning Tips & Common Mistakes

Start smaller than you think. Playtest with add_trauma(0.2) for a standard hit before reaching for 0.5. Players sitting close to screens are more sensitive than you are during development.

Tune decay, not max_offset. The duration of the shake matters more than the distance. A 0.3-trauma hit that lingers 0.8 seconds feels heavier than one that pops to 50 px and vanishes in 2 frames.

Cap rotation aggressively. max_roll = 0.08 (about 4.5°) is the sweet spot for most 2D games. Go above 0.15 and players start to feel motion-sick, especially on mobile.

Don't add trauma every frame for continuous effects. A flamethrower calling add_trauma(0.3) at 60 FPS locks the camera at 1.0 forever. Use add_trauma(0.3 * delta) for sustained effects so trauma accumulates at a controlled rate per second.

Reset offset when trauma hits zero. The code above already does this. Forgetting it leaves the camera permanently displaced by whatever noise value it landed on when trauma ran out.

Physics-interpolated cameras need extra care. If your project uses ProjectSettings → Physics → 3D → Run Physics in Thread or physics interpolation, offset the shake after interpolation runs. A common fix is to set position_smoothing_enabled = false on the shake camera and handle following manually — check the Godot forum thread on this topic for details.

Famous Examples

Nuclear Throne (Vlambeer, 2015) — Every gun has a distinct shake signature: the pistol nudges, the shotgun bucks, the laser rifle vibrates. Nijman's INDIGO 2013 talk (YouTube) walks through the exact moment screen shake was added to the prototype and the immediate "oh, that's it" reaction it produced. The core idea of intensity proportional to weapon weight maps directly to the trauma table above.

Celeste (Maddy Thorson & Noel Berry, 2018) — Uses extremely restrained shake. Dashing barely moves the camera; death is the only action with a large shake. This deliberate understatement keeps the game readable at high speed — when the shake arrives, it means something. The GDC 2017 level design talk (GDC Vault) touches on how every feedback element was kept minimal to serve precision platforming.

Hollow Knight (Team Cherry, 2017) — The nail clashes and parries use a brief, sharp shake paired with a freeze frame (hit-stop). The combination is what makes hits land: the freeze holds the frame at the moment of impact, then the shake announces the collision result. If you are building an action game, screen shake and hit-stop belong together.

Pixel art game characters in a colorful side-scrolling environment
Pixel art game characters in a colorful side-scrolling environment — Photo: Forse Jorre / Pexels

FAQ

Does screen shake hurt accessibility?

Yes, for some players — vestibular disorders make camera motion genuinely uncomfortable or painful. Add a Screen Shake slider (0–100 %) in your settings and multiply add_trauma() input by that factor. It costs ten lines of code and covers your players.

Should I use Camera2D.offset or Camera2D.position?

Use offset. Modifying position fights any smoothing or follow logic running on the same camera. offset is added on top of the camera's computed position, so it stacks cleanly with follow targets, limits, and zoom.

My shake looks robotic / too regular. What's wrong?

The noise frequency is probably too high. Try n.frequency = 0.3 in _make_noise(). Also make sure you are advancing _noise_t in _process(), not in _physics_process() — physics runs at a fixed 60 Hz, which creates a metronomic rhythm.

Can I use this in 3D?

Yes, but target rotation becomes three axes and offset becomes a 3D translation. Extend Camera3D instead of Camera2D, replace offset with h_offset / v_offset (or override the camera basis), and the rest of the logic stays identical.

How do I combine this with hit-stop?

Trigger add_trauma() immediately on impact. For hit-stop, set Engine.time_scale = 0.05 for 60–100 ms, then restore it to 1.0. Run the timer with process_callback = Timer.TIMER_PROCESS_PHYSICS and check that your shake camera uses _process() (not _physics_process()), so the shake keeps running during the freeze.


Screen shake is cheap, it's fast, and it's one of the highest-return investments in game feel. Paste the script above, call add_trauma(0.25) on your next bullet impact, and you will not go back. Once you feel the difference, you will want the full toolkit — explore the Game Feel Cookbook for hit-stop, squash & stretch, and everything else that makes players say "this just feels good."