Introducción al Desarrollo de Videojuegos con GameMaker · Module 11: Complete Development of a 2D Platformer
Mechanic Polish (Juice) and Visual Feedback
Lesson 53 of 60 · 60 min
Also available in: ES
Introduction to "Juice" and Game Feel
In game development, there is a crucial distinction between a mechanic that works and a mechanic that feels good. A character can run, jump, and collide with strict mathematical precision according to rules programmed in earlier modules; however, without immediate sensory feedback, the player will perceive controls as stiff, wooden, or disconnected. In indie game development, the term "Juice" refers to all the layers of visual, auditory, and temporal feedback added on top of a functional mechanic to maximize tactile and visual pleasure when interacting with the virtual world.
"Game Feel" does not alter the fundamental rules of the game's physics, but rather how those rules are communicated and how the imperfections of the human player are accommodated. When a character jumps, they shouldn't just shift their vertical position: the ground should emit dust particles, the character's body should compress before launch and stretch in the air, the camera can react with a subtle micro-shake on hard impacts, and the game should provide a slight margin of error if the user presses the button a few milliseconds too early or too late. In this lesson, we will cover full-spectrum polish for our 2D platformer in GameMaker, ensuring that every movement conveys weight, punch, and satisfaction.
Input Tolerance: Coyote Time and Jump Buffering
In a strict physics engine, if a character stops touching the ground for even a single frame (such as walking off the edge of a cliff into open air), the grounded flag instantly switches to false, disabling the jump command. To the human brain and visual refresh rates, this often feels like an unfair glitch: the player believes they pressed the button while still on the ledge, but the engine already registered a free fall. To resolve this, we implement two universal input tolerance mechanics: Coyote Time and Jump Buffering.
"Coyote Time" (named after the cartoon coyote who runs on air for a moment before falling) allows the player to jump during a small grace window (typically 5 to 8 frames at 60 FPS) after walking off a solid surface without jumping. Conversely, "Jump Buffering" caches a jump press in memory if the player hits the button in mid-air right before touching down (within a similar 5-to-8-frame window), automatically triggering the jump on the exact frame the character makes contact with the platform.
// Object: obj_player
// Event: Create
// Base movement variables (already existing)
hsp = 0;
vsp = 0;
grav = 0.4;
walk_spd = 3.5;
jump_spd = -7.5;
grounded = false;
// Input tolerances (Juice: Control Feel)
coyote_max = 6; // Grace frames after leaving the ground
coyote_timer = 0; // Countdown timer for late jumps
buffer_max = 6; // Frames to remember the jump press
buffer_timer = 0; // Countdown timer for early jumpsIn the player's Step event, we update these timers frame by frame before evaluating jump logic. If the player is on the ground (`grounded == true`), we reset the Coyote Time counter to its maximum value. Otherwise, we decrement the counter by 1 each step. For the Jump Buffer, whenever we detect a jump button press, we set `buffer_timer` to its maximum value. The jump will only fire if both margins align favorably.
// Object: obj_player
// Event: Step (Timer handling and jump logic section)
// 1. Read jump input
var _key_jump = keyboard_check_pressed(vk_space) || gamepad_button_check_pressed(0, gp_face1);
// 2. Manage Coyote Time
if (grounded) {
coyote_timer = coyote_max;
} else {
if (coyote_timer > 0) coyote_timer--;
}
// 3. Manage Jump Buffering
if (_key_jump) {
buffer_timer = buffer_max;
} else {
if (buffer_timer > 0) buffer_timer--;
}
// 4. Execute jump using tolerances
if (buffer_timer > 0 && coyote_timer > 0) {
vsp = jump_spd;
buffer_timer = 0; // Consume buffer immediately to prevent repeated jumps
coyote_timer = 0; // Consume coyote grace window
grounded = false;
}With these few lines of code, the jump feel improves dramatically. Players will no longer suffer frustrating falls when pushing jumps to the edge of ledges, nor will they lose momentum when attempting to chain bunny hops upon landing.
Elastic Deformation: Squash and Stretch
The classic animation principle known as Squash and Stretch is essential for giving mass, elasticity, and life to a 2D body. When an organic body collides forcefully with the ground, it flattens and widens horizontally (Squash) to dissipate kinetic energy; conversely, when accelerating upward or falling at high speeds, it elongates vertically and narrows horizontally (Stretch).

