Skip to content
GameGenresEspañol
Action

Fighting

An action game in which you control a fighter in close combat against one or more opponents, usually over several rounds, and win by reading their intentions and punishing their mistakes with well-timed attacks.

Also known as: fighting game, fighter, versus fighting game

Defining mechanics

  • One-on-one combat on a short stage seen from the side, with health bars and best-of-three rounds.
  • Attacks with different reach, startup and recovery; how long each phase lasts decides who hits first.
  • Blocking and high or low attacks, which force guessing and reacting.
  • Special moves performed with button combinations, and combos that chain hits together.
  • A roster of characters with different styles, which creates distinct matchups.

Gameplay loop

Control the distance, bait or wait for the opponent's mistake, punish it with the right attack and return to a safe distance.

Our demo

Controls. Against the AI: arrows or A/D to move, Z or F for the quick attack, X or G for the heavy one; hold «back» to block. Two players (key 2 on the title screen): player 1 uses A/D, F and G, and player 2 uses arrows, K and L. On mobile, the on-screen buttons.

Open full screen

Two fighters in side view, a quick attack, a heavy attack and blocking, with health bars and best-of-three rounds. You play against a simple AI or another person on the same keyboard. Just enough to feel what defines the genre, which is controlling distance, judging the timing of each attack and reading what the opponent will do.

Made with Kaplay, in about 195 lines of JavaScript.

