Coding Quests
The Scroll Library
Guides

Game Juice: Making the Brackeys Platformer Actually Feel Good

June 25, 202611 min read

The Brackeys platformer is probably the most-built first game in all of Godot. You move, you jump, you grab coins, you dodge a slime, you touch it or fall in a pit and die. It works. But play it for thirty seconds and something feels off. The jump is stiff. Nothing in the world reacts to you. The only sign that you did anything at all is a number ticking up in the corner.

So I took that exact game and spent a while closing the gap between "it functions" and "it feels good." Same levels, same art, same little slime. Every change is about feel, and that difference has a name: game juice.

Here is the finished Godot 4 project from the video. Crack it open and poke at it while you read, or use it as a reference for your own game.

The juiced platformer project

The full Godot 4 project from the video, plus a GAME_FEEL.md that maps every technique to where it lives in the code and which number tunes it.

Godot 4 · GDScript · 3.6 MB · .zip

Download the project

What "juice" actually means

Juice is the disproportionate pile of audio and visual feedback an action gets. A coin does not just disappear. It pops, sparkles, sings a note a little higher than the last one you grabbed, and floats a little "+1" into the air. None of that changes what the game does. The coin counter goes up either way. It changes how the game feels to touch, and feel is most of the reason some games are a joy to play and others are a chore.

The line everyone quotes, from the talk "Juice it or lose it" by Martin Jonasson and Petri Purho, is the whole philosophy in one sentence: add way more feedback than feels reasonable, then dial it back.

Feel starts with the jump, not the sparkles

Before you add a single particle, the controls have to feel fair. Here is the counterintuitive part: realistic physics feels bad in a platformer. The games that feel best are quietly cheating to flatter you. Every value below lives in a named constant at the top of player.gd, so you can feel each one out.

Momentum: acceleration and friction

The character ramps up to speed and coasts to a stop instead of snapping on and off. move_toward does the easing, and the air uses weaker values so ground and air movement feel distinct.

GDScript
const SPEED = 130.0
const ACCELERATION = 1300.0 # ground: reach top speed quickly
const FRICTION = 1600.0 # ground: stop quickly
const AIR_ACCELERATION = 900.0 # weaker steering in the air...
const AIR_FRICTION = 350.0 # ...and you keep momentum longer
func _handle_horizontal(delta):
var direction := Input.get_axis("move_left", "move_right")
if direction != 0.0:
var accel := ACCELERATION if is_on_floor() else AIR_ACCELERATION
velocity.x = move_toward(velocity.x, direction * SPEED, accel * delta)
else:
var fric := FRICTION if is_on_floor() else AIR_FRICTION
velocity.x = move_toward(velocity.x, 0.0, fric * delta)

Coyote time and jump buffering

Two small forgiveness timers. One keeps the jump alive for a moment after you leave a ledge. The other remembers a jump you pressed just before landing. Together they mean the jump almost never has to be frame-perfect.

GDScript
const COYOTE_TIME = 0.10 # still jumpable just after leaving a ledge
const JUMP_BUFFER = 0.10 # a press just before landing still counts
func _handle_jump(delta):
if is_on_floor():
coyote_timer = COYOTE_TIME
else:
coyote_timer = max(coyote_timer - delta, 0.0)
if Input.is_action_just_pressed("jump"):
jump_buffer_timer = JUMP_BUFFER
else:
jump_buffer_timer = max(jump_buffer_timer - delta, 0.0)
# A buffered press plus coyote grace = a jump. Neither has to be exact.
if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
velocity.y = JUMP_VELOCITY
jump_buffer_timer = 0.0
coyote_timer = 0.0

Variable jump height

Tap for a hop, hold for the full arc. The trick is tiny: if you release the button while still rising, cut the leftover upward speed.

GDScript
const JUMP_CUT = 0.45 # fraction of the rising speed kept on an early release
# Up is negative in 2D, so velocity.y < 0.0 means "still rising".
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= JUMP_CUT

Asymmetric gravity and a beat of hang

