fvtt-combat
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.
When & Why to Use This Skill
This Claude skill provides comprehensive expertise for developing and optimizing the Foundry VTT combat system. It assists developers in managing combat trackers, implementing custom initiative formulas, and leveraging combat hooks to create automated, turn-based encounter logic for game systems and modules.
Use Cases
- Implementing custom initiative rolling formulas and tie-breaking logic tailored to specific TTRPG rulesets.
- Automating turn-order transitions, such as skipping defeated combatants or managing round-based resource resets via workflow methods.
- Extending core Combat and Combatant document classes to add specialized properties or custom UI features.
- Utilizing combat hooks (combatStart, combatTurn, combatRound) to trigger visual effects, status updates, or system-specific events during encounters.
| 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.startedbefore accessing turn data - Consider defeated/hidden combatants
- Test with multiple GMs connected
References
Last Updated: 2026-01-05 Status: Production-Ready Maintainer: ImproperSubset