How it's made

  1. Setup and controls

    Two sets of virtual buttons are defined, one per player, so two people can share a keyboard. Against the AI, the human player can use either set. Walking back is slower than walking forward, because blocking should not be free. The REACTION_TIME constant sets how long the AI takes to notice what you do.

    const k = kaplay({
      width: 640, height: 360, letterbox: true, pixelDensity, background: [36, 28, 52],
      // Player 1: A/D, F or Z (quick), G or X (heavy). Player 2: arrows, K and L.
      buttons: {
        left1: { keyboard: ["a"] }, right1: { keyboard: ["d"] },
        quick1: { keyboard: ["f", "z"] }, heavy1: { keyboard: ["g", "x"] },
        left2: { keyboard: ["left"] }, right2: { keyboard: ["right"] },
        quick2: { keyboard: ["k"] }, heavy2: { keyboard: ["l"] },
      },
    });
    touchControls(k, [
      { button: "left1", label: "◀" }, { button: "right1", label: "▶" },
      { button: "quick1", label: "R", side: "right" }, { button: "heavy1", label: "F" },
    ]);
    
    const GROUND = 320;
    const BODY_WIDTH = 40; // width of each fighter's body
    const SPEED = 170;
    const BACK_SPEED = 110; // walking back is slower: blocking has a cost
    const REACTION_TIME = 0.22; // seconds the AI takes to "see" what you do
  2. The three phases of an attack

    Every attack goes through three phases. During startup it is telegraphed but cannot hit yet; during the active phase it can hit; during recovery the arm is still out and you cannot act. The quick attack starts and recovers fast but has short reach. The heavy one reaches far and deals more than double, but it is slow to come out and, if it misses, it leaves you exposed for half a second. No attack is best at everything, and choosing which to use for the distance is the basis of the genre.

    // Every attack has three phases measured in seconds (real games use frames):
    // startup: it is telegraphed but cannot hit yet; active: it can hit;
    // recovery: the arm is still out and you cannot do anything.
    // The quick attack hits early and recovers early, but has short reach.
    // The heavy one reaches far and hurts a lot, but leaves you exposed if it misses.
    const ATTACKS = {
      quick: { startup: 0.08, active: 0.08, recovery: 0.18, reach: 50, damage: 7, stun: 0.25 },
      heavy: { startup: 0.26, active: 0.1, recovery: 0.5, reach: 95, damage: 16, stun: 0.45 },
    };
  3. Fighters and controllers

    Each fighter stores its health, state and current attack, and receives a readInput() function that returns the current orders (left, right, quick attack, heavy attack). For a person, readInput() reads buttons; for the AI, it decides. Since the rest of the code only asks readInput(), the same fighter works for one player, two players or the machine.

    function makeFighter(x, col, readInput) {
      return add([
        rect(BODY_WIDTH, 90), pos(x, GROUND), anchor("bot"), color(col),
        { hp: 100, state: "free", timer: 0, attack: null, hasHit: false, dir: 1, blocking: false, readInput },
      ]);
    }
    // A human controller reads buttons; the AI returns the same orders (see "ai").
    const humanInput = (n) => () => ({
      left: isButtonDown(n.map((i) => "left" + i)),
      right: isButtonDown(n.map((i) => "right" + i)),
      quick: isButtonPressed(n.map((i) => "quick" + i)),
      heavy: isButtonPressed(n.map((i) => "heavy" + i)),
    });
  4. An AI that reads with a delay

    The AI checks your state every 0.22 seconds, roughly how long a person takes to react. If it sees you start an attack up close, it sometimes blocks; if it sees you in recovery, it punishes you; otherwise it keeps a medium distance and sometimes pokes. Because of that delay, it struggles to see a quick attack and almost always exploits a missed heavy attack, just like a human opponent.

    // The AI keeps a safe distance, sometimes blocks when it sees you attack and
    // punishes when it sees you in recovery. It reacts with a delay, like a person,
    // so it struggles to see a quick attack and exploits a missed heavy one.
    function aiInput(f, rival) {
      const ai = { seen: "free", clock: 0, willBlock: false };
      return () => {
        ai.clock -= dt();
        if (ai.clock <= 0) {
          ai.clock = REACTION_TIME;
          ai.seen = rival.state;
          ai.willBlock = chance(0.6);
        }
        const dist = Math.abs(rival.pos.x - f.pos.x) - BODY_WIDTH;
        const o = { left: false, right: false, quick: false, heavy: false };
        const forward = f.dir > 0 ? "right" : "left";
        const back = f.dir > 0 ? "left" : "right";
        const pick = () => (dist < ATTACKS.quick.reach ? "quick" : "heavy");
        if ((ai.seen === "startup" || ai.seen === "active") && dist < 110 && ai.willBlock) o[back] = true;
        else if (ai.seen === "recovery" && dist < ATTACKS.heavy.reach) o[pick()] = true;
        else if (dist > 85) o[forward] = true;
        else if (dist < 35) o[back] = true;
        else if (chance(dt() * 1.4)) o[pick()] = true;
        return o;
      };
    }
  5. The state machine

    Each fighter is always in one state: free, startup, active, recovery, stunned or blockstun. Only while free can it move and attack. Holding the direction away from the opponent means blocking. This state machine is the technical heart of every fighting game: it decides what you can do at each moment and how much you commit when you press a button.

    // Each fighter's state machine: free → startup → active → recovery → free.
    // Taking a hit leaves it "stunned" and blocking one, in "blockstun": in both it cannot act.
    function startAttack(f, type) {
      Object.assign(f, { attack: ATTACKS[type], state: "startup", timer: ATTACKS[type].startup, hasHit: false });
    }
    function updateFighter(f, rival) {
      f.dir = rival.pos.x > f.pos.x ? 1 : -1;
      f.timer -= dt();
      const o = f.readInput();
      if (f.state === "free") {
        const dx = (o.right ? 1 : 0) - (o.left ? 1 : 0);
        f.blocking = dx === -f.dir; // holding "back" means blocking
        f.pos.x += dx * (f.blocking ? BACK_SPEED : SPEED) * dt();
        if (o.quick) startAttack(f, "quick");
        else if (o.heavy) startAttack(f, "heavy");
      } else if (f.state === "startup" && f.timer <= 0) {
        f.state = "active";
        f.timer = f.attack.active;
      } else if (f.state === "active") {
        checkHit(f, rival);
        if (f.timer <= 0) {
          f.state = "recovery";
          f.timer = f.attack.recovery;
        }
      } else if (f.timer <= 0 && f.state !== "free") f.state = "free";
      f.pos.x = clamp(f.pos.x, 30, width() - 30);
    }
  6. Hits, blocks and punishes

    The attack connects if the arm's span touches the opponent's body during the active phase. If the opponent was blocking, they lose no health and are only pushed back a little. If they were in startup or recovery, the game marks it as a «punish». That is reading the opponent, because baiting the other player into attacking out of range and hitting them while they recover is the safest way to win.

    // The attack connects if the arm's span touches the rival's body. If the
    // rival was blocking, it loses no health. Hitting it during startup or
    // recovery is a punish: that is how you win when the other one takes a risk and misses.
    function checkHit(f, rival) {
      if (f.hasHit) return;
      const front = f.pos.x + f.dir * (BODY_WIDTH / 2);
      const tip = front + f.dir * f.attack.reach;
      const [a, b] = [Math.min(front, tip), Math.max(front, tip)];
      if (b < rival.pos.x - BODY_WIDTH / 2 || a > rival.pos.x + BODY_WIDTH / 2) return;
      f.hasHit = true;
      const isBlocking = (rival.state === "free" && rival.blocking) || rival.state === "blockstun";
      if (isBlocking) {
        Object.assign(rival, { state: "blockstun", timer: f.attack.stun * 0.6 });
        rival.pos.x += f.dir * 10;
        announce(t({ es: "Bloqueo", en: "Blocked" }), 0.4);
        return;
      }
      if (rival.state === "startup" || rival.state === "recovery") announce(t({ es: "¡Castigo!", en: "Punish!" }));
      rival.hp = Math.max(0, rival.hp - f.attack.damage);
      Object.assign(rival, { state: "stunned", timer: f.attack.stun });
      rival.pos.x += f.dir * 22;
      shake(f.attack.damage / 3);
    }
  7. Rounds and spacing

    Each round starts with the fighters in place and ends when a health bar reaches zero; the first to win two takes the match. The bodies cannot pass through each other and push apart when they touch, so the distance between the two, the spacing, is a resource won and lost step by step.

    // Best of three rounds. At the start of each one, the fighters go back to their places.
    const wins = [0, 0];
    let [round, paused] = [0, true];
    function startRound() {
      round++;
      [p1.pos.x, p2.pos.x] = [200, 440];
      for (const f of [p1, p2]) Object.assign(f, { hp: 100, state: "free", timer: 0, attack: null });
      announce(t({ es: `Ronda ${round}`, en: `Round ${round}` }), 1);
      wait(1, () => {
        announce(t({ es: "¡Pelea!", en: "Fight!" }), 0.6);
        paused = false;
      });
    }
    startRound();
    onUpdate(() => {
      if (paused) return;
      updateFighter(p1, p2);
      updateFighter(p2, p1);
      // Bodies cannot pass through each other: if they touch, both get pushed.
      const overlap = BODY_WIDTH - Math.abs(p2.pos.x - p1.pos.x);
      if (overlap > 0) {
        p1.pos.x -= p1.dir * overlap / 2;
        p2.pos.x -= p2.dir * overlap / 2;
      }
      const loser = [p1, p2].findIndex((f) => f.hp <= 0);
      if (loser < 0) return;
      paused = true;
      const winner = 1 - loser;
      wins[winner]++;
      announce(t({ es: `K.O. Gana ${names[winner]}`, en: `K.O. ${names[winner]} wins` }), 2);
      wait(2, () => (wins[winner] >= 2 ? go("end", names[winner]) : startRound()));
    });
  8. Showing the phases

    The arm is painted yellow during startup, red when it can hit and grey during recovery; the light blue shield means blocking and the white flash means you were hit. In a real game the animations give this information, and training modes show it this explicitly so players can learn. At the top are the health bars and a dot for each round won.

    // Draw the arm in the colour of its phase, the shield when blocking, and the HUD.
    // Seeing the phases on screen helps you learn when it is safe to attack.
    const PHASE_COLOR = { startup: rgb(255, 220, 80), active: rgb(255, 60, 60), recovery: rgb(150, 150, 170) };
    onDraw(() => {
      for (const f of [p1, p2]) {
        const front = vec2(f.pos.x + f.dir * (BODY_WIDTH / 2), GROUND - 66);
        const side = f.dir > 0 ? "left" : "right";
        if (f.attack && PHASE_COLOR[f.state]) {
          const length = f.state === "startup" ? 12 : f.attack.reach;
          drawRect({ pos: front, width: length, height: 12, anchor: side, color: PHASE_COLOR[f.state] });
        }
        if (f.state === "blockstun" || (f.state === "free" && f.blocking))
          drawRect({ pos: front.add(f.dir * 6, 20), width: 8, height: 70, anchor: "center", color: rgb(140, 230, 255) });
        if (f.state === "stunned") drawRect({ pos: f.pos, width: BODY_WIDTH, height: 90, anchor: "bot", color: WHITE, opacity: 0.4 });
      }
      // Health bars and rounds won (one dot per round).
      [p1, p2].forEach((f, i) => {
        const x = i === 0 ? 20 : width() - 20;
        const side = i === 0 ? "left" : "right";
        drawRect({ pos: vec2(x, 20), width: 260, height: 16, anchor: side, color: rgb(60, 40, 60) });
        drawRect({ pos: vec2(x, 20), width: 2.6 * f.hp, height: 16, anchor: side, color: f.color });
        drawText({ text: names[i] + "  " + "●".repeat(wins[i]), pos: vec2(x, 44), size: 14, font: FONT, anchor: side });
      });
      drawText({
        text: t({ es: "amarillo: arranque · rojo: golpe activo · gris: recuperación", en: "yellow: startup · red: active hit · grey: recovery" }),
        pos: vec2(width() / 2, GROUND + 22), size: 12, font: FONT, anchor: "center", color: rgb(200, 190, 220),
      });
    });
  9. Scenes

    The game has three scenes, title, game and end, and go() jumps between them. The game scene receives whether it is played against the AI or between two people, and creates the controllers accordingly.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]);
      add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 20)), anchor("center"), color(220, 210, 240)]);
      onKeyPress(["space", "enter"], () => next(false));
      onClick(() => next(false));
      onKeyPress("2", () => next(true));
    }
    
    scene("title", () =>
      screen(
        t({ es: "Lucha", en: "Fighting" }),
        t({
          es: "Flechas o A/D para moverte; Z o F, golpe rápido; X o G, golpe fuerte.\nMantén «atrás» (alejarte del rival) para bloquear.\nDos jugadores: J1 con A/D F G, J2 con flechas K L.\nEspacio o clic: contra la IA · tecla 2: dos jugadores",
          en: "Arrows or A/D to move; Z or F, quick attack; X or G, heavy attack.\nHold «back» (away from the opponent) to block.\nTwo players: P1 with A/D F G, P2 with arrows K L.\nSpace or click: against the CPU · key 2: two players",
        }),
        (two) => go("game", two),
      ),
    );
    
    scene("end", (winner) =>
      screen(
        t({ es: `Gana ${winner}`, en: `${winner} wins` }),
        t({ es: "Espacio o clic para la revancha contra la IA · tecla 2: dos jugadores", en: "Space or click for a rematch against the CPU · key 2: two players" }),
        (two) => go("game", two),
      ),
    );
    
    go("title");

