Saltar al contenido
GameGenresEnglish
Acción

Beat 'em up

Avanzas por un escenario peleando cuerpo a cuerpo contra oleadas de enemigos que te rodean, con golpes, agarres y a veces objetos del entorno usados como armas.

También se llama: yo contra el barrio, peleas callejeras, brawler

Mecánicas que lo definen

  • Movimiento en un plano con profundidad: además de avanzar a lo largo de la calle, te mueves hacia el fondo y hacia el frente, y solo golpeas a quien está en tu misma franja.
  • Combos cortos con un solo botón de ataque: varios golpes seguidos, el último más fuerte y que derriba o empuja al enemigo.
  • Grupos de enemigos que te rodean y atacan por la espalda; colocarte para tenerlos a todos delante es la mitad del juego.
  • Agarres, lanzamientos y armas que se recogen del suelo o se les quitan a los enemigos, además de comida que recupera vida.
  • Avance por tramos: la pantalla se detiene hasta que vences al grupo y luego te indica que sigas.
  • Cooperativo para dos o más jugadores en la misma pantalla.

Bucle de juego

Entras en un tramo, la cámara se bloquea, llegan los enemigos, te colocas para no quedar rodeado, encadenas golpes, vences al grupo y avanzas al siguiente.

Demo propia

Controles. Flechas o WASD para moverte a lo largo de la calle y hacia el fondo, espacio, Z o J para golpear. En móvil, los botones en pantalla.

Abrir en pantalla completa

Una calle de tres pantallas con tres peleas. En cada una la cámara se bloquea, llegan enemigos por los dos lados y no puedes seguir hasta vencerlos. Lo justo para sentir lo que define al género, que es pelear en grupo en un plano con profundidad.

Hecha con Kaplay, en unas 202 líneas de JavaScript.

