Agent skill

fvtt-sockets

This skill should be used when implementing multiplayer synchronization, using game.socket.emit/on, creating executeAsGM patterns for privileged operations, broadcasting events between clients, or avoiding common pitfalls like race conditions and duplicate execution.

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/fvtt-sockets

SKILL.md

Foundry VTT Sockets & Multiplayer

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

Overview

Foundry VTT uses Socket.io for real-time communication between server and clients. Understanding socket patterns is essential for multiplayer-safe code.

When to Use This Skill

  • Broadcasting events to other connected clients
  • Implementing GM-delegated operations for players
  • Synchronizing non-document state across clients
  • Creating animations/effects visible to all players
  • Avoiding duplicate execution in hooks

Socket Setup

Manifest Configuration

Request socket access in your manifest:

json
{
  "id": "my-module",
  "socket": true
}

Event Naming

Each package gets ONE event namespace:

  • Modules: module.{module-id}
  • Systems: system.{system-id}

Multiplex event types with structured data:

javascript
const SOCKET_NAME = "module.my-module";

game.socket.emit(SOCKET_NAME, {
  type: "playAnimation",
  payload: { tokenId: "abc123", effect: "fire" }
});

Registration Timing

Register listeners after game.socket is available:

javascript
Hooks.once("init", () => {
  game.socket.on("module.my-module", handleSocketMessage);
});

function handleSocketMessage(data) {
  switch (data.type) {
    case "playAnimation":
      playTokenAnimation(data.payload);
      break;
    case "syncState":
      updateLocalState(data.payload);
      break;
  }
}

Basic Socket Patterns

Emit to All Other Clients

javascript
function broadcastAnimation(tokenId, effect) {
  game.socket.emit("module.my-module", {
    type: "playAnimation",
    tokenId,
    effect
  });
}

Critical: Emitting client does NOT receive its own broadcast.

Self-Invoke Pattern

Always call handler locally when emitting:

javascript
function triggerEffect(tokenId, effect) {
  const data = { type: "effect", tokenId, effect };

  // Execute locally
  handleEffect(data);

  // Broadcast to others
  game.socket.emit("module.my-module", data);
}

function handleEffect(data) {
  const token = canvas.tokens.get(data.tokenId);
  token?.animate({ alpha: 0.5 }, { duration: 500 });
}

// Socket listener (for other clients)
Hooks.once("init", () => {
  game.socket.on("module.my-module", (data) => {
    if (data.type === "effect") handleEffect(data);
  });
});

ExecuteAsGM Pattern

Players often need GM-authorized operations (damage enemies, modify world data).

Native Socket Approach

javascript
const SOCKET_NAME = "module.my-module";

Hooks.once("init", () => {
  game.socket.on(SOCKET_NAME, async (data) => {
    // Only active GM handles this
    if (game.user !== game.users.activeGM) return;

    if (data.type === "damageActor") {
      const actor = game.actors.get(data.actorId);
      if (actor) {
        const newHp = actor.system.hp.value - data.damage;
        await actor.update({ "system.hp.value": Math.max(0, newHp) });
      }
    }
  });
});

// Player calls this
function requestDamage(actorId, damage) {
  game.socket.emit(SOCKET_NAME, {
    type: "damageActor",
    actorId,
    damage
  });
}

Limitations:

  • No return value
  • Manual GM check required
  • Fails silently if no GM connected

Socketlib Approach (Recommended)

Socketlib handles multiple GMs, return values, and error cases.

Dependency (module.json):

json
{
  "relationships": {
    "requires": [{
      "id": "socketlib",
      "type": "module"
    }]
  }
}

Registration:

javascript
let socket;

Hooks.once("socketlib.ready", () => {
  socket = socketlib.registerModule("my-module");

  // Register callable functions
  socket.register("damageActor", damageActor);
  socket.register("getActorData", getActorData);
});

async function damageActor(actorId, damage) {
  const actor = game.actors.get(actorId);
  if (!actor) return { success: false, error: "Actor not found" };

  const newHp = Math.max(0, actor.system.hp.value - damage);
  await actor.update({ "system.hp.value": newHp });
  return { success: true, newHp };
}

function getActorData(actorId) {
  return game.actors.get(actorId)?.toObject() ?? null;
}

Usage:

javascript
// Execute on GM client, get return value
async function applyDamage(actorId, damage) {
  try {
    const result = await socket.executeAsGM("damageActor", actorId, damage);
    if (result.success) {
      ui.notifications.info(`Damage applied. HP now: ${result.newHp}`);
    }
  } catch (error) {
    ui.notifications.error("No GM connected to process damage");
  }
}

Socketlib Methods

Method Target Awaitable Use Case
executeAsGM(fn, ...args) One GM Yes Privileged operations
executeAsUser(fn, userId, ...args) Specific user Yes Player-specific actions
executeForEveryone(fn, ...args) All clients No Broadcast effects
executeForOthers(fn, ...args) All except self No Sync without local call
executeForAllGMs(fn, ...args) All GMs No GM notifications
executeForUsers(fn, ids[], ...args) Listed users No Targeted messages

ExecuteForEveryone Example

javascript
// Trigger animation on ALL clients
function playGlobalEffect(effectData) {
  socket.executeForEveryone("renderEffect", effectData);
}

// Registered function
function renderEffect(data) {
  canvas.effects.playEffect(data);
}

