Adopting the SDK in an existing system
You have a system that runs on v12, v13 or v14, with sheets on Application v1 and types in template.json. This is how to move it onto the SDK, in the order that worked on a real one: a small system with two actor types, two item types, jQuery sheets and a template.json. The whole run took an afternoon. The CLI does most of the typing; the hand edits are listed at each step, and they are few.
What you keep: your Actor and Item classes, your rules, macros, packs, CSS and templates. What changes: the types become data models, the sheets become ApplicationV2, one registerSystem call replaces the init hook, and a build writes dist/.
Work on a branch. Every step below is a commit you can read.
Before you start
You need Node 26 or later, a package manager, and a Foundry v14 to test on. Run the audit first and read the report:
npx @vttforge/cli@latest auditEvery finding names a file and a line. Most of them go away in the next two steps.
The oldest code the commands read is v12. A v13 system goes straight through the steps below. A v12 system goes through Migrating from v12 first, for the breaks between v12 and v13 that no command rewrites. Anything older needs the data.data to system rename by hand before any of this helps, since every rewrite below looks for system.
1. v14 first
npx @vttforge/cli@latest migrate
npx @vttforge/cli@latest migrate --writeThe first run prints every edit it would make; the second makes them: bare mergeObject to foundry.utils.mergeObject, the v13 aliases to their namespaced paths, rollMode to messageMode, the manifest type and compatibility, the -= deletion keys. What it will not decide it lists as "needs a decision". On the system this was written against, two were left:
gridDistanceandgridUnitsat the manifest root became agridobject.- A
renderChatMessagehook becamerenderChatMessageHTML, withhtmlan element, sohtml.find(x).addClass(y)became a loop overhtml.querySelectorAll(x)andclassList.add(y).
Run audit again. What remains is the Application v1 classes and the template.json.
2. Types become data models
npx @vttforge/cli@latest migrate --data-models --style sdk --writeThis reads template.json and writes one class per type under scripts/data/, plus a templates.mjs per document with one function per shared template, which the types that listed it spread. Each field is typed from its default: a number is a NumberField, a string an HTMLField when the key reads like rich text, an object a SchemaField. The report ends with the documentTypes block for the manifest and the imports for the registration.
Hand edits:
Derived numbers move onto the model. A v1 system computes them in
Actor#prepareDerivedData. On the model the same code runs withthisas the system data andthis.parentas the actor:jsexport class CharacterData extends BaseTypeDataModel(defineCharacterDataSchema) { prepareDerivedData() { const actor = this.parent; this.hp.max = 10 + this.level * 2 + Math.floor((this.abilities.str - 10) / 2); this.encumbrance = actor.items.reduce((sum, i) => sum + i.system.weight * i.system.quantity, 0); } }The Actor class keeps
getRollDataand whatever else is not a derived value.Declare the types in the manifest. Paste the
documentTypesblock and addhtmlFieldsfor everyHTMLField:json"documentTypes": { "Actor": { "character": { "htmlFields": ["biography"] }, "npc": { "htmlFields": ["biography"] } }, "Item": { "gear": { "htmlFields": ["description"] }, "spell": { "htmlFields": ["description"] } } }Delete
template.json. While it exists Foundry resets thedocumentTypesentry of every type it lists on each start, so the manifest block does nothing until the file is gone. Stored actors keep their data: the model declares the same paths the template did.
3. Sheets become ApplicationV2
npx @vttforge/cli@latest migrate --sheets --writeFor each ActorSheet or ItemSheet this writes a file with .v2 before the extension: defaultOptions as DEFAULT_OPTIONS, the tabs entry as TABS with the ids read from the template, dragDrop as DRAG_DROP, getData as _prepareContext, every html.find(sel).click(handler) as an actions entry with the handler on (event, target), the other events as listeners in _onRender, Dialog.confirm and new Dialog as DialogV2, _onDropItem as onDropItem. It also edits the templates: data-action on the elements the selectors matched, and the tab links and panes marked for the SDK's tab action.
Hand edits, each marked TODO(migrate) in the file:
One template per type. A v1
get template()that picks a file by type has no static equivalent. Name the first one inPARTSand pick the real one when the parts are configured:jsstatic PARTS = { sheet: { template: 'systems/my-system/templates/actor/character-sheet.html' } }; _configureRenderParts(options) { const parts = super._configureRenderParts(options); parts.sheet = { ...parts.sheet, template: `systems/my-system/templates/actor/${this.document.type}-sheet.html` }; return parts; }Then delete the
get template()the codemod carried over.Drops. The base resolves the dropped document before calling you, so
onDropItem(droppedItem, event)receives an Item, not a payload. ReaddroppedItem.typewhere the old code calledfromDropData, and returntruewhen you handled the drop:jsasync onDropItem(droppedItem, event) { if (!this.actor.isOwner) return false; if (droppedItem.type === 'spell' && this.actor.type === 'npc') { ui.notifications.warn('NPCs cannot learn spells'); return true; } await this.document.createEmbeddedDocuments('Item', [droppedItem.toObject()]); return true; }The root
<form>. The SDK sheet already is a form, so a template that opens with one nests a form inside it and every edit is lost when the window closes. Make the root a<div>. Do not delete it: a part has to render one element, and a template that leaves<header>,<nav>and<section>at the top level fails to render.
When the .v2 file reads right, delete the old class and drop the .v2 from the name.
4. One registration
The init hook becomes a registerSystem call. Data models, document classes, initiative and sheets go in as options; what has no option goes in onBeforeInit or onAfterInit, which run inside init around the registrations:
import { registerSystem } from '@vttforge/core';
import { MySystemActor } from './actor.mjs';
import { MyActorSheet } from './actor-sheet.mjs';
import { MyItemSheet } from './item-sheet.mjs';
import { CharacterData } from './data/actor/character-data.mjs';
import { NpcData } from './data/actor/npc-data.mjs';
import { GearData } from './data/item/gear-data.mjs';
import { SpellData } from './data/item/spell-data.mjs';
registerSystem({
id: 'my-system',
actorDataModels: { character: CharacterData, npc: NpcData },
itemDataModels: { gear: GearData, spell: SpellData },
actorDocumentClass: MySystemActor,
combat: { initiative: { formula: '1d20 + @abilities.dex', decimals: 0 } },
sheets: [
{ id: 'actor', document: 'Actor', sheet: MyActorSheet, makeDefault: true },
{ id: 'item', document: 'Item', sheet: MyItemSheet, makeDefault: true },
],
onBeforeInit: () => {
game.mySystem = { MySystemActor };
},
onAfterInit: () => {
game.settings.register('my-system', 'hardMode', { scope: 'world', config: true, type: Boolean, default: false });
foundry.applications.handlebars.loadTemplates(['systems/my-system/templates/actor/parts/items.html']);
},
});The sheet id is the half of the key Foundry stores on every document whose owner picked the sheet. It is written down here so a bundler that renames the class cannot move it. Pick it once. The unregisterSheet calls a v1 system made for the core sheets are gone: core registers no default Actor or Item sheet on v14, and migrate already removed them.
5. A build
Three files and one install:
{
"name": "my-system",
"private": true,
"type": "module",
"scripts": { "build": "vttforge build", "dev": "vttforge dev", "audit": "vttforge audit" },
"dependencies": { "@vttforge/core": "^0.15.0" },
"devDependencies": { "@vttforge/cli": "^0.15.0", "@vttforge/vite-plugin": "^0.5.0", "vite": "^8.0.0" }
}// vite.config.mjs
import vttforge from '@vttforge/vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [vttforge({ id: 'my-system' })] });Add node_modules, dist and *.zip to .gitignore. The entry is scripts/main.mjs, so if your code lived in module/, move it; the data models are already under scripts/. Point the manifest at the bundle: "esmodules": ["main.mjs"].
pnpm install
pnpm builddist/ holds main.mjs, the manifest with the version synced from package.json, and lang/, templates/, styles/ copied over, plus the release zip. That directory is what ships. A release workflow that zips the checkout instead of dist/ ships a system with no main.mjs; audit rule 020 catches that, so run it once more on the finished branch.
6. Check
npx @vttforge/cli@latest auditZero findings is the target, and it was the result on the system this was written against. Then load a world on v14, open every sheet type, and search the console for Deprecated since Version 14. Each warning names its replacement. Go through the TODO(migrate) lines you have not yet touched; each one is a place the codemod could read but not decide.
For a module
The same steps, with two gone. A module has no template.json, so skip step 2: a sub-type a module adds is a data model from the start, under a key prefixed with the module id, and Modules shows the declaration. In step 4 the call is registerModule, which takes actorDataModels, itemDataModels, sheets, enrichers, onBeforeInit and onAfterInit, and prefixes the type keys for you. It has no actorDocumentClass and no combat: those belong to the running system. Everything else, audit, migrate, migrate --sheets, the build and the check, reads a module the way it reads a system.
What this run left untouched: the Actor class beyond its derived data, the macros, the compendium packs, the CSS, and the templates beyond the attributes the codemod added and the root element.