Combat
Combat
This document defines the battle system of Operation Tactics: turn order, movement, actions, damage, elements, statuses, and a battlefield that changes as the fight goes on. It is the authoritative spec — GAME_BIBLE.md summarizes it, and nothing here is superseded except by a newer version of this file.
What is assumed (FFT baseline). Turn order, the per-turn flow, option/tile selection, pathfinding, and units standing on tiles all behave like Final Fantasy Tactics. We state those behaviors here so no one (human or agent) re-invents or "fixes" them.
What is ours. The map is a living battlefield (mutation during combat), an element/status system, deterministic CT scheduling, and a fully deterministic simulation pipeline. These have no FFT equivalent and are specified precisely.
1. Assumed baseline (FFT-shaped, stated once)
- One unit per tile; a unit stands on its tile's surface (see MAP_RULES §7).
- Movement is bounded by a move budget and shown as a movement bubble (the reachability set — see the pathfinding pipeline).
- A unit's turn is: choose a destination (or hold) → face a direction → choose an action → pick a target → confirm.
- The action menu is Attack / Ability / Item / Wait. "Wait" ends the turn without acting.
- Occupied tiles are impassable — you cannot move through or stand on a tile another unit stands on.
- Targeting considers both horizontal range and vertical height difference.
- Anything not specified below is expected to follow FFT behavior unless it contradicts these rules.
2. Our divergences (defined here, no FFT equivalent)
| Feature | Rule |
|---|---|
| Turn order | Deterministic CT scheduling (§3), not a fixed round order |
| The map | Mutable mid-combat; every change is a commit (§9, §10) |
| Elements | Eight-element system with per-unit affinities and terrain elements (§7) |
| Statuses | A status catalog with tick-based durations (§8) |
| Simulation | Fully deterministic: integer math, seeded RNG, commit-ordered (§10) |
3. Turn order & the tick clock
The match has one global clock: an integer tick counter (tick, starting at 0, +1 per simulation tick). Everything scheduled in a battle — turns and world events alike — is keyed to this counter.
- Every tick, every unit's Charge Time (CT) increases by its SPD:
ct += spd. - A unit is eligible when
ct >= 100. - The actor is the eligible unit with the highest CT. Ties are broken by ascending
unit_id(stable ids assigned at match setup) — deterministic, never random. - After a unit acts,
ct -= 100; the overflow stays (a unit at CT 137 acts and sits at 37). This is why fast units act more often. - Movement does not consume CT. CT only schedules when a unit's turn comes up; the turn itself is free.
- Tick cadence is a presentation concern — the logic only cares that ticks are ordered.
[planned] — src/combat/battle.gd owns the tick counter, CT queue, and actor selection.
4. A unit's turn
When a unit is the actor:
- Move — pick a destination tile inside the movement bubble (or hold position). The path is computed by the pathfinder; entering hazardous tiles applies their effects as the unit walks (§5, §9).
- Face — pick one of the four directions. Facing determines which targets are in an action's arc and enables the back-attack bonus.
- Act — choose an action from the menu (Attack / Ability / Item / Wait).
- Target — if the action targets something, pick a tile or unit that satisfies range + line-of-sight + height reach (§6).
- Confirm — the turn is committed as one ordered set of decisions and resolved (§10).
Waiting is "Act = Wait": the unit ends its turn immediately.
5. Movement & occupancy
- Bubble: the set of tiles reachable within the unit's Move budget, computed by the pathfinder's reachability pass. Terrain cost, climb, and unit mobility all apply (see the rules layer).
- Blocking: a tile occupied by another unit (friend or foe) is impassable for both movement through and onto. Ranged actions may still target the occupant.
- Height: a unit stands at its tile's surface height (voxel geometry; see MAP_RULES §8). Reach checks use the height difference between attacker and target.
- Falling: if the tile under a unit is destroyed or removed (§9), the unit falls to the highest remaining surface at or below its position. Fall damage is documented in the tunable combat module (baseline:
max(0, levels_fallen - 1)×fall_damage_per_level). A unit with no surface below is removed from the battle.
[planned] — falling resolution; pathfinding and bubble logic are [built].
6. Actions & targeting
An action is a data record with the following fields (defined once, referenced by the menu, AI, and damage math):
| Field | Meaning |
|---|---|
kind |
attack / ability / item |
power |
base strength fed to the damage formula |
element |
one of the eight elements (or none) |
range |
max horizontal reach in tiles (manhattan distance) |
height_reach |
max absolute height difference (in levels) |
area |
target tile, radius-N blast, or line |
target_type |
enemy / ally / self / tile |
mp_cost |
MP spent (abilities) |
status |
status applied on hit (and its ticks) |
map_effect |
e.g. break block, create block, ignite (§9) |
Reach: a target is in range when the manhattan tile distance ≤ range and the height difference ≤ height_reach. Area effects apply to every unit standing in the affected tiles.
Line of sight: single-target actions require an unblocked line between attacker and target. A line is unblocked if no solid block whose rule carries BLOCKS_LOS intersects it. Abilities may declare they ignore LOS. [planned] — voxel raycast.
Resolving an attack (§7) and resolving a map effect (§9) both happen during the action's apply step; neither writes anything except through commits.
7. Stats, damage & elements
7.1 Streamlined stats
| Stat | Meaning |
|---|---|
hp / max_hp |
hit points |
mp / max_mp |
mana for abilities |
atk / def |
physical offense / defense |
mag / mdf |
magical offense / defense |
spd |
CT per tick (§3) |
move_budget |
movement points per turn (existing) |
jump_height |
max climbable rise (existing; jump ability later) |
7.2 Damage baselines
Physical: damage = max(1, (atk + action.power) - def) × modifiers
Magical: damage = max(1, (mag + action.power) - mdf) × modifiers
Modifiers (multiplicative, applied in order, each [0, 2]): element affinity (§7.5), height advantage (+height_bonus_per_level per level above the target), back attack (×back_multiplier when the target faces away), variance (DeterministicRng ±variance_pct). Final damage rounds to an integer.
Hit chance: hit = accuracy - evasion (from attacker action/stat minus defender stat), clamped to [min_hit, max_hit]; the roll uses DeterministicRng.chance(hit / 100).
Every constant above lives in one tunable combat module (src/combat/damage.gd, planned) — the same discipline as MovementRules. [planned].
7.3 Determinism
Combat randomness comes only from DeterministicRng, seeded from the match's simulation seed and fed by the commit sequence. The engine RNG is never used in a simulation path. Damage, hits, variance, and any future rolls are therefore identical on every client.
7.4 The eight elements
fire, ice, lightning, water, wind, earth, holy, dark (+ none).
There is no fixed element-vs-element matrix. An element matters through two mechanisms only:
- Affinities — per unit, per element, a multiplier (below).
- Terrain element — standing on an elemental tile grants resistance to that element (§7.6).
7.5 Affinities
Each unit has an affinity per element: a damage multiplier.
| Multiplier | Meaning |
|---|---|
0 |
immune |
< 0 |
absorbs (heals for that much) |
0.5 |
resists |
1 |
normal |
2 |
weak |
An action's element is looked up against the target's affinities and the result multiplies damage (floor at 0 for immunity; negative values heal).
7.6 Terrain element & standing resistance
A TerrainRule may declare an element (e.g. lava is fire). While a unit stands on an elemental tile, its affinity for that element is treated as min(affinity, 0.5) — standing on your element protects you, and the same terrain element in the rules layer drives hazard damage (§9).
7.7 Element × status interactions
| Condition | Effect |
|---|---|
wet + fire element |
+50% fire damage taken |
wet + ice element |
applies freeze (immobilize) |
| fire element on flammable terrain | ignites (field event, §9.4) |
lightning on wet |
+50% lightning damage taken |
The table is small and explicit on purpose; additions require a documented rule first.
8. Statuses
A status is a named effect on a unit with a duration in ticks (the global clock), counted from application and decremented every tick. Durations are deterministic and never "until cured" without a stated expiry.
Mobility statuses (built) — slow, stop, immobilize, float, swim, plus wet, cold, hot (see UnitProfile). slow/stop/immobilize already modify the movement budget; float/swim grant traits.
Combat statuses (planned) — poison (damage per tick), blind (accuracy penalty), silence (no abilities), charm (targets allies), protect/shell (halve physical/magical damage), regen (heal per tick), freeze (immobilize), haste.
Sources — terrain (hazard reports from the pathfinder), actions, field events. Application and expiry are commits (§10).
9. The living map
The battlefield is mutable, and every mutation is a commit (§10.2). Clients never mutate the map directly — they apply commits in order. This is what keeps a destructible battlefield in sync in multiplayer.
9.1 Destruction
A TerrainRule may be destructible with an integrity (hits to break). Damage against a block reduces integrity; an attack whose element matches the block's weakness (documented per material) deals double integrity damage. At zero, the block is removed (remove_block commit) and its support rules apply (§9.5). [planned] — TerrainRule fields.
9.2 Placement
Abilities may create blocks (walls, ice pillars, bridges). The voxel payload (position, type, shape, rotation) is a set_block commit. Placement follows the map's placement rules (MAP_RULES §6) — no overwriting solid space without a documented rule. [planned].
9.3 Scheduled field events
The match can carry a schedule: {tick, effect} pairs. When the tick counter reaches one, it fires as a field_event commit. Examples: lava rising (raising a surface by adding blocks), fire spreading to flammable neighbors, water freezing to ice, a bridge collapsing at a plot beat. The schedule is part of the match setup and is deterministic. [planned].
9.4 Collapse chains
When a block loses its support (the voxel below is removed or empty), gravity applies. Collapse is resolved as a deterministic flood from the removal point: supports are evaluated bottom-up, blocks that lose support fall, and a falling block either lands on a surface (becoming set_block) or continues down. Falling blocks and displaced units deal/apply documented impact effects. The order is fixed by the resolution algorithm — never by UI or timing. [planned].
9.5 Hazards & pathfinding
Hazards (hazard reports) already flow from the rules layer. Any map commit invalidates the surface graph cache, so the next movement bubble and the next hazard report reflect the new battlefield. [built] — hazard reporting; [planned] — commit-driven invalidation wiring.
10. The combat pipeline
Every battle step is a pure function of the commit log:
input (player/AI) ──► validate ──► Commit (seq, kind, payload)
│ │
└─────────── broadcast to all clients ◄┘
│
each client simulates, in order:
move (path + hazard effects) → resolve action (range/LOS/affinity/status)
→ apply map mutations → tick statuses → fire due field events
→ CT tick → next actor
- Order is the contract. Commits are sequence-numbered and append-only (
CommitLog); a gap or reorder is a de-sync by definition. The host arbitrates order; clients only ever apply. - Randomness comes from
DeterministicRngonly. - It runs headless. The whole pipeline is unit-testable without rendering (the pathfinder already is).
10.1 The commit vocabulary
| kind | payload (sketch) |
|---|---|
move_unit |
{unit_id, path, facing} |
wait |
{unit_id} |
use_action |
{unit_id, action_id, target_tile, target_unit} |
set_block |
{pos, type, shape, rot} |
remove_block |
{pos} |
apply_damage |
{unit_id, amount, element, source} |
apply_status |
{unit_id, status, ticks} |
field_event |
{tick, effect} |
end_match |
{outcome} |
Payloads are validated by the handler for their kind; the Commit class carries them as data.
11. AI
Enemy AI consumes the same reachability and cost model as the player (see the bible): it computes its movement bubble, scores candidate tiles (threat, hazard, height, cover), picks the best, then chooses an action whose targets are in range and LOS. The AI has no special rules — only its own scoring.
12. Framework layout
| Module | Status |
|---|---|
src/rules/elements.gd — the eight elements + affinity helpers |
[built] |
src/rules/unit_profile.gd — streamlined stats + affinities |
[built] |
src/net/commit.gd / commit_log.gd — commit + vocabulary |
[built] |
src/rules/terrain_rule.gd — element / destructible / integrity / flammable |
[planned] |
src/combat/battle.gd — tick clock, CT queue, actor selection |
[planned] |
src/combat/turn.gd — turn phases |
[planned] |
src/combat/action.gd — action data |
[planned] |
src/combat/damage.gd — formulas (single tunable module) |
[planned] |
src/combat/target.gd — reach/LOS resolution (uses pathfinder reachability) |
[planned] |
src/combat/field_effect.gd — scheduled events |
[planned] |
src/combat/collapse.gd — gravity resolution |
[planned] |
13. Agent rules
- Combat simulation is deterministic. Integer CT/ticks,
DeterministicRngonly, no engine RNG in a sim path. - All map mutations are commits. Nothing mutates the map out-of-band, in combat or out.
- Occupancy is a hard rule. One unit per tile; exceptions require a documented rule.
- Formulas live in one module. Damage/hit/status constants are not scattered once
damage.gdlands. - Docs change first. Update this file before changing combat code.
Status: [designed] — the spec is documented; elements and unit stats are built; the turn controller, damage math, targeting, field events, and collapse are [planned].