Introducción al Desarrollo de Videojuegos con GameMaker · Module 4: Controls, Movement, and Player Handling

Refining Controls and Movement Variables

Lesson 20 of 60 · 55 min

Also available in: ES

The Importance of Game Feel and Responsive Controls

In 2D platformer development, the difference between a frustrating experience and a deeply rewarding one rarely comes down to art style or storyline complexity; it almost entirely hinges on what game designers call "game feel" or control responsiveness. When players press a button on their keyboard or gamepad, they expect an immediate, predictable, and fair reaction. If the character takes a fraction of a second too long to stop, fails to jump because the button was hit a millisecond before landing, or moves along an unyielding airborne arc, players will not blame their own reflexes—they will blame the game.

Up to this point in the module, you have built the essential foundations: polling keyboard and gamepad inputs, calculating horizontal direction, flipping sprites horizontally with scale, and applying a simple vertical impulse to leave the ground. However, instantaneous velocity (where the character snaps from 0 to full speed in a single frame and halts on a dime) can feel stiff or overly robotic for many gameplay styles. Likewise, a fixed-height jump prevents players from modulating their trajectory to clear low ceilings or land precisely on tight ledges.

The goal of this lesson is to refine those rudimentary mechanics. You will learn how to structure fine-tuning variables for acceleration and friction, allow jump height to scale with how long the button is held (variable jump), and implement two cornerstone input-forgiveness techniques in modern platformer design: coyote time and jump buffering.

💡 While GameMaker offers a visual scripting alternative called GML Visual (using interconnected action blocks and icons), we will continue using GML Code throughout this course. Writing raw code grants complete control over the mathematical order of operations and makes fine-tuning physical formulas significantly easier.

Controlled Inertia: Horizontal Acceleration and Friction

In the previous lesson on horizontal movement, we assigned the target speed directly by multiplying the direction vector by maximum speed: whenever the player pressed right, the horizontal velocity jumped immediately from zero to, say, four pixels per frame. For strict arcade-style games, this provides immediate responsiveness, but it often lacks weight and organic motion.

To give the character a smooth transition without sacrificing responsiveness, we introduce two complementary variables: acceleration and friction. Acceleration defines how many pixels per frame we add to current velocity until reaching top speed. Friction dictates how many pixels per frame we strip away when no directional input is held, bringing the character smoothly down to zero.

A common beginner mistake is subtracting friction directly without checking if the value crosses zero. For instance, if the character is moving right at 0.2 pixels per frame and we apply 0.5 friction to the left, the resulting speed becomes -0.3, causing the character to jitter or slide backward infinitely. To prevent this, we write safe mathematical approximations or use precise logical comparisons with standard GML functions.

Horizontal Setup Variables in the Create Event

Open your primary player object, `obj_player`, and open its **Create** event. We will reorganize and expand the movement variables created in previous lessons to allow easy calibration from a single location in your project.

/// obj_player Create Event

// --- HORIZONTAL MOVEMENT VARIABLES ---
vel_x = 0;              // Current horizontal speed in pixels per frame
vel_x_max = 3.5;        // Maximum reachable horizontal speed
acel_x = 0.4;           // Speed increase per frame while holding direction
fric_x = 0.35;          // Speed reduction per frame after releasing controls

// --- VERTICAL MOVEMENT AND GRAVITY VARIABLES ---
vel_y = 0;              // Current vertical speed
vel_y_max = 8.0;        // Terminal fall speed cap
gravedad = 0.35;        // Constant downward force
vel_salto = -6.5;       // Initial jump impulse (negative value moves upward)

Pay close attention to these numeric values: `acel_x` is set to 0.4 and `fric_x` to 0.35. At a standard 60 frames per second, an increment of 0.4 means the character takes under ten frames (about one-sixth of a second) to ramp up from a dead stop to the top speed of 3.5. This provides a subtle sense of momentum without feeling sluggish or introducing perceptible input lag.

Calculating Acceleration and Deceleration in the Step Event

Inside the **Step** event of `obj_player`, replace the direct horizontal speed assignment with a conditional block that checks whether the player is actively providing movement input.

/// obj_player Step Event snippet: Horizontal movement

// 1. Directional input collection (-1 for left, 1 for right, 0 if idle)
var _tecla_der = keyboard_check(vk_right) || keyboard_check(ord("D"));
var _tecla_izq = keyboard_check(vk_left) || keyboard_check(ord("A"));
var _dir = _tecla_der - _tecla_izq;