Falling faster than you rise is the single biggest "this feels good" change. On top of that, gravity eases off right at the top of the arc for a readable beat of hang, and a terminal velocity keeps long falls controllable.

GDScript
const RISE_GRAVITY_MULT = 0.9 # a little floaty going up
const FALL_GRAVITY_MULT = 1.5 # snappier coming down
const APEX_SPEED_THRESHOLD = 45.0 # |vertical speed| under this = near the peak
const APEX_GRAVITY_MULT = 0.55 # extra-light gravity for a "hang"
const MAX_FALL_SPEED = 400.0 # terminal velocity
func _apply_gravity(delta):
if not is_on_floor():
var mult := RISE_GRAVITY_MULT if velocity.y < 0.0 else FALL_GRAVITY_MULT
if absf(velocity.y) < APEX_SPEED_THRESHOLD:
mult *= APEX_GRAVITY_MULT # ease off at the top of the arc
velocity.y += base_gravity * mult * delta
velocity.y = min(velocity.y, MAX_FALL_SPEED)

None of these announce themselves. The player never thinks "ah, coyote time." They just stop dying to jumps they were sure they made, and the controls start to feel like they are on their side.

Then you make the world react

Once the jump feels honest, juice is about making every action land with feedback that matches its weight. A few helpers do most of the work, and they live in an Autoload called Juice so coins, slimes, and the player can all reach for the same polish in a single line.

Squash and stretch

The character squishes on landing and stretches on take-off. Instead of a one-shot tween, a small damped spring drives the sprite's scale every frame, so a squash overshoots into a stretch and settles. That overshoot is what sells it.

GDScript
const SQUASH_STIFFNESS = 360.0 # higher = snappier recovery
const SQUASH_DAMPING = 16.0 # lower = more wobble and bounce
# "Kick" the spring. Positive = squash (wide, short); negative = stretch (tall).
func _squash_impulse(amount: float):
squash = amount
squash_vel = 0.0
# Integrate the spring every frame and write it to the sprite's scale.
func _update_squash(delta):
var force := -SQUASH_STIFFNESS * squash - SQUASH_DAMPING * squash_vel
squash_vel += force * delta
squash += squash_vel * delta
animated_sprite.scale = Vector2(1.0 + squash, 1.0 - squash)

Every big moment just kicks that one spring:

GDScript
_squash_impulse(SPAWN_SQUASH) # pop-in when the level starts
_squash_impulse(-JUMP_STRETCH) # stretch tall on take-off
_squash_impulse(land_amount) # squash on landing, scaled to fall speed
_squash_impulse(BOUNCE_SQUASH) # squash when you stomp an enemy

Screen shake, scaled to impact

A decaying shake offset added to the camera. Anything can request a shake and the strongest request wins. The important part is that a normal hop asks for almost nothing while a hard landing asks for a lot.

GDScript
var shake_strength := 0.0
const SHAKE_DECAY = 28.0
func add_shake(amount: float):
shake_strength = max(shake_strength, amount)
func _process(delta):
if shake_strength > 0.0:
shake_strength = move_toward(shake_strength, 0.0, SHAKE_DECAY * delta)
camera.offset = Vector2(randf_range(-1, 1), randf_range(-1, 1)) * shake_strength
# On landing: a gentle step barely registers, a long drop really thumps.
add_shake(clamp((impact - 120.0) / 220.0, 0.0, 2.0))

Hit-stop (freeze frames)

When you stomp the slime the whole game freezes for a few milliseconds, then snaps back. That pause is what makes the hit feel like it connected. The timer ignores time_scale, so it still ticks while everything else is frozen.

GDScript
# In the Juice autoload, so anything can call Juice.hit_stop().
func hit_stop(duration := 0.08) -> void:
Engine.time_scale = 0.0
await get_tree().create_timer(duration, true, false, true).timeout
Engine.time_scale = 1.0

One particle helper for everything

Dust, coin sparkles, slime goo, the death poof: all the same one-shot burst that spawns itself, fires everything at once for a pop, and cleans itself up.