What we simplified

  • The fighters are rectangles and the attacks are an arm that changes colour with its phase. There are no animations, no sound, no jumping and no crouching.
  • There are only attacks at one height, so blocking always works; real games add low attacks and throws that force you to guess how to block.
  • There are no combos, special moves, round timer or different characters.
  • Timings are measured in seconds; fighting games measure them in frames (at 60 per second, 0.1 s is 6 frames).

To modify it

  • Play ten rounds using only the heavy attack and ten using only the quick one. Note which one wins more against the AI and why.
  • Lower the heavy attack's recovery to 0.2 s and check whether the AI can still punish it. What happens to the balance?
  • Add crouching with a low attack that can only be blocked while crouching, to create a high/low guessing game.
  • Change the AI's REACTION_TIME constant to 0.1 and to 0.4 and describe how playing against it feels different.
Full code
// Fighting 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: [36, 28, 52],
  // Player 1: A/D, F or Z (quick), G or X (heavy). Player 2: arrows, K and L.
  buttons: {
    left1: { keyboard: ["a"] }, right1: { keyboard: ["d"] },
    quick1: { keyboard: ["f", "z"] }, heavy1: { keyboard: ["g", "x"] },
    left2: { keyboard: ["left"] }, right2: { keyboard: ["right"] },
    quick2: { keyboard: ["k"] }, heavy2: { keyboard: ["l"] },
  },
});
touchControls(k, [
  { button: "left1", label: "◀" }, { button: "right1", label: "▶" },
  { button: "quick1", label: "R", side: "right" }, { button: "heavy1", label: "F" },
]);