// 2. Horizontal speed modification using acceleration or friction
if (_dir != 0) {
    // Player wants to move: accelerate toward target direction
    vel_x += _dir * acel_x;
    
    // Ensure speed does not exceed the allowed cap
    vel_x = clamp(vel_x, -vel_x_max, vel_x_max);
} else {
    // Player released controls: apply friction to stop
    if (vel_x > 0) {
        vel_x = max(0, vel_x - fric_x);
    } else if (vel_x < 0) {
        vel_x = min(0, vel_x + fric_x);
    }
}

Let's examine the native `clamp(val, min, max)` function used here. It evaluates a value and ensures it never drops below `min` or climbs above `max`. In our code, it prevents continuous additions of `acel_x` from accelerating the player indefinitely. In the friction block, `max(0, vel_x - fric_x)` ensures that subtracting friction from a positive speed will never drop below zero, eliminating jitter or unwanted backward sliding.

Variable Jump: Airborne Adaptability

In classics like Super Mario Bros., Celeste, and Mega Man, jump height depends on how long the player holds the button: a quick tap produces a short hop to avoid low ceilings or settle onto nearby ledges, while holding the button propels the character to the peak of their jump arc. If jumps were always a fixed height, players would lose significant tactical freedom when navigating platforming routes.

The most elegant and responsive way to achieve this without altering base gravity is cutting upward momentum the exact moment the player releases the button. When the jump key is released while the character is still traveling upward (meaning vertical velocity `vel_y` is negative), we multiply that velocity by a fractional multiplier (such as 0.4 or 0.5) or clamp it to a smaller threshold.

Schematic diagram comparing a short jump arc versus a full jump arc, illustrating cut-off upon button release alongside coyote time and jump buffering windows.
Fine control mechanics: variable jumping cuts upward momentum upon button release, while jump buffering and coyote time expand input tolerance windows. · AI-generated image

Implementing Jump Cut in GML

To implement this behavior, first declare a multiplier variable in the **Create** event of `obj_player`, defining how much upward momentum remains when the button is released early:

/// Added to obj_player Create Event
multiplicador_corte_salto = 0.45; // Percentage of upward vertical speed retained upon releasing the button

Next, in the **Step** event, detect when the jump key is released using `keyboard_check_released()`:

/// obj_player Step Event snippet: Variable jump

// Detect release of common jump keys
var _solto_salto = keyboard_check_released(vk_space) || keyboard_check_released(ord("W")) || keyboard_check_released(vk_up);

// If player releases the button while ascending, dampen the ascent
if (_solto_salto && vel_y < 0) {
    vel_y = vel_y * multiplicador_corte_salto;
}

Notice the condition `vel_y < 0`. In GameMaker's 2D coordinate system, the Y axis increases downward; therefore, negative vertical velocity indicates upward motion on screen. If the player releases the button while already falling back toward the ground (`vel_y >= 0`), the condition evaluates to false, allowing downward gravity to continue uninterrupted.

Input Forgiveness: Coyote Time and Jump Buffering

Both display hardware and human reflexes introduce small millisecond delays: physical reaction latency, monitor refresh cycles, and game loop tick rates. This routinely causes two frustrating scenarios in platformers if left unaddressed in code:

Common Gameplay IssueTechnical Engine CauseControl Design Solution
The player runs off a ledge and presses jump right as they step into empty air, but the character plummets without jumping.The character stopped colliding with the ground one or two frames before processing the jump input; the engine considers them already airborne.Coyote Time: grant a grace window of several frames after leaving solid ground during which a jump input remains valid.
The player falls toward a platform and presses jump just before landing, but upon touching down, the character simply stands still.The `keyboard_check_pressed` check fired mid-air and expired instantly; upon landing, the key is no longer registered as freshly pressed.Jump Buffering: store the jump intent for a few frames prior to landing and fire the jump immediately upon touching ground.

The term "Coyote Time" references the classic Wile E. Coyote cartoons, where the coyote runs past the edge of a cliff and pauses briefly in mid-air before falling. In games, this grace window is typically tuned between 4 and 8 frames (roughly 60 to 130 milliseconds at 60 FPS). It is virtually imperceptible to the naked eye, yet completely eliminates the feeling that the game "ate my input."

Frame-Based Timers in GameMaker Without Alarms

While GameMaker features built-in alarms (`alarm[0]`), character physics and input logic are far more reliable and readable when handled with integer variables decremented frame-by-frame inside the **Step** event. This keeps all movement logic contained and synchronized within the exact same update pass.

Add these counter variables to the **Create** event of `obj_player`:

/// New variables in obj_player Create Event for input forgiveness

