FFFF GitHub - vttforge/vttforge: An SDK and CLI for building Foundry VTT v14+ systems and modules. · GitHub
[go: up one dir, main page]

Skip to content
vttforgePublic

About

An SDK and CLI for building Foundry VTT v14+ systems and modules.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

12 stars

Watchers

0 watching

Forks

Latest commit

 

History

276 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

VTTForge

An SDK and CLI for building Foundry VTT v14+ systems and modules.

CI npm version npm downloads License FoundryVTT TypeScript

Documentation · Getting started · API reference · Roadmap · Report a bug


Status: published on npm and usable today. Every package is on its own 0.x line, so a caret pins the minor and a minor bump is a breaking change. The versioning and Node support story is in STABILITY.md.

Start here

bun create vttforge my-system --type system --lang ts
cd my-system
bun run build

Pass --type module for a module. The two get the same treatment: the same sheet bases, the same data models, the same build, the same dev loop.

That scaffolds a Foundry-loadable package, installs it, and builds a dist/ plus a release zip with the manifest at the root, which is what foundryvtt.com expects. Every prompt is also a flag, so the same line runs in CI with --yes.

bunx @vttforge/cli init does the same thing if you would rather not go through create.

Then point Foundry at it:

bun run dev

This builds, symlinks dist/ into your Foundry data directory, and watches. Save a template and the open sheet redraws in place, with no page reload. The first run asks where Foundry keeps its data and remembers the answer; if Foundry runs in a container and cannot follow a symlink, it prints the compose mount to use instead.

Docs

The whole guide is at vttforge.dev/docs.

  • Getting started. Scaffold a project, point Foundry at it, build a release zip.
  • Data models. One schema that types itself, and what each field class quietly defaults to.
  • Sheets. The bases, the drop hooks, and why every sheet needs an id.
  • Rolls. postRoll, and how a card gets tagged as a critical or a fumble.
  • Dialogs. promptFields: a dialog from a list of fields, typed answers back.
  • Modules. Namespaced sub-types, the module api, a way out before the module is removed.
  • Settings and migrations. PackageConfig and the migration runner.
  • Sockets. registerSocket: one-way messages, and questions only the Gamemaster's client can answer.
  • Keywords. Rules terms defined once: @Keyword[id] in any text, and a journal the GM's client keeps current.
  • The dev loop. What reloads in place and what does not.
  • Testing. withMockFoundry in Vitest, Quench for what a mock cannot answer.
  • CLI reference. Every command, and every rule vttforge audit checks.

Also on the site: the API reference generated from the sources, the error catalogue, the recipes, and the stability policy.

Why this exists

I have run Foundry games for years, and every major version I watch the same thing happen. A module I depend on stops working. Some authors port it, at a cost of weeks; more often the repository goes quiet, someone opens an issue asking whether it will be updated, and nobody answers.

What breaks is the sheet plumbing, the registration boilerplate, the class that moved namespace. The rules of the game the author actually cared about are still correct.

VTTForge exists to hold that plumbing in one place. When Foundry moves it, the change lands here, and a system or module built on it updates a dependency instead of rewriting a sheet.

A sheet is registered under an id you choose, so the sheet your readers picked survives your next release. Migrations are versioned and GM-gated, so their worlds survive your schema changes. vttforge audit looks for the v14 breakages that fail silently. Every push boots a real Foundry and drives the example through it, to check that it still loads.

If this project ever goes quiet, the build output is plain ES modules that Foundry loads natively, with no runtime shim and no framework to unwind. Delete the dependency and your code is still your code.

What it takes off your hands

Foundry systems write these by hand:

#createDragDropHandlers() { /* the same 36 lines, in every sheet */ }

async _prepareContext(options) {
  const context = await super._prepareContext(options);
  context.tabs = { primary: this._prepareTabs("primary") };  // every group, every sheet
  return context;
}

async _onDropItem(event, data) {
  const item = await fromUuid(data.uuid);  // the fromUuid ceremony, again
}

VTTForge declares them instead:

import { BaseActorSheet, fields, type InferSchema } from "@vttforge/core";

class CharacterSheet extends BaseActorSheet() {
  static PARTS = { /* your templates */ };
  static TABS = { /* declared once; context.tabs is filled in for you */ };
  static DRAG_DROP = [{ dragSelector: ".item[draggable=true]" }];

  // fromUuid already done. Return undefined to fall through to Foundry.
  override async onDropItem(item, event) { /* … */ }
}

And one defineSchema() drives both runtime validation and your types:

