| name | fvtt-sockets |
| description | 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. |
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:
{
"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:
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:
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
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:
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
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):
{
"relationships": {
"requires": [{
"id": "socketlib",
"type": "module"
}]
}
}
Registration:
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:
// 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
// Trigger animation on ALL clients
function playGlobalEffect(effectData) {
socket.executeForEveryone("renderEffect", effectData);
}
// Registered function
function renderEffect(data) {
canvas.effects.playEffect(data);
}
ExecuteAsUser Example
// 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:
// 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:
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:
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// Good - clear, maintainable
game.socket.emit(SOCKET_NAME, {
type: "applyEffect",
targetId: token.id,
effectType: "fire",
duration: 3000
});
2. Batch Updates
// 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
const newHp = calculateHp(actor);
if (actor.system.hp.value === newHp) return;
await actor.update({ "system.hp.value": newHp });
4. Document Socket Messages
/**
* 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": trueto manifest - Use correct namespace (
module.Xorsystem.X) - Register listeners in
inithook - Use structured event data with
typefield - 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