Cómo está hecha

  1. Configuración y controles

    Se definen botones virtuales para las cuatro direcciones y para golpear, así el mismo código sirve para teclado y pantalla táctil. Las constantes clave son FLOOR_TOP y FLOOR_BOTTOM, que delimitan la franja de calle por la que puedes caminar, y LANE, la distancia de profundidad dentro de la cual dos personajes se pueden golpear.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [40, 36, 58],
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        punch: { keyboard: ["space", "z", "j"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "punch", label: "✊", side: "right" },
    ]);
    
    const LEVEL_WIDTH = 2240;
    const FLOOR_TOP = 215; // the street is a band: y is depth, not height
    const FLOOR_BOTTOM = 340;
    const SPEED_X = 170;
    const SPEED_Y = 110; // walking into the depth is slower, like in the classics
    const LANE = 16; // two fighters only touch if their feet are this close in depth
    const PLAYER_HP = 10;
  2. La calle

    El escenario es una pared con ventanas y un suelo con líneas que sugieren profundidad. Es decorado puro, sin colisiones; al final hay una puerta que marca la salida.

    function buildStreet() {
      add([rect(LEVEL_WIDTH, FLOOR_TOP), pos(0, 0), color(70, 62, 92)]);
      add([rect(LEVEL_WIDTH, 360 - FLOOR_TOP), pos(0, FLOOR_TOP), color(96, 96, 110)]);
      for (let x = 40; x < LEVEL_WIDTH; x += 160) {
        add([rect(70, 90), pos(x, 60), color(110, 140, 190), opacity(0.5)]); // windows
        add([rect(LEVEL_WIDTH, 2), pos(0, FLOOR_TOP + 40 + (x % 3) * 30), color(120, 120, 136)]);
      }
      add([rect(30, 120), pos(LEVEL_WIDTH - 60, FLOOR_TOP - 120), color(230, 200, 90)]); // exit door
    }
  3. El jugador y la profundidad

    Aquí está el truco del género: la coordenada y no es la altura, sino la profundidad en la calle. Por eso no hay gravedad, la velocidad hacia el fondo es menor que la horizontal y el orden de dibujo (z) se iguala a y: quien está más abajo en la pantalla está más cerca y se dibuja delante. La sombra bajo los pies ayuda a ver en qué franja estás.

    const player = add([
      rect(28, 60), pos(120, 280), anchor("bot"), color(120, 200, 255), opacity(1), z(0),
      { hp: PLAYER_HP, dir: 1, hurtTime: 0 },
    ]);
    player.add([rect(36, 8, { radius: 4 }), anchor("center"), color(0, 0, 0), opacity(0.3)]); // shadow
    const fist = player.add([rect(22, 10), pos(14, -40), color(255, 230, 160), opacity(0)]);
    onButtonDown("left", () => (player.dir = -1));
    onButtonDown("right", () => (player.dir = 1));
    player.onUpdate(() => {
      const vx = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0);
      const vy = (isButtonDown("down") ? 1 : 0) - (isButtonDown("up") ? 1 : 0);
      if (attackTime <= 0) player.move(vx * SPEED_X, vy * SPEED_Y); // you cannot walk mid-punch
      player.pos.y = clamp(player.pos.y, FLOOR_TOP + 10, FLOOR_BOTTOM);
      player.pos.x = clamp(player.pos.x, camLeft() + 16, camLeft() + 624);
      player.z = player.pos.y; // whoever is lower on screen is closer: draw it in front
      player.hurtTime -= dt();
      player.opacity = player.hurtTime > 0 && Math.floor(time() * 20) % 2 ? 0.3 : 1;
      fist.pos.x = player.dir > 0 ? 14 : -36;
      hud.text = t({ es: "Vida: ", en: "Health: " }) + "■".repeat(Math.max(0, player.hp));
    });
  4. El combo de tres golpes

    Cada pulsación lanza un golpe; si vuelves a pulsar dentro de la ventana de COMBO_WINDOW, sigue el siguiente de la cadena. El tercero hace más daño y empuja lejos al enemigo, lo que te da espacio cuando te rodean. Si pulsas mientras aún dura un golpe, la orden se guarda (búfer) y sale en cuanto termina: así el combo fluye aunque pulses un poco antes. Un golpe solo acierta si el enemigo está delante, cerca y en tu misma franja de profundidad.

    // Punch, punch, finisher: each press inside the window chains the next hit.
    // The third one hits harder and knocks the enemy away, which buys you space.
    const COMBO = [
      { damage: 1, knock: 40, time: 0.16 },
      { damage: 1, knock: 40, time: 0.16 },
      { damage: 2, knock: 420, time: 0.3 },
    ];
    const COMBO_WINDOW = 0.45; // seconds to press again and keep the chain
    let step = -1;
    let attackTime = 0;
    let comboTime = 0;
    let queued = false; // a press during a punch is remembered (input buffer)
    function punch() {
      step = comboTime > 0 ? (step + 1) % COMBO.length : 0;
      const hit = COMBO[step];
      attackTime = hit.time;
      comboTime = COMBO_WINDOW;
      fist.opacity = 1;
      fist.color = step === 2 ? rgb(255, 140, 90) : rgb(255, 230, 160);
      for (const e of get("enemy")) {
        const dx = (e.pos.x - player.pos.x) * player.dir; // > 0 means in front of you
        if (dx > 0 && dx < 62 && Math.abs(e.pos.y - player.pos.y) < LANE) hurtEnemy(e, hit);
      }
    }
    onButtonPress("punch", () => (attackTime > 0 ? (queued = true) : punch()));
    onUpdate(() => {
      attackTime -= dt();
      comboTime -= dt();
      if (attackTime <= 0) {
        fist.opacity = 0;
        if (queued) (queued = false), punch();
      }
    });
  5. Enemigos que rodean

    Cada enemigo elige un lado del jugador y camina hasta un punto a esa distancia, en su misma franja. Como la mitad elige la izquierda y la otra mitad la derecha, el grupo te rodea solo. Antes de golpear cambia de color durante casi medio segundo: esa telegrafía es tu aviso para salir de su franja o golpearlo primero, porque recibir un golpe lo aturde y cancela su ataque.

    // Each enemy picks a side of the player and walks to a spot there, so a
    // group surrounds you. Before hitting, it flashes for a moment (telegraph):
    // that is your cue to step out of its lane or to hit first.
    function spawnEnemy(side, delay) {
      const e = add([
        rect(28, 58), pos(camLeft() + 320 + side * 360, rand(FLOOR_TOP + 20, FLOOR_BOTTOM)), anchor("bot"),
        color(220, 90, 80), z(0),
        { hp: 4, side, state: "walk", timer: delay, knock: 0 },
        "enemy",
      ]);
      e.add([rect(36, 8, { radius: 4 }), anchor("center"), color(0, 0, 0), opacity(0.3)]);
      e.onUpdate(() => {
        e.z = e.pos.y;
        e.timer -= dt();
        e.knock *= 0.9;
        e.move(e.knock, 0);
        if (e.state === "stun") {
          if (e.timer <= 0) (e.state = "walk"), (e.timer = rand(0.3, 0.8));
          return;
        }
        const spot = vec2(clamp(player.pos.x + e.side * 52, camLeft() + 20, camLeft() + 620), player.pos.y);
        const near = Math.abs(spot.x - e.pos.x) < 8 && Math.abs(spot.y - e.pos.y) < 6;
        if (e.state === "walk") {
          e.color = rgb(220, 90, 80);
          if (!near) e.moveTo(spot, 95);
          else if (e.timer <= 0) (e.state = "windup"), (e.timer = 0.45);
        } else if (e.state === "windup") {
          e.color = rgb(255, 210, 120); // telegraph
          if (e.timer <= 0) {
            const dx = Math.abs(player.pos.x - e.pos.x);
            if (dx < 64 && Math.abs(player.pos.y - e.pos.y) < LANE && player.hurtTime <= 0) {
              player.hp--;
              player.hurtTime = 0.7;
              shake(4);
              if (player.hp <= 0) go("end", false);
            }
            e.state = "walk";
            e.timer = rand(0.8, 1.6); // cooldown before the next attack
          }
        }
      });
    }
    function hurtEnemy(e, hit) {
      e.hp -= hit.damage;
      e.state = "stun"; // getting hit cancels its own attack (hitstun)
      e.timer = 0.4;
      e.color = rgb(255, 255, 255);
      e.knock = hit.knock * player.dir;
      if (e.hp <= 0) destroy(e);
    }
  6. Oleadas que bloquean el avance

    Cada pelea es una entrada de la lista WAVES con la posición donde empieza y cuántos enemigos trae. Al llegar, la cámara se fija y los enemigos entran por los dos lados. Cuando no queda ninguno, aparece el aviso de avanzar y la cámara se libera. Tras la última pelea, llegar a la puerta gana la partida.

    // The street is split into fights. On reaching a fight the camera locks and
    // you cannot move on until every enemy is down, then a sign tells you to go.
    const WAVES = [
      { x: 400, enemies: 2 },
      { x: 1000, enemies: 3 },
      { x: 1600, enemies: 4 },
    ];
    let wave = 0;
    let lockX = null; // camera x while a fight is on
    let fighting = false;
    onUpdate(() => {
      if (!fighting && wave < WAVES.length && player.pos.x >= WAVES[wave].x) {
        fighting = true;
        lockX = camPos().x;
        for (let i = 0; i < WAVES[wave].enemies; i++) spawnEnemy(i % 2 ? -1 : 1, i * 0.6);
      }
      if (fighting && get("enemy").length === 0) {
        fighting = false;
        lockX = null;
        wave++;
        banner.text = wave < WAVES.length ? t({ es: "¡Avanza! →", en: "Go! →" }) : t({ es: "¡Hacia la salida! →", en: "To the exit! →" });
        wait(1.5, () => (banner.text = ""));
      }
      if (wave === WAVES.length && player.pos.x > LEVEL_WIDTH - 90) go("end", true);
    });
  7. Cámara que solo avanza

    La cámara sigue al jugador hacia la derecha pero nunca retrocede, y el jugador no puede salir de lo que se ve. Durante una pelea se queda fija. Así el escenario funciona como una cinta que avanza a tramos.

    // The camera only moves forward: going back is not allowed (classic belt scroll).
    let camX = 320;
    function camLeft() {
      return (lockX ?? camX) - 320;
    }
    onUpdate(() => {
      if (lockX === null) camX = clamp(Math.max(camX, player.pos.x), 320, LEVEL_WIDTH - 320);
      camPos(lockX ?? camX, 180);
    });
  8. Escenas

    El juego tiene tres escenas (title, game y end) y go() salta de una a otra. Al volver a game todo empieza de cero.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
      add([
        text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }),
        pos(center().add(0, 30)),
        anchor("center"),
        color(200, 210, 255),
      ]);
      onButtonPress("punch", next);
      onKeyPress("enter", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Beat 'em up", en: "Beat 'em up" }),
        t({
          es: "Flechas para moverte por la calle (también hacia el fondo), espacio o Z para golpear: tres golpes seguidos hacen un combo. Vence cada grupo para avanzar.\nPulsa espacio o toca para empezar.",
          en: "Arrows to move along the street (into the depth too), space or Z to punch: three hits in a row make a combo. Beat each group to move on.\nPress space or tap to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won) =>
      screen(
        won ? t({ es: "¡Calle despejada!", en: "Street cleared!" }) : t({ es: "Te derribaron", en: "You were knocked out" }),
        t({ es: "Pulsa espacio para jugar otra vez.", en: "Press space to play again." }),
        () => go("game"),
      ),
    );
    
    go("title");

Qué simplificamos

  • Los personajes son rectángulos con una sombra; no hay animaciones ni sonido.
  • Un solo botón de ataque y un solo tipo de enemigo; no hay salto, agarres, armas ni comida.
  • Los enemigos no se turnan para atacar; cada uno espera un tiempo al azar entre golpe y golpe.

Para modificarla

  • Añade un agarre, que al pulsar el golpe pegado a un enemigo aturdido lo sujete y el siguiente golpe lo lance hacia atrás.
  • Haz que como máximo dos enemigos puedan estar en «windup» a la vez (turnos de ataque) y compara cómo se siente con la versión original.
  • Añade cajas que se rompen y sueltan comida que recupera vida.
Código completo
// Beat 'em up (brawler) demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";

// #region setup
const k = kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [40, 36, 58],
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    punch: { keyboard: ["space", "z", "j"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "punch", label: "✊", side: "right" },
]);

const LEVEL_WIDTH = 2240;
const FLOOR_TOP = 215; // the street is a band: y is depth, not height
const FLOOR_BOTTOM = 340;
const SPEED_X = 170;
const SPEED_Y = 110; // walking into the depth is slower, like in the classics
const LANE = 16; // two fighters only touch if their feet are this close in depth
const PLAYER_HP = 10;
// #endregion

// #region street
function buildStreet() {
  add([rect(LEVEL_WIDTH, FLOOR_TOP), pos(0, 0), color(70, 62, 92)]);
  add([rect(LEVEL_WIDTH, 360 - FLOOR_TOP), pos(0, FLOOR_TOP), color(96, 96, 110)]);
  for (let x = 40; x < LEVEL_WIDTH; x += 160) {
    add([rect(70, 90), pos(x, 60), color(110, 140, 190), opacity(0.5)]); // windows
    add([rect(LEVEL_WIDTH, 2), pos(0, FLOOR_TOP + 40 + (x % 3) * 30), color(120, 120, 136)]);
  }
  add([rect(30, 120), pos(LEVEL_WIDTH - 60, FLOOR_TOP - 120), color(230, 200, 90)]); // exit door
}
// #endregion

scene("game", () => {
  buildStreet();
  const hud = add([text("", { size: 18, font: FONT }), pos(16, 12), fixed(), z(1000)]);
  const banner = add([text("", { size: 26, font: FONT }), pos(320, 120), anchor("center"), fixed(), z(1000)]);

  // #region player
  const player = add([
    rect(28, 60), pos(120, 280), anchor("bot"), color(120, 200, 255), opacity(1), z(0),
    { hp: PLAYER_HP, dir: 1, hurtTime: 0 },
  ]);
  player.add([rect(36, 8, { radius: 4 }), anchor("center"), color(0, 0, 0), opacity(0.3)]); // shadow
  const fist = player.add([rect(22, 10), pos(14, -40), color(255, 230, 160), opacity(0)]);
  onButtonDown("left", () => (player.dir = -1));
  onButtonDown("right", () => (player.dir = 1));
  player.onUpdate(() => {
    const vx = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0);
    const vy = (isButtonDown("down") ? 1 : 0) - (isButtonDown("up") ? 1 : 0);
    if (attackTime <= 0) player.move(vx * SPEED_X, vy * SPEED_Y); // you cannot walk mid-punch
    player.pos.y = clamp(player.pos.y, FLOOR_TOP + 10, FLOOR_BOTTOM);
    player.pos.x = clamp(player.pos.x, camLeft() + 16, camLeft() + 624);
    player.z = player.pos.y; // whoever is lower on screen is closer: draw it in front
    player.hurtTime -= dt();
    player.opacity = player.hurtTime > 0 && Math.floor(time() * 20) % 2 ? 0.3 : 1;
    fist.pos.x = player.dir > 0 ? 14 : -36;
    hud.text = t({ es: "Vida: ", en: "Health: " }) + "■".repeat(Math.max(0, player.hp));
  });
  // #endregion

  // #region combo
  // Punch, punch, finisher: each press inside the window chains the next hit.
  // The third one hits harder and knocks the enemy away, which buys you space.
  const COMBO = [
    { damage: 1, knock: 40, time: 0.16 },
    { damage: 1, knock: 40, time: 0.16 },
    { damage: 2, knock: 420, time: 0.3 },
  ];
  const COMBO_WINDOW = 0.45; // seconds to press again and keep the chain
  let step = -1;
  let attackTime = 0;
  let comboTime = 0;
  let queued = false; // a press during a punch is remembered (input buffer)
  function punch() {
    step = comboTime > 0 ? (step + 1) % COMBO.length : 0;
    const hit = COMBO[step];
    attackTime = hit.time;
    comboTime = COMBO_WINDOW;
    fist.opacity = 1;
    fist.color = step === 2 ? rgb(255, 140, 90) : rgb(255, 230, 160);
    for (const e of get("enemy")) {
      const dx = (e.pos.x - player.pos.x) * player.dir; // > 0 means in front of you
      if (dx > 0 && dx < 62 && Math.abs(e.pos.y - player.pos.y) < LANE) hurtEnemy(e, hit);
    }
  }
  onButtonPress("punch", () => (attackTime > 0 ? (queued = true) : punch()));
  onUpdate(() => {
    attackTime -= dt();
    comboTime -= dt();
    if (attackTime <= 0) {
      fist.opacity = 0;
      if (queued) (queued = false), punch();
    }
  });
  // #endregion

  // #region enemies
  // Each enemy picks a side of the player and walks to a spot there, so a
  // group surrounds you. Before hitting, it flashes for a moment (telegraph):
  // that is your cue to step out of its lane or to hit first.
  function spawnEnemy(side, delay) {
    const e = add([
      rect(28, 58), pos(camLeft() + 320 + side * 360, rand(FLOOR_TOP + 20, FLOOR_BOTTOM)), anchor("bot"),
      color(220, 90, 80), z(0),
      { hp: 4, side, state: "walk", timer: delay, knock: 0 },
      "enemy",
    ]);
    e.add([rect(36, 8, { radius: 4 }), anchor("center"), color(0, 0, 0), opacity(0.3)]);
    e.onUpdate(() => {
      e.z = e.pos.y;
      e.timer -= dt();
      e.knock *= 0.9;
      e.move(e.knock, 0);
      if (e.state === "stun") {
        if (e.timer <= 0) (e.state = "walk"), (e.timer = rand(0.3, 0.8));
        return;
      }
      const spot = vec2(clamp(player.pos.x + e.side * 52, camLeft() + 20, camLeft() + 620), player.pos.y);
      const near = Math.abs(spot.x - e.pos.x) < 8 && Math.abs(spot.y - e.pos.y) < 6;
      if (e.state === "walk") {
        e.color = rgb(220, 90, 80);
        if (!near) e.moveTo(spot, 95);
        else if (e.timer <= 0) (e.state = "windup"), (e.timer = 0.45);
      } else if (e.state === "windup") {
        e.color = rgb(255, 210, 120); // telegraph
        if (e.timer <= 0) {
          const dx = Math.abs(player.pos.x - e.pos.x);
          if (dx < 64 && Math.abs(player.pos.y - e.pos.y) < LANE && player.hurtTime <= 0) {
            player.hp--;
            player.hurtTime = 0.7;
            shake(4);
            if (player.hp <= 0) go("end", false);
          }
          e.state = "walk";
          e.timer = rand(0.8, 1.6); // cooldown before the next attack
        }
      }
    });
  }
  function hurtEnemy(e, hit) {
    e.hp -= hit.damage;
    e.state = "stun"; // getting hit cancels its own attack (hitstun)
    e.timer = 0.4;
    e.color = rgb(255, 255, 255);
    e.knock = hit.knock * player.dir;
    if (e.hp <= 0) destroy(e);
  }
  // #endregion

  // #region waves
  // The street is split into fights. On reaching a fight the camera locks and
  // you cannot move on until every enemy is down, then a sign tells you to go.
  const WAVES = [
    { x: 400, enemies: 2 },
    { x: 1000, enemies: 3 },
    { x: 1600, enemies: 4 },
  ];
  let wave = 0;
  let lockX = null; // camera x while a fight is on
  let fighting = false;
  onUpdate(() => {
    if (!fighting && wave < WAVES.length && player.pos.x >= WAVES[wave].x) {
      fighting = true;
      lockX = camPos().x;
      for (let i = 0; i < WAVES[wave].enemies; i++) spawnEnemy(i % 2 ? -1 : 1, i * 0.6);
    }
    if (fighting && get("enemy").length === 0) {
      fighting = false;
      lockX = null;
      wave++;
      banner.text = wave < WAVES.length ? t({ es: "¡Avanza! →", en: "Go! →" }) : t({ es: "¡Hacia la salida! →", en: "To the exit! →" });
      wait(1.5, () => (banner.text = ""));
    }
    if (wave === WAVES.length && player.pos.x > LEVEL_WIDTH - 90) go("end", true);
  });
  // #endregion

  // #region camera
  // The camera only moves forward: going back is not allowed (classic belt scroll).
  let camX = 320;
  function camLeft() {
    return (lockX ?? camX) - 320;
  }
  onUpdate(() => {
    if (lockX === null) camX = clamp(Math.max(camX, player.pos.x), 320, LEVEL_WIDTH - 320);
    camPos(lockX ?? camX, 180);
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
  add([
    text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }),
    pos(center().add(0, 30)),
    anchor("center"),
    color(200, 210, 255),
  ]);
  onButtonPress("punch", next);
  onKeyPress("enter", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Beat 'em up", en: "Beat 'em up" }),
    t({
      es: "Flechas para moverte por la calle (también hacia el fondo), espacio o Z para golpear: tres golpes seguidos hacen un combo. Vence cada grupo para avanzar.\nPulsa espacio o toca para empezar.",
      en: "Arrows to move along the street (into the depth too), space or Z to punch: three hits in a row make a combo. Beat each group to move on.\nPress space or tap to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (won) =>
  screen(
    won ? t({ es: "¡Calle despejada!", en: "Street cleared!" }) : t({ es: "Te derribaron", en: "You were knocked out" }),
    t({ es: "Pulsa espacio para jugar otra vez.", en: "Press space to play again." }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Que se lea la profundidad: sombras bajo los personajes y dibujar delante a quien está más abajo en la pantalla, para que el jugador sepa si está en la franja del enemigo.
  • Que golpear se sienta contundente con muy poco: una pausa breve al impactar, retroceso, parpadeo y una sacudida de cámara.
  • Una inteligencia artificial que rodea sin atacar toda a la vez: turnos de ataque y avisos (telegrafía) antes de cada golpe para que el jugador pueda reaccionar.
  • Evitar la monotonía: variar los tipos de enemigo, las armas y los escenarios para que pelear lo mismo durante media hora siga siendo interesante.

Historia

  1. 1984
    OrigenKung-Fu Master

    Kung-Fu Master (Irem, 1984), inspirado en películas de artes marciales de Hong Kong, se considera el primer beat 'em up; en él solo te movías a izquierda y derecha.

    Kung-Fu Master (Wikipedia)
  2. 1986
    Renegade

    Nekketsu Kōha Kunio-kun (Renegade en Occidente) introduce el formato de desplazamiento en cinta, en el que también te mueves hacia el fondo de la escena.

    Renegade (Wikipedia)
  3. 1987
    Double Dragon

    Double Dragon fija el modo cooperativo para dos jugadores y abre la edad de oro del género.

    Beat 'em up (Wikipedia)
  4. 1989
    Final Fight

    Final Fight, de Capcom, suma agarres, lanzamientos, armas de pocos usos y comida escondida en barriles, y es aclamado como uno de los mejores del género.

    Final Fight (Wikipedia)
  5. 1992
    Streets of Rage 2

    Streets of Rage 2 es de los primeros juegos de consola que igualan el prestigio de los beat 'em ups de arcade.

    Beat 'em up (Wikipedia)
  6. 1996

    Die Hard Arcade, de Sega, es el primer beat 'em up con gráficos 3D de polígonos texturizados; poco después el género casi desaparece de los arcades.

    Beat 'em up (Wikipedia)
  7. 2008
    Castle Crashers

    Castle Crashers, de The Behemoth, lleva el género a la distribución digital con un cooperativo aclamado para cuatro jugadores, en local o en línea, y personajes que suben de nivel.

    Castle Crashers (Wikipedia)
  8. 2020
    Streets of Rage 4

    Streets of Rage 4 renueva el interés por la saga y por el género, y supera los 2,5 millones de copias en un año.

    Beat 'em up (Wikipedia)

Juegos de referencia

1984

Kung-Fu Master

Irem (Takashi Nishiyama). arcade, NES

Recorres de lado a lado los cinco pisos de un templo golpeando y pateando a grupos de enemigos, con un jefe al final de cada piso. Se considera el primer beat 'em up.

1986

Renegade

Technōs Japan (Yoshihisa Kishimoto). arcade, NES, ZX Spectrum, Commodore 64

En Japón se llamó Nekketsu Kōha Kunio-kun. Peleas contra pandillas en escenarios por los que también te mueves hacia el fondo, el formato de «cinta» que adoptó todo el género.

1987

Double Dragon

Technōs Japan (Yoshihisa Kishimoto). arcade, NES, Master System, Mega Drive

Dos hermanos avanzan juntos por la ciudad a puñetazos; puedes quitarles las armas a los enemigos y usarlas contra ellos. Fijó el cooperativo para dos como forma clásica del género.

1989

Final Fight

Capcom (Akira Nishitani). arcade, SNES, Mega-CD

Uno o dos jugadores recorren una ciudad tomada por una banda. Puedes agarrar y lanzar a los enemigos, y en los barriles rotos hay armas de pocos usos y comida que te cura.

1992

Streets of Rage 2

Ancient (publicado por Sega). Mega Drive

Eliges entre cuatro personajes, del luchador lento y fuerte al patinador rápido, y cada uno tiene ataques especiales que cuestan vida. Fue de los primeros juegos de consola que estuvieron a la altura de los beat 'em ups de arcade.

2008

Castle Crashers

The Behemoth. Xbox 360, PlayStation 3, Windows, Nintendo Switch

Hasta cuatro caballeros pelean juntos, en local o en línea, y suben de nivel para repartir puntos entre sus atributos de combate. Llevó el beat 'em up a la distribución digital con humor de dibujo animado.

2020

Streets of Rage 4

Dotemu, Lizardcube y Guard Crush Games. Windows, Nintendo Switch, PlayStation 4, Xbox One

Continuación de la saga de Sega, con unos mil cuadros de animación por personaje y el mismo esquema de avanzar por la calle venciendo grupos de enemigos. Vendió más de 2,5 millones de copias en un año.

2024

Backstreet Warriors

SebaGamesDev. PICO-8, navegador, Windows, macOS, Linux

Un beat 'em up minimalista para PICO-8 en el que te enfrentas sin pausa a 99 rivales en un callejón, con un combo de tres golpes, patada en salto y paradas en el momento justo. Admite dos jugadores en el mismo teclado.

Ejercicio

Juega la demo y luego cambia LANE (la tolerancia de profundidad) a 4 y a 40: anota qué pasa con la estrategia en cada caso. Después añade un enemigo que no pueda ser empujado por el tercer golpe y piensa cómo cambia la forma de pelear en grupo.

Fuentes