ExecuteAsUser Example

javascript
// Ask specific player for input
async function promptPlayer(userId, question) {
  try {
    return await socket.executeAsUser("showDialog", userId, question);
  } catch {
    return null; // Player disconnected
  }
}

// Registered function
async function showDialog(question) {
  return new Promise(resolve => {
    new Dialog({
      title: question,
      buttons: {
        yes: { label: "Yes", callback: () => resolve(true) },
        no: { label: "No", callback: () => resolve(false) }
      }
    }).render(true);
  });
}

Data Synchronization

Document Updates (Automatic)

Foundry syncs document updates automatically:

javascript
// Syncs to all clients
await actor.update({ "system.hp.value": 50 });

// Does NOT sync (in-memory only)
actor.system.hp.value = 50;

Non-Document State

Use sockets for custom state:

javascript
let combatState = {};

Hooks.once("socketlib.ready", () => {
  socket.register("syncCombatState", (state) => {
    combatState = state;
    Hooks.callAll("combatStateChanged", state);
  });
});

function updateCombatState(newState) {
  combatState = newState;
  socket.executeForEveryone("syncCombatState", newState);
}

Ownership Considerations

Only owners can update documents:

javascript
// Player cannot update enemy
await enemyActor.update({ ... }); // Permission denied!

// Must delegate to GM
await socket.executeAsGM("updateEnemy", enemyId, changes);

Common Pitfalls

1. Emitter Doesn't Receive Broadcast

javascript
// WRONG - emitter never sees this
game.socket.on("module.my-module", playSound);
game.socket.emit("module.my-module", { sound: "bell.wav" });
// Sound plays for others, NOT for emitter!

// CORRECT - call locally AND emit
playSound({ sound: "bell.wav" });
game.socket.emit("module.my-module", { sound: "bell.wav" });

2. Duplicate Execution in Hooks

javascript
// WRONG - runs on ALL clients
Hooks.on("deleteItem", (item) => {
  item.parent.update({ "system.count": item.parent.items.length });
});

// CORRECT - only owner executes
Hooks.on("deleteItem", (item) => {
  if (!item.parent?.isOwner) return;
  item.parent.update({ "system.count": item.parent.items.length });
});

3. Race Conditions with Multiple GMs

javascript
// RISKY - activeGM can change during async
game.socket.on(name, async (data) => {
  if (game.user !== game.users.activeGM) return;
  await actor.update({ ... }); // Another GM might be active now!
});

// SAFE - socketlib guarantees atomic execution
await socket.executeAsGM("updateActor", actorId, data);

4. No Permission Check on Handlers

javascript
// VULNERABLE - any player can trigger
game.socket.on(name, (data) => {
  game.actors.get(data.id).update({ "system.hp": 9999 });
});

// SAFE - validate permissions
game.socket.on(name, (data) => {
  const actor = game.actors.get(data.id);
  if (!actor?.isOwner && !game.user.isGM) return;
  actor.update({ "system.hp": data.hp });
});

5. No GM Connected

javascript
// WRONG - silent failure
socket.executeAsGM("doThing", data);

// CORRECT - handle error
try {
  await socket.executeAsGM("doThing", data);
} catch {
  ui.notifications.warn("A GM must be connected for this action");
}

6. Update Storms

javascript
// WRONG - N clients = N updates
Hooks.on("updateActor", (actor, changes) => {
  actor.update({ "system.modified": Date.now() });
});

// CORRECT - only owner updates
Hooks.on("updateActor", (actor, changes) => {
  if (!actor.isOwner) return;
  if (changes.system?.modified) return; // Prevent loop
  actor.update({ "system.modified": Date.now() });
});

Best Practices

1. Use Structured Events

javascript
// Good - clear, maintainable
game.socket.emit(SOCKET_NAME, {
  type: "applyEffect",
  targetId: token.id,
  effectType: "fire",
  duration: 3000
});

2. Batch Updates

javascript
// Bad - 3 updates
await actor.update({ "system.hp": 10 });
await actor.update({ "system.mp": 5 });
await actor.update({ "system.status": "hurt" });

// Good - 1 update
await actor.update({
  "system.hp": 10,
  "system.mp": 5,
  "system.status": "hurt"
});

3. Skip No-Op Updates

javascript
const newHp = calculateHp(actor);
if (actor.system.hp.value === newHp) return;
await actor.update({ "system.hp.value": newHp });

4. Document Socket Messages

javascript
/**
 * Socket: module.my-module
 *
 * @event applyDamage
 * @param {string} actorId - Target actor
 * @param {number} damage - Damage amount
 * @param {string} type - Damage type (fire, cold, etc.)
 */

5. Prefer Socketlib for Complex Operations

Native sockets for simple broadcasts. Socketlib when you need:

  • Return values
  • Multiple GM handling
  • Permission-based execution
  • Error handling

Implementation Checklist

  • Add "socket": true to manifest
  • Use correct namespace (module.X or system.X)
  • Register listeners in init hook
  • Use structured event data with type field
  • Call handler locally when emitting (self-invoke pattern)
  • Check ownership in document operation hooks
  • Use socketlib for GM-delegated operations
  • Handle "no GM connected" errors
  • Batch related updates
  • Skip no-op updates
  • Test with multiple connected clients

References


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

Didn't find tool you were looking for?

Be as detailed as possible for better results