const GROUND = 320;
const BODY_WIDTH = 40; // width of each fighter's body
const SPEED = 170;
const BACK_SPEED = 110; // walking back is slower: blocking has a cost
const REACTION_TIME = 0.22; // seconds the AI takes to "see" what you do
// #endregion

// #region attacks
// Every attack has three phases measured in seconds (real games use frames):
// startup: it is telegraphed but cannot hit yet; active: it can hit;
// recovery: the arm is still out and you cannot do anything.
// The quick attack hits early and recovers early, but has short reach.
// The heavy one reaches far and hurts a lot, but leaves you exposed if it misses.
const ATTACKS = {
  quick: { startup: 0.08, active: 0.08, recovery: 0.18, reach: 50, damage: 7, stun: 0.25 },
  heavy: { startup: 0.26, active: 0.1, recovery: 0.5, reach: 95, damage: 16, stun: 0.45 },
};
// #endregion

// #region fighters
function makeFighter(x, col, readInput) {
  return add([
    rect(BODY_WIDTH, 90), pos(x, GROUND), anchor("bot"), color(col),
    { hp: 100, state: "free", timer: 0, attack: null, hasHit: false, dir: 1, blocking: false, readInput },
  ]);
}
// A human controller reads buttons; the AI returns the same orders (see "ai").
const humanInput = (n) => () => ({
  left: isButtonDown(n.map((i) => "left" + i)),
  right: isButtonDown(n.map((i) => "right" + i)),
  quick: isButtonPressed(n.map((i) => "quick" + i)),
  heavy: isButtonPressed(n.map((i) => "heavy" + i)),
});
// #endregion

