Skip to content

A reactive block always parries: the plain "blocked" outcome is effectively unreachable #18

Description

@WilsonNet

Summary

BLOCK_STARTUP_MS = 0 and PARRY_WINDOW_MS = 140, so the first 140ms of any
block is a parry
and a guard is effective the instant the button goes down.
A defender who presses block on reaction therefore gets a parry essentially every
time, and the plain blocked outcome — which the frame data treats as the normal
case — almost never happens.

That matters because a parry is by far the strongest defensive result in the
game: the attacker is stunned for GUARD_BREAK_STUN_MS (420ms) and the defender
is handed an instant Massive Strike. It is meant to be the reward for reading a
swing, and it is currently the reward for pressing RMB late.

Evidence

meleeSummary across six separate measured runs this session — a 16-fighter
deathmatch, several canonical duels, and a 16-bot match:

run blocks raised blockedHits parries
deathmatch (16) 14 0 24
deathmatch (16) 8 1 19
deathmatch (16) 11 0 21
duel 3 / 10 / 4 0 0 / 0 / 1
duel 11 / 7 / 8 / 6 0 2 / 1 / 3 / 1
duel 1 / 3 / 1 0 0

blockedHits is 0 in every run but one, where it is 1. outcomeByMove.slash
shows the same split: blocked: 0, parried: 24.

Why it happens

// resolveMelee
if (defender.blocking && def.blockable && !behind) {
  const outcome = defender.blockTimer <= PARRY_WINDOW_MS ? "parried" : "blocked";
}

blocking becomes true at blockTimer >= BLOCK_STARTUP_MS, and that constant is
0. So the reachable window for a plain block is blockTimer > 140ms — you
only get one by holding a guard up before the swing was thrown and keeping it
up. Reactive blocking, which is what both the bots and a human under pressure
actually do, lands inside the parry window every time.

Open question, deliberately not answered here

This may be tuning rather than a defect — but the numbers say the two outcomes are
not currently a choice, and the spec presents them as one. Worth deciding
explicitly:

  • Give the block real startup (BLOCK_STARTUP_MS > 0), so a guard thrown up late
    is a block and only an early one parries. This is the fighting-game answer and
    it makes the parry a read again.
  • Or narrow PARRY_WINDOW_MS well below the reaction time the AI achieves.
  • Or accept it, and change specs/melee.md to say the parry is the default
    outcome of a successful guard — because right now the spec and the data
    disagree.

Definition of done

  • A decision recorded in specs/melee.md with the number that implements it.
  • meleeSummary.blockedHits non-zero across a handful of canonical runs — the
    counter exists precisely so "this outcome never happens" is visible, and it is
    currently telling us something nobody has acted on.
  • The training probe gains a scenario that produces a plain block and asserts the
    outcome, alongside the existing parry one.

Notes

Found while reading meleeSummary for unrelated netcode work, which is the
counter doing its job — see docs/diagnostics.md on reading both halves of the
melee summary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions