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
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.
const SPEED = 130.0const ACCELERATION = 1300.0 # ground: reach top speed quicklyconst FRICTION = 1600.0 # ground: stop quicklyconst AIR_ACCELERATION = 900.0 # weaker steering in the air...const AIR_FRICTION = 350.0 # ...and you keep momentum longerfunc _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.
const COYOTE_TIME = 0.10 # still jumpable just after leaving a ledgeconst JUMP_BUFFER = 0.10 # a press just before landing still countsfunc _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.0Variable 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.
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_CUTAsymmetric 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.
const RISE_GRAVITY_MULT = 0.9 # a little floaty going upconst FALL_GRAVITY_MULT = 1.5 # snappier coming downconst APEX_SPEED_THRESHOLD = 45.0 # |vertical speed| under this = near the peakconst APEX_GRAVITY_MULT = 0.55 # extra-light gravity for a "hang"const MAX_FALL_SPEED = 400.0 # terminal velocityfunc _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.
const SQUASH_STIFFNESS = 360.0 # higher = snappier recoveryconst 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:
_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 enemyScreen 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.
var shake_strength := 0.0const SHAKE_DECAY = 28.0func 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.
# 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.0One 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.
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)Juice.spawn_burst(global_position, COIN_COLOR, 12, 95.0) # coin sparkleJuice.spawn_burst(global_position, SPLAT_COLOR, 16, 130.0) # slime gooJuice.spawn_burst(global_position, Color(1, 0.4, 0.4), 22, 150.0) # death poofA 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.
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.
# coin.gdfunc _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)# game_manager.gd: a combo that cools off if you stop collecting.const COMBO_WINDOW := 1.5func add_point() -> int: score += 1 combo += 1 combo_timer = COMBO_WINDOW return combofunc _process(delta): if combo_timer > 0.0: combo_timer -= delta if combo_timer <= 0.0: combo = 0 # chain brokenThe floating "+1" popup
A throwaway label that drifts up and fades out, so an abstract counter turns into a reward you can actually see.
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.
const LOOK_AHEAD = 16.0const ZOOM_PUNCH_DECAY = 5.0func 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.
# music.gdfunc 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.
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.
# slime.gdfunc _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
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.