// #region ai
// The AI keeps a safe distance, sometimes blocks when it sees you attack and
// punishes when it sees you in recovery. It reacts with a delay, like a person,
// so it struggles to see a quick attack and exploits a missed heavy one.
function aiInput(f, rival) {
  const ai = { seen: "free", clock: 0, willBlock: false };
  return () => {
    ai.clock -= dt();
    if (ai.clock <= 0) {
      ai.clock = REACTION_TIME;
      ai.seen = rival.state;
      ai.willBlock = chance(0.6);
    }
    const dist = Math.abs(rival.pos.x - f.pos.x) - BODY_WIDTH;
    const o = { left: false, right: false, quick: false, heavy: false };
    const forward = f.dir > 0 ? "right" : "left";
    const back = f.dir > 0 ? "left" : "right";
    const pick = () => (dist < ATTACKS.quick.reach ? "quick" : "heavy");
    if ((ai.seen === "startup" || ai.seen === "active") && dist < 110 && ai.willBlock) o[back] = true;
    else if (ai.seen === "recovery" && dist < ATTACKS.heavy.reach) o[pick()] = true;
    else if (dist > 85) o[forward] = true;
    else if (dist < 35) o[back] = true;
    else if (chance(dt() * 1.4)) o[pick()] = true;
    return o;
  };
}
// #endregion

