Claude Code Plugins

Community-maintained marketplace

Feedback

This skill should be used when working with the Combat tracker, Combatant documents, initiative rolling and sorting, turn/round management, or implementing combat hooks like combatStart, combatTurn, and combatRound.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name fvtt-combat
description This skill should be used when working with the Combat tracker, Combatant documents, initiative rolling and sorting, turn/round management, or implementing combat hooks like combatStart, combatTurn, and combatRound.

Foundry VTT Combat System

Domain: Foundry VTT Module/System Development Status: Production-Ready Last Updated: 2026-01-05

Overview

Foundry's combat system manages turn-based encounters through Combat and Combatant documents. Understanding the combat lifecycle is essential for game system development.

When to Use This Skill

  • Implementing initiative rolling formulas
  • Managing turn order and round progression
  • Hooking into combat events
  • Extending combat behavior for game systems
  • Creating combat-related UI features

Combat Document

Core Properties

game.combat            // Active combat encounter
game.combats           // All combat encounters
game.combats.active    // Currently active combat

// Combat properties
combat.round           // Current round number
combat.turn            // Current turn index
combat.active          // Is combat started?
combat.combatants      // EmbeddedCollection of Combatants
combat.combatant       // Current turn's Combatant
combat.scene           // Linked Scene

Combat Methods

// Start/end combat
await combat.startCombat();
await combat.endCombat();

// Navigation
await combat.nextTurn();
await combat.previousTurn();
await combat.nextRound();
await combat.previousRound();

// Initiative
await combat.rollInitiative(["combatantId"]);
await combat.rollAll();    // Roll for all
await combat.rollNPC();    // Roll for NPCs only
await combat.resetAll();   // Clear all initiative

Combatant Document

Core Properties

combatant.actor        // Associated Actor
combatant.token        // Token data (not Token instance)
combatant.initiative   // Initiative value
combatant.active       // Is current turn?
combatant.defeated     // Defeated status
combatant.hidden       // Hidden from players?
combatant.players      // Owning players

Combatant Methods

// Roll initiative for this combatant
await combatant.rollInitiative();

// Get Roll without rolling
const roll = combatant.getInitiativeRoll();
const customRoll = combatant.getInitiativeRoll("1d20+5");

Initiative

Roll Initiative

// Single combatant
await combat.rollInitiative("combatantId");

// Multiple combatants
await combat.rollInitiative(["id1", "id2"]);

// With options
await combat.rollInitiative(["id1"], {
  formula: "1d20 + @abilities.dex.mod",
  messageOptions: { flavor: "Initiative" },
  updateTurn: true
});

Custom Initiative Formula

Override in your system's Combatant class:

class MyCombatant extends Combatant {
  _getInitiativeFormula() {
    const actor = this.actor;
    if (!actor) return "1d20";

    // System-specific formula
    return `1d20 + @abilities.dex.mod + @initiative.bonus`;
  }
}

// Register
CONFIG.Combatant.documentClass = MyCombatant;

Custom Sort Order

class MyCombat extends Combat {
  _sortCombatants(a, b) {
    // Higher initiative first
    const initA = a.initiative ?? -Infinity;
    const initB = b.initiative ?? -Infinity;
    if (initA !== initB) return initB - initA;

    // Tie-breaker: alphabetical
    return a.name.localeCompare(b.name);
  }
}

CONFIG.Combat.documentClass = MyCombat;

Combat Hooks

combatStart

Hooks.on("combatStart", (combat, updateData) => {
  console.log(`Combat started: Round ${updateData.round}`);
});

combatTurn

Hooks.on("combatTurn", (combat, updateData, updateOptions) => {
  const combatant = combat.combatants.contents[updateData.turn];
  console.log(`${combatant.name}'s turn`);

  // updateOptions.direction: 1 (forward) or -1 (backward)
});

combatRound

Hooks.on("combatRound", (combat, updateData, updateOptions) => {
  console.log(`Round ${updateData.round} started`);
});

updateCombat

Hooks.on("updateCombat", (combat, changes, options, userId) => {
  if ("turn" in changes) {
    console.log("Turn changed");
  }
  if ("round" in changes) {
    console.log("Round changed");
  }
});

Extending Combat

Workflow Methods

Override these for system-specific behavior:

