Skip to Content
Combat System

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.ts
  • server/src/combat/CombatEngine.ts
  • server/src/combat/CombatAI.ts
  • server/src/db/combatQueries.ts
  • server/src/index.ts
  • server/src/realtime/RoomServer.ts
  • client/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: controllerPlayerId is 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 for USE_ABILITY.
  • itemId: selected consumable for USE_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_ATTACK
  • RANGED_ATTACK
  • USE_ABILITY
  • USE_ITEM
  • MOVE
  • DASH
  • DISENGAGE
  • DODGE_DEFEND
  • STABILIZE
  • REVIVE
  • AUTO_ACTION
  • TIMEOUT_END
  • END_TURN

CombatEntity

Identity and ownership:

  • id
  • name
  • type: PLAYER, ALLY, MOB
  • team: PLAYER, ALLY, ENEMY
  • controllerPlayerId
  • characterId
  • aiControlled

Combat state:

  • statuses
  • conditionState
  • turnResources
  • equipmentSnapshot
  • abilityLoadout
  • inventoryConsumables

Stats and position:

  • stats.str|dex|con|int|wis|cha
  • ac
  • hp
  • maxHp
  • position
  • initiative
  • movementPerTurn

Dice runtime:

  • fatePoolId
  • fatePoolIndex

Optional enemy-specific fields:

  • damage
  • xpReward

CombatState

  • combatId
  • locationId
  • status
  • round
  • turnQueue
  • currentTurnIndex
  • activeEntityId
  • entities
  • logs
  • turnDeadlineAt
  • defaultTurnTimeoutMs
  • occupiedCells
  • result
  • participantsByController
  • version

CombatLog

  • id
  • round
  • actorId
  • actorName
  • actionType
  • targetId
  • targetName
  • abilityId
  • itemId
  • roll
  • damageRoll
  • isHit
  • damage
  • autoResolved
  • timeoutTriggered
  • statusApplied
  • positionFrom
  • positionTo
  • narrative

Statuses and Condition State

Entity statuses:

  • ACTIVE: entity is currently available for normal participation.
  • KNOCKED: hero reached 0 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:

  • knocked
  • stabilized
  • bleedoutRoundsLeft
  • focusAbilityId
  • focusExpiresAtRound
  • disengageActive
  • defending

Turn Resources and Action Economy

Every normal turn starts with:

  • actionAvailable = true
  • bonusActionAvailable = true
  • moveRemaining = movementPerTurn
  • moveBudget = movementPerTurn

Default movement budget:

  • DEFAULT_MOVE_BUDGET = 6

Action economy rules:

  • Standard attacks consume Action.
  • USE_ABILITY consumes Action by default unless the ability metadata marks it as a bonus action.
  • USE_ITEM consumes Action by default unless the item metadata marks it as a bonus action.
  • MOVE consumes only movement points.
  • DASH consumes Action and grants another full move budget.
  • DISENGAGE consumes Action and sets conditionState.disengageActive = true.
  • DODGE_DEFEND consumes Action, sets conditionState.defending = true, and adds a temporary AC bonus.
  • END_TURN immediately 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 KNOCKED or DISABLED
  • all action, bonus action, and movement are exhausted

Constants

Key runtime constants currently used by the implementation:

  • CLOSE_RANGE = 1.5
  • NEAR_RANGE = 6
  • DEFAULT_TURN_TIMEOUT_MS = 30000
  • DEFAULT_MOVE_BUDGET = 6
  • STABILIZE_DC = 15

Range semantics:

  • Close maps to melee adjacency.
  • Near maps to the standard movement/ranged envelope.
  • Far is currently mapped to an extended range of 12.

Ownership Rules

Ownership is enforced server-side in CombatEngine.processPlayerAction:

  • The request must specify playerId, entityId, and the action payload.
  • The entityId must resolve to a combat entity.
  • That entity’s controllerPlayerId must match the request playerId.
  • That entity must currently be the activeEntityId.
  • KNOCKED and DISABLED entities cannot perform standard actions.

Realtime ownership is also guarded in RoomServer:

  • The websocket session playerId must match the combat_intent.playerId.
  • The transport no longer trusts a local playerCharacterId shortcut.

Initiative and Turn Lifecycle

Encounter start:

  1. The server resolves the encounter roster from questId, partyId, or explicit test overrides.
  2. Each player hero becomes a separate CombatEntity.
  3. Each enemy mob becomes a separate CombatEntity.
  4. The server creates fate pools per entity.
  5. Initiative is rolled per entity.
  6. The queue is sorted descending by initiative.
  7. startTurn() initializes the first active entity.