scene("game", (twoPlayers) => {
  add([rect(width(), height() - GROUND), pos(0, GROUND), color(70, 56, 90)]);
  const p1 = makeFighter(200, rgb(90, 170, 255), humanInput(twoPlayers ? [1] : [1, 2]));
  const p2 = makeFighter(440, rgb(255, 110, 90), null);
  p2.readInput = twoPlayers ? humanInput([2]) : aiInput(p2, p1);
  const names = [t({ es: "Jugador 1", en: "Player 1" }), twoPlayers ? t({ es: "Jugador 2", en: "Player 2" }) : t({ es: "IA", en: "CPU" })];
  const banner = add([text("", { size: 30, font: FONT }), pos(width() / 2, 140), anchor("center"), z(10)]);
  const announce = (txt, s = 0.7) => {
    banner.text = txt;
    wait(s, () => banner.text === txt && (banner.text = ""));
  };

  // #region states
  // Each fighter's state machine: free → startup → active → recovery → free.
  // Taking a hit leaves it "stunned" and blocking one, in "blockstun": in both it cannot act.
  function startAttack(f, type) {
    Object.assign(f, { attack: ATTACKS[type], state: "startup", timer: ATTACKS[type].startup, hasHit: false });
  }
  function updateFighter(f, rival) {
    f.dir = rival.pos.x > f.pos.x ? 1 : -1;
    f.timer -= dt();
    const o = f.readInput();
    if (f.state === "free") {
      const dx = (o.right ? 1 : 0) - (o.left ? 1 : 0);
      f.blocking = dx === -f.dir; // holding "back" means blocking
      f.pos.x += dx * (f.blocking ? BACK_SPEED : SPEED) * dt();
      if (o.quick) startAttack(f, "quick");
      else if (o.heavy) startAttack(f, "heavy");
    } else if (f.state === "startup" && f.timer <= 0) {
      f.state = "active";
      f.timer = f.attack.active;
    } else if (f.state === "active") {
      checkHit(f, rival);
      if (f.timer <= 0) {
        f.state = "recovery";
        f.timer = f.attack.recovery;
      }
    } else if (f.timer <= 0 && f.state !== "free") f.state = "free";
    f.pos.x = clamp(f.pos.x, 30, width() - 30);
  }
  // #endregion

  // #region hit
  // The attack connects if the arm's span touches the rival's body. If the
  // rival was blocking, it loses no health. Hitting it during startup or
  // recovery is a punish: that is how you win when the other one takes a risk and misses.
  function checkHit(f, rival) {
    if (f.hasHit) return;
    const front = f.pos.x + f.dir * (BODY_WIDTH / 2);
    const tip = front + f.dir * f.attack.reach;
    const [a, b] = [Math.min(front, tip), Math.max(front, tip)];
    if (b < rival.pos.x - BODY_WIDTH / 2 || a > rival.pos.x + BODY_WIDTH / 2) return;
    f.hasHit = true;
    const isBlocking = (rival.state === "free" && rival.blocking) || rival.state === "blockstun";
    if (isBlocking) {
      Object.assign(rival, { state: "blockstun", timer: f.attack.stun * 0.6 });
      rival.pos.x += f.dir * 10;
      announce(t({ es: "Bloqueo", en: "Blocked" }), 0.4);
      return;
    }
    if (rival.state === "startup" || rival.state === "recovery") announce(t({ es: "¡Castigo!", en: "Punish!" }));
    rival.hp = Math.max(0, rival.hp - f.attack.damage);
    Object.assign(rival, { state: "stunned", timer: f.attack.stun });
    rival.pos.x += f.dir * 22;
    shake(f.attack.damage / 3);
  }
  // #endregion

  // #region rounds
  // Best of three rounds. At the start of each one, the fighters go back to their places.
  const wins = [0, 0];
  let [round, paused] = [0, true];
  function startRound() {
    round++;
    [p1.pos.x, p2.pos.x] = [200, 440];
    for (const f of [p1, p2]) Object.assign(f, { hp: 100, state: "free", timer: 0, attack: null });
    announce(t({ es: `Ronda ${round}`, en: `Round ${round}` }), 1);
    wait(1, () => {
      announce(t({ es: "¡Pelea!", en: "Fight!" }), 0.6);
      paused = false;
    });
  }
  startRound();
  onUpdate(() => {
    if (paused) return;
    updateFighter(p1, p2);
    updateFighter(p2, p1);
    // Bodies cannot pass through each other: if they touch, both get pushed.
    const overlap = BODY_WIDTH - Math.abs(p2.pos.x - p1.pos.x);
    if (overlap > 0) {
      p1.pos.x -= p1.dir * overlap / 2;
      p2.pos.x -= p2.dir * overlap / 2;
    }
    const loser = [p1, p2].findIndex((f) => f.hp <= 0);
    if (loser < 0) return;
    paused = true;
    const winner = 1 - loser;
    wins[winner]++;
    announce(t({ es: `K.O. Gana ${names[winner]}`, en: `K.O. ${names[winner]} wins` }), 2);
    wait(2, () => (wins[winner] >= 2 ? go("end", names[winner]) : startRound()));
  });
  // #endregion

  // #region drawing
  // Draw the arm in the colour of its phase, the shield when blocking, and the HUD.
  // Seeing the phases on screen helps you learn when it is safe to attack.
  const PHASE_COLOR = { startup: rgb(255, 220, 80), active: rgb(255, 60, 60), recovery: rgb(150, 150, 170) };
  onDraw(() => {
    for (const f of [p1, p2]) {
      const front = vec2(f.pos.x + f.dir * (BODY_WIDTH / 2), GROUND - 66);
      const side = f.dir > 0 ? "left" : "right";
      if (f.attack && PHASE_COLOR[f.state]) {
        const length = f.state === "startup" ? 12 : f.attack.reach;
        drawRect({ pos: front, width: length, height: 12, anchor: side, color: PHASE_COLOR[f.state] });
      }
      if (f.state === "blockstun" || (f.state === "free" && f.blocking))
        drawRect({ pos: front.add(f.dir * 6, 20), width: 8, height: 70, anchor: "center", color: rgb(140, 230, 255) });
      if (f.state === "stunned") drawRect({ pos: f.pos, width: BODY_WIDTH, height: 90, anchor: "bot", color: WHITE, opacity: 0.4 });
    }
    // Health bars and rounds won (one dot per round).
    [p1, p2].forEach((f, i) => {
      const x = i === 0 ? 20 : width() - 20;
      const side = i === 0 ? "left" : "right";
      drawRect({ pos: vec2(x, 20), width: 260, height: 16, anchor: side, color: rgb(60, 40, 60) });
      drawRect({ pos: vec2(x, 20), width: 2.6 * f.hp, height: 16, anchor: side, color: f.color });
      drawText({ text: names[i] + "  " + "●".repeat(wins[i]), pos: vec2(x, 44), size: 14, font: FONT, anchor: side });
    });
    drawText({
      text: t({ es: "amarillo: arranque · rojo: golpe activo · gris: recuperación", en: "yellow: startup · red: active hit · grey: recovery" }),
      pos: vec2(width() / 2, GROUND + 22), size: 12, font: FONT, anchor: "center", color: rgb(200, 190, 220),
    });
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]);
  add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 20)), anchor("center"), color(220, 210, 240)]);
  onKeyPress(["space", "enter"], () => next(false));
  onClick(() => next(false));
  onKeyPress("2", () => next(true));
}