class CharacterData extends BaseTypeDataModel(defineCharacterSchema) {}

type CharacterSystem = InferSchema<ReturnType<typeof defineCharacterSchema>>;
// → { level: number; biography: string; hp: { value: number; max: number } }

Where Foundry already does something well, such as editImage on DocumentSheetV2 or the _getTabs() state machine, VTTForge leaves it alone.

And what a module writes by hand

A module reaches for all of the above and four more things a system never needs:

import { inject, moduleApi, registerSocket } from '@vttforge/core';

// The channel, the manifest flag it needs, and the sender the server vouched
// for rather than the one the payload claims.
const socket = registerSocket({ id: 'my-module', kind: 'module', requests: { createNote } });
await socket.askGm('createNote', { name });   // a player cannot write world documents

// Replaced on each re-render instead of stacking one button per render.
inject({ id: 'my-module', name: 'generate', hook: 'renderActorDirectory', render: () => button() });

// Another package's api, and which of not installed, switched off or
// publishing nothing it was.
const other = moduleApi<TheirApi>('their-module');

The fourth is convertSubTypes: a module's sub-types travel with the module, so switching it off marks every document using one invalid. One call turns them back into plain documents before that happens.

What is actually in the box

Most of these exist because something failed quietly in a real world.

Package What it does
@vttforge/core registerSystem / registerModule, sheet and enricher registration, BaseTypeDataModel + InferSchema<T>, BaseActorSheet / BaseItemSheet / BaseDocumentSheet / BaseApplication, PackageConfig, createMigrationRunner, and the VTTF-NNNN error catalogue. For modules: registerSocket, moduleApi / requireModuleApi, convertSubTypes, inject
@vttforge/cli vttforge init / dev / build / audit: scaffolding, the symlink-and-watch dev loop, the release zip, and a manifest linter
@vttforge/vite-plugin The build contract: browser-ESM output with no hashing, CSS bundled, manifest copied under Foundry's filename with version and entry paths rewritten
@vttforge/styles The Forge design system: W3C DTCG tokens compiled to CSS, .vttf-* primitives, sheet primitives, opt-in themes over cascade layers
@vttforge/testing withMockFoundry for Vitest, and a Quench half for what a mock cannot answer
@vttforge/types The Foundry surface the bases stand on: ApplicationV2Members, DocumentSheetV2Members, VttforgeClass
@vttforge/dev-module The in-world half of the dev loop, installed for you by vttforge dev
create-vttforge So bun create vttforge works

Three things it stops you getting wrong

Foundry keys a registered sheet by ${package id}.${class name} and saves that key on every document whose owner picked the sheet. A minifier renames classes, so the key moves and the reader's choice silently falls back to the default. Registering through sheets with an explicit id writes the key down instead of deriving it.

CONFIG.TextEditor.enrichers takes an entry and does nothing with it in four ways, none of which say so. onRender without an id never runs, a duplicate id loses to whoever registered first, and a pattern without the g flag throws inside someone's chat message. Registering through enrichers namespaces the id and checks the rest up front.

BaseActorSheet mixes in Handlebars, which is right for static PARTS and wrong for a canvas, an embedded PDF, or a framework mount. Using it for one of those renders nothing, with a clean console. BaseDocumentSheet is the same plumbing without the mixin.

Try the example

The repo ships a working Foundry v14 system. Requires Docker and a foundryvtt.com license:

git clone https://github.com/vttforge/vttforge && cd vttforge
bun install
bun run build
cp .env.example .env                             # FOUNDRY_LICENSE_KEY / USERNAME / PASSWORD
docker compose -f docker-compose.dev.yml up      # → http://localhost:30000

The repo also ships an example module, enabled the same way. It publishes an api, registers a socket, injects a button into the Items sidebar and converts its own sub-types back.

Create a world on VTTForge Example System and add a Character. The sheet renders the reference layout: quick stats, four tabs, an ability grid with roll buttons, an items list with kind pills. The demo migration runs at world load.

Design system

The Forge theme: warm-dark surfaces, an ember accent, a { d20 } mark, a 4-pt grid. It is live at vttforge.dev/design-system.

  • Tokens. packages/styles/tokens.json (W3C DTCG) compiles to dist/tokens.css. Three blocks: Forge, [data-theme="light"], and an opt-in [data-theme="foundry"] that follows the GM's Theme V2 setting.
  • Primitives. Buttons, badges, code blocks, form controls, tabs and the .sh-* sheet primitives consume only --vttf-* tokens. ARIA state drives the variants.
  • Themes. Four recipes (Codex, Parchment, Grimdark, Neon) in examples/themes/, each re-theming everything by overriding tokens on a parent class.
  • Brand. brand/ has the mark and rasters; usage rules in brand/README.md.

