Interactivo

¿Qué es el game feel?

La sensación táctil de un juego: el peso de un botón, el impacto de un golpe, la euforia de una racha. Abajo tomo seis técnicas que la industria del videojuego resolvió hace décadas y las traigo a la web, sin librerías, respetando siempre tus preferencias de movimiento y sonido.

El mes pasado estaba construyendo un pequeño juego multijugador en games.valdemird.com. Uno de los modos es una caza de números: una cuadrícula de casillas y tú marcas las correctas antes que tu rival. Funcionó a la primera. Tocabas una casilla, se encendía, el puntaje subía, la ronda terminaba. Correcto, y completamente muerto.

Me quedé ahí tocando la misma casilla una y otra vez, y se sentía como enviar un formulario. Así que dediqué una noche entera a una sola cosa: hacer que ese toque se sintiera bien. Ninguna función nueva. Ahora la casilla se aplasta cuando la golpeas, lanza unas partículas, el tono del clic sube a medida que crece tu racha, y al llegar a una racha de cincuenta el tablero entero tiembla. La lógica de abajo es la misma. La diferencia es que por fin la interfaz reconoce que hiciste algo.

Esa diferencia tiene nombre.

El término viene de Steve Swink, que escribió el libro sobre esto en 2008: Game Feel: A Game Designer’s Guide to Virtual Sensation. Su definición tiene tres partes: control en tiempo real, espacio simulado y pulido. La web te regala las dos primeras. Este artículo trata de la tercera: la capa de pulido, eso que Swink llama el “juice”, y lo poco que cuesta en realidad.

Cada demo de aquí abajo ejecuta las mismas funciones que corren en el juego. Tócalas. No son videos.

  1. 1981

    The Illusion of Life

    Los animadores de Disney Frank Thomas y Ollie Johnston publican los 12 principios de la animación. Squash & stretch es el principio número uno.

  2. 2008

    Swink le pone nombre

    El libro de Steve Swink le da vocabulario al campo: 'game feel' como control en tiempo real más espacio simulado más pulido.

  3. 2012

    Juice it or lose it

    Martin Jonasson y Petri Purho toman un clon aburrido de Breakout y le apilan efectos en vivo sobre el escenario. La charla se vuelve la referencia canónica del 'juice'.

  4. 2013

    The art of screen shake

    Jan Willem Nijman, de Vlambeer, enumera unos 30 trucos que hacen que Nuclear Throne se sienta violento e inmediato.

Squash & stretch

Este es el truco más viejo de la lista, más viejo que los videojuegos. Es el primero de los doce principios de animación de Disney, codificados por Frank Thomas y Ollie Johnston. Cuando sus animadores querían que una pelota que rebota se sintiera con masa, la aplastaban en el impacto y la estiraban en el rebote. El volumen se mantiene constante, así que más ancha implica más baja. Tu ojo lee esa deformación como peso y energía.

En la web es una animación de cuatro keyframes sobre un transform. Presiona el panel: se aplasta ancho y bajo, se pasa de largo alto y delgado al volver, y se asienta. Desactiva el juice para sentir la versión que publiqué primero.

Pulsa el panel. Cambia el interruptor para sentir la versión muerta.

El mismo clic, ~420ms de deformación. El interruptor es el antes/después.

La trampa es usar un solo scale(). Eso es un globo inflándose, no un objeto con peso. El truco está en animar scaleX y scaleY en oposición, de modo que el elemento conserve su área. En el juego, el tablero usa esto al aparecer: se planta en vez de caer desde arriba.

apps/web/src/styles/theme.css
/* El tablero aparece y se 'planta' con un squash rápido desde la base. */
@keyframes settleIn {
  0%   { opacity: 0; transform: scale(0.88); }
  50%  { opacity: 1; transform: scale(1.05, 0.92); }  /* squash: ancho + bajo */
  72%  { transform: scale(0.99, 1.02); }              /* stretch: angosto + alto */
  100% { transform: scale(1); }
}
.grid { transform-origin: 50% 100%; animation: settleIn 0.42s cubic-bezier(0.22, 1, 0.36, 1); }