scene("title", () =>
  screen(
    t({ es: "Lucha", en: "Fighting" }),
    t({
      es: "Flechas o A/D para moverte; Z o F, golpe rápido; X o G, golpe fuerte.\nMantén «atrás» (alejarte del rival) para bloquear.\nDos jugadores: J1 con A/D F G, J2 con flechas K L.\nEspacio o clic: contra la IA · tecla 2: dos jugadores",
      en: "Arrows or A/D to move; Z or F, quick attack; X or G, heavy attack.\nHold «back» (away from the opponent) to block.\nTwo players: P1 with A/D F G, P2 with arrows K L.\nSpace or click: against the CPU · key 2: two players",
    }),
    (two) => go("game", two),
  ),
);

scene("end", (winner) =>
  screen(
    t({ es: `Gana ${winner}`, en: `${winner} wins` }),
    t({ es: "Espacio o clic para la revancha contra la IA · tecla 2: dos jugadores", en: "Space or click for a rematch against the CPU · key 2: two players" }),
    (two) => go("game", two),
  ),
);

go("title");
// #endregion

Design challenges

  • Giving each attack a clear cost (startup, recovery, reach) so that no single button works for everything.
  • Making animations telegraph each attack just enough for the opponent to read it and react.
  • Balancing very different characters without making them the same.
  • An AI that plays with believable mistakes, not just perfect reflexes.
  • Teaching deep systems to newcomers without removing depth for experts.