class MyCombat extends Combat {
  // Called at start of each turn
  async _onStartTurn(combatant) {
    await super._onStartTurn(combatant);

    // Decrement duration effects
    const actor = combatant.actor;
    if (actor) {
      await this._decrementEffects(actor);
    }
  }

  // Called at end of each turn
  async _onEndTurn(combatant) {
    await super._onEndTurn(combatant);

    // Process end-of-turn effects
  }

  // Called at start of each round
  async _onStartRound() {
    await super._onStartRound();

    // Reset per-round resources
    for (const c of this.combatants) {
      await c.actor?.resetRoundResources?.();
    }
  }

  // Called at end of each round
  async _onEndRound() {
    await super._onEndRound();
  }

  async _decrementEffects(actor) {
    for (const effect of actor.effects) {
      if (effect.duration.rounds) {
        const remaining = effect.duration.rounds - 1;
        if (remaining <= 0) {
          await effect.delete();
        } else {
          await effect.update({ "duration.rounds": remaining });
        }
      }
    }
  }
}

CONFIG.Combat.documentClass = MyCombat;

GM-Only Execution

Workflow methods only run for one GM. Handle player-side logic with hooks:

// This runs for all clients
Hooks.on("combatTurn", (combat, update, options) => {
  // Player-safe logic here
});

Common Patterns

Get Current Combatant

function getCurrentCombatant() {
  const combat = game.combat;
  if (!combat?.started) return null;
  return combat.combatant;
}

Check If Actor's Turn

function isActorsTurn(actor) {
  const combat = game.combat;
  if (!combat?.started) return false;
  return combat.combatant?.actor?.id === actor.id;
}

Add Token to Combat

async function addToCombat(token) {
  let combat = game.combat;

  // Create combat if none exists
  if (!combat) {
    combat = await Combat.create({ scene: token.scene.id });
  }

  // Add combatant
  await combat.createEmbeddedDocuments("Combatant", [{
    tokenId: token.id,
    actorId: token.actor?.id,
    sceneId: token.scene.id
  }]);
}

Skip Defeated Combatants

class MyCombat extends Combat {
  async nextTurn() {
    let next = this.turn + 1;
    let round = this.round;

    // Skip defeated
    while (this.combatants.contents[next]?.defeated) {
      next++;
      if (next >= this.combatants.size) {
        next = 0;
        round++;
      }
    }

    return this.update({ turn: next, round });
  }
}

Common Pitfalls

1. Empty Combat Errors

// WRONG - errors with no combatants
const combat = await Combat.create({ scene: sceneId });
await combat.startCombat();  // Error!

// CORRECT - add combatants first
const combat = await Combat.create({
  scene: sceneId,
  combatants: [{ tokenId: token1.id }]
});
await combat.startCombat();

2. Token vs TokenDocument

// combatant.token is TokenDocument data, not Token
const tokenData = combatant.token;  // Plain object

// To get actual Token instance:
const token = canvas.tokens.get(combatant.tokenId);

3. Null Initiative

// Initiative can be null before rolling
const init = combatant.initiative;  // Could be null

// Handle null in comparisons
const sortedInit = init ?? -Infinity;

4. Workflow Method Timing

// _onStartTurn runs AFTER the turn changes
// Use hooks if you need to act BEFORE
Hooks.on("preUpdateCombat", (combat, changes) => {
  // Runs before any combat update
});

5. Linked vs Unlinked Tokens

// Be aware of token linking
if (combatant.token.actorLink) {
  // Changes affect the base Actor
} else {
  // Changes only affect this token's synthetic actor
}

6. Multiple GM Execution

// Workflow methods only run for one GM
// Don't rely on them for all-client logic

// Use hooks for all-client execution
Hooks.on("combatTurn", () => {
  // Runs on all clients
});

Implementation Checklist

  • Register custom Combat/Combatant classes in CONFIG
  • Override _getInitiativeFormula() for system formula
  • Implement _sortCombatants() for custom ordering
  • Use workflow methods for GM-only automation
  • Use hooks for all-client logic
  • Handle null initiative values
  • Check combat.started before accessing turn data
  • Consider defeated/hidden combatants
  • Test with multiple GMs connected

References


Last Updated: 2026-01-05 Status: Production-Ready Maintainer: ImproperSubset