GDScript
func spawn_burst(pos: Vector2, color := Color.WHITE, amount := 12, speed := 110.0) -> void:
var p := CPUParticles2D.new()
p.one_shot = true
p.explosiveness = 0.9 # release them all at once = a pop
p.amount = amount
p.spread = 180.0 # fire in every direction
p.color = color
get_tree().current_scene.add_child(p)
p.global_position = pos
p.emitting = true
get_tree().create_timer(1.2).timeout.connect(p.queue_free)
GDScript
Juice.spawn_burst(global_position, COIN_COLOR, 12, 95.0) # coin sparkle
Juice.spawn_burst(global_position, SPLAT_COLOR, 16, 130.0) # slime goo
Juice.spawn_burst(global_position, Color(1, 0.4, 0.4), 22, 150.0) # death poof

A sound for every action

Jump, double jump, land, stomp, hurt. The tutorial shipped with sound files it never played. Each one now plays through one helper that adds a little random pitch, so a hundred jumps in a row never sound identical.

GDScript
func _play_varied(sound: AudioStreamPlayer, low := 0.92, high := 1.09):
sound.pitch_scale = randf_range(low, high)
sound.play()

A rising-pitch coin combo

Grab coins quickly and each one plays a bit higher than the last. The coin asks the game manager for the current combo, then pitches its pickup sound up from there.

GDScript
# coin.gd
func _on_body_entered(body):
var combo := game_manager.add_point()
# Climbs with the combo, capped so it never gets shrill.
pickup_sound.pitch_scale = 1.0 + min(combo - 1, 8) * 0.07
Juice.spawn_burst(global_position, COIN_COLOR, 12, 95.0)
_spawn_popup(combo)
GDScript
# game_manager.gd: a combo that cools off if you stop collecting.
const COMBO_WINDOW := 1.5
func add_point() -> int:
score += 1
combo += 1
combo_timer = COMBO_WINDOW
return combo
func _process(delta):
if combo_timer > 0.0:
combo_timer -= delta
if combo_timer <= 0.0:
combo = 0 # chain broken

The floating "+1" popup

A throwaway label that drifts up and fades out, so an abstract counter turns into a reward you can actually see.

GDScript
func _spawn_popup(combo: int):
var label := Label.new()
label.text = "+1" if combo <= 1 else "+1 x%d" % combo
get_tree().current_scene.add_child(label)
label.global_position = global_position + Vector2(-6, -10)
var tween := label.create_tween()
tween.tween_property(label, "global_position",
label.global_position + Vector2(0, -14), 0.6) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.parallel().tween_property(label, "modulate:a", 0.0, 0.6) # float up and fade
tween.tween_callback(label.queue_free)

Camera look-ahead and a zoom punch

The camera leads slightly in the direction you are moving so you can see what you are running into, and it punches in a touch on big impacts before springing back.

GDScript
const LOOK_AHEAD = 16.0
const ZOOM_PUNCH_DECAY = 5.0
func add_zoom_punch(amount: float):
zoom_punch = max(zoom_punch, amount)
func _process(delta):
# Ease the camera toward a lead offset in the move direction.
var lead := signf(velocity.x) * LOOK_AHEAD if absf(velocity.x) > 20.0 else 0.0
look_ahead_x = lerp(look_ahead_x, lead, 1.0 - exp(-LOOK_AHEAD_SPEED * delta))
camera.offset.x = look_ahead_x
# Push in on impact, then settle back to the resting zoom.
zoom_punch = move_toward(zoom_punch, 0.0, ZOOM_PUNCH_DECAY * delta)
camera.zoom = base_zoom * (1.0 + zoom_punch)

Music that fades in and ducks

The track is an Autoload, so it never restarts between deaths. It fades in when a level starts, and on death it drops in volume and sweeps a low-pass filter down so the slow-mo beat goes muffled and distant, then swells back on respawn.