El transform-origin: 50% 100% hace un trabajo silencioso ahí. Anclar al borde inferior hace que el squash se lea como el objeto presionando contra el suelo, no flotando en su sitio. Si mueves el origen al centro, los mismos keyframes se sienten como un latido en lugar de un aterrizaje.

Hitstop

Confesión honesta: esta es la única técnica que todavía no está en el juego. Construí la demo de abajo para este artículo, porque ninguna lista de game feel está completa sin ella, y una vez que la notas ya no puedes dejar de verla.

El hitstop es una congelación. En el fotograma exacto del impacto, el juego detiene el tiempo durante unas pocas decenas de milisegundos y luego sigue. Los juegos de pelea viven de esto. Cuando Ryu conecta un puñetazo fuerte en Street Fighter, ambos personajes se bloquean durante un puñado de fotogramas. Smash Bros lo llama freeze frames; el término general es hitlag, definido para los juegos de pelea en el glosario de Infil. La pausa vende la colisión como algo que costó energía. Sin ella, dos objetos se atraviesan con toda cortesía.

Lleva el deslizador a cero para la versión cortés. Luego súbelo a 90 milisegundos y golpea de nuevo.

Golpea con hitstop en 0ms y luego en 90ms. La congelación es todo el efecto.

Ahí está lo sorprendente. Nada del movimiento cambió. La velocidad del golpe, el retroceso, las partículas son idénticos. La única diferencia es un setTimeout entre el contacto y la reacción, y tu cerebro lee esa pausa más larga como un golpe más pesado. Es el peso más barato que vas a añadir a una interfaz.

Screen shake

Si alguien es dueño de esta técnica, es Jan Willem Nijman, de Vlambeer. Su charla de 2013, The art of screenshake, es una ráfaga de trucos que convirtieron un shooter básico en algo que se siente retrocediendo contra ti. Nuclear Throne tiembla todo el tiempo, y dejas de notarlo conscientemente mientras lo sientes sin parar.

Tres magnitudes. Pequeña para una acción rutinaria, grande para un evento de verdad.

Pequeña / mediana / grande. Fíjate en la rotación que se va colando a medida que crece el impulso.

Las variantes mediana y grande añaden una fracción de grado de rotación. Esa rotación es justo lo que la mayoría de las implementaciones web se saltan. La traslación pura se lee como un glitch. Unas décimas de grado de rotación se leen como fuerza. Este es el código que está en producción, incluida la línea que todo el mundo olvida:

apps/web/src/lib/juice.tsx
function shake(intensity: ShakeIntensity = 'md') {
  if (reduced()) return;                       // respeta prefers-reduced-motion
  const el = document.querySelector('.shell') ?? document.body;
  const cls = `fx-shake-${intensity}`;
  el.classList.remove('fx-shake-sm', 'fx-shake-md', 'fx-shake-lg');
  void el.offsetWidth;                         // fuerza un reflow → reinicia la animación
  el.classList.add(cls);
  setTimeout(() => el.classList.remove(cls), 600);
}

void el.offsetWidth es la línea que sostiene todo. Si quitas y vuelves a poner la misma clase en el mismo fotograma, el navegador lo fusiona y la animación nunca se reinicia. Leer offsetWidth fuerza un reflow síncrono, que confirma la eliminación de la clase, de modo que al volver a añadirla arranca desde cero. Sin esa línea, los golpes rápidos dejan de temblar tras el primero. Perdí veinte minutos en exactamente esto antes de acordarme de que es un truco viejo.

Más es más. Si se ve bien con un poco de screen shake, probablemente se vea mejor con mucho.

— Jan Willem Nijman, Vlambeer — 'The art of screen shake' (2013)