History

  1. 1984
    OriginKarate Champ

    Karate Champ established and popularised one-on-one fighting. Heavyweight Champ (1976) already had fist fights, but it was considered a sports game.

    Fighting game (Wikipedia)
  2. 1984
    Yie Ar Kung-Fu

    Yie Ar Kung-Fu replaces Karate Champ's points with a health bar, which becomes the genre standard, and pits the player against opponents with distinct styles.

    Fighting game (Wikipedia)
  3. 1987
    Street Fighter

    Street Fighter introduces special attacks performed with joystick motions, such as the Hadouken.

    Street Fighter (Wikipedia)
  4. 1991
    Street Fighter II: The World Warrior

    Street Fighter II popularises combos, offers eight selectable fighters and puts the duel between two people at the centre of the arcades.

    Street Fighter II (Wikipedia)
  5. 1993
    Virtua Fighter

    Virtua Fighter is the first arcade fighting game with fully 3D polygon graphics and adds winning by ring-out.

    Virtua Fighter (Wikipedia)
  6. 1999
    Super Smash Bros.

    Super Smash Bros. brings together characters from several Nintendo franchises and replaces the health bar with a damage percentage; you win by knocking the opponent off the stage.

    Super Smash Bros. (Wikipedia)
  7. 2018
    FOOTSIES

    FOOTSIES strips the genre down to movement and one button to teach its fundamentals, which are spacing, hit confirms and whiff punishes.

    HiFight develops his own fighting game... called Footsies (EventHubs)

Reference games

1984

Karate Champ

Data East. arcade

Two karateka face off, each with two joysticks and 24 moves; every clean hit scores a half or full point and the first to two wins. It established one-on-one fighting.

1984

Yie Ar Kung-Fu

Konami. arcade

You face eleven martial arts masters, each with their own look and style. It replaced points with a health bar that empties with each hit.

1987

Street Fighter

Capcom. arcade

Ryu travels the world facing opponents. It introduced special attacks performed with a joystick motion plus a button, such as the Hadouken or the Shoryuken.

1991

Street Fighter II: The World Warrior

Capcom. arcade, Super NES

Eight fighters with their own styles and special moves. Its combos, discovered by accident, and its head-to-head duels defined the genre and revived the arcades.

1993

Virtua Fighter

Sega AM2. arcade

The first arcade fighting game with 3D polygon characters. Besides emptying the opponent's health, you can win by forcing them out of the ring.

1999

Super Smash Bros.

HAL Laboratory. Nintendo 64

Nintendo characters fight on open stages, up to four at a time. Each hit raises a damage percentage that makes it easier to launch the opponent off the stage, which is how you win.

2018

FOOTSIES

HiFight. Windows, iOS, Android

A minimal fighting game with no jumping, just walking and a single attack button. A fighting game community creator made it to practise the genre's essentials, which are spacing, hit confirms and whiff punishes.

2023

Monster Battlegrounds

Weenter. navegador, Windows, Linux

A beginner-friendly independent 2D fighting game in which monsters with their own movesets fight one on one. It plays in the browser against the AI or against another person on the same keyboard.

Exercise

Play the demo against the AI and then against another person on the same keyboard. Change the heavy attack's recovery and check when it stops being worth using. Then discuss why the low block and the high attack create a guessing game.

Subgenres

  • Mascot fighterYou pick famous characters from different franchises, often a company's mascots, and pit them against each other in one fighting game: that crossover of franchises is what defines it. It almost always follows platform fighter rules, with several rivals at once, and instead of draining a health bar you try to knock them off the stage.
  • Platform fighterYou fight several rivals on a stage with platforms you move around freely; there is no health bar, only accumulated damage that makes each hit launch them farther, and you win by knocking them out of bounds. The mechanic defines it, not who the fighters are.

Sources