// --- JUMP BUFFER ---
tiempo_buffer_max = 6;      // Frames to remember a jump press
contador_buffer = 0;        // Active countdown timer

// --- COYOTE TIME ---
tiempo_coyote_max = 6;      // Frames after falling where jumping is still allowed
contador_coyote = 0;        // Active countdown timer

en_suelo = true;            // Solid surface contact flag

Complete Integration of the Polished Movement System

Now that we understand each individual component, let's assemble the full, structured **Step** event for `obj_player`. For now, to test these input tolerances independently before diving into full solid tile collisions (Module 5), we will simulate ground contact using a basic threshold on the vertical `y` position.

/// Full obj_player Step Event with input forgiveness and variable jump

// 1. INPUT COLLECTION
var _tecla_der = keyboard_check(vk_right) || keyboard_check(ord("D"));
var _tecla_izq = keyboard_check(vk_left) || keyboard_check(ord("A"));
var _presiono_salto = keyboard_check_pressed(vk_space) || keyboard_check_pressed(ord("W"));
var _solto_salto = keyboard_check_released(vk_space) || keyboard_check_released(ord("W"));

var _dir = _tecla_der - _tecla_izq;

// 2. BASIC GROUND CHECK (Replaced in Module 5 by true collision checks)
// Simulating a floor line at coordinate Y = 384
en_suelo = (y >= 384);

// 3. COYOTE TIME MANAGEMENT
if (en_suelo) {
    contador_coyote = tiempo_coyote_max; // Reset window while touching ground
} else {
    if (contador_coyote > 0) {
        contador_coyote--; // Decrement each frame spent in the air
    }
}

// 4. JUMP BUFFER MANAGEMENT
if (_presiono_salto) {
    contador_buffer = tiempo_buffer_max; // Buffer the jump intent
} else if (contador_buffer > 0) {
    contador_buffer--; // Buffer expires if too much time passes
}

// 5. HORIZONTAL MOVEMENT WITH ACCELERATION AND FRICTION
if (_dir != 0) {
    vel_x += _dir * acel_x;
    vel_x = clamp(vel_x, -vel_x_max, vel_x_max);
    
    // Visual sprite flip matching movement direction
    image_xscale = _dir;
} else {
    if (vel_x > 0) {
        vel_x = max(0, vel_x - fric_x);
    } else if (vel_x < 0) {
        vel_x = min(0, vel_x + fric_x);
    }
}

// 6. JUMP EXECUTION USING COYOTE AND BUFFER TIMERS
if (contador_buffer > 0 && contador_coyote > 0) {
    vel_y = vel_salto;
    
    // Immediately consume timers to prevent accidental double jumps
    contador_buffer = 0;
    contador_coyote = 0;
}

// 7. VARIABLE JUMP CUT (HEIGHT BASED ON HOLD DURATION)
if (_solto_salto && vel_y < 0) {
    vel_y = vel_y * multiplicador_corte_salto;
}

// 8. GRAVITY AND TERMINAL VELOCITY
if (!en_suelo) {
    vel_y += gravedad;
    if (vel_y > vel_y_max) {
        vel_y = vel_y_max; // Limit terminal fall speed
    }
} else {
    // Grounded and not jumping: cancel downward vertical speed
    if (vel_y > 0) {
        vel_y = 0;
        y = 384; // Snap directly onto the test floor
    }
}

// 9. FINAL POSITION UPDATE
x += vel_x;
y += vel_y;

Take a close look at section 6 in the code above. The condition `contador_buffer > 0 && contador_coyote > 0` reads: "If the player pressed jump very recently AND still has grace time left since last touching ground, execute the jump." By immediately zeroing both counters (`contador_buffer = 0; contador_coyote = 0;`), we prevent the jump from retriggering on the next frame.

Comparing Movement Profiles

Tuning movement variables is not about guessing arbitrary numbers; it is about establishing the gameplay identity your level design demands. The table below outlines three common archetype configurations in 2D platforming using the exact code structure we just built:

Gameplay Profileacel_x / fric_xvel_salto / gravedadmultiplicador_corteController Feel
Classic Arcade (Snappy)acel: 0.6 / fric: 0.5salto: -6.0 / grav: 0.35corte: 0.50Instant response and sharp turns. Ideal for fast-paced action and combat platformers.
Heavy Platformer (Grounded)acel: 0.2 / fric: 0.15salto: -7.5 / grav: 0.45corte: 0.80Significant inertia and physical weight. Jumps require high commitment with limited air correction.
Precision Platformer (Celeste / Meat Boy)acel: 0.45 / fric: 0.4salto: -6.8 / grav: 0.38corte: 0.35Sub-pixel control. Noticeable contrast between short taps and full jumps; generous buffer window (7 frames).