Tiene razón casi siempre, con un matiz en el que la charla no se detiene: sacudir todo el viewport en la web es un disparador de mareo para algunas personas. El if (reduced()) return al inicio de esa función no es decoración. Es la diferencia entre tener juice y tener una queja de accesibilidad.

Partículas

Las partículas son la firma de Juice it or lose it. En esa charla de 2012, Martin Jonasson y Petri Purho toman un clon gris de Breakout y, efecto a efecto, lo entierran en escombros de colores hasta dejarlo eufórico. Su versión es jugable en el navegador con un deslizador por cada efecto, que es lo más parecido a una demo canónica que tiene el campo. Los ladrillos explotan en decenas de pedazos. La pantalla se llena de movimiento que no significa nada mecánicamente y todo emocionalmente.

Haz clic en cualquier parte del escenario. Mueve el deslizador para cambiar la cantidad.

haz clic donde sea

Cada partícula es un div con ángulo, distancia y vida aleatorios. La gravedad es un desplazamiento constante en la Y.

El detalle que importa aquí es dónde viven las partículas. En una app de React, crear cuarenta elementos por clic a través del estado dispararía cuarenta re-renders y hundiría los fotogramas en cuanto arrancara una racha. Por eso la función de burst esquiva a React y toca el DOM directamente:

apps/web/src/lib/juice.tsx
function burst(x: number, y: number, opts: BurstOptions = {}) {
  if (reduced()) return;
  const { count = 14, spread = 120, size = 10 } = opts;
  for (let i = 0; i < count; i++) {
    const p = document.createElement('div');
    p.className = 'fx-particle';
    const angle = (Math.PI * 2 * i) / count + Math.random() * 0.6;  // anillo + jitter
    const dist  = spread * (0.45 + Math.random() * 0.55);
    p.style.setProperty('--dx', `${Math.cos(angle) * dist}px`);
    p.style.setProperty('--dy', `${Math.sin(angle) * dist - 30}px`); // -30 = empujón de gravedad
    p.style.setProperty('--dur', `${0.55 + Math.random() * 0.4}s`);
    document.body.appendChild(p);
    p.addEventListener('animationend', () => p.remove());            // se limpia solo
  }
}

Repartir el ángulo base de forma pareja alrededor del círculo (2π · i / count) y luego sumarle un pequeño jitter aleatorio te da un estallido que se ve orgánico pero nunca se amontona. Los ángulos con Math.random() puro se apelmazan y dejan huecos. El - 30 en la Y es todo el motor de física: sesga cada partícula hacia arriba al nacer para que el arco parezca pelear contra la gravedad antes de caer. El CSS anima el resto.

Audio sintetizado

Esta es la que sorprende a la gente. No hay archivos de sonido en el juego. Ni un .mp3, ni un .wav. Cada sonido se genera en el navegador con la Web Audio API: un oscilador, una envolvente de ganancia y unos cuantos números.

Sin archivos de audio. Cada sonido se genera en vivo con la Web Audio API.

Todo sintetizado en vivo. Pulsa 'pop' varias veces y el tono trepa con tu racha.

Un sonido no es más que una frecuencia, una forma de onda y una envolvente de volumen en el tiempo. Una onda triangular corta que sube rápido y decae es un clic. Una onda senoidal que desciende es un golpe sordo. Este es el núcleo, unas quince líneas:

apps/web/src/lib/sfx.ts
function tone(freq: number, { type = 'triangle', dur = 0.12, gain = 0.14, glideTo } = {}) {
  const c = audio();                  // AudioContext compartido, creado al vuelo
  const t0 = c.currentTime;
  const osc = c.createOscillator();
  const g = c.createGain();
  osc.type = type;                    // 'sine' | 'square' | 'triangle' | 'sawtooth'
  osc.frequency.setValueAtTime(freq, t0);
  if (glideTo) osc.frequency.exponentialRampToValueAtTime(glideTo, t0 + dur);
  g.gain.setValueAtTime(0.0001, t0);
  g.gain.exponentialRampToValueAtTime(gain, t0 + 0.012);   // ataque rápido
  g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);   // decae al silencio
  osc.connect(g); g.connect(master);
  osc.start(t0); osc.stop(t0 + dur + 0.02);
}

