- Extend formula engine to 4 levels (math → conditionals → functions → strings) - Add custom UI override system with data-bind templates - Implement comprehensive data migration strategy with versioning - Add MigrationManager class with BFS pathfinding - Include migration UI flow and best practices Addresses all architectural review recommendations.
53 KiB
Schema System Architecture
Status: Design Phase Priority: Critical (Tier 1 Feature - 16% vote priority) Target Version: 2.0.0
Overview
The Schema System allows users to define custom RPG systems using human-readable YAML files instead of being locked into hardcoded stats. Inspired by Gemini Deep Research recommendations and Entity-Component-System (ECS) patterns.
Vision
Transform RPG Companion from a fixed D&D-style tracker into a universal RPG system that adapts to ANY tabletop game: Pathfinder, Cyberpunk RED, World of Darkness, homebrew systems, etc.
Architecture Overview
Three-Layer System
┌─────────────────────────────────────────┐
│ System Definition (YAML) │ ← Design Time
│ Rules, structure, formulas │
└─────────────────────────────────────────┘
↓ validates
┌─────────────────────────────────────────┐
│ Character Instance (JSON) │ ← Run Time
│ Actual character data │
└─────────────────────────────────────────┘
↓ renders via
┌─────────────────────────────────────────┐
│ Widget Dashboard (UI) │ ← User Interface
│ Dynamic widget rendering │
└─────────────────────────────────────────┘
System Definition Layer (YAML)
Schema Structure
# dnd5e.yaml - Example D&D 5th Edition Schema
meta:
name: "D&D 5th Edition"
version: "1.0.0"
author: "RPG Companion Community"
description: "Official D&D 5e ruleset"
tags: ["fantasy", "d20", "official"]
components:
# Core Abilities (STR, DEX, CON, etc.)
coreAbilities:
type: object
label: "Ability Scores"
icon: "🎲"
properties:
strength:
type: number
label: "Strength"
abbr: "STR"
min: 1
max: 30
default: 10
dexterity:
type: number
label: "Dexterity"
abbr: "DEX"
min: 1
max: 30
default: 10
constitution:
type: number
label: "Constitution"
abbr: "CON"
min: 1
max: 30
default: 10
intelligence:
type: number
label: "Intelligence"
abbr: "INT"
min: 1
max: 30
default: 10
wisdom:
type: number
label: "Wisdom"
abbr: "WIS"
min: 1
max: 30
default: 10
charisma:
type: number
label: "Charisma"
abbr: "CHA"
min: 1
max: 30
default: 10
# Derived Stats (calculated from abilities)
abilityModifiers:
type: object
label: "Ability Modifiers"
properties:
str_mod:
type: formula
formula: "floor((@coreAbilities.strength - 10) / 2)"
dex_mod:
type: formula
formula: "floor((@coreAbilities.dexterity - 10) / 2)"
con_mod:
type: formula
formula: "floor((@coreAbilities.constitution - 10) / 2)"
int_mod:
type: formula
formula: "floor((@coreAbilities.intelligence - 10) / 2)"
wis_mod:
type: formula
formula: "floor((@coreAbilities.wisdom - 10) / 2)"
cha_mod:
type: formula
formula: "floor((@coreAbilities.charisma - 10) / 2)"
# Resources (pools that track usage)
resources:
type: list
label: "Resources"
icon: "⚡"
items:
hitPoints:
type: resource
label: "Hit Points"
abbr: "HP"
current: 0
max:
type: formula
formula: "10 + @abilityModifiers.con_mod"
color: "#cc3333"
display: "bar"
spellSlots:
type: resource
label: "Spell Slots"
abbr: "Spells"
current: 0
max: 0
color: "#3366cc"
display: "dots"
# Skills
skills:
type: list
label: "Skills"
icon: "⚔️"
items:
acrobatics:
type: number
label: "Acrobatics"
baseAbility: "dexterity"
proficient: false
expertise: false
animalHandling:
type: number
label: "Animal Handling"
baseAbility: "wisdom"
proficient: false
expertise: false
arcana:
type: number
label: "Arcana"
baseAbility: "intelligence"
proficient: false
expertise: false
# ... more skills
# Conditions/Status Effects
statusEffects:
type: list
label: "Conditions"
icon: "✨"
items:
name:
type: text
label: "Condition Name"
duration:
type: number
label: "Rounds Remaining"
min: 0
effect:
type: text
label: "Effect Description"
# Inventory (simplified)
inventory:
type: object
label: "Equipment"
icon: "🎒"
properties:
carried:
type: list
label: "Carried Items"
worn:
type: list
label: "Worn Armor"
gold:
type: number
label: "Gold Pieces"
abbr: "GP"
default: 0
# Character Identity
identity:
type: object
label: "Character Info"
properties:
name:
type: text
label: "Name"
required: true
race:
type: text
label: "Race"
class:
type: text
label: "Class"
level:
type: number
label: "Level"
min: 1
max: 20
default: 1
background:
type: text
label: "Background"
# Progression tables for lookup() function
tables:
proficiency_bonus:
1: 2
2: 2
3: 2
4: 2
5: 3
6: 3
7: 3
8: 3
9: 4
10: 4
11: 4
12: 4
13: 5
14: 5
15: 5
16: 5
17: 6
18: 6
19: 6
20: 6
spell_slots:
# Wizard spell slots by level and spell level
1: [2, 0, 0, 0, 0, 0, 0, 0, 0]
2: [3, 0, 0, 0, 0, 0, 0, 0, 0]
3: [4, 2, 0, 0, 0, 0, 0, 0, 0]
# ... more levels
# Custom UI overrides (optional - for pixel-perfect layouts)
customUI:
# Most components use dynamic generation
# Optionally override specific components with custom HTML/CSS
coreAbilities:
template: "custom-templates/dnd5e-abilities.html"
style: "custom-templates/dnd5e-abilities.css"
# Prompt template for AI generation
prompts:
stats: |
Character Stats
---
HP: [@resources.hitPoints.current/@resources.hitPoints.max]
Spell Slots: [@resources.spellSlots.current/@resources.spellSlots.max]
Conditions: [List active conditions or "None"]
skills: |
Skills
---
[Skill Name]: [Modifier] | [Proficiency Status]
(List all relevant skills for the current scene)
# Widget layout suggestions
layout:
defaultTabs:
- name: "Combat"
widgets:
- type: "resources"
component: "resources"
x: 0
y: 0
w: 4
h: 3
- type: "skills"
component: "skills"
filter: ["acrobatics", "athletics", "stealth"]
x: 4
y: 0
w: 4
h: 4
- type: "statusEffects"
component: "statusEffects"
x: 8
y: 0
w: 4
h: 2
- name: "Character"
widgets:
- type: "coreAbilities"
component: "coreAbilities"
x: 0
y: 0
w: 6
h: 3
- type: "identity"
component: "identity"
x: 6
y: 0
w: 6
h: 3
Component Types
1. Object Components
Group related properties together.
identity:
type: object
properties:
name:
type: text
age:
type: number
Rendered as: Card with labeled fields
2. List Components
Collections of items.
skills:
type: list
items:
name:
type: text
value:
type: number
Rendered as: Vertical list, table, or grid
3. Resource Components
Tracked pools with current/max values.
hitPoints:
type: resource
current: 10
max: 20
display: "bar"
Rendered as: Progress bar or numeric display
4. Formula Components
Derived values calculated from other components.
armorClass:
type: formula
formula: "10 + @abilityModifiers.dex_mod + @equipment.armor.bonus"
Rendered as: Read-only calculated value
Character Instance Layer (JSON)
Instance Structure
Character data stored in extensionSettings.characterInstance:
extensionSettings.characterInstance = {
schemaId: "dnd5e-v1.0.0", // Which schema this uses
schemaVersion: "1.0.0", // Schema version
data: {
// Component data matching schema structure
coreAbilities: {
strength: 16,
dexterity: 14,
constitution: 15,
intelligence: 10,
wisdom: 12,
charisma: 8
},
abilityModifiers: {
// Calculated automatically via formula
str_mod: 3,
dex_mod: 2,
con_mod: 2,
int_mod: 0,
wis_mod: 1,
cha_mod: -1
},
resources: {
hitPoints: {
current: 12,
max: 22
},
spellSlots: {
current: 3,
max: 4
}
},
skills: [
{ name: "Acrobatics", value: 2, proficient: false },
{ name: "Athletics", value: 5, proficient: true },
{ name: "Stealth", value: 4, proficient: true }
// ... more skills
],
statusEffects: [
{ name: "Blessed", duration: 10, effect: "+1d4 to attacks" }
],
inventory: {
carried: ["Longsword", "Shield", "Healing Potion x2"],
worn: ["Chain Mail"],
gold: 47
},
identity: {
name: "Ragnar",
race: "Human",
class: "Fighter",
level: 3,
background: "Soldier"
}
},
// Metadata
createdAt: "2025-10-23T12:00:00Z",
updatedAt: "2025-10-23T14:30:00Z"
};
Formula Engine
Formula Syntax (Enhanced)
The formula engine supports progressively complex expressions to handle the wide range of TTRPG mechanics.
Level 1: Basic Math and References
// @ references components in character instance
@coreAbilities.strength // → 16
@abilityModifiers.str_mod // → 3
@resources.hitPoints.max // → 22
// Math operators
floor((@coreAbilities.strength - 10) / 2) // → 3
@coreAbilities.strength + 5 // → 21
(@level * 2) + @abilityModifiers.con_mod // → 8
Level 2: Conditional Logic
// Ternary operator
@coreAbilities.strength > 15 ? "Strong" : "Weak"
// Complex conditions
@coreAbilities.strength >= 13 && @coreAbilities.dexterity >= 13 ? 2 : 0
// Nested conditionals
@level >= 20 ? 6 : (@level >= 17 ? 5 : (@level >= 13 ? 4 : 3))
Level 3: Functions and Lookups
// Built-in functions
min(@coreAbilities.strength, @coreAbilities.dexterity)
max(@resources.hitPoints.current, 0)
clamp(@skills.stealth.value, 0, 20)
// Boolean functions
hasFeature("shield_master")
hasTrait("undead")
isProficient("athletics")
// Table lookups (for complex progression tables)
lookup("proficiency_bonus", @level) // → Returns value from table
lookup("spell_slots", @level, @spellcaster_class)
// Array operations
sum(@inventory.weapons.*.damage)
count(@statusEffects)
Level 4: String Manipulation
// String concatenation
concat(@identity.firstName, " ", @identity.lastName)
// String formatting
format("Level {0} {1}", @level, @identity.class)
// Conditional text
@resources.hitPoints.current > 0 ? "Alive" : "Unconscious"
Safe Expression Parser
// src/systems/schema/formulaEngine.js
export class FormulaEngine {
constructor(characterData) {
this.data = characterData;
this.cache = new Map(); // Memoize calculated values
}
// Evaluate formula string
evaluate(formula) {
// Check cache first
if (this.cache.has(formula)) {
return this.cache.get(formula);
}
// Replace @ references with actual values
const resolved = this.resolveReferences(formula);
// Safe eval using Function constructor (sandboxed)
try {
const result = this.safeEval(resolved);
this.cache.set(formula, result);
return result;
} catch (error) {
console.error('[Formula Engine] Error evaluating:', formula, error);
return 0; // Fallback
}
}
// Replace @component.path with actual values
resolveReferences(formula) {
const refRegex = /@([a-zA-Z0-9_.]+)/g;
return formula.replace(refRegex, (match, path) => {
const value = this.getValueByPath(path);
return value !== undefined ? value : 0;
});
}
// Get nested value from character data
getValueByPath(path) {
const parts = path.split('.');
let value = this.data;
for (const part of parts) {
if (value && typeof value === 'object') {
value = value[part];
} else {
return undefined;
}
}
return value;
}
// Safe evaluation (whitelist functions)
safeEval(expression) {
const allowedFunctions = {
// Math functions
floor: Math.floor,
ceil: Math.ceil,
round: Math.round,
abs: Math.abs,
min: Math.min,
max: Math.max,
pow: Math.pow,
sqrt: Math.sqrt,
// Utility functions
clamp: (val, min, max) => Math.max(min, Math.min(max, val)),
// Boolean functions (check character data)
hasFeature: (featureName) => {
return this.data.features?.some(f => f.name === featureName) || false;
},
hasTrait: (traitName) => {
return this.data.traits?.includes(traitName) || false;
},
isProficient: (skillName) => {
return this.data.skills?.find(s => s.name === skillName)?.proficient || false;
},
// Table lookup functions
lookup: (tableName, ...keys) => {
return this.lookupTable(tableName, keys);
},
// Array operations
sum: (array) => Array.isArray(array) ? array.reduce((a, b) => a + b, 0) : 0,
count: (array) => Array.isArray(array) ? array.length : 0,
// String functions
concat: (...strings) => strings.join(''),
format: (template, ...args) => {
return template.replace(/\{(\d+)\}/g, (match, index) => args[index] || '');
}
};
// Create sandboxed function
const func = new Function(...Object.keys(allowedFunctions), `return ${expression}`);
// Execute with whitelisted functions
return func(...Object.values(allowedFunctions));
}
// Lookup value from a progression table defined in schema
lookupTable(tableName, keys) {
const tables = this.schema.tables || {};
const table = tables[tableName];
if (!table) {
console.warn(`[Formula Engine] Table not found: ${tableName}`);
return 0;
}
// Simple single-key lookup
if (keys.length === 1) {
return table[keys[0]] || 0;
}
// Multi-key lookup (for 2D tables)
let value = table;
for (const key of keys) {
value = value?.[key];
if (value === undefined) return 0;
}
return value;
}
// Clear cache (call when character data changes)
invalidateCache() {
this.cache.clear();
}
}
// Usage:
const engine = new FormulaEngine(characterInstance.data);
const strMod = engine.evaluate("floor((@coreAbilities.strength - 10) / 2)");
console.log('STR Modifier:', strMod); // → 3
Schema Validation
JSON Schema Integration
Use JSON Schema to validate character instances:
// src/systems/schema/validator.js
import Ajv from 'ajv'; // Lightweight JSON Schema validator
export class SchemaValidator {
constructor() {
this.ajv = new Ajv({ allErrors: true });
}
// Convert YAML schema to JSON Schema
compileSchema(yamlSchema) {
const jsonSchema = {
type: 'object',
properties: {},
required: []
};
// Convert each component to JSON Schema property
for (const [componentName, component] of Object.entries(yamlSchema.components)) {
jsonSchema.properties[componentName] = this.convertComponent(component);
if (component.required) {
jsonSchema.required.push(componentName);
}
}
return this.ajv.compile(jsonSchema);
}
// Convert component definition to JSON Schema
convertComponent(component) {
switch (component.type) {
case 'object':
return {
type: 'object',
properties: this.convertProperties(component.properties)
};
case 'list':
return {
type: 'array',
items: this.convertComponent(component.items)
};
case 'resource':
return {
type: 'object',
properties: {
current: { type: 'number' },
max: { type: 'number' }
},
required: ['current', 'max']
};
case 'number':
return {
type: 'number',
minimum: component.min,
maximum: component.max,
default: component.default
};
case 'text':
return {
type: 'string',
minLength: component.minLength,
maxLength: component.maxLength
};
case 'formula':
// Formulas are always numbers
return { type: 'number' };
default:
return { type: 'string' };
}
}
// Validate character instance against schema
validate(characterInstance, schema) {
const compiled = this.compileSchema(schema);
const valid = compiled(characterInstance.data);
if (!valid) {
return {
valid: false,
errors: compiled.errors
};
}
return { valid: true };
}
}
Storage Layer
Hybrid Storage Strategy (Gemini Recommendation)
IndexedDB for internal operations:
- Fast local access
- Query capabilities
- No size limits (within reason)
File System Access API for import/export:
- User-friendly YAML files
- Version control compatible
- Shareable with community
// src/systems/schema/storage.js
export class SchemaStorage {
constructor() {
this.db = null;
this.init();
}
async init() {
// Initialize IndexedDB
const request = indexedDB.open('RPGCompanionSchemas', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Schemas store
if (!db.objectStoreNames.contains('schemas')) {
const schemaStore = db.createObjectStore('schemas', { keyPath: 'id' });
schemaStore.createIndex('name', 'meta.name');
schemaStore.createIndex('version', 'meta.version');
}
// Character instances store
if (!db.objectStoreNames.contains('characters')) {
const charStore = db.createObjectStore('characters', { keyPath: 'id' });
charStore.createIndex('schemaId', 'schemaId');
charStore.createIndex('name', 'data.identity.name');
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
console.log('[Schema Storage] IndexedDB initialized');
};
}
// Save schema to IndexedDB
async saveSchema(schema) {
const transaction = this.db.transaction(['schemas'], 'readwrite');
const store = transaction.objectStore('schemas');
const schemaWithId = {
id: `${schema.meta.name}-v${schema.meta.version}`,
...schema,
savedAt: new Date().toISOString()
};
await store.put(schemaWithId);
return schemaWithId.id;
}
// Load schema from IndexedDB
async loadSchema(schemaId) {
const transaction = this.db.transaction(['schemas'], 'readonly');
const store = transaction.objectStore('schemas');
return new Promise((resolve, reject) => {
const request = store.get(schemaId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// List all schemas
async listSchemas() {
const transaction = this.db.transaction(['schemas'], 'readonly');
const store = transaction.objectStore('schemas');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Export schema to YAML file
async exportSchema(schemaId) {
const schema = await this.loadSchema(schemaId);
// Convert to YAML
const yaml = this.toYAML(schema);
// Use File System Access API (if available)
if ('showSaveFilePicker' in window) {
const handle = await window.showSaveFilePicker({
suggestedName: `${schema.meta.name}.yaml`,
types: [{
description: 'YAML Schema',
accept: { 'text/yaml': ['.yaml', '.yml'] }
}]
});
const writable = await handle.createWritable();
await writable.write(yaml);
await writable.close();
} else {
// Fallback: download blob
const blob = new Blob([yaml], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${schema.meta.name}.yaml`;
a.click();
URL.revokeObjectURL(url);
}
}
// Import schema from YAML file
async importSchema() {
// Use File System Access API (if available)
if ('showOpenFilePicker' in window) {
const [handle] = await window.showOpenFilePicker({
types: [{
description: 'YAML Schema',
accept: { 'text/yaml': ['.yaml', '.yml'] }
}]
});
const file = await handle.getFile();
const yaml = await file.text();
const schema = this.fromYAML(yaml);
// Validate and save
await this.saveSchema(schema);
return schema;
} else {
// Fallback: file input
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.yaml,.yml';
input.onchange = async (e) => {
const file = e.target.files[0];
const yaml = await file.text();
const schema = this.fromYAML(yaml);
await this.saveSchema(schema);
resolve(schema);
};
input.click();
});
}
}
// Convert schema object to YAML string
toYAML(schema) {
// Use js-yaml library
return jsyaml.dump(schema, {
indent: 2,
lineWidth: 80,
noRefs: true
});
}
// Parse YAML string to schema object
fromYAML(yaml) {
// Use js-yaml library
return jsyaml.load(yaml);
}
}
Custom UI Override System
Rationale
While dynamic UI generation from schema definitions is powerful and efficient, some game systems benefit from highly stylized, pixel-perfect layouts that match their official character sheets. The Custom UI Override System allows schema authors to optionally provide custom HTML/CSS for specific components while maintaining the benefits of schema-driven data management.
Hybrid Approach
# In system.yaml
customUI:
# Override specific components with custom templates
coreAbilities:
template: "custom-templates/dnd5e-abilities.html"
style: "custom-templates/dnd5e-abilities.css"
# Other components use dynamic generation (default behavior)
# skills: (auto-generated)
# resources: (auto-generated)
Custom Template Structure
<!-- custom-templates/dnd5e-abilities.html -->
<div class="dnd5e-abilities-grid">
<div class="ability-card" data-ability="strength">
<div class="ability-label">STR</div>
<div class="ability-score" data-bind="@coreAbilities.strength">10</div>
<div class="ability-modifier" data-bind="@abilityModifiers.str_mod">+0</div>
</div>
<div class="ability-card" data-ability="dexterity">
<div class="ability-label">DEX</div>
<div class="ability-score" data-bind="@coreAbilities.dexterity">10</div>
<div class="ability-modifier" data-bind="@abilityModifiers.dex_mod">+0</div>
</div>
<!-- More ability cards... -->
</div>
/* custom-templates/dnd5e-abilities.css */
.dnd5e-abilities-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.ability-card {
background: linear-gradient(135deg, #8B0000, #4A0000);
border: 2px solid #FFD700;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.ability-label {
font-size: 14px;
font-weight: bold;
color: #FFD700;
text-transform: uppercase;
letter-spacing: 2px;
}
.ability-score {
font-size: 32px;
font-weight: bold;
color: white;
margin: 8px 0;
}
.ability-modifier {
font-size: 18px;
color: #AAA;
}
Data Binding
Custom templates use data-bind attributes with the same @ reference syntax as formulas:
// src/systems/schema/customUIRenderer.js
export class CustomUIRenderer {
constructor(schema, characterInstance, formulaEngine) {
this.schema = schema;
this.instance = characterInstance;
this.formulaEngine = formulaEngine;
}
// Render custom UI component
renderCustomComponent(componentName, container) {
const customUI = this.schema.customUI?.[componentName];
if (!customUI) {
// Fall back to dynamic generation
return false;
}
// Load custom template
const template = this.loadTemplate(customUI.template);
const style = this.loadStyle(customUI.style);
// Inject HTML
container.innerHTML = template;
// Inject CSS (scoped to this component)
this.injectScopedStyles(style, componentName);
// Bind data to template
this.bindData(container);
// Attach event listeners for editable fields
this.attachEditHandlers(container);
return true;
}
// Bind character data to [data-bind] attributes
bindData(container) {
const bindings = container.querySelectorAll('[data-bind]');
bindings.forEach(element => {
const reference = element.dataset.bind;
// Resolve @ reference
if (reference.startsWith('@')) {
const path = reference.substring(1);
const value = this.getValueByPath(path);
// Update element content
if (element.tagName === 'INPUT') {
element.value = value;
} else {
element.textContent = value;
}
// Store binding for updates
element.dataset.boundPath = path;
}
});
}
// Attach edit handlers to bound elements
attachEditHandlers(container) {
const editableElements = container.querySelectorAll('[data-bind][contenteditable]');
editableElements.forEach(element => {
element.addEventListener('blur', (e) => {
const path = e.target.dataset.boundPath;
const value = e.target.textContent.trim();
// Update character instance
this.setValueByPath(path, value);
// Re-calculate formulas
this.formulaEngine.invalidateCache();
// Re-render (to update any derived values)
this.bindData(container);
});
});
}
// Get value from character data by path
getValueByPath(path) {
const parts = path.split('.');
let value = this.instance.data;
for (const part of parts) {
value = value?.[part];
}
return value;
}
// Set value in character data by path
setValueByPath(path, value) {
const parts = path.split('.');
let obj = this.instance.data;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]];
}
obj[parts[parts.length - 1]] = value;
}
// Load template file
loadTemplate(templatePath) {
// Fetch from schemas directory or bundled templates
// For now, placeholder implementation
return `<div>Custom template: ${templatePath}</div>`;
}
// Load and inject scoped CSS
injectScopedStyles(css, componentName) {
// Scope CSS to this component to avoid conflicts
const scopedCSS = this.scopeCSS(css, `[data-component="${componentName}"]`);
const style = document.createElement('style');
style.textContent = scopedCSS;
style.dataset.component = componentName;
// Remove old style if exists
document.querySelector(`style[data-component="${componentName}"]`)?.remove();
document.head.appendChild(style);
}
// Scope CSS rules to a specific selector
scopeCSS(css, scope) {
// Simple CSS scoping (could use postcss for production)
return css.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|\s*{)/g, `${scope} $1$2`);
}
}
Benefits of Custom UI
- Pixel-Perfect Replication - Match official character sheet layouts exactly
- Advanced Styling - Use complex CSS effects, animations, gradients
- Brand Identity - Maintain game system's visual identity
- Performance - Static HTML faster than complex dynamic generation
- Community Templates - Share beautiful designs without coding
When to Use Custom UI
- Official character sheets - When brand accuracy matters
- Complex visual layouts - Intricate positioning, overlapping elements
- Themed experiences - Cyberpunk neon, fantasy parchment, horror gothic
- Special components - Character portraits, spell cards, inventory grids
When to Use Dynamic Generation
- Most components - Skills lists, stats, resources
- Rapid prototyping - Testing new game systems
- Flexible layouts - Need to work on mobile and desktop
- Community schemas - Easier to create without HTML/CSS knowledge
Data Migration Strategy
Versioning System
Every schema includes a version number, and character instances track which schema version they were created with. This enables automated migration when schemas are updated.
# system.yaml
meta:
name: "D&D 5th Edition"
version: "1.2.0" # Semantic versioning
author: "RPG Companion Community"
// character.json
{
schemaId: "dnd5e-v1.2.0",
schemaVersion: "1.2.0",
data: { /* character data */ }
}
Migration Functions
Each schema can define migration functions to transform character data between versions:
# system.yaml
migrations:
- from: "1.0.0"
to: "1.1.0"
script: "migrations/1.0.0-to-1.1.0.js"
- from: "1.1.0"
to: "1.2.0"
script: "migrations/1.1.0-to-1.2.0.js"
Migration Script Example
// migrations/1.0.0-to-1.1.0.js
/**
* Migration from v1.0.0 to v1.1.0
* Changes:
* - Renamed "classicStats" to "coreAbilities"
* - Added "abilityModifiers" as derived component
* - Changed spell slots from single number to object
*/
export function migrate_1_0_0_to_1_1_0(characterData) {
const migrated = { ...characterData };
// 1. Rename classicStats to coreAbilities
if (migrated.classicStats) {
migrated.coreAbilities = {
strength: migrated.classicStats.str,
dexterity: migrated.classicStats.dex,
constitution: migrated.classicStats.con,
intelligence: migrated.classicStats.int,
wisdom: migrated.classicStats.wis,
charisma: migrated.classicStats.cha
};
delete migrated.classicStats;
}
// 2. Initialize abilityModifiers (will be calculated by formulas)
migrated.abilityModifiers = {
str_mod: 0,
dex_mod: 0,
con_mod: 0,
int_mod: 0,
wis_mod: 0,
cha_mod: 0
};
// 3. Convert spell slots from number to object
if (migrated.resources?.spellSlots) {
const oldValue = migrated.resources.spellSlots;
migrated.resources.spellSlots = {
current: typeof oldValue === 'number' ? oldValue : 0,
max: typeof oldValue === 'number' ? oldValue : 0
};
}
return migrated;
}
Migration Manager
// src/systems/schema/migrationManager.js
export class MigrationManager {
constructor(schemaStorage) {
this.schemaStorage = schemaStorage;
}
// Check if character needs migration
async needsMigration(characterInstance, currentSchema) {
const charVersion = characterInstance.schemaVersion;
const schemaVersion = currentSchema.meta.version;
return charVersion !== schemaVersion;
}
// Migrate character to latest schema version
async migrateCharacter(characterInstance, currentSchema) {
const charVersion = characterInstance.schemaVersion;
const targetVersion = currentSchema.meta.version;
console.log(`[Migration] Migrating character from ${charVersion} to ${targetVersion}`);
// Get all migration steps needed
const migrationPath = this.getMigrationPath(
charVersion,
targetVersion,
currentSchema.migrations || []
);
if (migrationPath.length === 0) {
console.warn('[Migration] No migration path found');
return characterInstance; // Can't migrate
}
// Apply migrations sequentially
let migratedData = { ...characterInstance.data };
for (const migration of migrationPath) {
console.log(`[Migration] Applying: ${migration.from} → ${migration.to}`);
try {
// Load and execute migration script
const migrationFn = await this.loadMigration(migration.script);
migratedData = migrationFn(migratedData);
} catch (error) {
console.error('[Migration] Failed:', error);
throw new Error(`Migration failed at ${migration.from} → ${migration.to}: ${error.message}`);
}
}
// Update character instance
return {
...characterInstance,
schemaVersion: targetVersion,
data: migratedData,
updatedAt: new Date().toISOString(),
migratedFrom: charVersion
};
}
// Find shortest migration path between versions
getMigrationPath(fromVersion, toVersion, migrations) {
// Build graph of migrations
const graph = new Map();
migrations.forEach(m => {
if (!graph.has(m.from)) {
graph.set(m.from, []);
}
graph.get(m.from).push(m);
});
// BFS to find shortest path
const queue = [[fromVersion, []]];
const visited = new Set([fromVersion]);
while (queue.length > 0) {
const [currentVersion, path] = queue.shift();
if (currentVersion === toVersion) {
return path; // Found path
}
const neighbors = graph.get(currentVersion) || [];
for (const migration of neighbors) {
if (!visited.has(migration.to)) {
visited.add(migration.to);
queue.push([migration.to, [...path, migration]]);
}
}
}
return []; // No path found
}
// Load migration script
async loadMigration(scriptPath) {
// Dynamic import of migration script
const module = await import(`/schemas/${scriptPath}`);
// Migration script should export a function named migrate_X_X_X_to_Y_Y_Y
const fnName = Object.keys(module).find(key => key.startsWith('migrate_'));
if (!fnName) {
throw new Error(`Migration script ${scriptPath} does not export a migration function`);
}
return module[fnName];
}
// Backup character before migration
async backupCharacter(characterInstance) {
const backup = {
...characterInstance,
backedUpAt: new Date().toISOString()
};
// Save to separate backup store in IndexedDB
await this.schemaStorage.saveBackup(backup);
return backup;
}
// Restore character from backup
async restoreCharacter(backupId) {
return await this.schemaStorage.loadBackup(backupId);
}
}
Migration UI Flow
User loads character with old schema version
↓
App detects version mismatch
↓
Show migration prompt:
┌─────────────────────────────────────────┐
│ Character Migration Required │
├─────────────────────────────────────────┤
│ Your character "Ragnar" was created │
│ with schema version 1.0.0. │
│ │
│ The current schema is version 1.2.0. │
│ │
│ Changes in new version: │
│ • Renamed stats for consistency │
│ • Added ability modifiers │
│ • Improved spell slot tracking │
│ │
│ A backup will be created automatically.│
│ │
│ [Cancel] [Migrate Character] │
└─────────────────────────────────────────┘
↓
Create backup
↓
Apply migration(s)
↓
Validate migrated data against new schema
↓
Show success message
┌─────────────────────────────────────────┐
│ Migration Successful! ✓ │
├─────────────────────────────────────────┤
│ "Ragnar" has been updated to v1.2.0. │
│ │
│ Backup saved: backup-2025-10-23.json │
│ │
│ [View Backup] [Continue] │
└─────────────────────────────────────────┘
Migration Best Practices
- Always Create Backups - Automatic backup before any migration
- Sequential Migrations - Chain small migrations (1.0→1.1→1.2) instead of big jumps
- Validate After Migration - Ensure migrated data passes schema validation
- Provide Rollback - Allow users to restore from backup if issues arise
- Document Changes - Clear changelog in migration prompt
- Test Thoroughly - Test migrations with real character data before release
Version Compatibility Matrix
// In schema metadata
compatibility:
minAppVersion: "2.0.0" // Minimum RPG Companion version required
maxAppVersion: null // No maximum (null = any version)
breaking: false // Whether this is a breaking change
Widget Integration
Schema-Driven Widget Rendering
// src/systems/dashboard/schemaWidgets.js
export class SchemaWidgetRenderer {
constructor(schema, characterInstance, formulaEngine) {
this.schema = schema;
this.instance = characterInstance;
this.formulaEngine = formulaEngine;
}
// Render component as widget
renderComponent(componentName, container, config = {}) {
const component = this.schema.components[componentName];
const data = this.instance.data[componentName];
switch (component.type) {
case 'object':
this.renderObject(component, data, container);
break;
case 'list':
this.renderList(component, data, container, config);
break;
case 'resource':
this.renderResource(component, data, container);
break;
default:
console.warn('Unknown component type:', component.type);
}
}
// Render object component (e.g., coreAbilities)
renderObject(component, data, container) {
const html = `
<div class="schema-component schema-object">
<h4>${component.label || 'Component'}</h4>
<div class="schema-properties">
${Object.entries(component.properties).map(([key, prop]) => {
const value = data?.[key] ?? prop.default ?? '';
const displayValue = prop.type === 'formula'
? this.formulaEngine.evaluate(prop.formula)
: value;
return `
<div class="schema-property">
<label>${prop.label || key}:</label>
${prop.type === 'formula'
? `<span class="schema-value-readonly">${displayValue}</span>`
: `<input type="${prop.type === 'number' ? 'number' : 'text'}"
value="${displayValue}"
data-component="${component.label}"
data-property="${key}"
${prop.min ? `min="${prop.min}"` : ''}
${prop.max ? `max="${prop.max}"` : ''} />`
}
${prop.abbr ? `<span class="schema-abbr">(${prop.abbr})</span>` : ''}
</div>
`;
}).join('')}
</div>
</div>
`;
container.innerHTML = html;
// Add event listeners for editable fields
container.querySelectorAll('input').forEach(input => {
input.addEventListener('change', (e) => {
this.updateProperty(
e.target.dataset.component,
e.target.dataset.property,
e.target.value
);
});
});
}
// Render list component (e.g., skills)
renderList(component, data, container, config) {
const filter = config.filter; // Optional filter for specific items
const items = Array.isArray(data) ? data : [];
const filteredItems = filter
? items.filter(item => filter.includes(item.name))
: items;
const html = `
<div class="schema-component schema-list">
<h4>${component.icon || ''} ${component.label || 'List'}</h4>
<div class="schema-list-items">
${filteredItems.map((item, index) => `
<div class="schema-list-item">
${Object.entries(component.items).map(([key, itemProp]) => {
const value = item[key] ?? '';
return `
<span class="schema-item-${key}">
${itemProp.label ? `<label>${itemProp.label}:</label>` : ''}
${itemProp.type === 'number'
? `<input type="number" value="${value}" data-index="${index}" data-key="${key}" />`
: `<span>${value}</span>`
}
</span>
`;
}).join('')}
</div>
`).join('')}
</div>
<button class="schema-add-item">+ Add ${component.label}</button>
</div>
`;
container.innerHTML = html;
// Event listeners for list item editing
container.querySelectorAll('input').forEach(input => {
input.addEventListener('change', (e) => {
this.updateListItem(
component.label,
parseInt(e.target.dataset.index),
e.target.dataset.key,
e.target.value
);
});
});
// Add item button
container.querySelector('.schema-add-item').addEventListener('click', () => {
this.addListItem(component.label);
});
}
// Render resource component (e.g., HP)
renderResource(component, data, container) {
const current = data?.current ?? 0;
const max = typeof component.max === 'object' && component.max.type === 'formula'
? this.formulaEngine.evaluate(component.max.formula)
: (data?.max ?? 0);
const percentage = max > 0 ? (current / max) * 100 : 0;
const color = component.color || '#3366cc';
const html = `
<div class="schema-component schema-resource">
<div class="schema-resource-header">
<h4>${component.label || 'Resource'}</h4>
<span class="schema-resource-values">
<input type="number" class="schema-current" value="${current}" min="0" max="${max}" />
/ ${max}
</span>
</div>
${component.display === 'bar'
? `<div class="schema-resource-bar" style="background: linear-gradient(to right, ${color}, ${color});">
<div class="schema-resource-fill" style="width: ${100 - percentage}%"></div>
</div>`
: `<div class="schema-resource-dots">
${Array(max).fill('').map((_, i) => `
<span class="schema-dot ${i < current ? 'filled' : ''}" style="background: ${color}"></span>
`).join('')}
</div>`
}
</div>
`;
container.innerHTML = html;
// Update current value
container.querySelector('.schema-current').addEventListener('change', (e) => {
this.updateResource(component.label, 'current', parseInt(e.target.value));
});
}
// Update character property
updateProperty(componentName, propertyName, value) {
if (!this.instance.data[componentName]) {
this.instance.data[componentName] = {};
}
this.instance.data[componentName][propertyName] = value;
// Invalidate formula cache
this.formulaEngine.invalidateCache();
// Save character instance
this.saveInstance();
}
// Update list item
updateListItem(componentName, index, key, value) {
if (!Array.isArray(this.instance.data[componentName])) {
this.instance.data[componentName] = [];
}
if (!this.instance.data[componentName][index]) {
this.instance.data[componentName][index] = {};
}
this.instance.data[componentName][index][key] = value;
this.saveInstance();
}
// Add new list item
addListItem(componentName) {
if (!Array.isArray(this.instance.data[componentName])) {
this.instance.data[componentName] = [];
}
// Create empty item based on component definition
const component = this.schema.components[componentName];
const newItem = {};
for (const [key, prop] of Object.entries(component.items)) {
newItem[key] = prop.default ?? '';
}
this.instance.data[componentName].push(newItem);
this.saveInstance();
// Re-render component
this.renderComponent(componentName, container);
}
// Update resource value
updateResource(componentName, field, value) {
if (!this.instance.data[componentName]) {
this.instance.data[componentName] = {};
}
this.instance.data[componentName][field] = value;
this.saveInstance();
}
// Save character instance
saveInstance() {
// Update timestamp
this.instance.updatedAt = new Date().toISOString();
// Save to extension settings
updateExtensionSettings({ characterInstance: this.instance });
// Persist to storage
saveSettings();
}
}
AI Prompt Generation
Dynamic Prompt Builder
// src/systems/generation/schemaPromptBuilder.js
export function generateSchemaPrompt(schema, characterInstance) {
let prompt = '';
// Use schema's prompt templates
if (schema.prompts) {
for (const [section, template] of Object.entries(schema.prompts)) {
// Replace [@component.path] with actual values
const resolved = resolvePromptTemplate(template, characterInstance.data);
prompt += resolved + '\n\n';
}
} else {
// Fallback: auto-generate from components
for (const [name, component] of Object.entries(schema.components)) {
prompt += generateComponentPrompt(name, component, characterInstance.data[name]);
prompt += '\n\n';
}
}
return prompt.trim();
}
// Resolve [@reference] syntax in prompt templates
function resolvePromptTemplate(template, data) {
const refRegex = /\[@([a-zA-Z0-9_.]+)\]/g;
return template.replace(refRegex, (match, path) => {
const value = getValueByPath(data, path);
return value !== undefined ? value : '[Unknown]';
});
}
// Auto-generate prompt for a component
function generateComponentPrompt(name, component, data) {
let prompt = `${component.label || name}\n---\n`;
switch (component.type) {
case 'object':
for (const [key, prop] of Object.entries(component.properties)) {
const value = data?.[key] ?? prop.default ?? '';
prompt += `${prop.label || key}: ${value}\n`;
}
break;
case 'list':
if (Array.isArray(data)) {
data.forEach(item => {
const values = Object.entries(component.items)
.map(([key, prop]) => `${prop.label || key}: ${item[key]}`)
.join(' | ');
prompt += `${values}\n`;
});
}
break;
case 'resource':
prompt += `${component.label}: ${data?.current ?? 0}/${data?.max ?? 0}\n`;
break;
}
return prompt;
}
Migration from Hardcoded to Schema
Backward Compatibility Strategy
- Keep existing hardcoded mode as fallback
- Detect schema presence to switch modes
- Provide migration wizard to convert existing characters
// src/systems/schema/migration.js
export async function migrateToSchema() {
const currentStats = extensionSettings.userStats;
const currentClassicStats = extensionSettings.classicStats;
const currentLevel = extensionSettings.level;
// Load D&D 5e schema as default
const dnd5eSchema = await schemaStorage.loadSchema('dnd5e-v1.0.0');
// Map existing data to schema
const characterInstance = {
schemaId: 'dnd5e-v1.0.0',
schemaVersion: '1.0.0',
data: {
coreAbilities: {
strength: currentClassicStats.str,
dexterity: currentClassicStats.dex,
constitution: currentClassicStats.con,
intelligence: currentClassicStats.int,
wisdom: currentClassicStats.wis,
charisma: currentClassicStats.cha
},
resources: {
hitPoints: {
current: Math.round(currentStats.health),
max: 100 // Default, user can change
}
},
identity: {
name: getContext().name1,
level: currentLevel
},
inventory: currentStats.inventory
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Save migrated instance
await schemaStorage.saveCharacterInstance(characterInstance);
// Enable schema mode
updateExtensionSettings({
schemaMode: true,
activeSchemaId: 'dnd5e-v1.0.0',
characterInstance
});
console.log('[Schema Migration] Successfully migrated to D&D 5e schema');
}
Schema Editor UI
Visual Builder (Future)
┌─────────────────────────────────────────────────────────┐
│ Schema Editor: D&D 5e [Save] [×]│
├─────────────────────────────────────────────────────────┤
│ ┌─ Components ──────┐ ┌─ Editor ───────────────────────┐│
│ │ + Core Abilities │ │ Component: Core Abilities ││
│ │ + Ability Mods │ │ ││
│ │ + Resources │ │ Type: [Object ▼] ││
│ │ + Skills │ │ Label: [Core Abilities] ││
│ │ + Status Effects │ │ Icon: [🎲] ││
│ │ + Inventory │ │ ││
│ │ + Identity │ │ Properties: ││
│ │ │ │ ┌──────────────────────────────┐││
│ │ [+ Add Component] │ │ │ strength │││
│ └───────────────────┘ │ │ Type: number │││
│ │ │ Label: "Strength" │││
│ │ │ Min: 1, Max: 30 │││
│ │ │ Default: 10 │││
│ │ │ [Edit] [Delete] │││
│ │ │ │││
│ │ │ dexterity │││
│ │ │ Type: number │││
│ │ │ ... │││
│ │ └──────────────────────────────┘││
│ │ [+ Add Property] ││
│ └────────────────────────────────┘│
│ │
│ [YAML View] [Visual Builder] │
└─────────────────────────────────────────────────────────┘
Success Criteria
- ✅ Users can import D&D 5e schema YAML
- ✅ Character instance validates against schema
- ✅ Formula engine calculates derived stats correctly
- ✅ Schema-driven widgets render dynamically
- ✅ Users can edit character data through widgets
- ✅ AI prompts generate based on schema
- ✅ Export/import workflows work reliably
- ✅ Backward compatibility maintained (hardcoded mode still works)
- ✅ Migration wizard converts existing characters to schema
Open Questions
- Schema Marketplace: Should we host community schemas on GitHub?
- Version Compatibility: How to handle schema version upgrades?
- Formula Complexity: Limit formula depth to prevent infinite loops?
- Multi-Character: Support multiple character instances with different schemas?
- Real-Time Sync: Should formulas recalculate on every input change or debounced?
Next Steps
- Implement YAML parser and validator
- Build formula engine with safe evaluation
- Create IndexedDB storage layer
- Develop schema-driven widget renderer
- Design schema editor UI (YAML + visual builder)
- Create D&D 5e reference schema
- Build migration wizard
- Write documentation and tutorials