Design principles

  1. Zero lock-in. The output is plain .mjs that Foundry loads natively. Drop VTTForge whenever you like.
  2. Validated against something real. A real Foundry module uses every API before it ships. Several exist only because that module broke in a way nothing reported.
  3. Fail loudly or not at all. Where Foundry accepts something and quietly does nothing with it, VTTForge either refuses it up front or makes the mistake impossible to express.
  4. Current tooling. Bun with catalogs, Turborepo, tsdown, Vite, Biome, Changesets with npm OIDC trusted publishing (via the npm CLI, not bun publish: Bun has no OIDC trusted-publishing support yet), publint, attw, knip, syncpack, lefthook.
  5. Community first. MIT. No paid tiers.

Roadmap

  • ✅ Core SDK. Registration, data models with type inference, the sheet and application bases, migrations, the error registry
  • ✅ Build pipeline. One Vite config, a deployable dist/, a release zip
  • ✅ CLI. init with four templates, dev, build, audit
  • ✅ Dev loop. Templates, styles and language files reload in place, without a page refresh
  • ✅ End-to-end. Every push to main boots a real Foundry v14 in a container and drives the example system and module through it
  • ✅ Design system. Tokens, primitives, themes, brand
  • ✅ Published. Every package on npm under OIDC trusted publishing, with provenance
  • ✅ Documentation. vttforge.dev/docs carries the guide, the API reference, the recipes and the error catalogue
  • ✅ Modules. Sockets and GM-authoritative requests, the module api, sub-type conversion so a module can be uninstalled without stranding data, UI injection into applications you do not own
  • ✅ Decorators. @ActorDataModel, @ItemDataModel, @DocumentSheet, @SystemSetting and @OnHook, on the standard decorators the toolchain now supports
  • ✅ Typed Foundry surface. @vttforge/types describes the documents, game, ui, CONFIG, CONST, foundry.utils, dice, chat, combat, scenes, tokens, the canvas, ApplicationV2 and a hook map that types each hook's arguments by its name. The published type declarations for @vttforge/core carried 91 unknown at 0.18.1 and carry 49 at 0.19.0, and most of what is left is the SDK's own generics rather than a Foundry type nobody wrote down
  • 🛠️ Now. Other people's code. The API is shaped and the surface is typed, so what is left is finding where it does not hold in a system I did not write
  • 🚀 v1.0. A stable API, and enough adopters to know which of the remaining gaps are real

Where it is now

Every package is on a 0.x line, which under semver means a minor can break you, and here it regularly does. The API is still meeting real code for the first time. One module is built on it today, my own, and a good part of what the SDK does exists because that module broke in a way nothing reported.

What it needs now is other people's systems and modules, because the gaps that matter are the ones a second author finds and the first never did. If you build something on this and something is wrong, an issue with the case that broke is worth more to me than a pull request.

MIT, and it stays that way. No paid tier, no open core, nothing held back for a sponsor build.

The part that is still a promise

The argument is that when Foundry moves, the change lands in one place and your module updates a dependency instead of rewriting a sheet. It has been through one version bump so far. The SDK moved from v13 to v14, and one system built on it followed by updating the dependency and reading the changelog. One bump on one system is not a track record. I will say how each next one goes.

This project can also fail the same way the modules it replaces fail: one maintainer, a quiet repository, and the dependency everyone leaned on goes still. Putting the plumbing in one place only helps if that place has more than one person who understands it. If you want to be one of them, open an issue and say so.

Contributing

I shape the API against real Foundry projects, so the most useful contributions right now are:

  • Boilerplate you keep writing in your own system or module that VTTForge could remove
  • Rough edges in the packages as published
  • Documentation. Always welcome

See CONTRIBUTING.md.

License

MIT © Fabricio Cavalcante de Souza and contributors.


Disclaimer

VTTForge is an independent, community-developed project. It is not affiliated with, endorsed by, or sponsored by Foundry Gaming LLC. "Foundry Virtual Tabletop", "Foundry VTT", and "FVTT" are trademarks of Foundry Gaming LLC. It is also not related to The Forge, the Foundry hosting service; the name is a coincidence.

About

An SDK and CLI for building Foundry VTT v14+ systems and modules.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

0