Una de las cosas más escuchadas en charlas, videos o blogs de videojuegos es que: los platformers que se sienten "justos" no lo son. Hacen trampa constantemente, y lo hacen a favor del jugador.
Cuando alguien dice que Celeste o Super Mario Bros. tienen unos controles "perfectos", lo que percibe no es precisión. Es perdón. El juego está interpretando su intención y rellenando los huecos entre lo que quiso hacer y lo que físicamente hizo con el mando. Esos huecos son de milisegundos, pero deciden si una partida se siente nítida o injusta.
Ahí entran en juego los dos conceptos de los que trata este artículo: coyote time y jump buffering.
1. La verdad del juego no es la verdad del jugador
Un platformer mantiene un estado interno preciso. En el frame 1402 el personaje está tocando el suelo, en el frame 1403 ya no lo está. Para la simulación, ese cambio es instantáneo y binario.
El jugador no vive en ese mundo. Su input pasa por su sistema nervioso, por el mando, por el bus USB y por el bucle de eventos del motor antes de convertirse en una acción. Entre que decide saltar y que el motor registra la pulsación pueden pasar varios frames. Si la simulación exige que el botón se pulse exactamente en la ventana en la que el personaje toca el suelo, el jugador va a fallar saltos que en su cabeza eran correctos.
A 60 fps, un frame dura unos 16,6 ms. Los estudios sobre tiempo de reacción visual sitúan la respuesta humana en torno a los 200-250 ms. Eso son entre 12 y 15 frames de margen de error solo en la reacción, sin contar el viaje físico del input. Pedir precisión de un frame es pedir algo que el cuerpo humano no puede entregar de forma consistente.
La consecuencia de diseño es brutal: si tu juego solo refleja la verdad de la simulación, el jugador percibirá los fallos como culpa del juego, no suya. Y un jugador que se siente tratado injustamente deja de jugar. El trabajo del diseñador de game feel es reconciliar las dos verdades.
2. Coyote time: saltar después del borde
El coyote time (una referencia al Coyote del Correcaminos que sigue corriendo un instante sobre el vacío) es la ventana de gracia durante la cual el personaje todavía puede saltar aunque ya haya abandonado la plataforma.
El caso que resuelve es clásico: el jugador corre hacia el borde, quiere saltar justo al final, pulsa el botón uno o dos frames tarde, y el personaje ya está cayendo. Sin coyote time el salto se ignora y el jugador cae al vacío sintiendo que "el juego no le ha hecho caso". Con coyote time, durante una pequeña ventana tras dejar el suelo, el salto sigue siendo válido.
La implementación es un temporizador que se rellena mientras el jugador toca el suelo y decrece en el aire:
1public class CoyoteJump : MonoBehaviour
2{
3 [SerializeField] private float coyoteTime = 0.1f; // ~6 frames a 60fps
4 [SerializeField] private float jumpForce = 12f;
5
6 private float coyoteCounter;
7 private bool isGrounded;
8 private Rigidbody2D rb;
9
10 private void Awake() => rb = GetComponent<Rigidbody2D>();
11
12 private void Update()
13 {
14 // Mientras se toca el suelo, se recarga la ventana al máximo.
15 // Al dejar el suelo, empieza a vaciarse frame a frame.
16 if (isGrounded)
17 coyoteCounter = coyoteTime;
18 else
19 coyoteCounter -= Time.deltaTime;
20
21 if (Input.GetButtonDown("Jump") && coyoteCounter > 0f)
22 {
23 rb.velocity = new Vector2(rb.velocity.x, jumpForce);
24 coyoteCounter = 0f; // evitar doble salto
25 }
26 }
27}La clave está en la línea que pone coyoteCounter a cero tras saltar. Sin ella, un jugador podría dejar el suelo, saltar dentro de la ventana y volver a saltar en el aire porque el contador todavía es positivo. Consumir la ventana al usarla es lo que mantiene el truco honesto.
Un valor entre 0,08 y 0,12 segundos (unos 5-7 frames) es el rango habitual. Por debajo de 0,05 el jugador no lo nota y no aporta nada. Por encima de 0,15 empieza a sentirse como si el personaje "flotara", y los jugadores que sí dominan el juego notarán que algo es raro.
3. Input buffering: saltar antes de aterrizar
El coyote time perdona los saltos tarde. El input buffering (en concreto el jump buffering) perdona los saltos pronto.
El escenario es el inverso: el jugador viene cayendo hacia el suelo y quiere encadenar otro salto nada más aterrizar. Como anticipa el aterrizaje, pulsa el botón uno o dos frames antes de tocar el suelo. Sin buffering, en el momento de la pulsación el personaje sigue en el aire, el salto se descarta, y al aterrizar no pasa nada. El jugador siente que el juego se ha "comido" su input.
La solución es no descartar la pulsación, sino recordarla durante una pequeña ventana. Si el personaje toca suelo dentro de esa ventana, el salto guardado se ejecuta:
1public class JumpBuffer : MonoBehaviour
2{
3 [SerializeField] private float bufferTime = 0.1f; // ~6 frames a 60fps
4 [SerializeField] private float jumpForce = 12f;
5
6 private float bufferCounter;
7 private bool isGrounded;
8 private Rigidbody2D rb;
9
10 private void Awake() => rb = GetComponent<Rigidbody2D>();
11
12 private void Update()
13 {
14 // Al pulsar, guarda la intención en la ventana.
15 // Si no se usa, se vacía frame a frame.
16 if (Input.GetButtonDown("Jump"))
17 bufferCounter = bufferTime;
18 else
19 bufferCounter -= Time.deltaTime;
20
21 // Al tocar suelo, si hay un salto pendiente en la ventana, se ejecuta.
22 if (bufferCounter > 0f && isGrounded)
23 {
24 rb.velocity = new Vector2(rb.velocity.x, jumpForce);
25 bufferCounter = 0f;
26 }
27 }
28}La estructura es casi idéntica a la del coyote time, pero con la lógica invertida. El coyote time recuerda que hace poco había suelo. El buffer recuerda que hace poco hubo una pulsación. Son dos memorias cortas que apuntan en direcciones opuestas del tiempo.
4. Combinando los dos sin pisarse
En un controlador real, se requerirá usar los dos a la vez, y aquí aparece el primer problema sutil: si comparten la misma variable o el mismo if para ambos, pueden interferir. La forma limpia es mantener los dos contadores separados y comprobar la condición de salto una sola vez, contra cualquiera de las dos ventanas.
1private void Update()
2{
3 // Coyote: recuerda que hubo suelo recientemente
4 if (isGrounded)
5 coyoteCounter = coyoteTime;
6 else
7 coyoteCounter -= Time.deltaTime;
8
9 // Buffer: recuerda que se pulsó recientemente
10 if (Input.GetButtonDown("Jump"))
11 bufferCounter = bufferTime;
12 else
13 bufferCounter -= Time.deltaTime;
14
15 // Salta si hay intención pendiente Y todavía "cuenta" como en suelo
16 if (bufferCounter > 0f && coyoteCounter > 0f)
17 {
18 rb.velocity = new Vector2(rb.velocity.x, jumpForce);
19 bufferCounter = 0f; // consumimos la intención
20 coyoteCounter = 0f; // y el permiso de salto
21 }
22}Con esta combinación cubres los cuatro casos: salto a tiempo, salto un poco tarde (coyote), salto un poco pronto (buffer) y, lo más satisfactorio, salto pronto justo al borde de una plataforma (los dos trucos colaborando). El jugador percibe un único comportamiento coherente: "cuando quiero saltar, salto".
Un fallo habitual es resetear el coyoteCounter en el evento de aterrizaje en lugar de mientras isGrounded es verdadero. Si se hace en el aterrizaje, en el primer frame en suelo el contador todavía vale cero y el buffer no encuentra permiso para disparar, así que el salto bufferizado se pierde justo en el caso que se quería cubrir. Por lo tanto, se debe hacer la recarga del contador de coyote mientras se está en suelo, no en la transición.
5. Más allá del salto: buffering de ataques y combos
El jump buffering es el caso famoso, pero la idea general (recordar un input durante una ventana y ejecutarlo cuando el sistema esté listo) se aplica a cualquier acción con tiempos de recuperación.
Los juegos de acción y los de lucha llevan décadas haciéndolo. Cuando encadenas un combo en un character action como Devil May Cry o en un soulslike, el juego acepta la pulsación del siguiente golpe antes de que termine la animación del actual y la encola. Sin ese buffer ,el timing debería ser exacto al frame en el que la animación abre la siguiente ventana de ataque, que es justo lo que hace que un juego se sienta rígido.
Una cola de inputs generaliza el contador único a varias acciones, guardando además cuándo se pulsó cada una para poder liberarlas si ya no se necesitan:
1public class InputBufferQueue
2{
3 private struct BufferedInput
4 {
5 public string action;
6 public float timeStamp;
7 }
8
9 private readonly List<BufferedInput> buffer = new();
10 private readonly float window;
11
12 public InputBufferQueue(float window) => this.window = window;
13
14 public void Register(string action)
15 {
16 buffer.Add(new BufferedInput { action = action, timeStamp = Time.time });
17 }
18
19 // Devuelve true si la acción se pulsó dentro de la ventana y la consume
20 public bool TryConsume(string action)
21 {
22 buffer.RemoveAll(b => Time.time - b.timeStamp > window);
23 int idx = buffer.FindIndex(b => b.action == action);
24 if (idx < 0) return false;
25 buffer.RemoveAt(idx);
26 return true;
27 }
28}El sistema de combate consulta TryConsume("Attack") en el momento en que la animación abre la ventana de cancelación. Si el jugador se anticipó, la acción ya está esperando en la cola y el combo fluye. La misma estructura sirve para esquivas, bloqueos o cualquier acción que dependa de un estado que aún no está disponible en el frame de la pulsación.
6. Afinar las ventanas: cuántos frames perdonar
No existe un número mágico, pero sí rangos sensatos. La unidad correcta para pensar esto no son los segundos, son los frames, porque la percepción del jugador está atada a la tasa de refresco.
- Coyote time: 4-7 frames (0,07-0,12 s). Suficiente para perdonar la reacción tardía sin que se note que el personaje "flota".
- Jump buffer: 4-8 frames (0,07-0,13 s). Puede ser ligeramente más generoso que el coyote, porque anticiparse es más común que reaccionar tarde.
- Buffer de combate: 6-12 frames. Los combos toleran ventanas mayores porque el jugador encadena de forma deliberada, no reactiva.
Una buena práctica pasa por exponer estos valores como campos serializados en el inspector y dedicar una sesión entera solo a moverlos con un par de testers reales. Los números que se sienten bien para el desarrollador, que conoce el juego de memoria, casi siempre son demasiado tacaños para alguien que juega por primera vez. El game feel se afina con manos ajenas, no con hojas de cálculo.
Hay un detalle de fps que mucha gente pasa por alto: si defines la ventana en segundos, su duración en frames cambia con la tasa de refresco. Una ventana de 0,1 s son 6 frames a 60 fps pero 12 a 120 fps. Si tu juego corre a frame rate variable y notas que el feel cambia entre máquinas, plantéate contar la ventana en frames fijos o anclarla al fixedDeltaTime.
Un buen control no es el que obedece al jugador, sino el que adivina lo que el jugador quiso hacer.
7. Errores comunes y cómo depurarlos
Estos sistemas fallan de formas silenciosas: nada peta, simplemente el juego se siente raro y cuesta saber por qué. Estos son los sospechosos habituales.
-
El doble salto fantasma aparece cuando olvidas consumir la ventana tras saltar. El jugador salta dentro del coyote time y, como el contador sigue siendo positivo un frame más, dispara un segundo salto en el aire. Solución: poner el contador a cero en el mismo frame en que se ejecuta el salto.
-
El input "eliminado" al aterrizar suele ser el problema de orden de actualización: si se comprueba el buffer antes de recalcular
isGroundeden el mismo frame, el salto bufferizado se evalúa contra un estado de suelo obsoleto. Para evitarlo, hay que actualizar la detección de suelo antes de resolver el salto.
Cuidado con mezclar Update y FixedUpdate. La lectura de input con Input.GetButtonDown debe ir en Update (si no, puede perder pulsaciones entre frames físicos), pero la aplicación de fuerzas al Rigidbody va en FixedUpdate. Si se lee y aplica en el mismo FixedUpdate, se perderán inputs de forma intermitente y se producirá un bug imposible de reproducir a mano. El patrón seguro es: leer y bufferizar en Update, consumir y aplicar en FixedUpdate.
Para depurar todo esto, lo más útil es visualizar los contadores. Dibujar en pantalla el valor de coyoteCounter y bufferCounter cada frame, o pintar un gizmo de color cuando cada ventana está activa. En cuanto se ven los dos temporizadores moviéndose en tiempo real, los bugs de timing que parecían magia negra se vuelven obvios.
8. Conclusión
Coyote time y jump buffering no son parches ni atajos. Son el reconocimiento explícito de que el jugador y la simulación viven en marcos temporales distintos, y de que el trabajo del control es traducir entre ambos.
Lo interesante es que, cuando están bien afinados, son completamente invisibles. Nadie elogia el coyote time de Celeste porque nadie lo percibe como un sistema, solo perciben que el juego "se siente increíble". Y esa invisibilidad es exactamente el objetivo. El mejor game feel es el que el jugador atribuye a su propia habilidad, sin sospechar nunca que el juego llevaba todo el rato haciendo trampa a su favor.