GDScript
# music.gd
func duck(): # on death: drop volume and muffle through a low-pass filter
_sweep(DUCK_VOLUME, DUCK_CUTOFF, 0.25)
func restore(): # on (re)start: swell back to full, open volume
_sweep(NORMAL_VOLUME, OPEN_CUTOFF, 1.2)

The death, as one reusable moment

Falling in a pit and touching the slime both call the same die(). It poofs the player, flashes the screen red, ducks the music, shakes, punches the zoom, and drops into slow motion before the level reloads.

GDScript
func die():
if is_dead: return
is_dead = true
_play_varied(hurt_sound)
Juice.spawn_burst(global_position, Color(1, 0.4, 0.4), 22, 150.0) # red poof
Juice.flash_screen(Color(0.7, 0.05, 0.05, 0.55), 0.5) # red flash
Music.duck()
add_shake(7.0)
add_zoom_punch(0.18)
Engine.time_scale = 0.4 # slow-mo
await get_tree().create_timer(0.8, true, false, true).timeout
Engine.time_scale = 1.0
get_tree().reload_current_scene()

Stompable enemies

The slime is an Area2D, so it decides for itself whether you stomped it or ran into it: falling and above its middle is a stomp, anything else hurts you. Now the fun action is also the powerful one.

GDScript
# slime.gd
func _on_hitbox_body_entered(body):
if not body.is_in_group("player"): return
var above := body.global_position.y < global_position.y - 2.0
if body.velocity.y > 0.0 and above:
_squash_to_death()
body.bounce() # pop the player up and refresh the air jump
else:
body.die()

The real skill is restraint

Notice what does not shake the screen: a normal hop. If every little action rattles the camera and spits out particles, the big moments have nothing left to say and the whole thing reads as noise. Juice works because it is proportional. Small action, small reaction. Big moment, big reaction. The landing shake even scales with how fast you hit the ground, so a gentle step down barely registers and a long drop really thumps.

That is the part the "add way more, then dial it back" advice is really about. The adding is easy. The taste is in the dialing back.

Grab it and turn the knobs yourself

Game feel does not live on paper, it lives in your hands. The only way to learn it is to change one number, play, and feel the difference. So the project is built for exactly that: nearly all the feel lives in named constants at the top of the player script. Want a floatier game? Lower the fall gravity. Snappier? Raise the acceleration. More forgiving? Widen the coyote time. Bouncier stomps? Raise the bounce.

The included GAME_FEEL.md is a full map: every technique, where it lives in the code, and which constant tunes it.

Download the project and start tuning

Open player.gd, change one number, press play, feel it. That loop is the whole skill.

Godot 4 · GDScript · 3.6 MB · .zip

Get the files

FAQ

What is game juice in game development?

Game juice is the extra audio and visual feedback layered onto an action to make it feel satisfying, beyond what the action mechanically does. Think squash and stretch, screen shake, particles, sound effects, and floating score numbers. It does not change the rules of the game, only how good it feels to play, which is a huge part of why some games feel great to touch.

Does adding juice actually make a game better, or is it just flashy?

It genuinely makes games feel better to play, but only when it is proportional. Feedback should match the weight of the action: a small move gets a small reaction, a big moment gets a big one. Juice on everything turns into noise and buries the moments that should stand out. The craft is adding a lot, then pulling most of it back.

How do I add game feel to a Godot platformer?

Start with the controls, not the effects. Add coyote time, jump buffering, variable jump height, and faster falling than rising so the jump feels fair and weighty. Then layer feedback on top: squash and stretch on jump and land, screen shake scaled to impact, particles on land and pickup, and a sound for every action. Keep every value in a named constant so you can tune the feel by changing one number at a time.

godotgame-juicegame-feelplatformer2d

Reading is the map. The quest is the territory.

Build this for real in the 2D Top-Down Movement Quest. It's free, no card needed.

Start the Quest
Written by Coding Quests

We teach Godot 4 by making you build complete systems: inventories, save systems, souls-like controllers, enemy AI. The scrolls are free. The quests are where it sticks.