Multiplayer Combat System
Overview
This combat system is a server-authoritative, turn-based runtime for party encounters where each connected player controls exactly one hero. A combat encounter can include player heroes, allied entities, and enemies inside a single shared initiative order, but action ownership is strict:
- A player can only submit actions for the entity they control.
- A player cannot end another player’s turn.
- Enemy turns are always resolved by the server AI.
- AFK turns never stall the encounter because the server resolves a timeout fallback and advances initiative.
The implementation lives primarily in:
shared/src/models/combat.tsserver/src/combat/CombatEngine.tsserver/src/combat/CombatAI.tsserver/src/db/combatQueries.tsserver/src/index.tsserver/src/realtime/RoomServer.tsclient/src/components/CombatOverlay.tsx
Design Principles
- Server authority: all initiative, action validation, damage, statuses, timeouts, and queue advancement are resolved on the server.
- Ownership safety:
controllerPlayerIdis the single source of truth for who may drive a hero. - Group compatibility: each hero gets an individual turn in a shared initiative queue.
- Non-blocking progression: an inactive player cannot freeze the combat or the quest branch.
- Soft failure for heroes: heroes are knocked out instead of permanently dying.
- Shared occupancy rules: the same cell validation rules are used for manual movement, AI movement, and timeout actions.
Core Runtime Types
CombatAction
Fields:
type: action discriminator.entityId: required actor id.targetId: entity target for attacks, revive, stabilize, abilities, and items.abilityId: selected ability forUSE_ABILITY.itemId: selected consumable forUSE_ITEM.weaponId: optional weapon hint for future extensions.targetCell: destination cell for movement-like actions.path: step-by-step path for movement.metadata: reserved extension payload.
Supported CombatActionType values:
MELEE_ATTACKRANGED_ATTACKUSE_ABILITYUSE_ITEMMOVEDASHDISENGAGEDODGE_DEFENDSTABILIZEREVIVEAUTO_ACTIONTIMEOUT_ENDEND_TURN
CombatEntity
Identity and ownership:
idnametype:PLAYER,ALLY,MOBteam:PLAYER,ALLY,ENEMYcontrollerPlayerIdcharacterIdaiControlled
Combat state:
statusesconditionStateturnResourcesequipmentSnapshotabilityLoadoutinventoryConsumables
Stats and position:
stats.str|dex|con|int|wis|chaachpmaxHppositioninitiativemovementPerTurn
Dice runtime:
fatePoolIdfatePoolIndex
Optional enemy-specific fields:
damagexpReward
CombatState
combatIdlocationIdstatusroundturnQueuecurrentTurnIndexactiveEntityIdentitieslogsturnDeadlineAtdefaultTurnTimeoutMsoccupiedCellsresultparticipantsByControllerversion
CombatLog
idroundactorIdactorNameactionTypetargetIdtargetNameabilityIditemIdrolldamageRollisHitdamageautoResolvedtimeoutTriggeredstatusAppliedpositionFrompositionTonarrative
Statuses and Condition State
Entity statuses:
ACTIVE: entity is currently available for normal participation.KNOCKED: hero reached0 HP, is removed from initiative, and requires stabilize or revive support.STABILIZED: bleedout progression has stopped, but the hero is still out of the queue.DISABLED: entity is fully out of the encounter.ESCAPED: reserved exit state for future scripted or retreat flows.
conditionState fields:
knockedstabilizedbleedoutRoundsLeftfocusAbilityIdfocusExpiresAtRounddisengageActivedefending
Turn Resources and Action Economy
Every normal turn starts with:
actionAvailable = truebonusActionAvailable = truemoveRemaining = movementPerTurnmoveBudget = movementPerTurn
Default movement budget:
DEFAULT_MOVE_BUDGET = 6
Action economy rules:
- Standard attacks consume
Action. USE_ABILITYconsumesActionby default unless the ability metadata marks it as a bonus action.USE_ITEMconsumesActionby default unless the item metadata marks it as a bonus action.MOVEconsumes only movement points.DASHconsumesActionand grants another full move budget.DISENGAGEconsumesActionand setsconditionState.disengageActive = true.DODGE_DEFENDconsumesAction, setsconditionState.defending = true, and adds a temporary AC bonus.END_TURNimmediately passes initiative.
The turn does not auto-finish after a single attack. It ends when:
- the player explicitly sends
END_TURN - the server resolves
TIMEOUT_END - the active entity becomes
KNOCKEDorDISABLED - all action, bonus action, and movement are exhausted
Constants
Key runtime constants currently used by the implementation:
CLOSE_RANGE = 1.5NEAR_RANGE = 6DEFAULT_TURN_TIMEOUT_MS = 30000DEFAULT_MOVE_BUDGET = 6STABILIZE_DC = 15
Range semantics:
Closemaps to melee adjacency.Nearmaps to the standard movement/ranged envelope.Faris currently mapped to an extended range of12.
Ownership Rules
Ownership is enforced server-side in CombatEngine.processPlayerAction:
- The request must specify
playerId,entityId, and the action payload. - The
entityIdmust resolve to a combat entity. - That entity’s
controllerPlayerIdmust match the requestplayerId. - That entity must currently be the
activeEntityId. KNOCKEDandDISABLEDentities cannot perform standard actions.
Realtime ownership is also guarded in RoomServer:
- The websocket session
playerIdmust match thecombat_intent.playerId. - The transport no longer trusts a local
playerCharacterIdshortcut.
Initiative and Turn Lifecycle
Encounter start:
- The server resolves the encounter roster from
questId,partyId, or explicit test overrides. - Each player hero becomes a separate
CombatEntity. - Each enemy mob becomes a separate
CombatEntity. - The server creates fate pools per entity.
- Initiative is rolled per entity.
- The queue is sorted descending by initiative.
startTurn()initializes the first active entity.
Turn start:
- Reset
turnResources. - Clear temporary defend/disengage flags.
- Recompute
occupiedCells. - If the active entity is player-controlled, set
turnDeadlineAt = now + defaultTurnTimeoutMs. - If the active entity is AI-controlled, skip the deadline and resolve AI actions immediately.
During the turn:
- The entity may move, attack, use items, use abilities, or spend action economy in multiple steps.
- Each action is validated for ownership, resources, target legality, distance, and occupancy.
- The server updates combat state after each successful resolution.
Turn end:
END_TURNorTIMEOUT_ENDcallsadvanceTurn().- If the queue wraps,
roundis incremented. - Non-active or invalid queue members are skipped.
Knocked, Bleedout, Stabilize, Revive
Hero knockdown flow:
- A hero reaches
hp <= 0. - Server clamps HP to
0. - Server sets
KNOCKED. conditionState.knocked = trueconditionState.stabilized = falseconditionState.bleedoutRoundsLeft = max(1, 1 + CON mod)- The hero is removed from
turnQueue.
Enemy defeat flow:
- Enemies do not enter bleedout.
- At
0 HP, enemies getDISABLEDand are removed from the queue.
STABILIZE:
- Range:
Close - Cost:
Action - Check:
d20 + INT modvsSTABILIZE_DC - On success:
STABILIZEDis appliedbleedoutRoundsLeftis cleared- the hero remains out of the queue
REVIVE:
- Range:
Close - Cost:
Action - Restores positive HP
- Removes
KNOCKED - Removes
STABILIZED - Re-applies
ACTIVE - Re-inserts the hero into
turnQueue
Encounter failure rule:
- The encounter is
DEFEATwhen everyteam === PLAYERhero isKNOCKED,DISABLED, or at0 HP. - This is a scene failure, not character permadeath.
Item and Ability Runtime
Equipment snapshot
At combat start the server snapshots:
- equipped melee weapon
- ranged weapon
- armor
- shield
- attack bonus
- damage bonus
- AC base and AC bonus
The combat runtime uses these values for attack and defense calculations instead of a global hardcoded 1d6 baseline when gear is available.
Consumables
Combat consumables are loaded from character_inventory.
Supported v1 item patterns:
- healing item
- revive item
- throwable damage item
- hazard-ready combat item via metadata
When an item is successfully used:
- runtime quantity is decremented
- source inventory quantity is updated through
updateInventoryQuantity
Abilities
Abilities are loaded from character_abilities and abilities.
Runtime fields include:
primaryStatrangedamagesavedurationchargeschargesRemainingfocususagemechanicsmetadata
When an ability with charges is successfully used:
- runtime
chargesRemainingis decremented - DB
character_abilities.charges_remainingis updated
Occupancy Rules
Occupancy is computed into CombatState.occupiedCells using x,y keys.
Rules:
PLAYERandPLAYERmay share a tile.PLAYERandENEMYmay not share a tile.ENEMYandENEMYmay not share a tile.ALLYfollows enemy-style exclusivity unless future rules override it.
Occupancy is checked by:
- manual move actions
- AI move decisions
- timeout auto-actions
- encounter start validation
AI Rules
Enemy AI is deterministic and rule-based.
Priority order:
- Select the best hostile target using distance and low-HP pressure.
- If already in melee range, attack.
- Otherwise move toward the target within remaining movement.
- If melee becomes available after movement, attack.
- If no useful attack is available,
DODGE_DEFEND. - Always finish with
END_TURN.
AI never controls player heroes.
AFK Timeout Flow
Timeout behavior:
- The active player-controlled entity gets
turnDeadlineAt. - A backend watchdog scans active combats on an interval.
- If the deadline has passed, the server resolves a timeout for that active entity only.
Timeout resolution order:
- Move toward the best enemy target if useful.
- Attempt a basic attack.
- If no useful attack is available, use
DODGE_DEFEND. - Emit
TIMEOUT_END. - Advance the turn.
Important guarantee:
- one AFK player cannot block the combat or the quest branch
API and Realtime Contracts
HTTP
POST /api/combat/start
- Input:
locationIdquestId?partyId?players?for explicit test overridesmobsdefaultTurnTimeoutMs?
- Behavior:
- server resolves party roster from quest/party context unless explicit overrides are provided
- returns the initialized combat state
POST /api/combat/:id/action
- Input:
playerIdentityIdaction
- Behavior:
- validates ownership and active turn
- resolves the action server-side
- may immediately continue through AI turns
POST /api/combat/:id/end-turn
- Input:
playerIdentityId
- Behavior:
- only the active owner can end their own turn
GET /api/combat/:id/state
- Returns the full combat state snapshot including timeout and ownership fields.
Realtime
combat_intent client message:
combatIdplayerIdentityIdaction
combat_state and combat_event server messages broadcast the authoritative state snapshot to the room.
Quest Integration
Quest encounters use combat result as a branch input.
Supported outcomes:
VICTORY: success branchDEFEAT: fail or recovery branch
The Aldric cellar encounter already uses the new combat runtime and stores the generated combatId back into quest flow metadata.
Example Multiplayer Turn Scenario
Example:
- Player A controls Hero A.
- Player B controls Hero B.
- Two rats are enemies.
- The queue is
Hero A -> Rat 1 -> Hero B -> Rat 2.
Round 1:
Hero Astarts withAction,Bonus Action, andMove = 6.- Hero A moves 3 tiles.
- Hero A uses
MELEE_ATTACKon Rat 1. - Hero A still has movement left but no action, then sends
END_TURN. - The server advances to Rat 1.
- Rat 1 moves and attacks Hero B.
- The server advances to Hero B.
- Hero B does nothing before the deadline.
- The watchdog sees
turnDeadlineAtexpired. - The server attempts an automatic basic attack for Hero B.
- If no valid attack exists, the server applies
DODGE_DEFEND. - The server emits
TIMEOUT_ENDand advances to Rat 2. - Rat 2 attacks Hero A and drops Hero A to
0 HP. - Hero A becomes
KNOCKED, receives bleedout rounds, and is removed from the queue.
Round 2:
- Hero B reaches their turn.
- Hero B moves adjacent to Hero A.
- Hero B uses
STABILIZE. - If the check succeeds, Hero A becomes
STABILIZEDand remains out of the queue. - Later Hero B uses a revive-capable item on Hero A.
- Hero A regains HP, loses
KNOCKED, and is reinserted into the queue. - Combat continues until all rats are
DISABLED. - The encounter result becomes
VICTORYand the quest can continue down the success branch.
Assumptions and Out of Scope
Current assumptions:
- one player controls one hero in v1
- encounter roster is usually derived from quest or party context
- knocked heroes leave initiative until revived
- the watchdog runs in the backend process
Intentionally out of scope for v1:
- reactions
- opportunity attacks
- advanced line-of-sight blocking
- multi-hero ownership by one player
- permanent character death
- fully scriptable hazard persistence beyond the current item metadata pattern