Skip to content
GameGenresEspañol
Action

Beat 'em up

You advance through a level fighting waves of enemies that surround you hand to hand, with punches, grabs and sometimes objects from the setting used as weapons.

Also known as: brawler, beat-em-up, belt-scroller

Defining mechanics

  • Movement on a plane with depth: besides advancing along the street, you move towards the back and the front, and you only hit whoever is in your lane.
  • Short combos with a single attack button: several hits in a row, the last one stronger and knocking the enemy down or away.
  • Groups of enemies that surround you and attack from behind; positioning yourself so they are all in front of you is half the game.
  • Grabs, throws and weapons picked up from the floor or taken from enemies, plus food that restores health.
  • Progress in stretches: the screen stops until you beat the group and then tells you to move on.
  • Co-op for two or more players on the same screen.

Gameplay loop

You enter a stretch, the camera locks, enemies arrive, you position yourself to avoid being surrounded, chain hits, beat the group and move on to the next.

Our demo

Controls. Arrows or WASD to move along the street and into the depth, space, Z or J to punch. On mobile, the on-screen buttons.

Open full screen

A three-screen street with three fights. In each one the camera locks, enemies arrive from both sides and you cannot move on until you beat them. Just enough to feel what defines the genre, which is fighting a group on a plane with depth.

Made with Kaplay, in about 202 lines of JavaScript.

How it's made

  1. Setup and controls

    Virtual buttons are defined for the four directions and for punching, so the same code works with a keyboard and a touch screen. The key constants are FLOOR_TOP and FLOOR_BOTTOM, which bound the band of street you can walk on, and LANE, the depth distance within which two characters can hit each other.

    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. The street

    The setting is a wall with windows and a floor with lines that suggest depth. It is pure scenery, with no collisions; at the end there is a door marking the exit.

    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. The player and depth

    Here is the genre's trick: the y coordinate is not height but depth along the street. That is why there is no gravity, moving into the depth is slower than moving sideways and the draw order (z) is set to y: whoever is lower on the screen is closer and is drawn in front. The shadow under the feet helps you see which lane you are in.

    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. The three-hit combo

    Each press throws a punch; if you press again within the COMBO_WINDOW, the next hit in the chain follows. The third one deals more damage and pushes the enemy far away, which gives you room when you are surrounded. If you press while a punch is still going, the input is stored (buffer) and comes out as soon as it ends, so the combo flows even if you press a little early. A punch only lands if the enemy is in front, close and in your depth lane.

    // 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. Enemies that surround you

    Each enemy picks a side of the player and walks to a spot at that distance, in the same lane. Since half pick the left and half the right, the group surrounds you on its own. Before striking it changes colour for almost half a second: that telegraph is your cue to leave its lane or hit it first, because taking a hit stuns it and cancels its attack.

    // 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. Waves that block your progress

    Each fight is an entry in the WAVES list with the position where it starts and how many enemies it brings. On arrival, the camera locks and the enemies come in from both sides. When none are left, the go sign appears and the camera is released. After the last fight, reaching the door wins the game.

    // 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. A camera that only moves forward

    The camera follows the player to the right but never goes back, and the player cannot leave what is on screen. During a fight it stays put. This way the level works like a belt that moves forward in stretches.

    // 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. Scenes

    The game has three scenes, title, game and end, and go() jumps between them. Going back to game starts everything from scratch.

    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");

What we simplified

  • The characters are rectangles with a shadow; there are no animations or sound.
  • A single attack button and a single enemy type; there is no jump, no grabs, no weapons and no food.
  • Enemies do not take turns to attack; each one waits a random time between blows.

To modify it

  • Add a grab, so that punching right next to a stunned enemy holds it and the next punch throws it backwards.
  • Make at most two enemies able to be in «windup» at the same time (attack turns) and compare how it feels with the original version.
  • Add crates that break and drop food that restores health.
Full code
// 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

Design challenges

  • Making depth readable: shadows under the characters and drawing whoever is lower on screen in front, so the player can tell whether they are in an enemy's lane.
  • Making hits feel weighty with very little: a brief pause on impact, knockback, a flash and a camera shake.
  • An AI that surrounds without everyone attacking at once: attack turns and warnings (telegraphing) before each blow so the player can react.
  • Avoiding monotony: varying enemy types, weapons and settings so that the same fighting stays interesting for half an hour.

History

  1. 1984
    OriginKung-Fu Master

    Kung-Fu Master (Irem, 1984), inspired by Hong Kong martial arts films, is considered the first beat 'em up; in it you could only move left and right.

    Kung-Fu Master (Wikipedia)
  2. 1986
    Renegade

    Nekketsu Kōha Kunio-kun (Renegade in the West) introduces the belt-scroll format, where you can also move into the depth of the scene.

    Renegade (Wikipedia)
  3. 1987
    Double Dragon

    Double Dragon defines two-player co-op and ushers in the genre's golden age.

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

    Capcom's Final Fight adds grabs, throws, limited-use weapons and food hidden in barrels, and is acclaimed as one of the best in the genre.

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

    Streets of Rage 2 is one of the first console games to match the acclaim of arcade beat 'em ups.

    Beat 'em up (Wikipedia)
  6. 1996

    Sega's Die Hard Arcade is the first beat 'em up with texture-mapped 3D polygon graphics; soon after, the genre almost vanishes from arcades.

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

    The Behemoth's Castle Crashers brings the genre to digital distribution with acclaimed four-player co-op, local or online, and characters who level up.

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

    Streets of Rage 4 renews interest in the series and the genre, and passes 2.5 million copies within a year.

    Beat 'em up (Wikipedia)

Reference games

1984

Kung-Fu Master

Irem (Takashi Nishiyama). arcade, NES

You cross the five floors of a temple from side to side, punching and kicking groups of enemies, with a boss at the end of each floor. It is regarded as the first beat 'em up.

1986

Renegade

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

Released in Japan as Nekketsu Kōha Kunio-kun. You fight gangs in arenas where you can also move into the depth of the scene, the belt-scroll format the whole genre adopted.

1987

Double Dragon

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

Two brothers fight their way through the city together; you can knock weapons out of enemies' hands and use them against them. It set two-player co-op as the genre's classic form.

1989

Final Fight

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

One or two players cross a city taken over by a gang. You can grab and throw enemies, and broken barrels hide weapons with limited uses and food that heals you.

1992

Streets of Rage 2

Ancient (publicado por Sega). Mega Drive

You pick one of four characters, from the slow, powerful wrestler to the fast skater, and each has special attacks that cost health. It was one of the first console games to match the acclaim of arcade beat 'em ups.

2008

Castle Crashers

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

Up to four knights fight together, locally or online, and level up to spend points on their combat attributes. It brought the beat 'em up to digital distribution with cartoon humour.

2020

Streets of Rage 4

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

A continuation of Sega's series, with about a thousand animation frames per character and the same scheme of advancing down the street by defeating groups of enemies. It sold over 2.5 million copies within a year.

2024

Backstreet Warriors

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

A minimalist PICO-8 beat 'em up in which you face 99 rivals non-stop in a back alley, with a three-hit combo, a jump kick and well-timed parries. Two players can share the keyboard.

Exercise

Play the demo, then change LANE (the depth tolerance) to 4 and to 40 and note what happens to your strategy in each case. Then add an enemy that the third hit cannot push away and think about how it changes group fights.

Sources