Dos detalles hacen que el audio sintetizado se sienta vivo en lugar de robótico. El primero es la variación de tono: cada clic se multiplica por un pequeño factor aleatorio, así que no hay dos iguales. Diez clics idénticos seguidos suenan a máquina; diez clics desafinados cada uno un par de por ciento suenan a persona. El segundo es que la racha alimenta la frecuencia de forma directa:

apps/web/src/lib/sfx.ts
pop(streak = 0) {
  if (muted) return;
  const s = Math.min(streak, 24);          // tope para que nunca se vuelva chillón
  const base = 360 + s * 16;               // el tono sube 16Hz por paso de racha
  tone(vary(base, 0.04), { type: 'square', dur: 0.07, gain: 0.12 });
  tone(vary(base * 2, 0.04), { type: 'triangle', dur: 0.035,  // una octava de brillo
        gain: 0.025 + s * 0.0015 });       // ese brillo crece con la racha
}

Ese tono que sube hace un trabajo psicológico real. Una línea ascendente se lee como acumulación, como estar llegando a algún lado. Sientes la racha antes de haberla contado conscientemente. Hay además un truco más discreto que se llama ducking: cuando suena algo grande, la ganancia maestra baja un instante para que el evento importante atraviese todo lo demás, el mismo gesto que hace un locutor de radio al hablar por encima de una canción.

Celebración escalada

Cada técnica de arriba está bien por su cuenta. Apiladas y atadas a una racha, se vuelven un bucle de retroalimentación que no quieres soltar. Este es el sentido entero del modo caza de números, y es la demo a la que llevaría a un escéptico.

Toca la moneda. Sigue tocando. El tono trepa, el squash se dispara en cada golpe, las partículas se multiplican, y en los umbrales de tier el escenario empieza a temblar y a teñirse. Para un momento y todo decae hasta cero.

Toca rápido. Los tiers se desbloquean en 10 / 20 / 30 / 50. Deja de tocar y se reinicia.

Los tiers se desbloquean en 10 / 20 / 30 / 50. Quédate 1.6s sin tocar y la racha se reinicia. El sonido respeta el interruptor.

La escalada no es magia, es una función escalonada. La racha se mapea a un tier, y cada efecto se lee de ese tier: más partículas, más dispersión, un temblor mayor, un tinte más rojo, un tono más agudo.

apps/web/src/games/numberhunt/NumberHunt.tsx
function comboTier(streak: number): 0 | 1 | 2 | 3 | 4 {
  if (streak >= 50) return 4;
  if (streak >= 30) return 3;
  if (streak >= 20) return 2;
  if (streak >= 10) return 1;
  return 0;
}

// en cada marca exitosa:
sfx.pop(streak);                                              // el tono sigue a la racha
juice.burst(x, y, { count: 8 + tier * 5, spread: 80 + tier * 35 }); // más escombros por tier
juice.flash(flashColor, 0.07 + tier * 0.025);                // flash más brillante por tier
juice.shake(tier >= 3 ? 'md' : 'sm');                        // el tablero solo tiembla cuando se lo gana

Dos decisiones hacen que esto se sienta bien y no agotador. El tablero solo tiembla de tier tres en adelante, así que el efecto más fuerte se mantiene raro y sigue significando algo. Y la racha decae tras 1.6 segundos de inactividad, lo que convierte un contador en tensión. No estás solo acumulando puntos, estás manteniendo algo con vida. Ese decaimiento es lo que hace que la celebración se sienta ganada, porque se puede perder.

function onMark(cell: Cell) {
  setMarked((m) => [...m, cell]);
  setScore((s) => s + 1);
}