Turn start:

  1. Reset turnResources.
  2. Clear temporary defend/disengage flags.
  3. Recompute occupiedCells.
  4. If the active entity is player-controlled, set turnDeadlineAt = now + defaultTurnTimeoutMs.
  5. 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_TURN or TIMEOUT_END calls advanceTurn().
  • If the queue wraps, round is incremented.
  • Non-active or invalid queue members are skipped.

Knocked, Bleedout, Stabilize, Revive

Hero knockdown flow:

  1. A hero reaches hp <= 0.
  2. Server clamps HP to 0.
  3. Server sets KNOCKED.
  4. conditionState.knocked = true
  5. conditionState.stabilized = false
  6. conditionState.bleedoutRoundsLeft = max(1, 1 + CON mod)
  7. The hero is removed from turnQueue.

Enemy defeat flow:

  • Enemies do not enter bleedout.
  • At 0 HP, enemies get DISABLED and are removed from the queue.

STABILIZE:

  • Range: Close
  • Cost: Action
  • Check: d20 + INT mod vs STABILIZE_DC
  • On success:
    • STABILIZED is applied
    • bleedoutRoundsLeft is 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 DEFEAT when every team === PLAYER hero is KNOCKED, DISABLED, or at 0 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:

  • primaryStat
  • range
  • damage
  • save
  • duration
  • charges
  • chargesRemaining
  • focus
  • usage
  • mechanics
  • metadata

When an ability with charges is successfully used:

  • runtime chargesRemaining is decremented
  • DB character_abilities.charges_remaining is updated

Occupancy Rules

Occupancy is computed into CombatState.occupiedCells using x,y keys.

Rules:

  • PLAYER and PLAYER may share a tile.
  • PLAYER and ENEMY may not share a tile.
  • ENEMY and ENEMY may not share a tile.
  • ALLY follows 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:

  1. Select the best hostile target using distance and low-HP pressure.
  2. If already in melee range, attack.
  3. Otherwise move toward the target within remaining movement.
  4. If melee becomes available after movement, attack.
  5. If no useful attack is available, DODGE_DEFEND.
  6. Always finish with END_TURN.

AI never controls player heroes.

AFK Timeout Flow

Timeout behavior:

  1. The active player-controlled entity gets turnDeadlineAt.
  2. A backend watchdog scans active combats on an interval.
  3. If the deadline has passed, the server resolves a timeout for that active entity only.

Timeout resolution order:

  1. Move toward the best enemy target if useful.
  2. Attempt a basic attack.
  3. If no useful attack is available, use DODGE_DEFEND.
  4. Emit TIMEOUT_END.
  5. 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:
    • locationId
    • questId?
    • partyId?
    • players? for explicit test overrides
    • mobs
    • defaultTurnTimeoutMs?
  • 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:
    • playerId
    • entityId
    • action
  • Behavior:
    • validates ownership and active turn
    • resolves the action server-side
    • may immediately continue through AI turns

POST /api/combat/:id/end-turn

  • Input:
    • playerId
    • entityId
  • 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:

  • combatId
  • playerId
  • entityId
  • action

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 branch
  • DEFEAT: 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:

  1. Hero A starts with Action, Bonus Action, and Move = 6.
  2. Hero A moves 3 tiles.
  3. Hero A uses MELEE_ATTACK on Rat 1.
  4. Hero A still has movement left but no action, then sends END_TURN.
  5. The server advances to Rat 1.
  6. Rat 1 moves and attacks Hero B.
  7. The server advances to Hero B.
  8. Hero B does nothing before the deadline.
  9. The watchdog sees turnDeadlineAt expired.
  10. The server attempts an automatic basic attack for Hero B.
  11. If no valid attack exists, the server applies DODGE_DEFEND.
  12. The server emits TIMEOUT_END and advances to Rat 2.
  13. Rat 2 attacks Hero A and drops Hero A to 0 HP.
  14. Hero A becomes KNOCKED, receives bleedout rounds, and is removed from the queue.

Round 2:

  1. Hero B reaches their turn.
  2. Hero B moves adjacent to Hero A.
  3. Hero B uses STABILIZE.
  4. If the check succeeds, Hero A becomes STABILIZED and remains out of the queue.
  5. Later Hero B uses a revive-capable item on Hero A.
  6. Hero A regains HP, loses KNOCKED, and is reinserted into the queue.
  7. Combat continues until all rats are DISABLED.
  8. The encounter result becomes VICTORY and 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
Last updated on