In GameMaker, modifying `image_xscale` and `image_yscale` directly for visual effects introduces a critical risk: if the instance's collision mask depends on the sprite scale, altering these properties modifies the character's physical bounding box, causing them to get stuck inside walls or the floor. The professional, safe solution is to leave native scale variables intact for physics and define custom variables strictly dedicated to visual rendering in the Draw event.
// Object: obj_player
// Event: Create
// Visual deformation variables independent of physical collision
x_scale_display = 1.0;
y_scale_display = 1.0;
// Helper variable to detect exact ground impact
was_grounded = true;In the Step event, we detect the exact frame the character jumps and the exact frame they touch down after falling. When either happens, we inject asymmetric values into `x_scale_display` and `y_scale_display`. Then, on every frame, we use the linear interpolation function `lerp()` to gradually return these values to their neutral 1.0 scale.
// Object: obj_player
// Event: Step (Squash and Stretch section)
// Detect jump to stretch character vertically (Stretch)
if (buffer_timer == 0 && coyote_timer == 0 && vsp == jump_spd) {
x_scale_display = 0.7; // Narrows horizontally
y_scale_display = 1.3; // Stretches upward
}
// Detect ground impact (Squash)
if (!was_grounded && grounded) {
// Impact compresses character against the floor
x_scale_display = 1.35;
y_scale_display = 0.65;
}
// Store grounded state for the next step
was_grounded = grounded;
// Continuous elastic return toward neutral scale (1.0)
x_scale_display = lerp(x_scale_display, 1.0, 0.15);
y_scale_display = lerp(y_scale_display, 1.0, 0.15);💡 Remember that `lerp(a, b, amt)` nudges 'a' toward 'b' by percentage 'amt' each frame. With an amount of 0.15, the deformation smoothly bounces back without requiring complex harmonic oscillator formulas.
To render this deformation without disrupting physical colliders, we override the default rendering inside the player's Draw event. We call `draw_sprite_ext()`, multiplying the horizontal facing direction (`facing`, holding 1 or -1 for right or left) by our custom scale.
// Object: obj_player
// Event: Draw
// Ensure obj_player sprite origin is set to 'Bottom Centre'
// so that deformation anchors to the feet rather than hovering mid-air.
draw_sprite_ext(
sprite_index,
image_index,
x,
y,
facing * x_scale_display,
y_scale_display,
0,
c_white,
image_alpha
);Camera Shake (Screen Shake)
Screen shake is one of the most effective ways to communicate impact, weight, and kinetic energy. Slamming into an obstacle, landing from a high fall, or defeating an enemy comes alive when the viewport is jolted. However, an uncalibrated or lingering shake causes motion sickness and disorientation. A good screen shake must be abrupt at its onset and decay rapidly in an exponential drop-off.
In Module 10, we learned how to configure a smooth follow camera using the view camera (`view_camera[0]`). To add shake without causing the camera to drift away from its target over time, we take the ideal target position (centered on the player) and apply a randomized offset that decays with each passing frame.
// Object: obj_camera
// Event: Create
target = obj_player;
cam = view_camera[0];
cam_w = camera_get_view_width(cam);
cam_h = camera_get_view_height(cam);
// Screen Shake variables
shake_magnitude = 0; // Current shake strength in pixels
shake_remain = 0; // Remaining decaying magnitude
shake_fade = 0.9; // Multiplicative decay coefficientTo allow any entity in the game (the player, a projectile, or a boss) to trigger camera shake without needing direct access to camera internals, we create a global helper function or script called `screen_shake()`.
// Script: scr_screen_shake
// Callable from any event on any object
function screen_shake(_magnitude, _force_overwrite = false) {
if (instance_exists(obj_camera)) {
with (obj_camera) {
// Overwrite if new shake is stronger or force overwrite is requested
if (_magnitude > shake_remain || _force_overwrite) {
shake_magnitude = _magnitude;
shake_remain = _magnitude;
}
}
}
}Finally, in the Step (or preferably End Step) event of `obj_camera`, we calculate standard camera tracking and add the pseudo-random perturbation generated by `random_range()` scaled to the remaining shake magnitude.
// Object: obj_camera
// Event: End Step
if (instance_exists(target)) {
// Ideal tracking destination without shake
var _dest_x = target.x - (cam_w / 2);
var _dest_y = target.y - (cam_h / 2);
// Smooth camera tracking
var _cur_x = camera_get_view_x(cam);
var _cur_y = camera_get_view_y(cam);
var _final_x = lerp(_cur_x, _dest_x, 0.1);
var _final_y = lerp(_cur_y, _dest_y, 0.1);
// Calculate shake offset
var _offset_x = random_range(-shake_remain, shake_remain);
var _offset_y = random_range(-shake_remain, shake_remain);
// Decay remaining shake
shake_remain = lerp(shake_remain, 0, 0.1);
if (shake_remain < 0.1) shake_remain = 0;
// Clamp to room bounds to prevent seeing outside the room
_final_x = clamp(_final_x + _offset_x, 0, room_width - cam_w);
_final_y = clamp(_final_y + _offset_y, 0, room_height - cam_h);
camera_set_view_pos(cam, _final_x, _final_y);
}Damage Flash (White Flash) and Hitstop
When two objects collide with critical gameplay consequences (such as stomping an enemy or taking hazard damage), immediate numeric state updates like `hp -= 1` feel cold and abstract. High-caliber action and platformer titles rely on two complementary visual cues to make impacts feel tangible: monochromatic flashing (typically solid white) and micro-pauses (Hitstop or Sleep Frames).
A monochromatic flash forces an entity's sprite to render purely white for 2 to 4 frames. In GameMaker, this can be achieved efficiently without duplicate sprites or custom shaders by using the GPU fog functions (`gpu_set_fog`). Enabling GPU fog with pure white color and zero distance forces every non-transparent pixel submitted to the GPU to be tinted solid white while preserving its alpha silhouette.
// Object: obj_enemy_parent (or obj_player)
// Event: Create
flash_timer = 0; // Duration of flash in frames// Object: obj_enemy_parent (or obj_player)
// Event: Draw
if (flash_timer > 0) {
flash_timer--;
// Force solid silhouette color replacement on GPU
gpu_set_fog(true, c_white, 0, 0);
draw_self();
// IMPORTANT: Turn fog off immediately to avoid corrupting subsequent draw calls
gpu_set_fog(false, c_white, 0, 0);
} else {
draw_self();
}The second technique, "Hitstop," deliberately freezes gameplay updates and sprite animations for a tiny fraction of a second (typically 3 to 6 frames, or roughly 0.05 to 0.10 seconds). This micro-freeze gives the player's brain time to register the weight and consequence of the hit before physics resume and the target flies backward or explodes.
To implement Hitstop without halting UI elements or locking the GameMaker engine loop, we use a global effect manager or an existing persistent controller like `obj_game_controller`.
// Object: obj_game_controller
// Event: Create
hitstop_frames = 0;
// Public function to trigger freeze frames
function trigger_hitstop(_frames) {
if (instance_exists(obj_game_controller)) {
obj_game_controller.hitstop_frames = _frames;
}
}// Object: obj_game_controller
// Event: Step
// Decrement Hitstop timer when active
if (hitstop_frames > 0) {
hitstop_frames--;
}In entities handling physics logic and animations (like `obj_player` and enemy objects), we place a guard clause at the very top of their Step events. If `hitstop_frames > 0`, we bypass physical movement and freeze animation speed (`image_speed = 0`). Once the freeze expires, regular updates resume.
// Object: obj_player (and obj_enemy_parent)
// Event: Step (At the very top of the event)
if (instance_exists(obj_game_controller)) {
if (obj_game_controller.hitstop_frames > 0) {
image_speed = 0; // Freeze sprite animation frame
exit; // Exit Step event early; skip movement and gravity calculations
} else {
image_speed = 1; // Restore normal animation playback
}
}Juice Techniques Matrix
The following table outlines how the feedback components covered in this session interact and when they should be deployed to maintain visual harmony:
| Juice Technique | Recommended Trigger | Optimal Parameters | Player Psychological Effect |
|---|---|---|---|
| Coyote Time | Walking off a ledge into mid-air | 5 to 8 frames (0.08s - 0.13s) | Responsive and fair control feel; prevents accidental missed jumps. |
| Jump Buffer | Pressing jump just before hitting ground | 5 to 8 frames (0.08s - 0.13s) | Removes input drop frustration caused by millisecond-early presses. |
| Squash & Stretch | Jump takeoff and solid ground landing | Scale 0.7/1.3 on jump; 1.35/0.65 on landing | Communicates weight, elasticity, and organic physical momentum. |
| Screen Shake | Damage taken, explosions, heavy falls | Magnitude 2 to 6 px; rapid decay | Conveys force, shockwaves, and solid physical impact. |
| White Flash | Any direct hit to player or enemy | 2 to 4 frames | Clear, unmistakable visual confirmation of impact on target. |
| Hitstop | Landed attack on enemy or lethal blow | 3 to 6 frames of total freeze | Adds dramatic weight and physical crunch to combat impacts. |
Practical Integration: The Full Impact Cycle
To see how these techniques harmonize, let's examine what happens when a player drops onto an enemy's head to stomp them. In an unpolished game, the enemy instance simply disappears via `instance_destroy()` while the player's vertical speed is reset to `vsp = jump_spd`. While functional, the interaction feels hollow and lifeless.
By applying our suite of juice effects, that single interaction becomes a coordinated event: on impact, both player and enemy freeze for 4 Hitstop frames; the enemy sprite flashes bright white; the camera shakes by 3 pixels; the player squashes outward horizontally before bouncing upward; a puff of dust particles bursts from the point of contact; and a punchy sound effect plays.
// Object: obj_player
// Event: Step (Vertical collision with enemies section)
var _enemy = instance_place(x, y + vsp, obj_enemy_parent);
if (_enemy != noone) {
// Check if player is descending onto the top of the enemy
if (vsp > 0 && y < _enemy.y) {
// 1. Physics: Bounce the player
vsp = jump_spd * 0.85;
// 2. Player juice (Squash on stomp)
x_scale_display = 1.4;
y_scale_display = 0.6;
// 3. Enemy juice (White flash and damage)
_enemy.flash_timer = 4;
_enemy.hp -= 1;
// 4. Subtle, punchy screen shake
screen_shake(3);
// 5. Freeze frames (Hitstop) to emphasize impact force
trigger_hitstop(4);
// 6. Audio cue
audio_play_sound(snd_stomp, 10, false);
}
}Coordinating these sensory cues elevates a routine collision check into a satisfying, cinematic gameplay moment. None of these techniques require expensive processing power or external libraries—just deliberate attention to detail and sound implementation.
Hands-On Exercises
1. Calibrating Coyote Time and Jump Buffering: Modify the `coyote_max` and `buffer_max` variables in `obj_player`'s Create event to test extreme values: first set both to 0 frames and play for two minutes trying to execute edge jumps. Next, set both to 15 frames. Notice how 0 feels unforgiving and stiff, while 15 makes the character feel floaty or prone to accidental double-jumps in mid-air. Dial in the sweet spot for your movement speed (typically between 5 and 7 frames). Verification criterion: The player should consistently execute natural ledge jumps without noticeable input lag or feeling like they are stepping on invisible platforms.
2. Wall Impact Squash and Stretch: Using `x_scale_display` and `y_scale_display`, program an elastic reaction in `obj_player`'s Step event when the character runs into a solid wall (`obj_solid`) at full horizontal speed. On impact, compress the character horizontally (`x_scale_display = 0.75`) and stretch them vertically (`y_scale_display = 1.25`) for a single frame before interpolating back to 1.0 via `lerp()`. Verification criterion: Sprinting head-on into a wall causes the sprite to momentarily squash against the surface without pushing the physical collision mask into the wall tile.
3. Damage Reaction with Screen Shake and Red Flash: In the event where the player takes hazard damage or collides with an enemy hitbox, call `screen_shake(6)` and set `flash_timer = 5` on the player. Update `obj_player`'s Draw event so that taking damage triggers a solid red flash instead of white using `gpu_set_fog(true, c_red, 0, 0)`. Verification criterion: Taking damage results in an unmistakable screen shudder that decays smoothly, while the character silhouette flashes pure red for 5 frames.
Summary
- "Juice" and "Game Feel" turn mechanically correct systems into tactile, reactive, and rewarding experiences through visual, temporal, and auditory feedback.
- Coyote Time and Jump Buffering provide input forgiveness for human reaction limits by caching jump commands across 5-to-8-frame windows.
- Squash and Stretch simulates weight and elasticity by modifying separate rendering variables in the Draw event without altering physical collision masks.
- Screen Shake delivers physical impact by applying decayed pseudo-random offsets to camera view coordinates.
- Monochromatic damage flashes via gpu_set_fog() and hitstop freezes make collisions punchy and readable without hurting engine performance.
Practise what you learned
Exercise 1 · 1 point
Why is it recommended to use custom variables (such as `x_scale_display` and `y_scale_display`) instead of directly modifying `image_xscale` and `image_yscale` when implementing squash and stretch?
Exercise 2 · 1 point
Match each game feel ('juice') technique with its corresponding definition:
- Coyote Time
- Jump Buffering
- Squash
- Stretch
Exercise 3 · 1 point
Arrange the steps executed in `obj_player`'s Step event in chronological order to process jumping with Coyote Time and Jump Buffering:
- 1Update the Jump Buffering timer (set to max if jump was pressed, or decrement by 1 if waiting).
- 2Update the Coyote Time timer (reset to max if grounded, or decrement by 1 if airborne).
- 3Check if both timers are greater than zero simultaneously.
- 4Detect and store the jump key or button press in a local variable.
- 5Assign vertical jump speed (`vsp = jump_spd`) and immediately reset both timers to zero.
Sign in to save your progress — Sign in