Correcto. Se publica. No te cuenta nada más allá de un número que cambió. Esto es lo que tenía el día uno, y por eso me gasté la noche.

function onMark(cell: Cell, e: PointerEvent) {
  const next = streak + 1;
  setStreak(next);
  setMarked((m) => [...m, cell]);
  sfx.pop(next);
  juice.burst(e.clientX, e.clientY, { count: 6, spread: 64, size: 7 });
  clearTimeout(decay.current);
  decay.current = setTimeout(() => setStreak(0), 1600);
}

Ocho líneas más. La misma corrección, más peso, sonido y una razón para seguir.

La parte que no es opcional

Todo lo de aquí está diseñado para captar atención, lo que significa que todo lo de aquí puede volverse un ataque contra alguien que no lo pidió. Los trastornos vestibulares hacen que el screen shake dé náuseas. Hay gente que trabaja en silencio a propósito. La web tiene una señal real para lo primero, la media query prefers-reduced-motion, y una cortesía básica para lo segundo, y ambas son una línea.

Mira de nuevo cada función de arriba. Todas empiezan igual:

apps/web/src/lib/juice.tsx
function reduced(): boolean {
  return matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// shake, flash y burst empiezan con: if (reduced()) return;

Si el sistema operativo pide movimiento reducido, el temblor, el flash y las partículas sencillamente no ocurren. El juego sigue siendo del todo jugable. Por eso mismo las demos del blog respetan la misma preferencia. Si tu sistema tiene el movimiento reducido activado, las demos de movimiento de esta página también se silencian, y estás leyendo un artículo que sobre todo demuestra su propia ausencia. El sonido recibe el mismo trato mediante un interruptor de silencio persistente, audible por defecto pero a un clic del silencio y recordado entre sesiones.

Por qué no confío en 'le pongo juice después' como plan

Cada vez que me dije que puliría la sensación cuando la lógica estuviera lista, la lógica se endureció alrededor de no tener ninguna sensación. Los manejadores de clic quedaron escritos asumiendo una respuesta síncrona e instantánea, y meterles después una ventana de reacción de 400ms implicaba desenredarlos. La noche que describí al principio funcionó porque el juego era pequeño. En algo más grande, la capa de feedback quiere ser hermana de la lógica desde el inicio: un juice.burst() junto al setState, no atornillado en una pasada posterior.

Para profundizar: las charlas y el libro que definieron esto

Casi todo lo de aquí se apoya en un puñado de charlas conocidas y un libro. Si quieres los originales:

  • Steve Swink, Game Feel: A Game Designer’s Guide to Virtual Sensation (2008) — el libro que le puso nombre al campo. game-feel.com, o el ensayo más largo Game Feel: The Secret Ingredient.
  • Los doce principios de animación de Disney — squash & stretch es el principio uno, de The Illusion of Life de Frank Thomas y Ollie Johnston. Resumen.
  • Martin Jonasson y Petri Purho, Juice it or lose it (2012) — la charla, y el Breakout jugable que llenan de juice en vivo.
  • Jan Willem Nijman / Vlambeer, The art of screenshake (2013) — la charla. Unos 30 trucos en 25 minutos.
  • Mark Brown, Secrets of Game Feel and Juiceel video-ensayo de Game Maker’s Toolkit, si prefieres ver a leer.
  • Hitstop / hitlag — definido para los juegos de pelea en el glosario de Infil.

La caza de números se juega idéntica con todos los efectos quitados. Nadie pierde la partida por haberla silenciado o por apagar el movimiento. Esa es la línea. El juice es algo que pones encima de algo que ya funciona, nunca una pieza de la que dependa si funciona o no.

Vuelve arriba y machaca la moneda una vez más. Ese tirón que sientes, esa pequeña resistencia a parar, es la razón entera por la que vale la pena escribir todo esto. Son ocho líneas de código entre enviar un formulario y algo que no quieres soltar.