Gamepad Support

Earlier in this module, we examined `gamepad_axis_value()` and `gamepad_button_check()`. To ensure our control refinements benefit players using USB or console controllers, simply extend the boolean input checks at the start of the Step event:

/// Combined Keyboard + Gamepad Polling
var _gamepad_id = 0;
var _eje_h = gamepad_is_connected(_gamepad_id) ? gamepad_axis_value(_gamepad_id, gp_axislh) : 0;

// Apply an analog stick deadzone to avoid unintended drifting
var _stick_der = (_eje_h > 0.25);
var _stick_izq = (_eje_h < -0.25);

var _tecla_der = keyboard_check(vk_right) || keyboard_check(ord("D")) || gamepad_button_check(_gamepad_id, gp_padr) || _stick_der;
var _tecla_izq = keyboard_check(vk_left) || keyboard_check(ord("A")) || gamepad_button_check(_gamepad_id, gp_padl) || _stick_izq;
var _presiono_salto = keyboard_check_pressed(vk_space) || keyboard_check_pressed(ord("W")) || gamepad_button_check_pressed(_gamepad_id, gp_face1);
var _solto_salto = keyboard_check_released(vk_space) || keyboard_check_released(ord("W")) || gamepad_button_check_released(_gamepad_id, gp_face1);

By gathering all input sources at the start of the frame into underscore-prefixed local variables (such as `_tecla_der` or `_presiono_salto`), the rest of the physics code remains completely unchanged regardless of which peripheral is connected. Decoupling input collection from physical simulation is an industry best practice we will uphold across this entire course.

Practical Exercises

Complete these practical exercises in your project to solidify your mathematical grasp of movement refinement mechanics:

1. **Tuning Variable Jump Cutoff**: In the Create event of `obj_player`, modify `multiplicador_corte_salto`. Test with a value of `0.1`, then test with `0.8`. Compare tapping the button briefly against holding it down. Verification: With `0.1`, a tap should lift the character just a couple of pixels off the ground, demonstrating an aggressive upward momentum cut.

2. **Calibrating Coyote Grace Time**: Temporarily increase `tiempo_coyote_max` to `20` frames in the Create event. Run the game, walk off a raised ledge, and hit jump noticeably late, while visibly airborne. Verification: You should observe the character jumping mid-air nearly a third of a second after stepping off solid ground. Once verified, return the variable to a natural window (between 5 and 7 frames).

3. **Slippery Surfaces (Ice Mechanic)**: In the Create event, declare a boolean variable `en_hielo = false;`. In the Step event, modify friction logic so that when `en_hielo` is true, horizontal friction is reduced to one-fourth of its standard rate (`fric_x * 0.25`). Add a toggle key (such as the H key) to switch `en_hielo = !en_hielo;`. Verification: Toggling ice on and releasing direction keys at full sprint should cause the character to skid a significantly longer distance before stopping.

Summary

  • Game feel and control responsiveness are the defining factors behind a high-quality 2D platformer.
  • Balanced horizontal inertia combines smooth acceleration bounded by clamp() with safe friction using min() and max() to prevent overshoot jitter.
  • Variable jump height is achieved by scaling downward vertical velocity (vel_y < 0) using a fraction upon detecting keyboard_check_released().
  • Coyote time gives players a short grace window of frames after leaving a platform during which jump inputs remain valid.
  • Jump buffering captures early jump inputs before touching the ground and triggers the jump immediately on the frame of landing.
  • Decoupling input gathering from physical simulation ensures clean, maintainable, multi-device game code.

Practise what you learned

Exercise 1 · 1 point

Why does GameMaker check the condition `vel_y < 0` to dampen a jump when the player releases the jump button?

You are practising without an account: nothing is saved. Sign up to keep your progress.

Exercise 2 · 1 point

To keep horizontal velocity within an allowed range without exceeding limits, the built-in function is used. Furthermore, to decelerate a positive velocity toward a stop without dropping below zero and causing unintended backward motion, you use `vel_x = (0, vel_x - fric_x)`.

You are practising without an account: nothing is saved. Sign up to keep your progress.

Exercise 3 · 1 point

Match each variable or function to its specific role in refining movement feel:

  • acel_x
  • fric_x
  • multiplicador_corte_salto
  • clamp()
You are practising without an account: nothing is saved. Sign up to keep your progress.

Sign in to save your progress — Sign in