Claude-skill-registry fvtt-data-storage
This skill should be used when choosing between flags, settings, or files for data storage, implementing document flags, registering module settings, handling file uploads, or migrating data between storage types. Covers namespacing, scope types, and performance optimization.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/fvtt-data-storage" ~/.claude/skills/majiayu000-claude-skill-registry-fvtt-data-storage && rm -rf "$T"
skills/data/fvtt-data-storage/SKILL.mdFoundry VTT Data Storage
Domain: Foundry VTT Module/System Development Status: Production-Ready Last Updated: 2026-01-04
Overview
Foundry VTT provides three primary storage mechanisms: Flags (document-attached), Settings (global config), and Files (external storage). Choosing the wrong method is a common source of bugs and performance issues.
When to Use This Skill
- Deciding where to store module/system data
- Implementing document-specific custom properties
- Creating module configuration options
- Handling large datasets that impact performance
- Migrating data between storage types
Quick Decision Matrix
| Need | Use | Why |
|---|---|---|
| Data on specific document | Flags | Travels with document, respects permissions |
| Global module config | Settings (world) | Synced to all clients, GM-controlled |
| Per-device preference | Settings (client) | localStorage, user-specific |
| Large datasets | Files | No performance impact on documents |
| Export/import data | Files | Portable, shareable |
Flags
Flags attach key-value data to Documents (Actors, Items, Scenes, etc.).
Basic Usage
// Set a flag await actor.setFlag('my-module', 'customProperty', { value: 42 }); // Get a flag const data = actor.getFlag('my-module', 'customProperty'); // data === { value: 42 } // Delete a flag await actor.unsetFlag('my-module', 'customProperty'); // Direct access (read-only) const value = actor.flags['my-module']?.customProperty;
Namespacing
Always use your module ID as the scope:
// CORRECT - Uses module ID await doc.setFlag('my-module-id', 'flagName', value); // WRONG - Generic scope causes collisions await doc.setFlag('world', 'flagName', value);
Batch Updates
// BAD - Three database writes await actor.setFlag('myModule', 'flag1', value1); await actor.setFlag('myModule', 'flag2', value2); await actor.setFlag('myModule', 'flag3', value3); // GOOD - Single database write await actor.update({ 'flags.myModule.flag1': value1, 'flags.myModule.flag2': value2, 'flags.myModule.flag3': value3 });
Nested Flag Operations
// Delete nested key (V10+) await doc.unsetFlag('myModule', 'todos.completedItem'); // Alternative: Foundry deletion syntax await doc.setFlag('myModule', 'todos', { '-=completedItem': null });
Pitfalls
1. Periods in Object Keys Break getFlag:
// BROKEN - Period in key causes issues await doc.setFlag('myModule', 'data', { 'some.key': 'value' }); const result = doc.getFlag('myModule', 'data'); // result !== { 'some.key': 'value' } - Data corrupted! // WORKAROUND - Use class instance (treated as complex object) class MyData { constructor(data) { Object.assign(this, data); } } await doc.setFlag('myModule', 'data', new MyData({ 'some.key': 'value' }));
2. Inactive Module Throws Error:
// UNSAFE - Throws if module not installed const value = doc.getFlag('optional-module', 'flag'); // SAFE - Handle missing module const value = doc.flags['optional-module']?.flag ?? defaultValue;
Settings
Settings store global configuration with different scopes.
Scope Types
| Scope | Storage | Editable By | Synced | Use For |
|---|---|---|---|---|
| localStorage | Any user | No | UI prefs, device settings |
| Database | GM only | Yes | Module config, rules |
(V13+) | Database | That user | Yes | Per-user cross-device |
Registration
Hooks.once('init', () => { // Client setting - per-device game.settings.register('myModule', 'theme', { name: 'UI Theme', hint: 'Select your preferred theme', scope: 'client', config: true, type: String, choices: { light: 'Light', dark: 'Dark' }, default: 'light', onChange: value => applyTheme(value) }); // World setting - shared, GM-only game.settings.register('myModule', 'enableFeature', { name: 'Enable Feature', hint: 'Turns on the special feature for all users', scope: 'world', config: true, type: Boolean, default: false, requiresReload: true // V10+ prompts user to reload }); });
Hidden Settings with Menus
For complex config, hide the setting and use a FormApplication:
// 1. Register menu button game.settings.registerMenu('myModule', 'configMenu', { name: 'Advanced Configuration', label: 'Configure', icon: 'fas fa-cog', type: MyConfigApp, restricted: true // GM only }); // 2. Register hidden backing setting game.settings.register('myModule', 'config', { scope: 'world', config: false, // Hidden from settings UI type: Object, default: { option1: true, threshold: 10 } }); // 3. Access in FormApplication class MyConfigApp extends FormApplication { getData() { return game.settings.get('myModule', 'config'); } async _updateObject(event, formData) { await game.settings.set('myModule', 'config', foundry.utils.expandObject(formData) ); } }
Setting Types
// Choices dropdown game.settings.register('myModule', 'mode', { type: String, choices: { a: 'Option A', b: 'Option B' }, default: 'a' }); // Number with range slider game.settings.register('myModule', 'volume', { type: Number, range: { min: 0, max: 100, step: 5 }, default: 50 }); // File picker game.settings.register('myModule', 'backgroundImage', { type: String, filePicker: 'image', // 'audio', 'video', 'any' default: '' }); // DataModel for validation (recommended) game.settings.register('myModule', 'validated', { type: MyDataModel, default: {} });
onChange Behavior
// Client scope: fires only on this client game.settings.register('myModule', 'clientSetting', { scope: 'client', onChange: value => { // Only runs locally } }); // World scope: fires on ALL clients game.settings.register('myModule', 'worldSetting', { scope: 'world', onChange: value => { // Runs everywhere when GM changes it // Re-fetch to ensure consistency const current = game.settings.get('myModule', 'worldSetting'); } });
Files
Use file storage for large datasets or exportable data.
When to Use Files
- Data > 100KB that would slow document operations
- Export/import functionality
- Asset management (images, audio)
- Sharing data between worlds
FilePicker Upload
// Upload a file const file = new File( [JSON.stringify(data, null, 2)], 'export.json', { type: 'application/json' } ); await FilePicker.upload( 'data', // source: 'data', 'public', 's3' 'myModule/data', // target directory file, {}, { notify: true } );
Reading Files
// Fetch and parse JSON async function loadData(path) { const response = await fetch(path); if (!response.ok) throw new Error(`Failed to load ${path}`); return response.json(); } const data = await loadData('modules/myModule/data/config.json');
Lazy Loading Pattern
Store file reference in flag, load on demand:
// Store reference await actor.setFlag('myModule', 'dataFile', 'myModule/data/actor-123.json'); // Load when needed async function getActorData(actor) { const path = actor.getFlag('myModule', 'dataFile'); if (!path) return null; return loadData(path); }
Common Mistakes
Wrong: Flags for Module Config
// BAD - Config doesn't belong on a random document await game.user.setFlag('myModule', 'globalConfig', config); // GOOD - Use world setting game.settings.register('myModule', 'globalConfig', { scope: 'world', config: false, type: Object });
Wrong: Large Data in Flags
// BAD - Slows every actor update await actor.setFlag('myModule', 'history', arrayWith10000Entries); // GOOD - Store in file, reference in flag const file = new File([JSON.stringify(history)], `${actor.id}-history.json`); await FilePicker.upload('data', 'myModule/history', file); await actor.setFlag('myModule', 'historyFile', `myModule/history/${actor.id}-history.json`);
Wrong: Client Setting for Shared State
// BAD - Each user sees different value game.settings.register('myModule', 'gameRule', { scope: 'client', // Wrong scope! type: Boolean }); // GOOD - World scope for shared rules game.settings.register('myModule', 'gameRule', { scope: 'world', type: Boolean });
Migration Between Storage Types
Flag to Setting
Hooks.once('ready', async () => { const version = game.settings.get('myModule', 'schemaVersion') ?? 0; if (version < 2) { // Collect data from actor flags const migrated = {}; for (const actor of game.actors) { const old = actor.getFlag('myModule', 'oldData'); if (old) { migrated[actor.id] = old; await actor.unsetFlag('myModule', 'oldData'); } } // Store in setting await game.settings.set('myModule', 'migratedData', migrated); await game.settings.set('myModule', 'schemaVersion', 2); ui.notifications.info('MyModule: Migration complete'); } });
Setting to File (Large Data)
async function migrateToFile() { const largeData = game.settings.get('myModule', 'bigSetting'); // Export to file const file = new File( [JSON.stringify(largeData, null, 2)], 'migrated-data.json', { type: 'application/json' } ); await FilePicker.upload('data', 'myModule', file); // Update setting to path reference await game.settings.set('myModule', 'dataPath', 'myModule/migrated-data.json'); await game.settings.set('myModule', 'bigSetting', null); }
Implementation Checklist
- Use module ID as flag scope (never 'world' or generic names)
- Register settings in
hookinit - Use
for shared config,scope: 'world'
for preferencesscope: 'client' - Batch flag updates with
when setting multipledocument.update() - Use files for data > 100KB
- Handle missing flags/settings with defaults
- Add
for settings that need refresh (V10+)requiresReload: true - Use DataModel for setting validation when possible
References
Last Updated: 2026-01-04 Status: Production-Ready Maintainer: ImproperSubset