15 KiB
Plan 5: Deeper Combat Design Spec
Purpose
The Plan 4 release delivered a playable MVP. Playtest feedback surfaced four combat-feel gaps: deterministic attacks (every click is a hit), no hit/miss randomness, terrain that doesn't reward positioning, and a small "every fight feels the same" effect from the above. Plan 5 deepens the combat layer — engine-only changes — without introducing new infrastructure, new storage, or new external dependencies.
Success Criteria
Plan 5 succeeds when a new user, without developer assistance, can:
- Click the Attack button on an enemy unit and see a non-zero miss rate (roll-based, not always-hit).
- Read the action log entry and see the roll, the threshold, and the outcome (hit, miss, miss-with-concealment, crit).
- Position a unit in a forest and see the defender's miss rate increase.
- Position a unit in rough terrain and see only movement slowdown (no defensive bonus).
- Move a unit into water and see it spend the rest of its turn "wading" (no further actions).
- Surround a target with allies and see flanking bonuses.
- Win a match and feel that outcomes depend on positioning and randomness, not pure stat comparison.
In Scope
To-hit + damage model
A successful attack is no longer guaranteed. The engine computes a per-attack threshold; the roll either meets it (hit) or doesn't (miss). Damage is rolled within ±15% variance on top of the deterministic base.
- To-hit threshold:
threshold = 40 + (attacker.attack * 3) - (target.defense + terrain.defenseBonus) - flankingBonus- 40 is the base (matches the "60% hit rate at parity" feel).
attacker.attack * 3rewards higher-attack units.(target.defense + terrain.defenseBonus)rewards higher-defense units and favorable terrain.flankingBonusis +15 per ally in any of the 4 orthogonally-adjacent tiles of the target, capped at +30 (3+ allies = +30).- Floor: 5 (always at least 5% chance to hit a target you can reach).
- Ceiling: 95 (always at least 5% chance to miss, so flanking has teeth).
- Roll:
random_int(1, 100). Server-side, via the existingrandom_int(). - Outcome:
isHit = roll >= threshold. - Damage on hit:
damage = round( max(1, attacker.attack + attacker.attackBonus - (target.defense + terrain.defenseBonus)) * random_int(85, 115) / 100 )- Variance is ±15% on the deterministic damage floor.
- Floor: 1 damage (cannot deal 0 on a hit).
- Damage on miss: 0. Logged as such for transparency.
Crits
A roll >= 95 is a crit (5% chance on any attack). On crit:
- Damage is doubled before the variance multiplier (so a crit can deal substantially more than normal).
- Log entry includes
— crit.
Crits bypass the to-hit threshold (the roll is always >= 95, always >= any threshold up to 95). The threshold ceiling at 95 ensures a 5% crit window still exists.
Forest concealment
A defender standing on forest gets an additional 15% miss chance. This is a second independent random roll (after the to-hit roll passes, the engine rolls random_int(1, 100) > 15; if that roll fails, the attack is a miss-with-concealment instead of a hit). Implementation:
- Forest defender:
isHit = (roll >= threshold) AND (concealmentRoll = random_int(1, 100) > 15) - Other terrain:
isHit = (roll >= threshold)(no concealment roll).
The action log entry for a forest miss includes — concealed.
This is independent of the to-hit roll. The flow:
- Roll to-hit. If miss, log miss, damage 0.
- If to-hit, roll concealment (only on forest). If concealment fails, log
miss — concealed, damage 0. - If hit, roll damage variance, log
hit(orcritif roll was >= 95).
The to-hit threshold formula does not include a concealment bonus; concealment is a separate roll on top of an already-passing to-hit. This is so the to-hit formula stays readable (one calculation) and the concealment is a clean, independent effect.
Flanking
A unit attacking a target with one or more allies in the target's 4 orthogonally-adjacent tiles (N/S/E/W) gets a flanking bonus to its to-hit threshold. Specifically:
flankingBonus = min(countAlliesAdjacentTo(target) * 15, 30)- The flanking check counts any non-defeated ally of the attacker. Multiple allies stack up to 2 (cap 30).
- Diagonal positions do not count (e.g., a unit at
(x+1, y+1)relative to the target does not contribute). - The target itself does not contribute (obviously — it is the target).
- Defeated allies do not contribute.
Water traversal
Currently water tiles have movementCost() === null (impassable). Under Plan 5:
- A unit can move INTO a water tile; the cost is 3 movement points.
- After moving into water, the unit's
actionsRemainingis forced to 0 (no further move, attack, or ability this turn). - The unit's
wadedThisTurnflag is set to true. startTurn()resetswadedThisTurnto false.- A unit on water can still attack from there (only the entering ends the turn; standing on water doesn't restrict attacks after the turn boundary).
This makes water a tactical choice: pay 3 movement to get there, but your turn is over. The visual: a unit on a water tile gets a bf-unit--wading class (low opacity, slight blur) for the rest of its turn. startTurn() clears the class on the next turn's start.
Rough terrain
No change to existing rules: cost 2 movement, no defense bonus, no concealment, no crit window. Rough slows approach but is not a defensive position.
Blocking
No change. Impassable.
Out of Scope
- New terrain types (e.g., high ground, walls, doors). Plan 5 keeps the existing 5 terrain types.
- New unit archetypes. The 4 archetypes (defender, striker, support, scout) and their stat ranges are unchanged.
- New stats (e.g., luck, precision). To-hit is derived from the existing attack/defense/speed stats; no new stat axis.
- New abilities. The 3 abilities (heal, area_damage, buff) are unchanged.
- New victory conditions. The 2 existing conditions (eliminate_all, hold_objective) are unchanged.
- UI redesign. The match view's layout and interaction model are unchanged; only the action log gets longer strings.
- A "fog of war" or hidden-information system. The engine is fully observable to both players in hot-seat mode; Plan 5 keeps that.
- Per-turn re-rolling of starting positions. The starting position is what the scenario sets.
System Boundaries (unchanged from Plan 1)
- Content library (Domain) — owns archetypes, abilities, terrain, and now the new combat math.
- Scenario editor (web) — unchanged. Existing JSON shape; no new fields.
- Rules engine (Domain) —
CombatEngineis the single authority. Plan 5 modifiesattack()to add to-hit, concealment, variance, crits, and water-wade. - Battle interface (web) — unchanged structurally. The JS renderer just reads the (now richer) action log strings.
The Application layer is unchanged except for a small extension to ScenarioSerializer to round-trip wadedThisTurn in the UnitState. Web layer is unchanged. Storage layer is unchanged.
Data and Action Flow
Per-attack flow (modified)
- User clicks the Attack button on an enemy unit adjacent to one of their units.
- JS reads the current match state from
localStorage, builds the action POST withX-CSRF-TokenandX-Turn-Token, and POSTs to/matches/current/attack. - Server:
PostMatchAttack::handlecallsMatchActionSupport::verify(existing CSRF/turn-token check). - Server:
CombatEngine::attack(...)runs: a. Verifies the attacker can act, the target is a valid enemy, and the target is in attack range (existing checks). b. Computes the to-hit threshold:40 + attack*3 - (defense + terrainDefense) - flanking. c. Rollsroll = random_int(1, 100). d. Ifroll < threshold: log miss, return without state change to attacker (still spends the attack action). e. If the target is on forest: rollconcealmentRoll = random_int(1, 100). IfconcealmentRoll <= 15: logmiss — concealed, return. f. Compute damage:round(max(1, attack+bonus - defense-terrainDefense) * random_int(85, 115) / 100). g. Ifroll >= 95: double the damage before rounding. Logcrit. h. Apply damage to the target. Log hit (or crit) with the roll and threshold. - Server returns the new match state with the action log entry.
- JS replaces the local envelope, re-renders.
Per-turn flow (modified for water)
- End Turn: same as today.
CombatEngine::endTurn()rotates the active team, refreshes the next team's units, and resetsactionsRemaining,hasAttacked,hasUsedAbility, andwadedThisTurn.- The new turn token is computed and returned to the JS as before.
Water-entry flow (new)
- JS sends a move action with destination on a water tile.
CombatEngine::move(...)validates the destination is in attack range (1 step), is not blocking, and the unit has actions remaining.- After moving, the engine sets
wadedThisTurn = trueon the unit andactionsRemaining = 0. - The new
UnitStateis included in the response. - JS renders the unit on the water tile with the
bf-unit--wadingclass. No further action buttons are enabled for that unit this turn (the existing "actions remaining" UI gate already handles this).
Validation and Failure Handling
- The to-hit formula is always evaluated; no random roll is skipped. Every attack produces an action log entry, even if the result is "miss."
- Water-wade is enforced server-side. The JS cannot bypass it by simply not sending the wade flag — the flag is part of the
UnitStateand the engine sets it. - A 5% crit window always exists (threshold ceiling at 95). A 5% hit window always exists (threshold floor at 5).
- The forest concealment is independent of to-hit. A unit in forest can still be hit (the concealment roll is a 15% miss chance, not a 100% miss chance).
- The
random_int(1, 100)call uses PHP's CSPRNG (/dev/urandomon Linux,BCryptGenRandomon Windows). No seeding; no reproducibility.
Security and Quality Requirements
Inherited from the design spec:
- Every state-changing form or request uses and verifies a CSRF token before mutation.
- All rendered output is escaped for its output context.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- JavaScript passes ESLint using
airbnb/baseand Prettier checks. - Composer configuration passes
composer validate --strict.
Plan 5-specific:
- The
random_int(1, 100)calls are documented with their purpose at the call site (to-hit roll, concealment roll, damage variance). - The to-hit constants (40, 3, 15, 5, 95) are named constants in
CombatEngine, not magic numbers, so they're easy to retune. Documentation at the constant declaration explains the choice. - The action log entry format is documented as a string contract; any deviation is a bug.
- The Plan 5 changes do not introduce new PHPCS warnings. The
random_intand to-hit math are short; the log format string is < 200 chars; no line-length regression. - Plan 5 changes are covered by unit tests at the engine level (hit, miss, crit, variance, flanking, terrain, water-wade) and by an integration test that asserts the action log entry format on a sample attack.
Verification Strategy
Unit tests
tests/Unit/Domain/CombatEngineTest.php(existing, extended):testAttackRollsAboveThreshold— attack withattack=10, defense=0, no terrainproduces threshold 70, roll of 80 yields hit, damage within ±15% of base.testAttackMissesWhenRollBelowThreshold— same setup, roll of 30 yields miss, damage 0, log has— miss.testAttackCritsOnRollAtOrAbove95— roll 95 yields crit, damage doubled.testAttackForestDefenderRollsConcealment— defender on forest, to-hit passes, concealment roll 10 yields— miss — concealed.testAttackForestDefenderPassesConcealmentMostOfTheTime— repeated roll sample, 80%+ hits through concealment.testAttackFlankingBonus— ally in N/S/E/W of target reduces threshold by 15 (30 with 2+).testAttackDoesNotFlankFromDiagonal— ally at a corner does not contribute.testAttackFlankingCapsAt30— three allies give 30, not 45.testAttackDamageVariance— repeated runs, damage falls within[floor*0.85, ceil*1.15].testAttackThresholdFloorAt5— very-low-attack vs very-high-defense never drops below 5.testAttackThresholdCeilingAt95— very-high-attack vs very-low-defense never exceeds 95.
Per-unit-state tests
tests/Unit/Domain/UnitStateTest.php(existing, extended):testStartTurnResetsWadedFlag— unit that waded haswadedThisTurn = falseafterstartTurn().testWithWadedSetsFlag—withWaded(true)returns a unit with the flag set.- Round-trip via
ScenarioSerializer::unitToArray/unitFromArraycarries the flag.
Per-terrain tests
tests/Unit/Domain/TerrainTest.php(new):testWaterIsTraversableWithCost3—Water->movementCost() === 3.testForestCostIs2—Forest->movementCost() === 2(existing; regression).testWaterIsNotBlocking—Water !== Blocking.testForestConcealmentConstantIs15— a named constantForest::CONCEALMENT_MISS_CHANCE === 15.
Integration tests
tests/Integration/PostMatchActionTest.php(existing, extended):testAttackActionLogIncludesRollAndThreshold— fresh attack, response body, assertmatch.actionLog[-1]matches/^\S+ attacked \S+ \(rolled \d+ \/ needed \d+\) for \d+ damage/.testAttackActionLogFlagsMiss— when a miss happens (force via seeded test), log entry contains— miss.testMoveIntoWaterEndsTurn— fresh unit on dry tile, move to adjacent water tile, response carrieswadedThisTurn = trueandactionsRemaining = 0.testStartTurnResetsWadedFlag— end turn, next turn the unit'swadedThisTurnis back to false.
Scenario fixtures
public/assets/scenarios/last-stand.json— unchanged on disk; the validator's water-traversable rule allows units to START on water, and the editor's water traversal works the same. No JSON edits needed.public/assets/scenarios/skirmish.json— unchanged (no water in this scenario).public/assets/scenarios/hold-the-line.json— unchanged.
Manual usability check
A tester with no BattleForge context can:
- Open the dev URL, click Play bundled → Skirmish.
- Move a unit next to an enemy, click Attack, see the action log entry show
rolled X / needed Y for Z damage(or— miss). - Move a unit into forest, defend with it, see
— concealedin the log when an attack misses. - Move a unit into water, see the rest of the turn's actions disabled and the wading visual.
- Surround an enemy with two allies, see the threshold drop and more hits land.
Release Boundary
Plan 5 is releasable when:
- Every in-scope capability above is implemented and the verification suite is green (PHPUnit, PHPStan, PHPCS, ESLint, Prettier).
- A new playthrough of Skirmish and Hold the Line shows the action log entries in the documented format.
- A playthrough of Last Stand (which has water tiles) shows the wade behavior.
- The to-hit formula's constants (40, 3, 15, 5, 95) are documented as named constants and easy to retune.
- The pre-existing PHPCS baseline (91 warnings across 18 files) is unchanged.