# DivineVTT Extension API docs DivineVTT is a virtual tabletop and a worldbuilder in one app. These docs cover its Extension API: how systems and modules (packages) declare character sheets, rules, content, themes and scripts, and the api object a package script uses. Rendered site: https://divinevtt.com/docs. Page index: https://divinevtt.com/llms.txt --- # Extension API `api: 1` You extend DivineVTT with **packages**. A package is a folder with a `pack.json` manifest, a set of facet files, and optionally a script. There are two roles, and they share one format: - A **system** is the base ruleset a world runs on: character sheets, conditions, the calendar, dice, initiative, token vitals, money, what a hidden thing takes to notice. A world has exactly one. - A **module** adds things on top of a system: new sheet sections, automation, chat cards, granted items, content, a theme. A world can enable several, and a campaign can override the set for its own table. Most packages use one or both of these paths: - **Declarative facets**: JSON files describing sheets, conditions, calendars, dice, vitals, catalogs and twenty other kinds. This is the main path. It needs no code, it is editable in [Studio](https://divinevtt.com/docs/guides/studio), and it works everywhere, including the hosted service. - **A script**: an ES module that registers behavior through the `api` object. Use it for formula functions, custom sheet nodes, panels, action sources, effects, and reacting to what happens at the table. Scripts from anyone but the first party run in a [sandbox](https://divinevtt.com/docs/guides/sandbox) with the permissions the manifest declares. Everything documented here is stable. Anything not documented is internal and can change. Within a major version the API only grows: fields and methods get added, never removed or repurposed, so a package built for `api: 1` keeps working as the app changes. ## Start here - [Your first module](https://divinevtt.com/docs/getting-started): two files, a formula function, a setting, a hook. - [Studio](https://divinevtt.com/docs/guides/studio): build either role in the app, with a live preview, lint, and a sandboxed test harness. - [Building a system](https://divinevtt.com/docs/guides/systems): what a system is made of and the smallest one that plays. - [Packages & the pack chain](https://divinevtt.com/docs/guides/packages): the manifest, every facet kind, and how a world resolves content across its system and modules. ## Then, by what you are making - [Authoring character sheets](https://divinevtt.com/docs/guides/character-sheets): the node vocabulary that builds a sheet, no code required. - [Rules facets](https://divinevtt.com/docs/guides/rules): conditions, dice, turn order, vitals, measurement, detection, skills, tracked pools, money, realism and the wound table, weapon properties, region switches, sounds, calendar, GM screen, theme, star charts. - [Shipping content](https://divinevtt.com/docs/guides/content): catalogs, overrides, books, drop tables, loot tables. - [Scripting with the api](https://divinevtt.com/docs/guides/scripting), [Effects](https://divinevtt.com/docs/guides/effects), [Automation patterns](https://divinevtt.com/docs/guides/automation), and [Permissions and the sandbox](https://divinevtt.com/docs/guides/sandbox). ## How content resolves A world resolves content through a **pack chain**: the system first, then the enabled modules in the GM's order. Each facet kind merges its own way. A later `characterSheet` replaces an earlier one for the same entity kind, `conditions` union by id, `bindings` shallow-merge, `currency` is taken whole from the last pack that declares one, and so on. The [Packages guide](https://divinevtt.com/docs/guides/packages#facets) has the full table, and [Facet schemas](https://divinevtt.com/docs/reference/facets) lists every field of every kind. --- # Your first module The smallest useful module is two files: a manifest and a script. This one adds a `half(x)` function you can call from any sheet formula. We'll start there and grow it. If you would rather not touch files, [Studio](https://divinevtt.com/docs/guides/studio) does all of this in the app; the format is the same. ## 1. The manifest A package is a folder containing `pack.json`. For a module, set `role` to `"module"` and point `extends` at the systems it targets (`"*"` means any). ```jsonc // my-first-module/pack.json { "schemaVersion": 1, "id": "my-first-module", // your unique id; never use the "builtin:" prefix "version": "1.0.0", "displayName": "My First Module", "role": "module", "api": 1, "extends": "*", // works on any system "script": "./main.js", "permissions": ["ui"], // what the script may do; the GM confirms these "facets": {} } ``` ## 2. The script `script` points at an ES module whose default export is a `register` function. The host calls it once when the package loads and passes it the [`api` object](https://divinevtt.com/docs/reference/api). ```js // my-first-module/main.js export default function register(api) { // Callable from any computed/roll node as `my_first_module:half()`. api.register.formulaFunction("half", { params: ["x"], expr: "floor(x / 2)" }); } ``` That's a complete, working module. Registration is synchronous: do it inside `register` and return. The function is written as a formula, not as JavaScript, on purpose. Your module runs in a [sandbox](https://divinevtt.com/docs/guides/sandbox), and only the declarative `{ params, expr }` form can be evaluated on the host's synchronous formula path. A JS function would register and then never be called. ## 3. Load it Zip the folder and import it in Studio, or drop it on the marketplace's uploads view. Then a GM opens the world's Modules screen, adds the module to the enabled list, and confirms the permissions it declares. On load the host runs `main.js` in its own frame, calls `register(api)`, and your formula function joins the registry. ## 4. Use it The name is auto-qualified to `packId:name`, with dashes in the id normalized to underscores. So in any `computed` sheet node you can now write: ``` my_first_module:half(level) ``` Build the qualified name with `api.ids.field("half")` instead of typing it by hand. ## 5. Add a setting and react to events Declare settings in the manifest. They render with the same field renderer as sheets, and you read them with `api.settings.get`. ```jsonc // pack.json: add a settings array "settings": [ { "type": "field", "id": "verbose", "fieldType": "checkbox", "label": "Verbose logging", "default": false } ] ``` ```js // main.js export default function register(api) { api.register.formulaFunction("half", { params: ["x"], expr: "floor(x / 2)" }); api.hooks.on("pack.enabled", (p) => { if (api.settings.get("verbose")) api.log("active in world", p.worldId); }); } ``` `api.log` prefixes the console with your package id. The [Hooks reference](https://divinevtt.com/docs/reference/hooks) lists every event. ## 6. Expose state and clean up `register` may return `{ exports, dispose }`. Other packages read `exports` through `api.packs.get(id)`. `dispose` runs when your package is disabled, and the host also tears down every registration and subscription you made. ```js return { exports: { hello: () => "hi from my-first-module" }, dispose: () => api.log("disposed"), }; ``` ## The reference module [`builtin:demo-utils`](https://divinevtt.com/docs/examples/demo-utils) is the shipped version of this shape: a formula function, settings, hooks, and exports, all in one small file. Read it next to this page. ## Where to next - [Studio](https://divinevtt.com/docs/guides/studio): the same work in the app, with lint, templates that write the code, and a harness that runs it sandboxed. - [Authoring character sheets](https://divinevtt.com/docs/guides/character-sheets): build a sheet with the declarative node vocabulary, no script required. - [Building a system](https://divinevtt.com/docs/guides/systems): when you want a whole ruleset rather than an addition to one. - [Scripting with the api](https://divinevtt.com/docs/guides/scripting): custom sheet nodes, panels, action sources, and the execution model. - [Automation patterns](https://divinevtt.com/docs/guides/automation): make a sheet do things, like auto-fields, click-to-roll, rest buttons, and class resolution. --- # Studio Studio is the in-app editor for systems and modules. It reads and writes the same pack format described everywhere else in these docs, so anything you build here can be exported as a zip and hand-edited, and anything hand-written can be imported and finished here. Open it from the hub rail at `/app/studio`. ## The library The library lists the systems and modules you have authored. Everything on it is CRUD over the pack format: - **New** scaffolds a starter pack. Pick a role. A module also picks which system it extends (or any). - **Import** reads a zip in the on-disk layout: `pack.json`, one `/.json` per facet, the script the manifest names, and any other [files the pack ships](https://divinevtt.com/docs/guides/packages#files-a-package-ships). The pack keeps the id its pack.json declares (see [Package ids](https://divinevtt.com/docs/guides/packages#package-ids)). - **Export** writes that zip, pretty-printed so it stays hand-editable. - **Duplicate** clones a pack under a new id. **Save to library** persists a private draft on the server. Nothing in the library publishes anything. A pack in your library is usable by your own worlds at once: pick it as a world's system, or enable it as a module in the world's Settings. Attribution is stamped by the editor: the creator's user id on first save, and an append-only trail of who else has saved it. A **locked** pack opens read-only for anyone but its creator. That is a courtesy for zips passed around; for packs stored on the server the owner check is real. ## The editor The editor fills the window and never scrolls the page. A **rail** down the left lists every destination in the pack, the **workspace** takes the rest, and an **inspector** column appears on the right while a sheet node is selected. Each of the three scrolls on its own. Across the top: undo and redo (`Ctrl+Z`, `Ctrl+Shift+Z`, a hundred steps deep), **Export .zip**, **Release update**, and **Save**. `Ctrl+S` saves too, wherever you are. Saving validates against the server first and shows the server's own sentence when something is wrong, so what Studio accepts is exactly what a world will load. The zip Export writes carries the manifest, the facets and the text files. It does not carry uploaded binaries; for those, save and use **Download** in the library, which rebuilds the pack with its sounds, images and fonts inside. ### The rail Seven groups, in order: | Group | What is in it | | --- | --- | | Sheets | Character sheets, Variables & connections | | Rules | Conditions, Turn order, Dice, Token vitals, Pools & resources, Skills, Money, Weapon properties, Prices, Measurement, Noticing things, Time skips | | Look | Theme, Calendar, GM screen cards, Token borders | | Content | Compendium | | Code | Script, Raw JSON, Test it, Settings | | Files | All files, and the pack's file tree | | Pack | Pack details | A dot beside a row means the pack defines that part. A pack is not required to define everything; a module usually defines one or two. Rows are ordinary buttons in reading order, so tab and screen readers get the structure for free, and the up and down arrows walk the whole rail including across group headings. Selecting a row that maps to one facet also gets you an **Edit as JSON** button in the workspace header, which opens the same facet in the raw editor. ## Character sheets Under the **Character sheets** row hangs one sub-row per sheet kind. Selecting one opens the tab strip, the node tree and the live preview side by side; the inspector appears as soon as you select a node. **Adding a sheet.** The `+` on the Character sheets row lists the core kinds the pack does not have yet (player character, NPC, item, spell) and then **New kind…**, which is how a system invents something the engine has no name for: a ship, a stronghold, a vehicle. That dialog asks for a name, a plural, a kind id, an icon from a curated lucide list, a one-line summary, and whether a GM can hand one to a player. The id follows the name until you type in the id box, and it is fixed once the sheet exists, because every entity, profile and compendium entry of that kind is stored under it. The dialog says so. **The kind's own menu** carries: - **Rename…** for a kind the pack declared. On an engine kind the item is there and disabled, saying the engine names that one. - **Duplicate…** copies the sheet into a new kind, through the same dialog pre-filled with " copy". Field ids and tab ids are not re-minted: a field id is a key inside one entity's profile, and two kinds never collide. For a module reading the system's sheet the item is disabled and says why: there is no module-owned sheet to copy. - **Delete this sheet**, behind a confirm naming what falls back to the generic sheet. The Character sheets row's own menu adds **Replace this sheet entirely** (see below) and **Discard unsaved changes**. **The tree.** Drag rows to reorder, or drop them into a row, column or grid. The `+` on a row adds a node inside a container or after a leaf; the palette is grouped by what a node is for, not by the system that first wanted it: Layout, Inputs, Derived, Trackers, Rows, From your rules, **Patterns**, Static. Every entry carries a sentence saying what the mechanism is. **Patterns** are whole subtrees, dropped in as one collapsible card you can move or delete in one go: "Level-driven progression", "Tiers you spend and refill", "Pools that a rest refills", "A pool of dice you spend", "Magazine and reserve", "Wound table", "Carried weight", and "Machine: parts, damage and compartments". Each is built only from engine node types with neutral placeholder labels, so nothing about your game is assumed. The automation half of each one is a script template in the code workbench. ### The palette belongs to the system What the palette offers is not one list for everybody. Everyone gets the universal nodes above - layout, fields, derived values, trackers, rows, static text and the patterns built from them. Beyond that you are offered whatever the packs in front of you declare: the system you are extending, anything it requires, and your own pack, in that order, so the pack you are editing has the last word on what a widget is called. That is why a fantasy system shows no machine boards. `systems`, `criticals` and `compartments` describe a machine - a ship, a mech, a stronghold - and the engine draws them for anyone, but the OFFER is 2d6 SciFi's, which is why opening 2d6 SciFi (or a module built on it) shows them as **Power**, **Critical hits** and **Compartments** under a **Ship** heading with starters that already look like a ship's. **More node types**, last in the palette, is everything else the engine can draw that nobody in front of you offers. Nothing is out of reach: a system being invented today can still start from a hit-location board. Taking one from there asks whether to add the declaration to the pack you are editing, and saying yes puts it in your palette from then on - and in the palette of anyone building a module on top of you. **Declaring node types yourself** is a `palette` facet, editable under Raw JSON until it has a form. Each entry is `{ type, label, hint?, icon?, group?, default? }`. `type` is an engine node type, or `self:` for one your own script registers with `api.register.sheetNodeType` - which is how a system puts a node type nobody else has into an author's palette: the script draws it, the declaration offers it. `default` is a starter node dropped in place of the engine's neutral seed, so an author begins from your shape of the widget. The server validates all of it at save, including the starter, as a sheet node. **The inspector** shows the selected node's property form. Any node takes a **Visible when** and a **Read-only when** formula, and the older equality guard (`visibleIf`) stays for the simple case. Every number that may be automatic has a small **fx** toggle beside it: off is a number, on is a formula. That covers a field's starting value, its min and max, a resource's starting current and max, and a dots field's count. A formula box checks itself as you type, completes over the sheet's own field ids and the pack's derived bindings, and its **Try it** button works the formula out against the preview's scratch profile so you see the number rather than trusting it. The full rules are in [Formulas on any field](https://divinevtt.com/docs/guides/character-sheets#formulas-on-any-field). ## Modules edit the system's sheet in place Open a module against a system and the sheet you see is the system's, composed with whatever your module has already added. Base nodes render greyed. Your own render normally. A base node you have overridden carries an **override** chip; a base node you have switched off carries **hidden** and keeps a "Show again". Every gesture writes an extension, never a fork: | What you do | What is stored | | --- | --- | | Add a node into a system tab | `insert` after the row above it, or `appendTo` when there is nothing to place it after | | Add a node into something your module added | inside that same block | | Edit a base node's properties | `patch`, holding only the keys that differ | | Delete a base node | `hide` | | Add a value to a base dropdown | `addOptions` | | Add a tab | `tabs`, appended after the system's, marked as yours | Field ids are namespaced to your pack for you. Base tabs cannot be deleted or reordered, and the disabled close button says why: a module adds to a sheet and never takes the system's parts away, so the sheet still makes sense when the system changes. The same rule stops you moving or duplicating a base node. The inspector on a base node lists every property you have overridden with the system's value beside it and a **reset** button per property, plus a whole-node reset. A base `select` or `badge` shows the system's values read-only with a "system" chip, and your own additions with a remove button, so widening somebody's dropdown never means copying their list. **Preview against.** A module whose `extends` is `*`, or names a system you do not have installed, has nothing to compose against. Pick a system in the **Preview against** box and its sheets appear. The pick lives in your browser and is never written into the pack: every edit still writes a portable extension keyed to real tab and field ids, so nothing is guessed. With no system picked you get a plain outline of what the module already changes. **Replace this sheet entirely** is the escape hatch, in the Character sheets menu, behind a confirm that states the cost: the module ships a whole copy from then on, and the fields, fixes and tabs the system adds later never reach your players. The kind's menu grows **Stop replacing, go back to extending** so the decision is reversible. ## Rules, Look and Content Each of the remaining parts is a form over one facet, with its own preview where one makes sense: the dice tray runs your modifiers through the same transform the real tray uses, the vitals editor draws a real token with the table's own HUD, measurement gives you a grid with a token to drag, and the theme editor applies your tokens to a sampler of real components without restyling the editor around it. Anything you added, you can drop. The **⋯** menu in the workspace header carries **Remove from this pack**, and the confirm names what goes with it, counted off the facet itself ("12 months, 7 weekdays, 2 moons"), rather than a vague warning. It is one undo step like any other. ### The Compendium The **Compendium** part authors `catalog` facets: the monsters, spells, items and rulebooks a GM can drop into a world. A pack may carry several groups, and each group is one facet, so moving an entry between groups moves it between facets. Groups are created, renamed and deleted from the Layers menu beside the group picker. **New entry** offers the eight core kinds plus every kind the pack, its base system, or a pack it requires has declared, so a 2d6 SciFi pack can create ships and a 5e pack creatures. An entry is edited **through its real sheet**: the same `DynamicSheet` a GM will see once the entry is in their world, bound to the entry's profile. Above the sheet sit the name, the key (derived from the name until you type over it), summary, tags, group, and image, plus prototype-token framing for `npc` and `pc`, a start and end time for an `event`, and a page list with a format for a `lore` entry that reads as a book. **Override an existing entry…** lists the entries of the packs this one extends or requires, and creates an entry that patches one instead of adding another. Only the sections you switch on are saved; everything else keeps following the original, and a button drops anything that has drifted back into agreement. See [Overriding another pack's entries](https://divinevtt.com/docs/guides/content#overriding-another-pack-s-entries). Entries can be duplicated, moved between groups, and tagged in bulk from a checkbox selection. Studio never materializes anything: what a GM ends up with in a world is the server's business. ### Token borders The **Token borders** part (under Look) manages the ring pictures a pack ships for a GM's border picker, the `tokenBorders` facet. **Adding.** Drop any number of pictures on the drop zone. With **Split sheets into separate borders** on (the default), a sheet of several rings, five in a row or even touching, becomes one border per ring. Each ring is found, the picture is re-centred on it, and its seat is measured: how far in the portrait stops, and how far spikes and glow overhang the token. A review lists every border with its seat drawn over a stand-in portrait, so you can name it, file it in a folder (for the whole batch or one at a time), tag it, and put it in a series with a tier. A picture with no transparent middle is flagged: it is kept as it is with the default seat. **Add** uploads each picture to `assets/borders//.webp` straight away and adds the borders to the list, which saves with the pack. A border's id comes from its name and is fixed from then on, because tokens store it. **Managing.** Folders and series sit in a rail beside the grid, each with a count; a folder can be renamed any time and deleted once it is empty, a series renamed or deleted (its borders just leave it). Search reaches names, folders, series and tags, the tag chips above the grid filter on click, and a checkbox selection can be moved, tagged, put in a series (numbered in the order shown) or deleted in one go. Picking a border opens its editor: name, folder, tags, series and tier, and two sliders, **Portrait** and **Art size**, with the seat drawn large and at map size while you drag. **Measure again** reads the stored picture for a border that came in without numbers. Deleting a border takes it out of the list at once and its picture off the server at the next save, so Ctrl+Z brings the border back whole until then. ## Pack details The manifest, in full. - **Name**, **Author**, **Version**, **Description**. - **How it is listed**: category and licence. - **What it layers on** (modules): the system this module extends or "Any system", and whether the GM turns it on for the table or each player turns it on for themselves. - **Packs it needs**: `requires` rows, each a pack and a version range. A pack you require also widens what your sheet extensions may name. - **What this pack may do**: a checkbox per permission with the description a GM will read. A pack is granted exactly what it declares and nothing else, so ask for the least that works. A local module is told which permissions it can never hold. - **Fixed**: the pack id and the script path, read-only, each with a sentence saying who owns it. The lock checkbox is here too. **Listing** shows where the pack stands: *Private to you*, *In review*, *Live in the marketplace*, or *Taken down*. One button submits it for review or pulls it back. **Listing text and art** opens the marketplace's own editor for the readme, the cover and the screenshots, so there is one upload path rather than two. **Released versions** lists every release with its date and notes. The last three carry a **Download** link, which is a normal pack zip of exactly what shipped; older rows read "Too old to download". A package over 8 MB of text keeps no copies at all and reads "Too large to keep a copy of" - the release is still listed, it just cannot be handed back. Only the owner sees those links. Pack lint runs over all of this and shows up in the code workbench's Problems panel and in Test it. It catches what the validators cannot: a script with no default export, a `mount()` or a chat card in a pack that will run sandboxed, a formula function registered as a JS function, a facet declared in the manifest with nothing in it, a semantic binding used in a formula that the pack never maps, and, the one that used to cost an evening, **an `api` call whose permission the manifest does not declare**, named method by method. ## Files The **Files** group in the rail holds the pack as the folder it is: `pack.json`, a row per facet, the entry script (marked "entry"), any other text files, and the binaries stored on the server. Clicking a row goes where that thing is edited, so a facet row opens its form (or the raw editor when it has no form) and a script row opens a tab in the code workbench. Right-click any row for **New script…**, **New folder…**, **Upload files…**, **Copy path**, **Rename…** and **Delete**. What cannot be renamed says why rather than disappearing: a binary is re-uploaded under the new name, and the entry script is named by the manifest. Drop files onto a folder in the tree to put them there. Text files (`.js`, `.mjs`, `.json`, `.md`, `.css`, `.txt`) join the draft, so they undo and save with the pack. Everything else uploads straight away and, unless you drop it somewhere else, lands in `assets/`. Selecting a binary previews it: images draw, audio plays, and everything gets **Copy path** and **Copy api.assets.url(…)**. The two are not the same string, and the panel says so: a facet wants the full path, and [`api.assets.url()`](https://divinevtt.com/docs/reference/api#api-assets) is rooted at `assets/` so it wants the rest. ## The code workbench The Script part is a code editor, not a text box. **Tabs.** One per open file, dragged to reorder, middle-clicked to close, with the close control doubling as the unsaved marker. The entry file is chipped. Open tabs are remembered per pack. **The `+`** opens the same dialog as *New script…* in the tree: an empty file, or one of the templates. The templates are the behaviours packs keep rebuilding: react to something, add a panel, add a sheet widget, add a formula function, add actions to a sheet, and the four automation halves of the sheet patterns (resolve a level into the sheet's numbers, grant actions from a table, spend a pool when it is rolled, answer damage over a threshold). Fill in two or three boxes and readable JavaScript is written into a new file or inserted into the one you have open. Applying a template also ticks the permissions it needs in the manifest. Nothing regenerates over your edits afterwards; the code is yours. **Completions** know the pack. Typing `api.` offers the real surface with each method's signature, its one-line doc, and the permission it needs. Inside `hooks.on("` you get the events the app actually sends. Inside `getField("`, `setField("` and `reads: [` you get the field ids of the sheet a player will resolve. Inside `from "./` you get this pack's own modules. `Ctrl+Space` opens the list at any time. **Navigation.** `Ctrl+click` or `F12` follows an import to that file, or a symbol to its top-level declaration anywhere in the pack. A breadcrumb above the editor says which file and which declaration you are in. **The command palette** is `Ctrl+Shift+P`, and lists every editing command with its shortcut: find and replace, go to line, multiple selections, comment toggle, move and duplicate lines, fold, reindent, tab size, word wrap. A command that cannot run right now is not in the list. **The bottom panel** holds Problems, Console, Outline and Harness as tabs. Drag its top edge to resize it, maximize it to read a long run, or hide it with `Ctrl+J`; clicking the open tab collapses it. The problems it lists are the same diagnostics that squiggle in the editor, so the gutter and the panel can never disagree, and clicking either jumps to the line. The scratch table opens as a split to the right when you want to watch what the code does to a sheet. **Under it all, one status line**: errors and warnings, Run and Stop, the file size against the 2 MB limit, the cursor position, the indent, the wrap, the language, and the commands button. `Ctrl+S` saves the pack. `Ctrl+Enter` runs it in the harness and opens the console to watch. Each file is highlighted and linted by its extension: JavaScript, JSON, Markdown, CSS, or plain text. ## Test it The harness runs the pack's script the way a player's browser will: sandboxed, in a real isolated frame, through the real permission gate. Only the world behind it is fake. - **Permissions are the manifest's.** The granted list is printed under the Run button, and a pack that declares none is told every privileged call will be refused. A **Grant everything** switch exists for diagnosis, and says that is what it is for. A refused call names the method and the permission it wanted, and points at the checkbox in Pack details. - **A scratch table.** Two entities of your first sheet kind and one of the second, seeded from your own sheet defaults, held in memory. `api.data` reads and writes hit those, with the same namespacing rule the server enforces. - **Events.** Fire any hook with an editable JSON payload, seeded from a sample, or roll a real dice expression and have the result arrive as `action.rolled`. - **What it registered** lists every registration as it happens, including the ones a sandboxed pack cannot mount yet. - **The stage** shows the results where they will actually live: a registered panel in the sidebar slot, contributed action groups, cards in a scratch chat, and the scratch entities' real sheets with your nodes on them. - **The console** is filterable, timestamped, and a line naming `file.js:12` opens that file at that line. - **Reload when the code changes** restarts the frame shortly after you stop typing, so the loop is edit, look, edit. Testing the trusted path would test a lie: a user pack never takes it in production, and code that only works trusted (a `mount()`, a function-valued formula) would quietly pass. ## Raw JSON Every facet is editable as the file it is saved as. Pick one from the bar, or land here from a form's **Edit as JSON**, or from a facet row in the file tree that has no form yet. **New facet** offers every kind the format defines and the server validates. Editing is checked in two stages when you click away, not on every keystroke: the client says whether it is a JSON object with an `id`, and then the server's own validator says whether it is a facet, in its own words. Reading and writing are both plain `JSON.parse`, so unknown keys round-trip exactly, and changing a facet's `id` in the text rewrites the manifest's declaration to follow it. ## From Studio to a table 1. Save. The pack is in your library. 2. For a system: create a world and choose it under "Another system", or change an existing world's system in its Modules section. 3. For a module: open the world's Modules section, add it to the enabled modules, and order it. The GM sees the permissions the manifest declares and confirms them before the module runs. Toggling applies live. 4. A campaign's System tab can override the module list and settings for that table only. 5. To share: Export a zip, or publish to the marketplace. ## Publishing Publishing sends a library pack to the marketplace's review queue. It shows as pending until an admin approves it; only approved packs appear in the free catalog and can be installed by anyone. You can withdraw a pending pack. The marketplace's uploads view accepts a zip directly for declarative packs, and the same review applies. Listings can carry a cover and screenshots uploaded through the pack's assets. Ratings and reviews come from users who own the pack. Permissions and scope on the manifest are what a GM sees on the listing and in the consent dialog, so declare exactly what the script uses. See [Permissions and the sandbox](https://divinevtt.com/docs/guides/sandbox). ## Releasing an update, and going back Releasing a new version replaces a pack's content **in place**: the id stays the same, so every world that enabled it keeps it and picks up the new build at once. **Release update** in the top bar saves first and then asks for the version and the notes; the version box is pre-filled and refuses one that was already released, so no two releases share a label. Your notes become the changelog entry on the listing. Because a release overwrites, the previous build would otherwise be gone. Each release therefore keeps a copy of what it shipped, and the version history in Pack details offers **Download** for the last three. Older releases stay listed with their notes and read "Too old to download", and a package too big to store copies of reads "Too large to keep a copy of". The download is a normal pack zip: unzip it, or import it as a new library pack to compare against what you have now. Only the pack's author sees those links. --- # Building a system A **system** is the ruleset a world runs on. A world has exactly one. It is picked when the world is created (a built-in, or one of your own from [Studio](https://divinevtt.com/docs/guides/studio)) and can be changed later in the world's Settings. Campaigns inside the world may override the module list for their own table, but the system is the world's. A system is a package like any other: a `pack.json` with `"role": "system"` and a set of facet files. Everything the engine knows about your rules comes from those facets. The engine itself has no opinion about hit points, ability scores, initiative, money, or what a hidden thing takes to notice. Where a facet is absent, the engine either falls back to a plain default or leaves that part of the table switched off. ## What a system is made of Every facet is one JSON file at `/.json`, declared in the manifest's `facets` map. The server accepts these kinds: | Group | Kinds | Documented in | | --- | --- | --- | | Sheets and data | `characterSheet`, `sheetExtensions`, `bindings`, `skills`, `tracks` | [Character sheets](https://divinevtt.com/docs/guides/character-sheets), this page, [Rules facets](https://divinevtt.com/docs/guides/rules) | | Rules of play | `conditions`, `dice`, `turnTracker`, `vitals`, `measurement`, `detection`, `currency`, `economy`, `realism`, `properties`, `regionSystems` | [Rules facets](https://divinevtt.com/docs/guides/rules) | | Content | `catalog`, `dropTables`, `lootTables`, `gmScreen`, `sounds` | [Shipping content](https://divinevtt.com/docs/guides/content) | | World and look | `calendar`, `timeUnits`, `theme`, `astrography` | [Rules facets](https://divinevtt.com/docs/guides/rules) | The exact fields of every facet are on the generated [Facet schemas](https://divinevtt.com/docs/reference/facets) page. The merge rule each kind follows when a world resolves its pack chain is in the [Packages guide](https://divinevtt.com/docs/guides/packages#facets). `tags` is reserved in the manifest vocabulary but has no validator yet, so a pack that declares it is rejected. Leave it out. ## The smallest system A playable system is a sheet for player characters and a bindings facet so modules can find your fields. Nothing else is required. ```jsonc // my-system/pack.json { "schemaVersion": 1, "id": "my-system", "version": "1.0.0", "displayName": "My System", "role": "system", "api": 1, "facets": { "characterSheet": ["pc"], "bindings": ["core"] } } ``` ```jsonc // my-system/characterSheet/pc.json { "id": "pc", "displayName": "Character", "entityKind": "pc", "version": "1.0.0", "tabs": [{ "id": "core", "label": "Core", "body": [ { "type": "grid", "columns": 2, "variant": "card", "children": [ { "type": "field", "id": "body", "fieldType": "number", "label": "Body", "default": 2 }, { "type": "field", "id": "mind", "fieldType": "number", "label": "Mind", "default": 2 } ]}, { "type": "resource", "label": "Health", "currentField": "hp", "maxField": "hp_max", "tone": "hp" } ]}] } ``` ```jsonc // my-system/bindings/core.json { "id": "core", "bindings": { "hp": "hp" } } ``` What you get with only that: - Sheets for `pc`. Every other kind (`npc`, `item`, `spell`, ...) renders the engine's generic sheet for that kind until you ship one. - Tokens draw **no bars**. A system that declares no `vitals` bars gets none; the engine no longer invents an HP bar, because not every ruleset has hit points. - A basic **rounds** initiative tracker rolling `1d20` plus a token field named `initiative`. - A drag ruler that counts every square as one, with no unit name. - No money, no skill tree, no tracked pools, no detection checks, no wound table, no star charts. Each of those appears the moment its facet does. Add facets one at a time and watch the table grow the matching control. The [Rules facets](https://divinevtt.com/docs/guides/rules) page walks every kind in the order most systems need them. ## Entity kinds and sheets The engine stores these kinds: `pc`, `npc`, `item`, `spell`, `quest`, `faction`, `region`, `event`, `lore`. A `characterSheet` facet is scoped to one `entityKind`, and a system ships one per kind it cares about. The last package in the chain with a template for a kind wins outright, so a module that wants a different NPC sheet replaces the system's rather than patching it. Use `sheetExtensions` to add to a sheet instead. A faction has no tab of its own, so it is made in the Codex, like lore and quests. It carries a sheet when a package in the chain ships one for `faction`, and stays a plain article when none does. ### Kinds the engine has no name for A system that needs ships, strongholds, warbands or fleets does not wait for the engine to learn those words. Set `declaresKind` on the sheet for a kind of your own: ```jsonc { "id": "ship", "displayName": "Ship", "entityKind": "ship", "version": "1.0.0", "declaresKind": { "label": "Ship", "plural": "Ships", "icon": "rocket", "assignable": true, "summary": "Hulls the crew can board, fly and break." }, "tabs": [ /* ... */ ] } ``` The world editor grows a Ships section, entities of that kind get your sheet, and when `assignable` is true a GM can grant one to players the way a character is granted. The engine's own kinds ignore `declaresKind`. Which deck plan a ship is connected to at a table is campaign state on the roster, not a sheet field; see the machine-board nodes in [Character sheets](https://divinevtt.com/docs/guides/character-sheets). ## Bindings: the vocabulary modules rely on Modules built for any system (`extends: "*"`) do not know your field ids. They ask for a **semantic key** and your `bindings` facet answers with a field id: ```jsonc { "id": "core", "bindings": { "hp": "hp", "defense": "ac", "speed": "speed", "level": "level", "inventory": "inventory", "currency": "purse", "size": "size", "maxStack": "max_stack" }, "labels": { "defense": "AC", "level": "Level" }, "sizes": { "tiny": 0.5, "small": 1, "medium": 1, "large": 2, "huge": 3, "gargantuan": 4 }, "derived": { "dex_mod": "floor((dex - 10) / 2)", "prof": "2 + floor((level - 1) / 4)" } } ``` - `bindings` maps a well-known key to one of YOUR field ids, so a module that knows nothing about the system can still find its inventory or its defence number. The documented keys are `inventory`, `hp`, `defense`, `speed`, `level`, `currency`, `size`, `maxStack`, and `languages` (the sheet's languages line, read by the [languages facet](https://divinevtt.com/docs/guides/rules#languages)); the built-in systems also bind `drops`, `attacks` and `spells` (node ids the action seam targets). Unbound keys resolve to `undefined` and consumers degrade. `ac` is accepted as a deprecated alias of `defense` when reading, never when writing new packs. - `labels` is what engine copy should CALL a bound concept where its own UI must name one: "AC" where the engine says defence, "CR" where it says level. - `sizes` maps the values of the field bound to `size` onto a token footprint in grid squares, so how big a creature's token is is the system's rule rather than a knob a GM turns per token. - `derived` declares **virtual fields**: a formula the engine evaluates wherever the name appears, so your content can say `prof + dex_mod` instead of restating the arithmetic in every sheet node. ### Keys of your own The key vocabulary is open: the validator takes any key that is a non-empty string, and Studio's **Variables & connections** panel offers the well-known keys first (with what each is for, and which of them you have not answered yet) and an **Add your own binding** below them. Use a well-known key wherever one fits - a shared id is the entire point, since two systems that both mean "the inventory" have to both answer to `inventory` or a module written for either finds neither. A key you invent is for THIS pack's own modules, which read it back with `api.system.binding("myKey")`; no stranger's module knows to ask for it. The same panel edits all four maps above, one explanation beside each. Action sources contributed by modules target the `attacks` and `spells` slots; the host maps a slot to the sheet's actions node through this same resolution. ## Rules the engine reads directly A few things are read by the engine itself rather than by a module. Declare them in the system so the table behaves: - `vitals` says what a token shows: bars, badges, hurt states, and where damage spills when the primary track is empty. - `turnTracker` picks the combat model and what initiative rolls. - `detection` names the noticing seam, so stashes and hidden bodies reveal by the system's own check. - `measurement` sets the diagonal rule, the unit, and range bands. - `currency` defines what money is; `economy` says what it is worth here. - `realism` renames and tunes the armour, wear, encumbrance and wound model. - `conditions` are what actions, wounds and the tracker apply by id. Each is covered on the [Rules facets](https://divinevtt.com/docs/guides/rules) page. ## Settings a GM can turn `manifest.settings` is an array of sheet field nodes. The world's Modules screen renders one form per pack, and a campaign's Modules tab lets a table override them. A script reads them with `api.settings.get`, and at a table it may set one for that table alone with `api.settings.set` (api 1.4; the table's GM only). Two keys are also read by the engine on any pack in the chain, which is how a table dials a rule without editing a module: | Setting key | Read by | Meaning | | --- | --- | --- | | `priceScale` | the economy | Multiplier on every numeric price (0 to 100). | | `npcDropDurability` | realism | How intact looted NPC gear is, 0 to 1. | Studio's Settings panel edits the schema and previews it with the same control the GM will see. ## How a world resolves your system with modules A world builds a **pack chain**: the system, then the enabled modules in the GM's order. Each facet kind merges its own way, and the rules are the same for a system and a module: a module is a later voice in the same conversation. A campaign may swap the module set for its own table (and carry its own settings values), which is why some resolvers take a campaign id. The full merge table is in the [Packages guide](https://divinevtt.com/docs/guides/packages#facets). ## From your machine to a world - **Studio** saves a system into your library. Create a world, choose "Another system", and pick it. Editing the pack later updates every world on it. - **Export** writes the same files as a zip (`pack.json`, one file per facet, the script). Anyone can import that zip in their own Studio. - **Marketplace**: publish from Studio or the marketplace's uploads view. The pack sits as pending until an admin approves it, then anyone can install it. ## Worked examples - `builtin:dnd5e-forgotten-realms`: a full system. Calendar, sheets for every kind, conditions, bindings, time skips, dice, a rounds tracker, measurement, detection, GM screen cards, currency. - `builtin:scifi2d6`: a full system with skills on a seven-rung ladder, characteristics as the wound track with a cascade, armour as one plain value, ship sounds, a catalog, star charts and a theme. Nine modules that are on by default add the rest: the equipment and creature compendiums, attachments, ammunition, the coverage and encumbrance model, the wound table, ports and salvage, ship operations, and the shots drawn on the map. - A dice-pool system needs no script at all. Its rolls are pools read by outcome tables in the dice facet (see [Dice](https://divinevtt.com/docs/guides/rules#dice)), its sheets use clocks and checkboxes counted as 1 or 0, and a group the players run together, a crew or a ship, is a kind the pack declares. The [built-in packs](https://divinevtt.com/docs/examples/builtins) page lists every shipped pack with the facets it uses. --- # Importing battlemaps A prepared battlemap from another tool can come in with its walls, doors, lights and grid already placed, instead of being traced again by hand. ## Where to find it - **New region from a file**: in the world editor, open **Maps & regions** and choose **Import battlemap**. It makes a new battlemap region named after the scene and opens it. - **Into the region you are editing**: in the region editor, open Region settings, then the **Map** tab, and choose **Import a battlemap file…**. You can also drop the file straight onto the map. The dialog shows what the file holds (walls, windows, doors, lights, grid) before anything changes, and lets you leave out the walls, the lights or the grid. ## Supported files | File | Made by | Picture | | --- | --- | --- | | `.dd2vtt` | Dungeondraft (format 0.2 and 0.3), Dungeon Alchemist | inside the file | | `.uvtt` | Arkenforge and other Universal VTT exporters | inside the file | | `.df2vtt` | DungeonFog | inside the file | | `.json` | a Foundry VTT scene saved with **Export Data** (v10 to v13; older scenes work too) | a separate file you add | A Foundry scene names its background picture but does not carry it. Drop the picture into the dialog with the scene (or choose it when asked). A picture of the same name is picked up by itself. If the picture is a different size from the scene, say a 2x export, everything is scaled to fit it. ## What comes across - **Walls** keep their movement, sight and light blocking. Foundry terrain walls become terrain walls, and walls you can walk through stay walkable. - **Windows** become window walls: sight and light pass, movement does not. In Universal VTT files these are the open portals Dungeondraft writes. - **Object outlines** in a Universal VTT file (pillars, trees, statues) block sight and light but not movement. - **Doors** become real doors, hinged on one end of the doorway. Foundry doors keep their state: open doors start open, locked doors start locked, and secret doors stay hidden from players until you reveal them. - **Lights** keep their position, reach, colour and brightness. A Foundry cone becomes a spotlight aimed the same way. - **Grid** size and position are set to match the picture, with the unit and distance per cell when the file gives them. Universal VTT files do not, so the world's system decides (five-foot squares in a D&D world). ## What does not - Foundry tokens, tiles, drawings, journal pins, sounds and the foreground overlay are not imported. The dialog lists what was left behind. - Lights here have one reach, so Foundry's bright and dim radii become one light that reaches as far as the dim light did. Darkness sources and switched-off lights are left out. - One-way walls and Foundry proximity walls block from every side and at every distance. - A map with its lighting baked into the picture may look doubly lit until you adjust or remove its lights. - Hex grids from Foundry come across with the right cell size; check the grid lines sit on the picture with the Grid tool. ## Replacing and undoing Importing into a region that already has walls, doors or lights replaces them, and the dialog says how many will go. Chests, stashes, teleporters and sound emitters stay. The whole import is one step in the region editor's history, so a single Ctrl+Z puts the old walls, doors, lights and grid back. The map picture is not part of the history; upload the old one again if you replaced it. ## Size limits Pictures go through the ordinary map upload, so the same limits apply as for any map, and big pictures are tiled in the background the same way. A Universal VTT file with a 50 MB picture inside is fine. ## Going the other way To send a map out to Foundry or to a tool that reads Universal VTT files, see [Exporting maps](https://divinevtt.com/docs/guides/exporting-maps). --- # Exporting and importing worlds and maps A world, or one map of it, can be saved as a single file and brought back in, on the same server or another one. Nothing is lost on the way: walls, doors, lights, painted effects, sheets and the library all come across as they were. | File | Holds | Made by | Imported from | | --- | --- | --- | --- | | `.dvworld` | a whole world | world **Settings**, **Export** | the dashboard, **Import world** | | `.dvmap` | one map, its building, optionally its nested maps | **Maps & regions**, a map's right-click menu (or **Export .dvmap** on the entry map); in the region editor, Region settings, **Map**, **Export…** | **Maps & regions**, **Import map** | Only someone who can edit a world (its owner or a co-author) can export it or import a map into it. An imported world is a new world of yours; the original is never touched. ## What a world file carries - **Maps**: every region with its original picture (and night picture), grid, walls, windows, doors, chests and stashes with their loot, teleporters, sound emitters, lights, painted effects, particles, post-processing, fog and weather settings, buildings with all their floors and states, and where each map sits on its parent. - **The Codex**: every entry of every kind, with its sheet, portrait, token framing, tags and relations; Codex links and @mentions keep pointing at the right entries. - **The library**: books and scrolls with their pages, drawings and pictures, and the language each is written in. - **The rest of the world**: calendars, the timeline (world canon), timeline tags, custom languages, custom token borders, NPC groups, the sound board and the sounds it uses, and the world's module list and settings. - **Tables, if you ask**: with **Include tables (campaigns)** on, the tables you run come too: tokens on every map, door and chest states, encounters, scheduled weather, quests, memories, markers, ink, overrides, the party's track and the gameplay journal. ## What never leaves the server - Accounts, email addresses and sign-ins. Players are listed by name only, so you know whom to invite again. - Chat, including whispers and private rolls, and anything only one player was meant to see: private notes on creatures, personal notebooks, each player's own fog memory, private journal entries. - Who plays which character. Assign characters again after inviting players. - Tables other GMs run in your world, even with **Include tables** on. - Packs. The file names the system and modules the world uses (with their versions) but does not contain them. Importing on a server that lacks one lists it; the world still opens and behaves as it does when a pack is switched off. Install the pack to get its rules and content back. ## Importing a map A map arrives as a new map (with its floors and nested maps, if they were exported). It is not placed on any of your maps; drag it where it belongs. NPCs and items the map points at, such as a chest's loot, a door's key or the map's Connected NPCs, come with it: importing into the world the map came from uses the existing entries, importing elsewhere reuses an entry added from the same compendium, and otherwise makes a copy. Tags join your world's tags by name. ## Pictures and storage Map pictures are stored and then prepared in the background exactly like an upload, so a big world's maps show at full size first and get their smaller versions and zoom tiles within minutes. The import counts against the storage of the world's owner, and one that would not fit is refused before anything is written, with the space it needs. ## The file itself A `.dvworld` or `.dvmap` is an ordinary zip: - `manifest.json`, always first: the format (`"divinevtt"`), the kind (`map` or `world`), the format `version`, the exporting app version and time, the source world (id, name, system, packs with versions), row counts, and the list of documents and files. - `tables/.json`: one document per database table, each with its own `version`, holding the rows as the database stores them. - `assets/.`: every stored file once, named by its content, so a picture used by two maps is carried once. Inside a document a stored file is written as `dvasset:`, where the key is where the file lived on the exporting server; the manifest says which `assets/` file each key is. On import every id (maps, entries, doors, sounds, tables) gets a new one, applied everywhere the old one appears, so the same file can be imported many times into the same server. A file made by a newer DivineVTT than the server is refused with a message saying so; update the server, or export again from one running the same version. A damaged or cut-short file is refused before anything changes. --- # Exporting maps A map made or finished here can go out to another tool with its walls, doors, lights and grid, in the same formats the [battlemap import](https://divinevtt.com/docs/guides/importing-battlemaps) reads. ## Where to find it In the region editor, open Region settings, then the **Map** tab, and choose **Export…**. Pick a format; the dialog shows what goes out, what changes on the way, and what the format has no room for, before anything downloads. | Choice | File | Picture | | --- | --- | --- | | DivineVTT map (.dvmap) | our own archive: everything on the map, for another world here | inside the file, untouched | | DivineVTT map with nested maps (.dvmap) | the same, with every map placed inside this one | inside the file, untouched | | Universal VTT (.dd2vtt) | one `.dd2vtt` file, the Dungeondraft format | inside the file | | Universal VTT (.uvtt) | the same file with the `.uvtt` extension some tools look for | inside the file | | Foundry VTT scene + picture (.zip) | a zip with the scene `.json` and the picture | in the zip, untouched | | Foundry VTT scene JSON only | the scene `.json` | not included; the scene names it | A `.dvmap` downloads straight away, with no dialog: nothing is lost on the way, so there is nothing to warn about. It keeps the night map, effects, chests, sounds and the other floors of a building too. Bring it into any world with **Maps & regions**, **Import map**; see [DivineVTT files](https://divinevtt.com/docs/guides/divinevtt-files). The other formats are for other tools. The picture is always the original you uploaded, not a smaller copy. A Universal VTT file keeps a PNG, WebP or JPEG as it is; an AVIF or GIF is turned into a PNG, since Universal VTT readers do not take those. A Foundry zip keeps the picture exactly as uploaded. ## Loading it in Foundry VTT 1. Unzip the download. It holds `your-map.json` and `your-map.png` (or `.webp`, `.jpg`). 2. Upload the picture to the top level of your Foundry User Data folder (Foundry's file browser, **Upload**, with the root folder open). The scene looks for it there by name. 3. In the **Scenes** sidebar, create a scene (any name), right-click it and choose **Import Data**. Pick `your-map.json` and confirm. The scene takes the name, size, grid, walls, doors and lights from the file. 4. If you put the picture somewhere else, open the scene's configuration and point **Background Image** at it. The walls do not move. The scene is written in the Foundry v12 shape, which v13 also imports. ## Loading a Universal VTT file Tools that read Universal VTT files open it directly. In Foundry that is the **Universal Battlemap Importer** module: create a scene with it and choose the file. It also comes back into this app through **Import a battlemap file…**. ## What goes out - **Walls** keep their movement, sight and light blocking. In Foundry every combination has a match (terrain walls included); sound blocking follows how much the wall damps sound here. - **Windows** go out as windows: open portals in a Universal VTT file (the way Dungeondraft writes them), walls that let sight and light through in Foundry. - **Walls you can walk through** that block sight (force walls, pillars) go out as object outlines in a Universal VTT file. - **Doors** go out hinged where they are. Foundry keeps their state: open, locked and secret doors stay that way, and glass doors let sight through. - **Lights** keep their position, reach, colour and brightness. Lights here have one reach; in Foundry it becomes the dim radius, and the bright radius is set to half of it. Spotlights keep their cone and aim in Foundry, torches flicker as Foundry torches, and old lamps become Foundry's flickering light. - **Grid** size and position go out. Foundry also gets the unit and the distance per cell. A Foundry hex grid is written as odd rows (pointy top) or odd columns (flat top), which is how this app lays hexes out. ## What changes on the way - A Universal VTT file has only plain closed doors: open, locked and secret doors go out closed and visible, and glass doors go out as ordinary doors. - A Universal VTT file has no spotlights or flicker: every light goes out round and steady. It also has only square cells, so a hex grid goes out as squares of the same size. - Terrain walls go out to a Universal VTT file as object outlines, which block sight fully. Bars go out as windows in both formats. - Walls that block from one side only block from both. - Foundry needs a whole number of pixels per cell, at least 50. A smaller or fractional grid is exported as a bigger scene (the picture stretches to fit, so everything stays on it). Brought back here, it lines up within a pixel. - A grid that does not start at the picture's corner is carried in Foundry with a background offset and a thin padding border, and in a Universal VTT file through the map origin. Tools other than this one and Foundry often assume the picture starts on a grid line. - A Foundry door is open or locked, not both; a door that is both goes out open. Door keys and sliding doors have no Foundry equivalent: they go out as ordinary doors. ## What is not exported Neither format has room for chests, stashes, teleporters, sound emitters, painted effects, particle effects, post-processing, the night map, or the other floors of a building. The dialog lists what the map has of these ("Not exported: 3 particle effects, 1 chest."). Tokens are part of a campaign, not the map, so they never go out. ## Size A big picture exports fine: it is streamed from storage, never loaded whole. Inside a Universal VTT file the picture is stored as text, which makes the file about a third bigger than the picture, and some tools refuse pictures over 16384 px on a side; the dialog warns when a map is that big. A Foundry zip cannot hold a picture over 4 GB; export the scene JSON alone and copy the picture across yourself. --- # Packages & the pack chain A package is a folder with a `pack.json` manifest, one JSON file per facet, and an optional script. A **system** is a world's base ruleset (one per world). A **module** adds capability (many per world). Both use the same format and differ by `role`. ## The layout ``` my-pack/ pack.json the manifest characterSheet/pc.json one file per facet: /.json conditions/core.json bindings/core.json main.js the script the manifest names, if any lib/tables.js more script files, imported by the entry assets/thud.ogg anything else the package ships ``` A zip of that folder is what Studio imports and exports and what the marketplace accepts. Built-ins live on disk in the same shape. ## Files a package ships Everything that is not `pack.json` and not a `/.json` is a **file**: helper modules the entry script imports, and the sounds, images, fonts and PDFs a facet or `api.assets.url()` points at. The manifest and the facets are refused as files by name, because a package with two disagreeing copies of its own manifest is worse than one that cannot be saved. | Kind | Extensions | Cap | Where it lives | | --- | --- | --- | --- | | Text | `js`, `mjs`, `json`, `md`, `css`, `txt` | 2 MB each | Stored with the package and edited in Studio | | Binary | `ogg`, `mp3`, `wav`, `m4a`, `flac`, `webm`, `png`, `jpg`, `jpeg`, `webp`, `gif`, `avif`, `woff`, `woff2`, `ttf`, `otf`, `pdf` | 25 MB each | Uploaded, and served back byte for byte | A thousand files per package, so a bestiary can carry art for every creature. Binary files do NOT count against the package's own size budget (24 MB of manifest, facets and text): they live in object storage and a package only references them. There is deliberately no `svg`, `html` or `xml`: those run script when a browser opens them from our own origin, and unlike a listing's cover art (which is re-encoded) a package file is served exactly as it was uploaded. Paths are archive-relative with forward slashes, at most 180 characters and eight folders deep. Each name starts with a letter or digit and then takes letters, digits, `.`, `_` and `-`, which is what stops a path climbing out of the package. Every rejection is a sentence naming the path, and the same check runs in the zip reader and on the server, so a path cannot be accepted by one and refused by the other. `assets/` is the folder [`api.assets.url()`](https://divinevtt.com/docs/reference/api#api-assets) is rooted at, and `/api/packs//assets/` serves it to anyone who can see the package. The `.js` and `.mjs` files are the ones the entry script may [import](https://divinevtt.com/docs/guides/scripting#more-than-one-file); the rest are content. Studio's Files section is the tree, the uploader and the previewer for all of it, and every file rides in the zip both ways. ### Storage and publishing A package's binary files and its listing images share one ceiling, 1 GB by default; the moderators can raise it for a package that needs more, such as a large art or map pack. Whose storage they use depends on where the package is: - **Private, or waiting for review:** the files count against your plan, like any other upload. - **Published (approved):** they are the platform's, free or paid. - **Unpublished again:** they stay the platform's for 30 days, because worlds that got the package still load them, and then count against you again. Importing a zip, in Studio's library or the marketplace's upload box, creates the package first, under the id its pack.json declares (see [Package ids](#package-ids)), and then uploads its files a few at a time. Files that do not arrive can be retried on their own, and a release from a zip skips files the package already holds unchanged and removes stored files the new zip dropped. To go to review, a listing needs a cover or a screenshot and a description of at least 20 characters (the package's description or its listing text). A moderator who sends a package back or takes it down leaves a note, which you see on your creator dashboard, the listing and in Studio. ## The manifest ```jsonc // pack.json { "schemaVersion": 1, // required, must be exactly 1 "id": "my-homebrew", // required, unique, kept on import (see Package ids). "builtin:" is reserved "version": "1.0.0", // required, content version (semver, you own it) "displayName": "My Homebrew", // required "description": "One paragraph for the listing.", "category": "Automation", // free text for browsing "role": "system", // "system" | "module" (default "system") "api": 1, // extension API this package needs: 1, or "1.4" for a minor (see Api versions) "author": "You", // optional "license": "MIT", // optional "extends": ["builtin:dnd5e-forgotten-realms"], // modules only; "*" or omitted = any system "requires": { "some-lib": "^1.0.0" }, // load-order + dependency "script": "./main.js", // optional ES-module entry (relative; no ".." or leading "/") "requiresScript": false, // true = package is inert without its script "permissions": ["read-world", "ui"], // what the script may do; see the sandbox guide "scope": "shared", // "shared" (default) | "local" (each player enables it for themselves) "locked": false, // opens read-only in Studio for anyone but the creator "settings": [ /* sheet field nodes, rendered by the sheet field renderer */ ], "facets": { // which facet ids this package ships, by kind "characterSheet": ["pc"], "conditions": ["core"], "bindings": ["core"] } } ``` Studio stamps two more fields: `createdById` (the creator's user id) and `editedBy` (an append-only trail of who else has saved the pack). Leave them alone in a hand-written pack. What the server enforces when a package is installed: - `schemaVersion` must be exactly `1`. `id`, `version`, and `displayName` must be non-empty. Missing any of these rejects the package. - `api` may be an integer major (`1`) or a `"major.minor"` string (`"1.3"`). A server whose api is older than the one declared refuses the package and says to update the server. A package written for an earlier major is upgraded when it lands (see [Api versions](#api-versions) below). - `script` must be a relative path with no `..` and no leading `/`, and the file must exist in the zip. - Only the facet kinds listed in `facets` are loaded, and each listed id must have its file. An unknown kind rejects the package. An unknown kind on a newer package running on an older host is skipped with a warning. - Unknown `permissions` are dropped, not fatal. A `local` module keeps only the permissions a local module may hold. Settings defaults live per field (the `default` on each settings field node). There is no separate defaults map. ### Package ids A zip you import keeps the `id` in its pack.json. That id is what everything else refers to: another package's `extends` and `requires`, and the namespace your own field ids carry (`my-module` namespaces its fields `my_module:`). So a module and the system it builds on can be written, zipped and imported as a pair, and the module's references still point at the system. - A kept id is 3 to 63 characters of lowercase letters, digits and dashes, starting and ending with a letter or a digit (`heist-rules`, `fitd-crews`). - An id that does not fit that (too short, say) is replaced with a generated one, the way a package made in Studio gets one. A module whose fields are namespaced with the id it declared will then fail to import, so pick an id that fits. - `builtin:` is reserved for packages that ship with the app. A zip that uses it is refused. - An id that is already in use is refused. If it is your own package, the zip is a new version of it: release an update to that package (Studio or your creator dashboard) rather than importing it again, so every world using it keeps it. If someone else has it, change the `id` and import again. - Ids are first come, first served on a server, and an id never changes once the package exists: a release keeps it, whatever the new zip's pack.json says. Packages made or duplicated in Studio get a generated id rather than the one in the draft, because they are copies and must not take the original's place. ## Facets A facet is a declarative slice of content. Each kind has its own schema (see [Facet schemas](https://divinevtt.com/docs/reference/facets)) and its own merge rule. A world resolves facets across its **pack chain**: the system first, then the enabled modules in the GM's order. A campaign may override the module list for its own table, and a few resolvers (realism, economy, properties, drop, loot and roll tables, theme) read that campaign chain when one is open. | Facet | Purpose | Merge rule | | --- | --- | --- | | `characterSheet` | Per-`entityKind` sheet template | Last package with a template for that kind wins (whole-template replacement) | | `sheetExtensions` | Add to, override and hide parts of the winning template | In chain order: `appendTo`, `insert`, `tabs`, `addOptions`, `patch`, `hide`. Structure is append-only; a field id the sheet already has is skipped, and `patch` wins per key | | `bindings` | Semantic key to field id map, labels, sizes, derived fields | Shallow-merged; later wins per key | | `skills` | Skill tree, ladder, training | Skills union by id; `training` merges per key; the rest is the last facet's | | `tracks` | Tracked pools (resources, injuries) | Union by id | | `conditions` | Status conditions and their turn automation | Union by id; later wins on the same id | | `dice` | Quick rolls, modifiers, tray presentation | Entries append; same id replaces | | `turnTracker` | Combat model and initiative | Last wins | | `turnTrackerLayout` | How the table shows a fight: the carousel strip across the top of the map, the marker on the active token, the "your turn" alert (see [Turn order layout](https://divinevtt.com/docs/guides/rules#turn-order-layout)) | Last wins, on the campaign chain at a table. Options it names in `settings` are read from the declaring package's own settings | | `vitals` | Token bars, badges, states, cascade | Bars, badges, states union by id; cascade and audience last-wins | | `measurement` | Diagonal rule, units, range bands | Last wins | | `detection` | Passive and active noticing, scans | Last wins | | `currency` | Denominations | Last wins as a whole set | | `economy` | Price scale | Last scale wins; a GM setting overrides | | `realism` | Zones, materials, labels, tuning, encumbrance, wounds | Labels and tuning merge per key; zones and materials replace wholesale; wounds and the rest last-wins | | `properties` | Weapon property vocabulary | Union by id | | `regionSystems` | Region switches | Last wins | | `sounds` | Sound library and hooks | Libraries accumulate (keyed by pack and id) | | `tokenFx` | What an attack or a spell dropped on a token looks like on the map | Effects union by id, later wins; rules concatenate with the later package's first, since the first rule that matches wins | | `palette` | The node types Studio offers a sheet author (see [Who is offered which nodes](https://divinevtt.com/docs/guides/character-sheets#who-is-offered-which-nodes)) | Read in Studio only: the edited package's, its system's and its requirements' declarations merge, the package in hand last | | `tokenBorders` | Ring images a GM can put around a token, with folders, series and tags for the picker | Accumulate; each border is keyed by pack and id, so two modules never collide | | `calendar` | Calendar definition | Last wins | | `timeUnits` | Quick time-skips, units | Last wins | | `theme` | Design-token overrides, fonts, skins | Tokens shallow-merge; fonts union by family; skins last-wins | | `astrography` | Star-chart vocabulary | Last wins | | `catalog` | Predefined content | Bundles accumulate; an entry with `overrides` patches an earlier entry | | `dropTables` | Loot by creature tag | Union by table id | | `lootTables` | Container fills | Tables union by id; qualities from the last facet that declares any | | `rollTables` | Random tables (name lists, encounters), read by scripts through `api.tables` | Union by table id; a later package's table replaces an earlier one in place. Read on the campaign chain at a table | | `languages` | The languages a world knows; a library volume can be written in one (see [Languages](https://divinevtt.com/docs/guides/rules#languages)) | Union by id; a later package's language replaces an earlier one in place. Read on the campaign chain at a table | | `gmScreen` | GM starter reference cards | Union by card id | | `tags` | The tag vocabulary a setting is filed under | Validated and stored per pack; nothing resolves the chain yet, so a world still builds its own tag library | A few rules worth knowing: - `conditions` ids are append-only. Deprecate by changing a `label`, never by deleting or renaming an `id`, because stored actions reference conditions by id. - `sheetExtensions` never move or reparent existing nodes. They add nodes (`appendTo`, `insert`), add tabs (`tabs`), widen a closed option list (`addOptions`), override what a base node says (`patch`), and stop one rendering (`hide`). Anything targeting something the winning template does not have is skipped silently. A node whose field id the sheet already carries is skipped too (first declaration wins), so two modules wanting the same engine-vocabulary field on an item do not draw it twice. To restructure a sheet, ship a full `characterSheet` instead. The keys and their caps are in [Extending an existing sheet](https://divinevtt.com/docs/guides/character-sheets#extending-an-existing-sheet). - A module's field ids must be namespaced `packId:fieldId` (dashes become `_`), so formulas can reference them without colliding with the system's own fields. The validator enforces this for module-shipped data nodes. A module may also declare fields in the namespace of any pack it `requires`, because the dependency guarantees that pack is in the chain. The one exception: a namespaced-*type* node may bind a **core** field id to render a read-only derived view, as long as it ships `"readonly": true`. - A node type your own script registers is written `self:` inside your own facets, never with your id spelled out. The host rewrites it to your package's real id when the template resolves, which is what keeps a copy working: a package made or duplicated in Studio is given a fresh id, so a hardcoded prefix would stop matching what the script registers. Other packages' node types are written out in full. See [`self:`](https://divinevtt.com/docs/guides/character-sheets#placing-your-own-node-self). - A `tokenBorders` facet lists its borders and the pictures ride beside it as files: `tokenBorders/borders.json` in the zip, and each border's `image` naming a square picture under `assets/` (Studio stores them as `assets/borders//.webp`). The ring is centred with a transparent middle; `inset` says how far in from its outer edge the portrait stops and `scale` how far the art may overhang the token. A token stores the border's `id`, so rename a border by its `name` and leave the id alone. Studio's **Token borders** part finds each ring and writes both numbers for you. - Where a facet is absent the engine falls back or switches that part of the table off. [Building a system](https://divinevtt.com/docs/guides/systems#the-smallest-system) lists the fallbacks; [Rules facets](https://divinevtt.com/docs/guides/rules) notes each one. ## Random tables A `rollTables` facet ships random tables as plain data, so a package with no script at all can carry them: a name list, a wandering-encounter table, a table of rumours. Scripts read them with [`api.tables`](https://divinevtt.com/docs/reference/api#api-tables), roll the table's dice with `api.dice.roll` so the table sees them, and take the row the total lands on. The field-by-field shape is `RollTablesFacet` on the [Facet schemas](https://divinevtt.com/docs/reference/facets#rolltablesfacet) page. ```jsonc // rollTables/tables.json, listed in pack.json as "facets": { "rollTables": ["tables"] } { "id": "tables", "tables": [ { "id": "my-pack:tavern-names", "name": "Tavern names", "category": "Names", "tags": ["town"], "rows": [{ "text": "The Drowned Rat" }, { "text": "The Gilded Anchor", "weight": 2 }, { "text": "The Last Lantern" }] }, { "id": "my-pack:road", "name": "On the road", "formula": "1d100", "rows": [ { "min": 1, "max": 60, "text": "Nothing but weather" }, { "min": 61, "max": 90, "text": "Travellers", "table": "my-pack:travellers" }, { "min": 91, "max": 100, "text": "Trouble", "table": "my-pack:trouble", "count": "1d2" } ] } ] } ``` A table is written one of two ways: - **Weights.** Rows carry an optional whole-number `weight` (default 1), and a row's share of the die is its weight: the first table above rolls `1d4`, and the Gilded Anchor comes up on a 2 or a 3. A list with no numbers at all is every weight 1, `1d`. - **Ranges.** Every row names the totals it covers with `min` and, when it covers more than one, `max`. Together the rows must cover one unbroken run with no gaps and no overlaps; they need not be written in order. No weights on a range table. `formula` is optional. Without it the table rolls one die as large as it needs: the highest `max` of a range table (which must then start at 1), the sum of the weights of a weighted one. A die has at most 1000 sides, so a larger table names its formula. A total outside the table (a modifier, a formula that overshoots) reads the nearest end. A row may roll another table as well: `table` names it (any table in the chain, another package's too), and `count` says how many times, as a whole number up to 20 or a dice formula. A row that only rolls another table may leave `text` empty. Limits: 200 tables per facet, 1000 rows per table and 10,000 across the facet, 1000 characters of row text, 120 of name and 1000 of description, 16 tags. Table ids are letters, digits and `: . _ -`. Tables merge along the chain by id, so give yours a `packId:` prefix unless you mean to replace someone else's. ## Semantic bindings A system ships a `bindings` facet mapping well-known semantic keys to its own field ids, so a system-agnostic module can find data it didn't define: ```jsonc { "id": "dnd5e-bindings", "bindings": { "inventory": "inventory", "hp": "hp", "defense": "ac", "speed": "speed", "level": "cr", "currency": "purse", "size": "size", "maxStack": "max_stack" } } ``` ```js api.system.binding("inventory"); // the system's inventory field id, or undefined ``` Resolution runs through the world's pack chain. The current key vocabulary is `inventory`, `hp`, `defense`, `speed`, `level`, `currency`, `size`, `maxStack`, `languages`, and it grows over time. `ac` is read as a deprecated alias of `defense`. Unbound keys return `undefined`, so degrade gracefully when a binding is absent. The vocabulary is open, not closed: the validator takes any non-empty key, and a key you invent is for your OWN modules to read back through `api.system.binding("...")` - a shared key is what lets a stranger's module find your data, so use one of the documented ones wherever one fits. This is what makes `extends: "*"` real: the [`item-automation`](https://divinevtt.com/docs/examples/item-automation) module resolves a drop target's inventory through this exact call. The same facet carries `labels`, `sizes` and `derived`; see [Building a system](https://divinevtt.com/docs/guides/systems#bindings-the-vocabulary-modules-rely-on). ## Durability & module storage Disabling or uninstalling a package never deletes user data. - A sheet template with an unknown node type renders a preserving placeholder ("provided by *pack*, disabled") and survives round-trips. - A formula referencing a missing function evaluates to `null` and badges the field instead of erroring. - Chat cards persist a plain-data snapshot at emit time, so history stays readable after a module is gone. - Catalog entries already materialized into a world stay when the pack goes. Module state on a document lives under the reserved `ext` namespace: `profile.ext[packId].key`, written through [`api.data.flags`](https://divinevtt.com/docs/reference/api#api-data). The validators reject `ext` and `ext.*` as node ids, so a template can never seed over flag storage. Sheet UIs flush only the top-level keys the user touched and the server merges per key, so a module patching its own keys concurrently is never reverted by a sheet save. Per-row module data goes in a parallel structure under `ext[packId]` keyed by the row's `id`, never as extra keys on the row itself. See [Stored data types](https://divinevtt.com/docs/reference/data-types) for the row shapes. ## Diagnostics: what a world actually resolves A chain resolves silently. The last package shipping a `characterSheet` for an entity kind wins outright, `sheetExtensions` append and patch on top of whatever that turned out to be, and **every mismatch is skipped by design**: an unknown tab id, a patch naming a node the winning template has not got, an anchor that is not there. That is the correct behaviour, because a module aimed at a system it turns out not to be layered over must change nothing. It is also invisible, which is why a world can spend months rendering a sheet a module forked and never gain anything the original has learned since. A world's **Modules** screen carries the report. Findings come first, then the chain in apply order, then who owns each sheet, the full operation log, and the merged facets. A world with nothing wrong reads as a short, quiet page. ### What each finding means | Finding | What happened | What to do | | --- | --- | --- | | **Sheet replaced** | Two packages ship a `characterSheet` for the same kind. The later one wins outright and the earlier one renders nowhere. | Usually a fork that outlived its reason. Turn the fork into a `sheetExtensions` facet so it layers instead of replacing, or accept the loss deliberately. | | **No such tab** | An `appendTo` or `insert` names a tab the winning template has not got. | Check the tab id against the sheet actually in force, not the sheet you wrote against. | | **No such anchor** | An `insert` names a node to sit after, and no node in that tab carries the id. | The anchor moved or was renamed. Pick another, or use `after: null` to place at the top of the tab. | | **No such node** | A `patch` or `hide` names a node id the template does not carry. | Same cause as a missing anchor: the base sheet changed under you. | | **No such field** | An `addOptions` names a field id nothing in the template carries. | Check the field id; option additions only reach fields that exist. | | **Field has no option list** | The field exists but carries no options, or the addition's `when` guard matches no copy of it. | A repeated field (one `subclass` per class) needs a `when` that matches one of them exactly. | | **Already bound, dropped** | A node binds a profile key the sheet already binds. First declaration wins, so the node was dropped rather than shown twice. | Usually correct and nothing to do. Namespace the field id if you meant a second, separate value. | | **Tab id already taken** | A new tab reuses an id the sheet already has. Both tabs exist; anything addressing that id reaches the first. | Rename the tab, prefixed with your package id. | | **Property never patched** | A `patch` set `type`, `id`, `children`, `item` or `tabs`. The merge never writes those: they define the tree, and changing one orphans the values stored under it. | Drop the key. Structure stays the base template's. | | **Aimed at an undeclared kind** | A `sheetExtensions` facet extends an entity kind this world cannot store. | The module expects a system that declares that kind. Enable it, or the extension does nothing here. | | **Redefined later** | A later package wrote over an earlier one's key in a merged facet. | Usually the point of installing it. The list names the keys so you can check. | | **Placeholder instead of a control** | The resolved template places a node type nothing installed registered a renderer for. This is what draws "needs a module that isn't installed" on a real sheet. | Enable the package that provides it, or check the next finding. | | **Script failed to load** | The package's facets resolved, so its fields are on the sheets, but its code never ran. | The browser console carries the import error. Nothing the script does is happening. | | **Scripts switched off** | The server has package scripts disabled. Declarative content still applies. | Nothing to fix in the package. | Only a world's editor or owner can read the report: it names every package in the chain and everything each of them failed to place, which is authoring information rather than table information. ## Versioning A package's own `version` is yours to set, and releasing one replaces the package's content **in place** under the same id, so every world that enabled it picks the new build up at once. A version that has already been released is refused, which is what keeps a release label meaning one build. Because a release overwrites, each one keeps a copy of what it shipped. The **last three** releases can be downloaded as a zip by the package's owner from its version history in Studio or on its listing page; older releases stay in the changelog with their notes and nothing to download. The zip is a normal package zip, so a build you have since replaced can be imported back as a new library package and compared against what you have now. ### Api versions `api` governs both the script API and the declarative schemas. Per-facet `version` strings are content versions only. Anything reachable but not documented here is internal, and packages depending on it will break. Servers do not all run the same release: divinevtt.com is current, and a self-hosted server runs whatever its owner last installed. The `api` your manifest declares is how a server decides whether it can run your package. - **The minor** goes up when the api gains something. If your script calls a method that arrived in api 1.4, declare `"api": "1.4"`: a 1.3 server then refuses the package with a message to update, instead of loading it and failing on that line at someone's table. Studio warns you when a call is newer than the api you declare, and names the minor to write. - **The major** goes up when something changes shape or goes away. The old surface is not kept around. Instead, a package written for the old major is upgraded when it lands on the newer server: renamed calls are rewritten in your scripts, reshaped facets are converted, and the package as it was is kept on the server. - When something your package used was **removed**, there is nothing to rewrite it to. Uploading or saving it is refused with a list of every place to change, by file and line. A package already stored on a server that upgrades is switched off with that list instead: it drops out of every world that uses it until you change it, and the world's settings say why. Studio's **Upgrade** button makes the automatic changes in the editor and shows you what is left. The api is at 1.6. What each minor of api 1 added, so you can tell which to declare (anything not listed has been there since `1`): | Minor | Added | | --- | --- | | 1.1 | `api.chat.post`, `api.dice.roll`, `api.canvas` (`draw`, `clear`, `view`), `register.canvasTool`, `register.command` | | 1.2 | `api.sheets` (`get`, `patch`) | | 1.3 | `api.tables`, `api.user`, `chat.update`, `canvas.activateTool` / `deactivateTool`, `ui.refreshPanel`, `packs.emit` / `on`, a card's `actions`, an anonymous `relay.toGM`, a command's `args` and `complete`, a map tool's `keys` and `rightClick` | | 1.4 | `api.settings.set` | | 1.5 | `api.views`, `register.view`, `api.relay.to`, a card posted `{ to }`, the `chat.message` hook | | 1.6 | `api.tokens` (`list`, `get`, `update`, `create`, `remove`, `setCondition`), the `token.*`, `combat.*` and `door.changed` hooks | The scan that finds your api calls reads the source; it does not run it. It follows `api.something` and the name your entry's `register` gives the api object, but not the api object once it has been copied into another name or taken apart (`const { data } = api`). Keep calls written as `api.…` and upgrades can carry them for you. --- # Authoring character sheets A character sheet is a `characterSheet` facet: a tree of **nodes** grouped into **tabs**, scoped to one entity kind. It's plain JSON, so the visual sheet editor is just CRUD over this tree, and so is writing one by hand. Per-entity values live in the entity's profile, keyed by each node's `id`. This page shows how to compose the tree. The full field-by-field listing for every node is on the generated [Sheet nodes](https://divinevtt.com/docs/reference/nodes) page. ## The shape ```jsonc { "id": "my-pc", "displayName": "Adventurer", "entityKind": "pc", // one of: pc, npc, enemy, item, spell, quest, // faction, region, event, lore "version": "1.0.0", "tabs": [ { "id": "core", "label": "Core", "icon": "user", "body": [ /* nodes */ ] } ], "summaryFields": ["hp", "ac"] // field ids surfaced in compact / card views } ``` Each tab needs a non-empty `id`, a `label`, and a non-empty `body`. `icon` is an optional [lucide](https://lucide.dev) icon name. A tab (or any node) may carry a [`visibleIf`](#conditional-visibility) condition. ## A worked example A card of three ability scores: ```jsonc { "type": "grid", "columns": 3, "variant": "card", "children": [ { "type": "field", "id": "str", "fieldType": "number", "label": "STR", "default": 10 }, { "type": "field", "id": "dex", "fieldType": "number", "label": "DEX", "default": 10 }, { "type": "field", "id": "con", "fieldType": "number", "label": "CON", "default": 10 } ] } ``` Then derived values that read those fields: ```jsonc { "type": "row", "children": [ { "type": "computed", "expr": "floor((str - 10) / 2)", "label": "STR mod", "format": "signed" }, { "type": "roll", "roll": "floor((dex - 10) / 2)", "label": "DEX check", "die": "1d20" } ]} ``` And a resource bar for HP: ```jsonc { "type": "resource", "label": "Hit points", "currentField": "hp", "maxField": "hp_max", "tone": "hp" } ``` That's a working tab: a card of three ability scores, a signed modifier computed live, a click-to-roll d20 check, and an HP bar. ## The node vocabulary Nodes fall into a few families. The [Sheet nodes reference](https://divinevtt.com/docs/reference/nodes) has every field on each. - **Layout.** `row`, `col`, `grid` hold `children`. `grid` takes `columns`. Any of them takes `variant: "card"` to render as a bordered panel. - **Static.** `text` (a heading or label via `variant`), `divider` (a rule), `article` (the entity's rich lore block). - **Inputs.** `field` is the workhorse. Its `fieldType` is one of `text`, `number`, `textarea`, `select`, `checkbox`, `badge`, `cycle`, `dots`, `clock` (and a few more on the reference page). `select` and `badge` need an `options` array. `cycle` needs `steps`, each with a numeric `value` a formula can read. Set `searchable: true` on a long `select`. A `clock` is a progress clock: a circle of `max` segments (default 4) with the number filled stored as the value. Inside a `list` row, give it `maxAuto` naming a `cycle` on the same row and each row picks its own size. - **Derived.** `computed` shows a read-only formula result. `roll` shows a derived value that's also click-to-roll into the table chat. See [Formulas](#formulas) below. A `roll` with `pool` rolls a dice pool instead of die + modifier: its `roll` formula is the NUMBER of dice, and `pool` says the die size, whether to keep only the best or worst die, and what an empty pool rolls. Give it `outcomes` naming a dice-facet outcome table and the prompt asks the table's questions (position, effect) and the chat card shows how the result reads. ```jsonc { "type": "roll", "label": "Prowl", "roll": "prowl", "pool": { "sides": 6, "keep": "highest", "zero": "2d6kl1" }, "outcomes": "fitd-action" } ``` - **Structured data.** `list` (a repeatable sub-form), `actions` (attacks and spells with an automation-aware editor), `inventory`, `spellbook`, `drops`, `linked`. These store arrays under their `id`. The shapes are on the [Stored data types](https://divinevtt.com/docs/reference/data-types) page. ### `linked`: a list of things in your world `linked` is the plain list of references. `kind` is any entity kind the world declares - a core one (`item`, `npc`, `spell`, `faction`, `region`, ...) or one a pack invented with `declaresKind` - so a sheet can carry a fleet of ships, a list of contacts, or a bag of items with none of the inventory's machinery: ```json { "type": "linked", "id": "fleet", "kind": "ship", "label": "Fleet", "addLabel": "Add a ship", "allowPlain": false, "columns": [ { "id": "role", "fieldType": "select", "label": "Role", "width": 12, "options": [{ "value": "escort", "label": "Escort" }, { "value": "hauler", "label": "Hauler" }] }, { "id": "note", "fieldType": "text", "label": "Note" } ] } ``` Picking a row searches the world's entities of that kind AND the compendium, and a compendium pick materializes the entry before linking it. Each row stores a stable id, the entity id, and a snapshot of the name taken at link time, so a row survives the entity being deleted. `allowPlain` lets a row be a typed name with no entity behind it; leave it off for a list that must hold real things. `columns` are extra per-row fields you name: an `id` (the row key the value stores under, never `id`, `entityId` or `name`), an optional `label`, and a `fieldType` of `text`, `number`, `checkbox` or `select` - a row is one line, so the wider field types are not offered. A `select` column needs `options`. At most eight columns; past that the shape you want is a `list`. Use `inventory` instead when you want the item list that knows about carrying things: quantity and stacking, an equipped flag, containers, encumbrance, price, durability and the loot and drop seams all read that node. `linked kind=item` is the same list without any of it. - **Widgets.** `resource` (value/max bar with a drag slider), `autofield` (a value that's a formula by default but can be overridden by hand and reset), `slots` (a pip/slot tracker in tiers), `progress` (a read-only band bar). These do presentation only, never rules. Any number they need comes from a pack-supplied formula. - **Machine boards.** Three widgets for anything that is a machine rather than a body: a ship, a vehicle, a stronghold. Studio does not offer these to every author - see [Who is offered which nodes](#who-is-offered-which-nodes) - but the engine draws them for any pack that places one. `systems` is a power board: the pack lists each subsystem with a `draw` formula, the crew switch them on and off, and the board shows the total against a `supply` formula. `criticals` is a damage board: hits by location and severity, each severity carrying the book's text, a `state` (degraded, disabled, destroyed) that greys the matching system out on the power board, numeric `mods` that flow through the effective-value seam so every formula reads the damaged value, and the two repair checks (a patch that holds for a while, a repair that spends parts). With `onDamage`, every drop of the watched pool at the table rolls for a critical on the server, nothing on a miss, and a hit lands at a rolled location with a severity set by the damage. `compartments` lists the rooms of a hull and the state each is in; a room entering a state marked `seals` raises the switch the board names (an alarm) on the map the entity is connected to at the table, a hit whose severity names a `room` breaches a random whole room into that state, and a power board whose mains die (`regionSwitch`) throws that map's own power switch. Which map that is lives on the table's roster, not on the sheet: the GM connects a ship to its deck plan per campaign, and everyone granted the ship can board it. The pack supplies every rule; the boards only keep the state and do the arithmetic. The 2d6 SciFi ship sheet is the worked example. ## Who is offered which nodes Every node type above is part of the engine and renders for any pack that places one. What Studio OFFERS in its palette is narrower, and it is the system's to decide. Universal nodes - layout, fields, derived values, trackers, rows, static text - are offered to everybody. The machine boards are not: they describe a machine, and a system about people has no use for a power board. Neither is `spellbook`, which is one system's model of casting (levels, prepared toggles, slots spent per cast) rather than a mechanism every game wants; the 5e system declares it and gets it back under its own name. A pack puts a node type back in the palette by declaring it in a `palette` facet: ```json { "id": "scifi2d6-palette", "nodes": [ { "type": "criticals", "label": "Critical hits", "group": "Ship", "hint": "Where a hit lands on the hull, and what it wrecks.", "icon": "Wrench", "default": { "type": "criticals", "id": "criticals", "label": "Critical hits", "locations": [{ "id": "hull", "label": "Hull", "severities": [{ "text": "The hull is holed." }] }] } } ] } ``` The declarations of the pack being edited, the system it extends and everything it requires are merged, with the pack in hand last, so a system names a widget for its own authors and a module may rename it again. `type` is an engine node type or `self:` for one your script registers - which is how a system puts a node type nobody else has into the palette. `default` is a starter node, validated as a sheet node at save, dropped in place of the engine's neutral seed. Studio edits this facet as a form (**Palette**, under Sheets): one row per node type, with the name, the hint, the icon and the heading it should carry. The type picker lists every engine node type and any `self:` type it can see your pack registering, and a type can be typed in full when it cannot. Nothing is hidden away: Studio's **More node types** holds everything it does not offer, and taking one from there offers to write the declaration for you. ## Field ids and the profile A node's `id` is the key its value is stored under in the entity's profile. The validator enforces: - An `id` must be non-empty and must not start with `ext` (that namespace is reserved for [module storage](https://divinevtt.com/docs/guides/packages#durability-module-storage)). - Ids must be unique within a container. - A module-shipped sheet (or `sheetExtensions`) must namespace its field ids as `packId:fieldId` (dashes become `_`). A module may also declare fields in the namespace of any pack it `requires`, because the dependency guarantees that pack is in the chain. A system's own sheet uses bare ids. ## Engine vocabulary A handful of profile keys are not yours to name. The engine reads them itself, whichever system is loaded: a weapon's range, a piece's durability and what it covers, an item's carry cost, a price, whether a thing can be used or filled. They live in namespaces no package owns, listed in full on the [Engine profile keys](https://divinevtt.com/docs/reference/engine-keys) page: ``` builtin:realism_core: builtin:transforms: builtin:economy: builtin:spell_components: ``` **These ids are NOT namespaced to your pack.** The rule above exists so two modules cannot fight over one key; these keys are common ground instead, so the sheet-extension validator accepts them from anybody and asks for no prefix. Write the id exactly as the reference gives it. `my_pack:builtin:realism_core:range` is a key the engine has never heard of, and the field it names is stored and read by nothing. ```json { "type": "field", "id": "builtin:realism_core:range", "fieldType": "number", "label": "Range" } ``` Studio offers them under **What the engine reads** in the "Add node" palette, and beside the field-id box in the Inspector, so you place one without typing an id. The Inspector also says what reads the key and what the world still needs for it to matter. That last part is the catch worth knowing: some keys only work if a pack in your chain declares the facet they read against. `builtin:realism_core:range` is a distance in the `measurement` facet's own unit, and the falloff is derived over the bands that facet declares - so in a system with no bands, the number is stored and nothing ever reads it. Pack lint warns about exactly this, naming the facet to add. The reference page's **Needs** column says which keys have one. ## Formulas `computed` and `roll` nodes evaluate a formula against the sheet's fields: - Bare identifiers reference fields by id: `floor((str - 10) / 2)`. - `packId:fn(...)` calls a [registered formula function](https://divinevtt.com/docs/guides/scripting#formula-functions): `my_homebrew:half(level)`. - Built-in functions: `floor`, `ceil`, `round`, `abs`, `sign`, `min`, `max`, and `if(condition, then, otherwise)`. - Comparisons answer `1` or `0`: `< <= > >= == !=`. Join them with the words `and`, `or`, `not` (lowercase, and no field may be named one of them). A single `=` is not an operator; the editor says so and points at it. - Precedence, loosest binding first: `or`, `and`, `not`, the comparisons, `+ -`, `* / %`, unary minus, then calls and brackets. So `level + 1 >= 5 and hp > 0` needs no brackets, and `not level >= 5` reads the way it is said. Anything but `0` counts as true, except a value that fell apart into `NaN`, which counts as false so a broken sub-expression cannot show a section it was meant to hide. Nothing short-circuits: every branch is worked out, which no formula can tell apart because none of them has an effect. Only the FINAL answer has to be a real number, so `if(hp > 0, dmg / hp, 0)` still answers `0` when `hp` is `0`. - A missing field reads as `0`. A parse error yields `null` and badges the field instead of crashing. Because `-` is the minus operator, an identifier can't contain it. That's why field ids and formula-function package prefixes normalize `-` to `_`. A `roll` node rolls `die` (default `1d20`) plus the `roll` formula as the modifier. Set `flat: true` to roll just the dice (a damage or HP roll). Conditional advantage and disadvantage are pack-supplied: when `advantageIf` is truthy the node rolls `advDie` instead (for example `disDie: "2d20kl1"` when `disadvantageIf` fires). Rolling only happens at the table. In the editor a `roll` renders as a plain value. Three more refs reach past a plain field. A `select` option may carry `data`, an object of numbers, which a formula reads as `:` for whichever option is chosen (a power plant type's Power per ton). A list's rows total as `:sum:` and count as `:count`. A `select` option inside a list row may also carry `fill`: values written into the rest of the row when it is picked, which is how a premade part fills in its tons and cost and a "custom" choice leaves them to type. A `resource` may set `maxAuto`, a formula for its max with the same override-and-reset control as an `autofield`. ## Formulas on any field Anything numeric on a sheet can be a formula, so a creator automates a sheet without writing a line of script. Every one of these is a pack formula in the grammar above, evaluated by the same evaluator `computed` uses. **A value that computes itself, and can still be typed over.** Put `auto` on a `number` field and the formula is the value: it shows an "auto" badge, typing over it stores an override, and the badge resets it back to the formula. This is the `autofield` node's behaviour, on an ordinary field that keeps its stepper, its adjust box and its range. ```jsonc { "type": "field", "id": "prof", "fieldType": "number", "label": "Proficiency", "auto": "2 + floor((level - 1) / 4)" } ``` A field with `auto` is **not seeded**, exactly like an `autofield`: an unset value means "the formula decides", and a stored `default` would read as a hand override the first time the sheet compared the two. The override flag lives beside the value under `__manual`. **A range that follows the sheet.** `minAuto` and `maxAuto` beat the plain `min` / `max` when set, and are re-evaluated on every render, so a carry-weight slider tracks the strength it is computed from: ```jsonc { "type": "field", "id": "carried", "fieldType": "number", "label": "Carried", "min": 0, "maxAuto": "floor(str * 15)" } ``` On a `dots` field, `maxAuto` is the number of dots, and on a `clock` the number of segments. A `resource` takes `maxAuto` and `currentAuto` on the same terms (each gets the override-and-reset control); a read-only `progress` bar takes `currentAuto` for its fill, with nothing to override. `slots` tiers and `tracks` maxima were already formula-driven: a tier's `totalField` can be written by an `autofield`, and every `TrackDef.max` is a formula in the sheet's scope. **Show and lock by formula.** Every node accepts `visibleWhen` and `readonlyWhen`: a formula that is ON for anything but `0`. `visibleWhen` shows the node, `readonlyWhen` renders it and everything under it read-only. ```jsonc { "type": "row", "title": "Tier 2", "visibleWhen": "level >= 5", "children": [] } ``` A comparison answers `1` or `0`, and so does a checkbox (ticked is `1`), so both feed the "anything but 0" rule directly and a total can count ticked boxes. `and` / `or` / `not` join two of them: `"visibleWhen": "level >= 5 and not sworn"`. The same operators work in any formula, so a value can branch too: ```jsonc { "type": "field", "id": "attacks", "fieldType": "number", "label": "Attacks", "auto": "if(level >= 5, 2, 1)" } ``` A formula that does not parse leaves the node **visible and editable** - the same rule `visibleIf` follows, because a typo that hides a section reads to its author as data loss. Both guards must pass when a node carries `visibleIf` and `visibleWhen`. **Inside a list row**, all of the above resolve in the ROW first and fall back to the sheet around it, so a row can be gated on one of its own columns or on the character's level. That fallback is for formulas only: a field input still reads and writes its own row, or editing a blank cell would look like editing the sheet. Every one of these formulas is validated when the pack is saved, and the error names the node's position and the property. In Studio each of them is an "fx" toggle beside the value in the node inspector. ## Conditional visibility Any tab or node may carry a `visibleIf` to show it only in certain states: ```jsonc { "type": "field", "id": "rage", "fieldType": "checkbox", "visibleIf": { "field": "class_name", "equals": "Barbarian" } } ``` The condition reads the top profile value of `field`, falling back to that field's template default when unset (so a pre-existing entity behaves as if freshly seeded). Use `{ field, equals }` or `{ field, in: [...] }`. Hidden is not deleted: a hidden section keeps its stored data and its defaults still seed, so this is a safe presentation control. It's also the primitive behind sheet variants (tag sections against a variant `select` field). A malformed `visibleIf` on a verbatim extension node counts as visible, since hiding on a typo would read as data loss. ## Extending an existing sheet To change whatever sheet won the chain rather than replacing it, ship a [`sheetExtensions`](https://divinevtt.com/docs/reference/facets) facet. It carries six keys, applied in this order once the base template is picked: `appendTo`, `insert`, `tabs`, `addOptions`, `patch`, `hide`. | Key | What it does | | --- | --- | | `appendTo` | `{ "": [nodes] }` - nodes added at the END of an existing tab's body | | `insert` | `{ tab, after, nodes }` - nodes placed after a named node, or at the start when `after` is `null` | | `tabs` | Whole new tabs, appended after the base template's | | `addOptions` | Values appended to an existing `select` or `badge` | | `patch` | Property overrides on a base node, matched by id | | `hide` | Base node ids that stop rendering | **Structure stays append-only.** An extension adds nodes and tabs, changes what a node says, and stops one rendering. It never moves, reorders or reparents what the base declares, so the tree the system ships is still the tree that renders and a stored value can never be orphaned. To restructure, ship a full `characterSheet` instead - and know that a fork replaces the base template outright and stops following the system pack's own updates. Everything an extension targets is skipped in silence when the winning template does not carry it: an unknown tab id, a missing `insert` anchor, a `patch` on an id that is not there, a field `addOptions` cannot find. An extension written for one system applied to another simply does less, rather than failing the pack. Studio writes all six for you. Opening a module shows the base system's sheets with the base nodes greyed, and adding, editing, deleting or extending a dropdown on one of them writes the matching key. See [Studio](https://divinevtt.com/docs/guides/studio#modules-edit-the-system-s-sheet-in-place). ### Placing a node where it belongs: `insert` `appendTo` can only reach the end of a tab, which is the wrong place for a field that reads beside another one. `insert` names an anchor: ```json { "insert": [{ "tab": "core", "after": "ac", "nodes": [{ "type": "field", "id": "my_pack:shield_wear", "fieldType": "number", "label": "Shield wear" }] }] } ``` The anchor is looked up anywhere in the tab, not just at its top level, so an insert lands inside the row or card that holds the node it belongs beside. `"after": null` puts the nodes at the start of the tab. Inserted nodes follow exactly the namespacing and first-declaration-wins rules appended ones do. At most 64 inserts per extension. ### Changing what a base node says: `patch` ```json { "patch": [ { "id": "ac", "set": { "label": "Defence" } }, { "id": "speed", "set": { "readonly": true } } ] } ``` `set` is shallow-merged onto the matched node, one key at a time, and later packs in the chain win per key. `type`, `id`, `children`, `item` and `tabs` are not patchable and the validator rejects them: those are structure, and structure is the base template's. `when` narrows the match the same way `addOptions` does below, for a template that repeats one field id behind different guards. At most 64 patches. Reach for `patch` to relabel a field, mark one read-only, retune a `min`/`max`, or hang a formula on a base node. It costs no fork, so the sheet keeps tracking the system's updates everywhere you did not touch. ### Switching a node off: `hide` ```json { "hide": ["encumbrance", "inspiration"] } ``` Ids are dropped from the resolved template recursively. **Hiding is not deleting**: values already stored under those ids stay in the profile and come back the moment the module is disabled, exactly as a module's own fields leave their values behind. A container emptied this way stays, empty - its layout belongs to the base template, not to the extension. At most 64 hides. ### Renaming a kind the system invented: `renameKind` A system that declares a kind of its own (a sheet with `declaresKind`, such as a crew) names it once. A setting module that turns the crew into a mercenary company renames it without forking the sheet: ```json { "entityKind": "crew", "renameKind": { "label": "Company", "plural": "Companies", "icon": "Shield" } } ``` Every key is optional (`label`, `plural`, `icon`, `summary`); what you leave out keeps the system's words, and whether a GM can grant one stays the system's to say. Renames apply over the declaration in chain order, so the last module to rename a kind wins. A kind the engine names itself (`npc`, `pc`, ...) or one nothing in the chain declares is left alone. An extension may carry a rename and nothing else. Studio does not write this key yet; add it to the facet's JSON. ### Placing your own node: `self:` A pack that ships a script AND a facet placing that script's node has to name the node type inside its own JSON. Write it as `self:`: ```json { "appendTo": { "spells": [{ "type": "self:oath-grants" }] } } ``` The host rewrites `self:` to your pack's real id when the template resolves. Use it always, and never hardcode your own id: a zip import keeps the id in your pack.json only when it can, and a pack made or duplicated in Studio gets a fresh one, so a hardcoded prefix can stop matching what your script registers and the sheet shows "needs a module that isn't installed" instead of your node. Node types belonging to OTHER packs are written out in full, as before. ### Adding a value to somebody else's dropdown A closed option list is the one addition a node cannot express: a subclass, an ancestry, a background is a new *value* of a field the base system already owns. `addOptions` appends values to an existing `select` or `badge`: ```json { "id": "paladin-oaths-ext", "entityKind": "pc", "addOptions": [{ "field": "subclass", "when": { "field": "class_name", "equals": "Paladin" }, "options": [{ "value": "Oath of Vengeance", "label": "Oath of Vengeance" }] }] } ``` `when` picks which node you mean when a template repeats a field id behind different `visibleIf` guards (the 5e sheet carries one `subclass` select per class); it must match that node's guard exactly. Leave it out to extend every select carrying the id. Values already present are skipped, so two modules adding the same option leave one entry, and a field the winning template does not carry is skipped in silence, the same as an unknown tab id. This is additive only: an addition cannot remove or relabel a value that is already there. Reach for it instead of forking the whole `characterSheet` facet to change one dropdown, since a fork replaces the base template outright and stops tracking the system pack's own updates. --- # Rules facets Every facet on this page is a declarative JSON file a system (or a module) ships. None of them needs a script. For each one: what it does at the table, the fields that matter, how it merges along the pack chain, and what happens when it is absent. The complete field tables are on [Facet schemas](https://divinevtt.com/docs/reference/facets). Formulas in these facets use the same grammar as sheet formulas: bare field ids, `floor`, `ceil`, `round`, `abs`, `sign`, `min`, `max`, `if`, the comparisons `< <= > >= == !=` joined with `and` / `or` / `not`, and the system's [derived fields](https://divinevtt.com/docs/guides/systems#bindings-the-vocabulary-modules-rely-on). Facets the server evaluates (vitals, detection, turn order) run their formulas over the token's flat numeric snapshot, so only top-level numeric fields are in scope there. ## Conditions Status effects the system defines: poisoned, stunned, bleeding. Actions apply them by id, the wound table applies them, the turn tracker ticks them. ```jsonc { "id": "core", "displayName": "Conditions", "version": "1.0.0", "conditions": [ { "id": "poisoned", "label": "Poisoned", "icon": "skull", "description": "Disadvantage on attack rolls and ability checks." }, { "id": "bleeding", "label": "Bleeding", "tickDamage": "1d4", "durationTurns": 3, "maxStacks": 3 } ]} ``` Per condition you may declare what the server does at the start of the bearer's turn: tick damage or healing, a duration in turns after which it falls off, and a stack limit. Merge: union by id, later packs win on the same id. Ids are append-only; deprecate by relabelling, never by deleting, because stored actions reference them. ## Dice Reshapes the table's dice tray and says how rolls read. Three lists and a layout: - `quickRolls`: prominent buttons, either a fixed expression or a die with a count stepper. A stepper die may keep only its best or worst die (`"keep": "highest"`) and name what an empty pool rolls (`"zero": "2d6kl1"`, with `"min": 0`). - `modifiers`: toggles that transform the expression before it is rolled. A modifier is "add a die and keep the highest" or "append +2"; the label is your word for it (Advantage, Boon, Aim). - `outcomes`: named outcome tables. A table reads a rolled total in bands (`{ "min": 4, "max": 5, "label": "Partial success", "tone": "partial" }`), optionally with a critical checked first (`{ "face": 6, "count": 2 }` is "two sixes"), and may ask the roller questions before the throw (`choices`, such as position and effect). A sheet `roll` node or a quick roll names a table by id (`"outcomes": "action"`); the server reads the result against the campaign's own copy of the table, and the chat card shows the band, its note and the answers. The same shape covers a 2d6 move read as 6- / 7-9 / 10+. - `presentation`: a matrix layout for systems that roll pools. ```jsonc { "id": "dice", "outcomes": [{ "id": "move", "label": "Move", "bands": [ { "max": 6, "label": "Miss", "tone": "fail" }, { "min": 7, "max": 9, "label": "Weak hit", "tone": "partial" }, { "min": 10, "label": "Strong hit", "tone": "success" } ] }] } ``` A system can ship nothing but this one facet. Merge: entries append along the chain, same id replaces (for outcome tables too, so a module can reword a table the system declared). A `local`-scope module may ship a dice facet that only the enabling player sees (`builtin:matrix-dice`). ## Turn order `turnTracker` picks the combat model and what initiative rolls. ```jsonc { "id": "ticks", "model": "ticks", "displayName": "Tick clock", "initiativeDice": "1d10", "initiativeBonusField": "agi_mod", "startBase": 20, "turnBudget": 5, "surprisePenalty": 5, "actionCosts": [{ "id": "light", "label": "Light action", "ticks": 3 }], "surpriseCondition": "surprised", "visibility": "shared" } ``` - Models: `rounds` (roll once, sort, cycle) and `ticks` (a shared clock where the lowest tick acts next and an action adds its cost back) are fully built. `popcorn`, `sides`, `cards` and `free` fall back to a sorted list for now. - Initiative rolls `initiativeDice` (default `1d20`) plus either a token field (`initiativeBonusField`) or a formula (`initiativeBonusFormula`, which wins). - `actionSurchargeFormula` (ticks) is what every action costs a combatant on top of its price, over its token's snapshot: a wound that slows every swing. Each quick-spend button adds it. - `visibility` is the system default for what players see of the order. Merge: last wins. Absent: a rounds tracker on `1d20` plus a field named `initiative`, shared visibility. ### Turn order layout `turnTrackerLayout` changes how the table shows a fight, not how it runs. It is meant for modules: a world without one keeps the turn order in its side panel, and nothing else changes. With `"layout": "carousel"` the table adds, beside that panel: - a strip across the top of the map with the combatants in turn order, starting with the one acting, drawn larger and highlighted. Each shows its token's own art, its conditions with the rounds left, and its health as the viewer may see it. A line marks where the order goes round to those who have acted. Clicking a portrait centres the map on that token (a player only on tokens they can see). The GM gets Next turn and End beside the round; everything else stays in the panel; - a pulsing marker on the active combatant's token, which follows it; - for the player whose character is up, a "your turn" message and a short chime through the table's sound settings. ```jsonc { "id": "my-carousel", "layout": "carousel", "strip": true, "marker": true, "markerStyle": "ring", "markerColour": "gold", "othersHealth": "states", "alert": true, "chime": true, "settings": { "markerStyle": "markerStyle", "markerColour": "markerColour" } } ``` - `markerStyle`: `ring`, `arrow` or `both`. `markerColour`: `gold`, `white`, `red`, `blue`, `green`, `side` (green for the party, red for foes) or a `#rrggbb` colour. It is also the strip's highlight. - `othersHealth` is what a player sees of creatures that are not theirs: `states` (the bloodied and dying marks the map shows everyone), `bar` (those and a bar without numbers) or `none`. Numbers only ever show where the table has them: for the GM, and for a player's own characters. - `settings` maps an option to a setting id in the same package's manifest. When the GM has a value there of the right kind (a checkbox for `strip`, `marker`, `alert` and `chime`, one of the listed values for the rest), it wins over the value in the facet. That is how a module lets the GM tune the carousel from its settings without a script. Merge: last wins, read on the campaign chain, so a table that turns the module off gets its panel back. A later package can declare `"layout": "panel"` to switch the carousel off again. ## Token vitals What a token shows about itself on the map, and what running out means. ```jsonc { "id": "vitals", "bars": [ { "id": "hp", "label": "Hit points", "field": "hp", "maxField": "hp_max", "primary": true, "audience": "gm", "tempField": "hp_temp" }, { "id": "stress", "field": "stress", "max": 10, "style": "pips", "color": "#8a7d9e", "audience": "owner", "direction": "up" } ], "badges": [ { "id": "ac", "label": "AC", "icon": "shield", "field": "ac", "audience": "all", "when": "always" }, { "id": "pp", "label": "Passive perception", "icon": "eye", "expr": "10 + floor((wis - 10) / 2)", "audience": "gm" } ], "states": [ { "id": "bloodied", "maxFraction": 0.5, "label": "Bloodied", "color": "#a84a3a" }, { "id": "dying", "maxFraction": 0.05, "label": "Dying", "icon": "skull" } ], "cascade": { "into": ["str", "dex"], "choiceField": "spill_pref", "choiceDefault": "higher", "downAt": 2, "downCondition": "unconscious", "outAt": 3, "outCondition": "dead" } } ``` - **Bars** are stats the system points at. `field: "hp"` reads the engine HP column; anything else reads the snapshotted profile field. One bar is `primary`: damage lands on it and the states read its fraction. `style` is a smooth bar or pips; `display: "chip"` puts the value in the readout row instead of drawing a stripe. `hideWhenFull` keeps a healthy map clean. `tempField` opts into an absorb pool (temporary hit points). `direction: "up"` marks a track that fills as things get worse (stress, heat): it is drawn like any bar but is never where damage lands or what the states read, and it cannot be `primary`. With no primary, damage and states use the first bar that is not rising; a system with only rising tracks has none. - **Badges** are small discs with an icon and a field or formula. - **States** tint the token at or below a fraction of the primary bar. The engine ships bloodied and dying defaults; yours override by id. - **Audience** is the sharp edge: the server redacts token payloads per viewer, so `"all"` publishes that number to every player. A token's own controller always sees their bars. Default is `gm`. - **Cascade** is for systems that do not stop at zero. Overflow spills into the listed bars in order (skipping empty ones), a sheet field lets the character choose, and `downAt`/`outAt` count how many empty tracks apply which condition. 2d6 SciFi's Endurance spilling into Strength and Dexterity is the worked example. Merge: bars, badges and states union by id; `cascade` and `healthBarAudience` are last-wins. Absent: no bars at all. ### Threshold wounds Some systems have no pool for damage to empty. A hit's damage is held up against a ladder of thresholds, and the highest one it reaches is the wound it leaves: a Light one, a Serious one, a Grave one, each with a few slots on the sheet. Armour does not soak anything; it raises the thresholds. ```jsonc "thresholds": { "tiers": [ { "id": "light", "label": "Light", "threshold": 5, "slots": 5, "field": "wound_light", "armorKey": "thr_light" }, { "id": "serious", "label": "Serious", "threshold": 12, "slots": 3, "field": "wound_serious", "armorKey": "thr_serious" }, { "id": "grave", "label": "Grave", "threshold": 20, "slots": 2, "field": "wound_grave", "armorKey": "thr_grave" } ], "overflow": { "id": "dying", "label": "Dying", "condition": "dying", "counterField": "dying", "counterStart": 4, "nonlethalCondition": "unconscious" }, "modifierFormula": "0 - 2 * (exhaustion >= 4)", "critUpgrade": 1, "conditionUpgrades": [{ "condition": "bleeding", "tiers": 1 }], "armorStack": "best" } ``` - `tiers` go lowest threshold first. `field` is the numeric sheet field that counts a tier's filled slots; declare pip bars over the same fields to show them on the token, and a `slots` node to show them on the sheet. - An item the target has equipped adds `armorKey`'s value to that tier's threshold (`armorStack`: the best piece, or `"sum"`). Armour piercing on the attack comes off each bonus, never below the bare number; a property with `ignoreDR` ignores the bonuses entirely. - A critical adds `critUpgrade` tiers (default 1), a weapon property's `critUpgrade` adds more, its `woundUpgrade` upgrades every wound it lands, and each of `conditionUpgrades` the target carries adds its tiers. - A full tier passes the wound to the next one (`rollUp`, default true). Past the last slot is the `overflow`: its condition goes on, and `counterField` is set to `counterStart` unless the countdown is already running. A nonlethal blow applies `nonlethalCondition` instead. - `modifierFormula` is added to every threshold, over the target's numeric snapshot. Healing clears slots of `healTier` (default the first). With `thresholds` declared, dropping an attack on a token never empties a bar and never rolls the wound table. The resolve window shows the thresholds the blow met, what upgraded it and the tier it lands as; the tier follows the referee's edits to the damage and to hit or crit, and the referee may pick one by hand. Last pack to declare wins. ## Measurement How the drag ruler counts, what a cell is worth, and range bands. ```jsonc { "id": "grid", "diagonal": "alternating", "unitsPerCell": 5, "unit": "ft", "bands": [{ "id": "short", "label": "Short", "max": 30 }, { "id": "long", "label": "Long", "max": 120 }, { "id": "extreme", "label": "Extreme", "max": 100000 }] } ``` `diagonal` is one of `equidistant`, `manhattan`, `alternating` (5-10-5), or `euclidean`. Range bands let a weapon or a fitted sight declare a modifier per band, and dropping an attack on a target applies the right one automatically. Make the last band large; anything past it is out of range. Merge: last wins. Absent: equidistant, distances in cells, no bands. ## Noticing things `detection` names the system's perception seam so stashes, hidden containers and hidden bodies reveal by its own check. ```jsonc { "id": "perception", "displayName": "Perception", "passiveExpr": "10 + floor((wis - 10) / 2) + skill_perception * prof", "checkDice": "1d20", "checkBonusExpr": "floor((wis - 10) / 2) + skill_perception * prof", "range": 15, "difficulties": [{ "label": "DC 10", "value": 10 }, { "label": "DC 15", "value": 15 }, { "label": "DC 20", "value": 20 }], "scan": { "displayName": "Sensor sweep", "checkDice": "2d6", "checkBonusExpr": "skill_sensors + int_dm" } } ``` A hidden thing carries one number, its find score. `passiveExpr` auto-spots anything at or below it within `range`; `checkDice` plus `checkBonusExpr` is the active search. `difficulties` is the ladder offered wherever a GM sets a find score, so the number typed is the system's number. `scan` is the same idea in orbit: a real rolled card that reveals hidden bodies whose `scanScore` the total meets. Merge: last wins. Absent: stashes reveal only by the GM's hand, and no sweep exists. ## Tracked pools `tracks` is a catalog of pools a character did not author but does spend: class resources, injuries, ammunition reserves, corruption. ```jsonc { "id": "resources", "tracks": [ { "id": "my-system:rage", "name": "Rage", "source": "Barbarian", "group": "resources", "max": "2 + floor(level / 6)", "when": { "field": "class_name", "equals": "Barbarian" }, "recovers": "long", "toggleable": true }, { "id": "my-system:injury", "name": "Lingering injury", "group": "injuries", "recovers": "never" } ]} ``` `max` and `showWhen` are formulas in the sheet's own scope, so a pool grows with the character and there is nothing to migrate on level up. `when` is a text gate for the common "only for this class" case. A pool with no `max` is a counter. A sheet shows a group through a `tracks` node, and a module running its own rest asks the host to refill pools with `ctx.rest` (see [Automation patterns](https://divinevtt.com/docs/guides/automation#resting)). Per character only `{ current, active }` per id is stored. Merge: union by id. ## Skills A skill tree with foci that train independently, and the ladder that gives each tier its worth. ```jsonc { "id": "skills", "checkDie": "1d20", "replacesProficiency": false, "abilities": [{ "id": "str", "label": "Strength" }, { "id": "dex", "label": "Dexterity" }], "abilityModFormula": "floor(({ability} - 10) / 2)", "checkBetter": { "label": "Advantage", "die": "2d20kh1" }, "checkWorse": { "label": "Disadvantage", "die": "2d20kl1" }, "checkModifierFormula": "encumbrance_dm", "skills": [ { "id": "athletics", "name": "Athletics", "ability": "str", "profField": "skill_athletics", "subskills": [{ "id": "athletics:climbing", "name": "Climbing" }, { "id": "athletics:swimming", "name": "Swimming" }] } ], "training": { "subskill": [50, 200, 1000], "parent": [100, 400], "parentShare": 0.5 }, "ladder": { "parentMax": 2, "subskillMax": 3, "parentStacks": true, "untrainedPenalty": 0 } } ``` - The default ladder is D&D-shaped: a parent skill worth +1 then +2, a focus worth +1, +2, +3 with rerolls at the top two, both stacking. 2d6 SciFi's shape is seven rungs, a -3 untrained penalty, and a focus that replaces its parent rather than adding. Declare only the ladder keys you change. - `profField` points a skill at a field the sheet already has; otherwise the skills node owns the tier itself. A focus that other formulas must read needs its own `profField`, because formulas read flat fields only. - Training is a roll, never a flat award: the training table turns a check total into points, and points cross the thresholds in `training`. - `checkModifierFormula` is a modifier every check gets: encumbrance, fatigue, an unhealed wound. Compute it once, name it here. - Short rung names (`parentShort`, `subskillShort`) switch the sheet control from a pip to a stepper, which is what a long ladder needs. Merge: skills union by id, `training` merges per key, the rest is the last facet's. Absent: no skill tree, and the sheet's own proficiency fields stand alone. ## Money and prices `currency` says what money is. `economy` says what it is worth here. ```jsonc { "id": "coins", "denominations": [ { "id": "cp", "label": "copper piece", "abbr": "cp", "value": 1, "weight": 0.02, "color": "#b87333" }, { "id": "sp", "label": "silver piece", "abbr": "sp", "value": 10, "weight": 0.02 }, { "id": "gp", "label": "gold piece", "abbr": "gp", "value": 100, "weight": 0.02 } ]} ``` ```jsonc { "id": "grim", "displayName": "Copper economy", "priceScale": 0.2 } ``` The base denomination has `value: 1`. There is no automatic conversion anywhere: ten copper dropped in a chest is ten copper when picked up. `value` is only used to total a pile or render a price. Item prices are stored as a number of base units under the engine key `builtin:economy:price_base`, rendered in the largest denomination that divides cleanly, and `priceScale` multiplies them: at a fifth of book price a sword stops being gold and starts being silver. Merge: currency is last-wins as a whole set (denominations are relative to each other); economy is last scale wins, and a GM's `priceScale` setting on any pack beats both. Absent currency: no money UI at all. ## Realism Armour that soaks rather than deflects, gear that wears, encumbrance, ammunition and attachments, and the wound table. Everything is optional and merges over engine defaults, so a pack that only renames "DR" to "Soak" is two lines. ```jsonc { "id": "realism", "labels": { "dr": "Soak", "durability": "Condition", "glance": "Deflected" }, "zones": [{ "id": "head", "label": "Head", "weight": 1 }, { "id": "torso", "label": "Torso", "weight": 4 }, { "id": "arms", "label": "Arms", "weight": 2 }, { "id": "legs", "label": "Legs", "weight": 3 }], "materials": [{ "id": "cloth", "label": "Cloth", "hardness": 1 }, { "id": "steel", "label": "Steel", "hardness": 7 }], "glanceRatio": 0.8, "situational": [{ "id": "prone", "label": "Target prone", "dm": 2 }], "encumbrance": { "capacity": "str + end", "unit": "kg", "massField": "weight", "bands": [{ "atFraction": 1, "label": "Heavy load", "dm": -1 }, { "atFraction": 2, "label": "Overloaded", "dm": -3 }] }, "attachmentSlots": [{ "id": "sight", "label": "Sight" }, { "id": "muzzle", "label": "Muzzle" }], "protections": [{ "id": "rad", "label": "Radiation", "key": "rad_protection", "kind": "max", "bands": [{ "min": 1, "label": "Shielded" }] }], "repairNeedsKit": true, "npcDropDurability": 0.6, "wounds": { /* see below */ } } ``` - `zones` is the body plan (or a hull's hardpoints); `materials` the substance table with a hardness. Both replace wholesale when given, because a body plan is a coherent set. `labels` and `tuning` merge per key. - `glanceRatio` opens the glancing-blow band as a fraction of the target number. `situational` are toggles the resolve window offers. - `encumbrance` is "is it too much": a capacity formula over the carrier's sheet, a mass field on items, and bands where the heaviest that applies wins. - `calibres` declares ammunition families and the classes of round made for each; `attachmentSlots` names where things bolt onto a weapon. - `protections` are scales the coverage panel reads off worn gear: `max` takes the best rated number, `union` collects words (the atmospheres a mask breathes). Breached gear counts for nothing. - `repairNeedsKit` makes repair spend a carried item that declares it repairs that item type. `npcDropDurability` is how worn looted NPC gear arrives; a GM's setting of the same name overrides it. 2d6 SciFi is the worked example of spreading these over packs. The base system declares only `labels`, `situational` and a `glanceRatio` of 1; the body plan and materials, encumbrance, the protection readings and the repair rule come from `builtin:scifi2d6-realism` (2d6 SciFi: Realism), calibres and attachment slots from `builtin:scifi2d6-ammunition`, and the wound table from `builtin:scifi2d6-wounds`. Each is a switch a world can turn off; with realism off, armour is the plain `dr` value and nothing wears. ### The wound table What a hit that gets through does to a body. A severity die down the side, columns by kind of harm, and in every cell the words the referee reads out plus the parts the engine acts on. ```jsonc "wounds": { "die": "1d10", "bonusPer": 5, "columns": [{ "id": "blade", "label": "Blade", "types": ["slashing", "piercing", "wound:blade"] }, { "id": "other", "label": "Other", "types": ["*"] }], "rows": [ { "min": 1, "max": 4, "band": "Flesh wound", "cells": { "blade": { "text": "A shallow cut. It bleeds.", "bleed": 1 } } }, { "min": 9, "max": 10, "band": "Lethal injury", "cells": { "blade": { "text": "The blade finds an artery.", "conditions": ["unconscious"], "stats": [{ "field": "end", "dice": "2d6", "permanent": true }] }, "other": { "text": "Crushed.", "dead": true } } } ], "bleed": { "field": "bleed", "condition": "bleeding" }, "deadCondition": "dead", "triggers": { "spill": true, "down": true, "hitFraction": 0.5, "pcOnly": true } } ``` A column matches the hit's damage type or a tag the attack carries; `*` is the column for whatever matched nothing else. A cell may apply conditions, add bleed, roll a loss against a vitals bar's field (which is how a system whose pools are its characteristics gets weaker and dies by its own rules), or kill outright. `bleed` names the numeric token field where bleed accumulates and the condition shown while it is above zero; each of the bearer's turns then costs the bleed in damage until the field reaches zero or the condition is removed. `triggers` say when the table rolls at all: on spill past the primary track, on going down, on a single hit worth a fraction of the maximum, and only for PCs automatically (the GM rolls for everything else from the token menu). Every wound is posted to chat. ## Weapon properties `properties` is a vocabulary of named effect bundles a weapon carries as tags. ```jsonc { "id": "properties", "properties": [ { "id": "bypass", "label": "Armour-defeating", "description": "Ignores flat soak.", "effects": { "ignoreDR": true } }, { "id": "sundering", "label": "Sundering", "scaled": true, "effects": { "armorWear": 1 } }, { "id": "brittle", "label": "Brittle", "effects": { "selfWear": 2, "note": "Snaps on a natural 1." } } ]} ``` Effects the resolver can carry out: `ap` (soak ignored), `ignoreDR`, `rerollDamageBelow`, `critOn`, `armorWear`, `selfWear`, `nonlethal`, and for a system with threshold wounds `critUpgrade` and `woundUpgrade`. A rule only a human can adjudicate goes in `note` and is shown as text, not faked. A `scaled` property reads its number off the tag (`sundering:2`). Merge: union by id. ## Region switches `regionSystems` names the switches a region can carry, so the engine never hardcodes what "power" or an "alarm" is. ```jsonc { "id": "ship-systems", "switches": [ { "id": "power", "label": "Mains power", "default": true, "icon": "power", "lights": { "types": ["ceiling", "console"], "liveWhen": "on" }, "sounds": { "tags": ["hum"], "liveWhen": "on" }, "cues": { "off": "breaker-trip" }, "darkensInterior": true, "locksAutoDoors": true, "staggerOn": true }, { "id": "alarm", "label": "Alarm", "default": false, "icon": "siren", "lights": { "types": ["alarm"], "liveWhen": "on" }, "sounds": { "tags": ["alarm"], "liveWhen": "on" } } ]} ``` A switch governs lights by type and sound emitters by tag; ungoverned ones are always live. `darkensInterior` throws an interior into true blackout, `locksAutoDoors` leaves automatic doors dead and shut for players, `staggerOn` plays the lights back one by one. The GM flips switches from the table's Systems menu; a ship's power board and sealed compartments flip them too. Merge: last wins. Absent: no Systems menu. ## Sounds The system ships the sounds its setting is made of, so the GM's board starts populated. ```jsonc { "id": "ship-sounds", "sounds": [ { "id": "hatch", "label": "Hatch hiss", "source": { "kind": "synth", "preset": "hiss" }, "loop": false, "tags": ["door"] }, { "id": "hum", "label": "Hull hum", "source": { "kind": "synth", "preset": "hum" }, "loop": true, "gain": 0.4, "tags": ["hum"] } ], "hooks": { "doorOpen": "hatch", "doorClose": "hatch" } } ``` A sound is a file the pack ships or a synth preset the client renders. Tags are the words region switches bind to. `hooks` map the moments the engine knows (`doorOpen`, `doorClose`, `containerOpen`, `lampFault`, `autoDoor`) to a sound id. Merge: libraries accumulate across the chain (keyed by pack and id). ## Calendar and time skips `calendar` is the world's own year: months and their lengths, weekdays, an era, leap rules, moons, seasons, and the length of a day. Nothing here is Gregorian; the engine only needs it to be consistent. Studio's calendar panel picks seasons and leap days by month name and draws the year strip, which is the safest way to author one. `timeUnits` adds the table's two quick-skip buttons (a short and a long jump) in your units. Merge: both last-wins. ## GM screen cards `gmScreen` ships the starter reference cards a GM's prep desk is populated from: condition summaries, a travel-pace table, a crit table. Each card is plain text with line breaks, an optional tint, and a preferred size. The desk holds a snapshot; repopulating lays down the fresh set. Merge: union by card id. The validator caps a facet at 60 cards. ## Theme Restyle the whole experience with design-token overrides, web fonts, and a skin, safely enough for a sandboxed module. ```jsonc { "id": "bureau", "displayName": "Bureau Records", "tokens": { "--font-display": "'Special Elite', monospace", "--radius-md": "0px" }, "dark": { "--color-bg": "#14110c" }, "light": { "--color-bg": "#efe6d2" }, "fonts": [{ "family": "Special Elite", "google": "Special+Elite" }], "librarySkin": "federal", "containerSkin": "industrial" } ``` - Token names must start with one of `--color-`, `--font-`, `--text-`, `--weight-`, `--line-`, `--tracking-`, `--space-`, `--radius-`, `--shadow-`, `--ease-`, `--duration-`, `--lib-`, or be `--hairline` or `--focus-ring-color`. Anything else is silently dropped, which is why Studio's theme panel checks every row before you save. - Values may hold colours, font stacks, shadows, `calc()`, `var()`. No `url()`, no imports, no script: that is what keeps theming safe. - `dark` and `light` apply only under that base theme. Fonts load from Google Fonts only. `librarySkin` and `containerSkin` pick host-owned skins. Merge: tokens shallow-merge (later wins per key), fonts union by family, skins last-wins. `builtin:fbc-control` and `builtin:scifi2d6-orbital` are two complete themes. ## Star charts `astrography` is the vocabulary that lets the engine draw a star chart without knowing anything about the setting. Its presence is the whole feature gate: a world without it never sees a chart tool, layer or button. - `fields` name the keys in a world's data bag, in display order, with decode `tables` (a code letter to words) and hover `details`. - `glyph` draws a world declaratively: dot size, colour by field, a ring when a field is set, a caption field, a mark for GM notes. - `links` are outbound URL templates for a world's detail card; `routeKinds` draw different lanes different ways with a key. - `loreField` renders as prose; `noteField` is the GM's private note and `gmFields` are stripped from every player's copy of the chart server-side. - `trade` makes ports sell: shelves of catalog categories gated by port grade and law level, markups by grade, restock days, and a haggle formula. No trade block, no markets. - `unit` and `hex` set what a hex step is called and the grid geometry. Merge: the chart is last wins as a whole; `trade` merges on its own, from the last facet in the chain that declares it, so a module can ship `{ id, displayName, trade }` and put a market on the system's chart without restating it. `builtin:scifi2d6` is the reference chart and `builtin:scifi2d6-trade` the market on it. ## Languages `languages` lists the tongues a world's characters can speak and read. The engine uses them for one thing: a library volume can be written in one, and a reader whose characters do not know it gets gibberish instead of the words. ```jsonc { "id": "my-languages", "languages": [ { "id": "common", "name": "Common", "script": "latin" }, { "id": "dwarvish", "name": "Dwarvish", "script": "runic", "aliases": ["Dwarven"] }, { "id": "elvish", "name": "Elvish", "script": "flowing", "description": "Looping, joined strokes." } ]} ``` - `script` is how the writing LOOKS to someone who cannot read it: `latin`, `runic`, `flowing`, `angular`, `glyphs` or `dots` (default `latin`). Each language also draws from its own slice of its script's letters, so two languages in one script still look different. - `aliases` are other names a sheet may use for it. **Who knows what.** Per character at a table, the union of two sources: 1. The sheet field the system binds to the semantic key `languages` (in its `bindings` facet, `{ "bindings": { "languages": "languages" } }`). It is read as free text or a list: split on commas, semicolons, new lines, slashes and "and", notes in brackets dropped, each piece matched to a language's name, id or alias ignoring case and punctuation. Unmatched pieces are shown to the GM. 2. Languages the table's GM gives the character in the table's **Languages** tab. The GM can also add custom languages (a name and a script) to the world there. A viewer reads a language when any character they control at that table knows it. The table's GM reads everything. **Reading.** A GM sets a volume's language in the library editor, or on a volume open at the table. When the server hands that volume to someone who cannot read it, it rewrites every page first: the same paragraphs, headings, lists, spacing and punctuation, words of about the same length, in the language's letters, the same every time. Links, mentions and alt text go with the words; a PDF page is replaced by made-up paragraphs. The response marks the volume `unreadable` and the reader says what it is written in. The real text never leaves the server for that viewer, and a player who cannot read a volume cannot write in it either. Merge: union by id; a later package's language replaces an earlier one in place. Read on the campaign chain at a table. The Languages tab only appears on the rail when the table's chain declares languages or the world has custom ones. The `languages` and `dnd5e-languages` marketplace packs are the reference. --- # Shipping content Rules say how a world works. Content is the stuff in it: monsters, items, spells, books, what a wolf leaves behind, what a locker holds. A pack ships content through four facets, and none of it is copied into a world until a GM asks for it. ## The catalog A `catalog` facet is a compendium of predefined entries. It stays pack-side and read-only until a GM **materializes** an entry from the world's Compendium screen ("Add to world"), which copies one real entity, with its sheet profile and token framing, into the world. A world that never touches the goblin never carries the goblin row. ```jsonc { "id": "monsters", "displayName": "Monsters", "group": "Monsters", "entries": [ { "key": "goblin", "kind": "npc", "name": "Goblin", "summary": "Small, mean, plentiful.", "tags": ["Humanoid", "Goblinoid"], "image": "https://example.com/goblin.webp", "token": { "size": 1, "borderId": "iron", "imgScale": 1.1 }, "profile": { "hp": 7, "hp_max": 7, "ac": 15, "str": 8, "dex": 14, "attacks": [{ "id": "scimitar", "name": "Scimitar", "attack": { "bonus": 4 }, "damage": [{ "dice": "1d6+2", "type": "slashing" }] }] }, "article": { "type": "doc", "content": [] } } ]} ``` Write it by hand, or author it in **Studio's Compendium part**, which is the same JSON edited through the sheet the entry will render in: groups down the left, the entry through its real `DynamicSheet` on the right, with the key, name, summary, tags, image and token framing above it. That is the shorter path for anything with stats, because the field ids you type into are the ones the target system actually resolves rather than ones you copied out of a template. See [Studio](https://divinevtt.com/docs/guides/studio#the-compendium). - `key` is the provenance id: stable within the pack, used to dedupe and to re-sync copies when the pack updates. It is 1 to 127 characters, lowercase letters, digits, `-` and `_`, and unique within its facet. - `kind` is one of `npc`, `item`, `spell`, `faction`, `event`, `lore`, `quest`, `region`, or a kind a system declared for itself (a ship, a stronghold). Whether a given world can store one is decided where the entry is materialized, not here. - `profile` is keyed to the **target system's** sheet field ids and merged over the resolved template defaults at materialize time. A materialized monster then renders through the same sheet, info panel and attack pipeline as one built by hand. The row shapes for `attacks`, `inventory` and friends are on [Stored data types](https://divinevtt.com/docs/reference/data-types). - `tags` are catalog-side labels; materializing mirrors them into world tags, which is what drop tables match on. - `token` carries prototype-token framing for `npc` and `pc` entries. - `time` places an `event` on the timeline; `book` turns a `lore` entry into a readable artifact (see below). - `group` is the heading the browser files the bundle under. ### Overriding another pack's entries An entry with `overrides` adds nothing. It **patches** an entry an earlier pack in the chain shipped, named as `"::"` or a bare key matched across the chain. Profile keys deep-merge (arrays replace wholesale); name, summary, tags, image, article and token replace when given. Studio's **Override an existing entry…** builds one for you: it lists the entries of every pack this one extends or requires, fills in the `overrides` reference, and then shows each overridable section as a switch. A section left alone is absent from the saved patch and keeps following the original, so an override stays a diff rather than drifting into a copy. This is how `builtin:realistic-weapons` re-skins the SRD weapons with soak-aware attacks, and it reverts the instant the module is switched off. Entries already materialized into a world are untouched. An override whose target is not in the chain is dropped silently. ### Books and scrolls A `lore` entry with a `book` reads as a library artifact: ```jsonc { "key": "field-manual", "kind": "lore", "name": "Field Manual", "book": { "format": "book", "pages": [ { "title": "Cover", "image": "https://example.com/manual-1.webp" }, { "title": "Chapter one", "content": { "type": "doc", "content": [] } } ]}} ``` `format` is `book`, `notebook`, `scroll`, `letter` or `loose_pages`. A page is either a rich-text document or a rasterized image (a PDF page rendered to WebP), and image pages are uneditable. ### Images `image` and `token.image` are absolute URLs or pack-relative paths. A path is resolved against the pack's own [files](https://divinevtt.com/docs/guides/packages#files-a-package-ships), so ship the artwork with the pack (`assets/goblin.webp`) rather than pointing at somebody else's server. That works the same for a built-in reading off disk and for an uploaded pack reading its stored files. Studio's image boxes list the pack's own image files and still accept a full `https://` link. The images a pack uploads for the marketplace (a cover, screenshots) are for its listing, not for content. ## Drop tables What a **kind** of creature carries, authored once against a world tag instead of onto forty sheets. Searching a body rolls what the creature itself carries plus every drop table whose tag it wears. ```jsonc { "id": "beasts", "displayName": "Beasts", "tag": "Beast", "entries": [ { "id": "beasts-0", "name": "builtin:dnd5e-treasure::pelt", "chance": 80, "qtyMin": 1, "qtyMax": 1 }, { "id": "beasts-1", "name": "Sinew", "chance": 50, "qtyMin": 1, "qtyMax": 3 } ]} ``` The tag is matched by label, case-insensitively, because a pack cannot know the ids a world generated for its own tags. Entries are `drops` rows: `chance` is a percentage from 0 to 100, and `name` is either a catalog ref (`::`, materialized when it drops) or plain text. Merge: union by table id, so a module can retune one table without restating the set. `builtin:dnd5e-creature-drops` and `builtin:dnd5e-treasure` are the references. ## Loot tables What a GM fills a container from. A system ships its tables (a wreck's lockers, a garrison armoury) and the qualities every table can be rolled at; the loot window offers both as pickers and a button. ```jsonc { "id": "lockers", "qualities": [ { "id": "poor", "label": "Picked over", "rolls": [1, 2], "maxTier": 1 }, { "id": "rich", "label": "Untouched", "rolls": [4, 8], "maxTier": 3 } ], "tables": [ { "id": "galley", "label": "Galley stores", "entries": [ { "ref": "builtin:scifi2d6::tv-rations", "weight": 5, "qtyMin": 2, "qtyMax": 10, "tier": 1 }, { "ref": "builtin:scifi2d6::tv-medkit", "weight": 1, "tier": 2 } ]} ]} ``` A quality decides how many picks a fill makes and the deepest tier it reaches; entries are weighted against the others the quality can reach. Entries name catalog refs, which materialize on the fill. Merge: tables union by id, the qualities are the last facet's that declares any. Absent: the fill control is hidden at the table. 2d6 SciFi's salvage tables ship in `builtin:scifi2d6-trade`, not in the base system, so a world with that module off fills by hand. ## GM screen cards The `gmScreen` facet is content too: the reference cards a GM's prep desk starts with. It is covered with the other rules facets on the [Rules facets](https://divinevtt.com/docs/guides/rules#gm-screen-cards) page. ## Sounds A `sounds` facet ships the sounds a setting is made of, as files or synth presets, and binds them to the moments the engine knows. See [Rules facets](https://divinevtt.com/docs/guides/rules#sounds). --- # Importing characters and monsters A character someone already built on D&D Beyond, a party that lives in Foundry VTT, a stack of monsters from a 5etools file: you can bring them into a world instead of typing them in again. The import fills this world's own sheets, so what arrives is a normal character or NPC you can edit, roll from, and put on the map. Importers come with a world's system. A D&D 5e world has three, from the **D&D 5e: Importers** module, which every 5e world has switched on unless its GM turned it off under Modules. ## Where to import - **At the table**, the GM opens Characters and presses **Import** beside "New character". Player characters and NPCs made here belong to this table, like any character made at it. - **In the world editor**, the NPC library has **Import** beside "New NPC", for creatures and people that belong to the whole world. Choose the importer, give it the link or the file, and press **Read**. Nothing is saved yet: the dialog shows what it made and what it could not carry over. When a file holds several monsters, tick the ones you want, then **Import**. ## D&D Beyond Paste the character's link, such as `https://www.dndbeyond.com/characters/12345678` (a share link works too), or just its number. - **A public character** needs nothing else. On D&D Beyond, a character's privacy setting (in its character settings) must be **Public**. - **A private character** can be imported as yourself: paste your D&D Beyond `CobaltSession` cookie in the field below the link. In a browser signed in to D&D Beyond, open the developer tools, find the cookie named `CobaltSession` for dndbeyond.com, and copy its value. The server trades it with D&D Beyond for a short-lived key, fetches the character once, and forgets both. The cookie is never stored or written to a log, and it is only ever sent to dndbeyond.com. It changes when you sign out of D&D Beyond. D&D Beyond does not offer this as a public service, and it can change without notice. If an import that used to work stops, the character is still yours on D&D Beyond; tell us and we will look. The portrait comes across when the character has one. ## Foundry VTT In Foundry, right-click a character or NPC in the Actors directory and choose **Export Data**. Choose that `.json` file in the dialog (or paste its contents). Characters go to the player character sheet, NPCs to the creature sheet. Exports from the dnd5e system 3.x and from 4.x and later both work. Foundry keeps its images on your Foundry server, so the portrait does not come across; add it on the sheet. ## 5etools Choose a bestiary file (the kind with a `"monster"` list) or paste one monster's JSON. Every monster in the file is listed; pick the ones you want. Monsters that 5etools stores as a copy of another ("like a goblin, but...") cannot be imported on their own and are listed as skipped. Lair actions and regional effects live in a separate 5etools file, so they are not part of a monster's import. ## What comes across For a character: ability scores (worked out the way D&D Beyond does, with species and feat bonuses, ability score improvements and any overrides), hit points with current and temporary ones, armor class, saving throw and skill proficiency (expertise included), speed and senses, languages and armor, weapon and tool proficiencies, class, subclass, level, species and background, alignment, experience, currency, class and species features, feats and the background feature as text, personality, appearance and backstory, attacks for equipped weapons, spell slots and the spellbook with what is prepared, and the inventory with quantities and what is equipped. For a creature: the whole statblock (size, type, alignment, armor class, hit points and hit dice, speed, abilities, saves, skills, senses, languages, resistances, immunities, vulnerabilities, condition immunities, challenge and XP), traits, and every action that rolls as an action row with its attack bonus, damage dice and type, and save DC. Legendary actions, reactions and anything that does not roll come across as traits, with their text. **Multiclass characters keep every class** on the automated 5e sheet: each with its subclass, level and hit die, the class they started in first (D&D Beyond marks it; Foundry's is the original class). Spent hit dice are kept per die size, and a warlock's Pact Magic slots stay a pool of their own beside the other spell slots. On the plain 5e sheet the class field holds the whole line, "Fighter 5 / Rogue 3". **Items and spells are matched to the world's compendium by name.** A longsword, a cloak of protection or fireball arrives as the compendium's entry, linked the same way "Add to world" links it, so its rules come with it. Anything the compendium does not have (homebrew gear, spells from other books) arrives as a row with its own name and text in the notes. ## What does not The dialog lists what it could not carry for each import, and nothing is dropped silently. The usual ones: - **Choices the sheet does not offer**: a species or subclass that is not in the automated sheet's list is left empty and noted. The plain 5e sheet takes any name. - **Half proficiency** (Jack of All Trades) and **bonuses to saving throws** from items or auras, which the sheet has no place for. - **Pact Magic** on the plain 5e sheet: its slots are added to the slots of their level, so they refresh with the others on a long rest. The automated sheet keeps them as their own pool (see below). - Class and species **actions** that are not weapon or spell attacks come across as feature text, not as buttons. - A **+1 weapon** is linked to the plain compendium weapon, and the bonus is in the row's notes. When the world runs the **D&D 5e: Feats** module, a D&D Beyond or Foundry character's feats are linked to its Feats tab instead of arriving as text: matched by name, the 2014 or 2024 version picked by what the source says, and the choices the source recorded filled in. The source's scores and proficiencies already include the feats, so linking changes none of them. A feat the module does not know stays in the features list. When the world runs **Item Automation**, equipped weapons and armor already bring their own attacks and armor class, so the import leaves those to it. --- # Scripting with the api Declarative facets cover most packages. A script is for behavior the JSON can't express: formula functions, custom sheet nodes and panels, derived action groups, pushed effects, chat commands, drawing on the map, and reacting to what happens at the table. A scripted package ships an ES module whose default export is a `register` function. ```js // main.js export default function register(api) { // synchronous registration return { exports: {}, // optional: other packages read this via api.packs.get(id) dispose() {}, // optional: called on disable / uninstall }; } ``` `register` runs once per load. Do all registration synchronously and return. See [The api object](https://divinevtt.com/docs/reference/api) for the full surface. ## More than one file `manifest.script` names the **entry**. Every other `.js` or `.mjs` the package [ships as a file](https://divinevtt.com/docs/guides/packages#files-a-package-ships) is a module the entry can import, so a script that outgrows one file splits the way any ES module does: ```js // main.js import { rollTable } from "./lib/tables.js"; export default function register(api) { /* ... */ } ``` - Specifiers are **relative**: `./lib/tables.js`, `../shared.js`. Write the extension; the resolver does no guessing. A bare specifier is left exactly as written and fails the way it would in any browser, because there is no package manager inside a pack. - The file has to be one the package ships. A missing one is refused before the script runs, naming both sides: `"main.js" imports "./lib/tables.js" but "lib/tables.js" is not in the pack`. - Cycles are refused too, with the chain spelled out: `import cycle: main.js -> lib/a.js -> lib/b.js -> lib/a.js`. Both errors arrive as a normal load failure, so a broken graph badges the package rather than dying silently inside the frame. - Only `.js` and `.mjs` are modules. JSON, Markdown and CSS a package ships are files it can serve, not files it can import. - `import()` at runtime is not rewritten, so use static imports. Non-code files live under `assets/` and are reached by URL: ```js const thud = new Audio(api.assets.url("thud.ogg")); ``` The argument is the path inside `assets/`. The URL is the same shape whether the package is a built-in reading off disk or an uploaded one reading its stored files, and building it needs no permission because the server gates the fetch. In Studio, a file's **Copy api.assets.url(…)** button writes the call for you. ## Lifecycle The host tracks every registration and subscription a script makes. Disabling a package runs its `dispose`, tears all of them down, and inertifies its `api` handle: a retained reference that calls the API afterwards logs a warning and no-ops. So you rarely clean up registrations yourself. Do clean up your own resources (timers, caches, pushed effects) in `dispose` and in a node's `unmount`. A package whose script fails to import, or whose `register` throws, is isolated: its declarative facets still apply, the package is badged "script error", and the rest of the app keeps running. ## Execution model Know what this is before you ship a script. - Scripts run on the client. There is no server-side script execution. - First-party built-ins are **trusted** and import into the app's own realm. Every other pack is **sandboxed**: it runs in an isolated frame with no access to the app's cookies, storage, DOM or network, and every privileged `api` call is checked against the permissions the manifest declares. The GM confirms those permissions before enabling the module. A call the manifest did not ask for rejects with a message naming what it needed, so a missing declaration reads as a missing declaration and not as a broken api. See [Permissions and the sandbox](https://divinevtt.com/docs/guides/sandbox#when-a-call-is-refused). - `api.data` adds no privilege and removes none. The server enforces its ACL exactly as it does for the app, so a module can never read or write more than the user running it could. - Every callback (hooks, formula functions, renderers, action handlers) is wrapped. A throwing callback is logged and isolated to your package. After repeated failures the package is runtime-disabled; a hung sandboxed module is terminated. Module renderers mount inside error boundaries, and the screen hosting the disable-package UI never mounts module renderers, so a broken package can't brick its own kill switch. - Server calls are rate-limited per pack, and `api.ui.confirm` has a cooldown. ## Multiplayer execution Every client loads the same server-resolved, version-pinned pack set and runs every enabled script. Side-effecting hook reactions fire on every client, so when you react to a [hook](https://divinevtt.com/docs/reference/hooks) with a server write, gate it on the acting user so it happens once, not once per connected client. Work that needs GM authority goes through `api.relay`: send a message to the GM's client and let it act. ## Settings The `settings` your manifest declares are what a GM fills in on the world's Modules screen, and a campaign can override any of them for its own table. `api.settings.get(key)` reads the result: the world's value, the table's over it, and the field's `default` when neither is set (or when a stored value no longer has the type the default has). `api.settings.set(key, value)` (api 1.4) is how a table keeps its own values without anyone opening the world: it writes the campaign's value and never the world's. Only the table's GM may call it, the server tells every screen at the table, and each copy of your package gets a `settings.changed` hook, so redraw from there rather than from the promise: ```js const TABLE_KEY = "tableRules"; api.hooks.on("settings.changed", (p) => { if (p.packId === api.packId) api.ui.refreshPanel("table.sidebar"); }); // The world's list stays the base; a table adds to it under its own key. const worldRules = () => String(api.settings.get("rules") ?? "").split("\n").filter(Boolean); const tableRules = () => api.settings.get(TABLE_KEY) ?? []; const addRule = (text) => api.settings.set(TABLE_KEY, [...tableRules(), text]); ``` A key the manifest does not declare is fine: it does not show in the settings screens, and it reads back through `get`. Keep a table's additions under their own key, as above, so the world's value is never overwritten from a table. The Safety Tools marketplace pack keeps its lines and veils this way. ## Registries & namespacing Every name you register is auto-qualified to `packId:name`, in the live registry and in persisted references (sheet templates, stored formulas, chat history). Core uses unqualified names, so everything unprefixed is reserved for first-party use. - Registering the same name twice from one package warns and last-wins. That's what supports hot-reload during development. - Two packages can't collide. A name is always prefixed with the calling package's id, even if you pass a name that already contains a colon, so you can't register into another package's namespace. Build qualified ids with `api.ids.field` and `api.ids.nodeType` rather than assembling strings by hand. See [namespacing](https://divinevtt.com/docs/reference/nodes) for the dash rules. ## Formula functions ```js api.register.formulaFunction("half", { params: ["x"], expr: "floor(x / 2)" }); ``` Callable from any `computed` or `roll` node as `packId:half(level)` (the prefix normalizes dashes to underscores). `expr` is a pack formula over the named parameters. Use this form: it works sandboxed and trusted alike. The older JS-function form (`(x) => Math.floor(x / 2)`) registers but is never called for a sandboxed pack, because the host evaluates formulas synchronously and cannot reach into the frame. Pack lint flags it. A function that fails evaluates to `null` and the referencing field badges instead of crashing. See [Formulas](https://divinevtt.com/docs/guides/character-sheets#formulas). ## Sheet node renderers A custom node is registered with a renderer. Two shapes exist: **Declarative** (works on both trust levels): `present(ctx)` returns a presentation the host draws, and `onAction(action, arg, ctx)` handles the controls it declared. This is the portable form, and the one Studio's script templates write. The vocabulary (lines, entries, buttons, toggles, pips, steppers, inputs, selects, and groups to arrange them) is in [Permissions and the sandbox](https://divinevtt.com/docs/guides/sandbox#declarative-renderers). ```js api.register.sheetNodeType("statblock", { present(ctx) { return { title: "Stat block", lines: [{ text: `Level ${ctx.value ?? 1}` }], controls: [{ kind: "stepper", label: "Level", value: ctx.value ?? 1, action: "level", min: 1, max: 20 }] }; }, async onAction(action, arg, ctx) { // A stepper hands you the new value, already clamped to min/max. if (action === "level") await api.data.profiles.patch(ctx.entityId, { [api.ids.field("level")]: arg }); }, }); ``` **Mounted** (trusted packs only): `mount(el, ctx)` draws into a host element and returns a per-instance handle. ```ts api.register.sheetNodeType("statblock", { mount(el: HTMLElement, ctx): InstanceHandle | void, update?(ctx): void, // renderer-level fallback unmount?(): void, // renderer-level fallback }); // InstanceHandle = { update?(ctx), unmount?() } ``` The host prefers the instance handle over the renderer-level `update`/`unmount`, so the same node type can mount in several places at once (side-by-side sheets, party views) without the module juggling shared state. Keep caches and listeners inside the mount closure and tear them down in the handle's `unmount`. The mounted node `ctx` carries the node's data plus a live seam into the sheet (`getField`, `setField`, `setFields`, `evalFormula`, `roll`, `rest`). That seam, and the re-render gotchas, are in [Automation patterns](https://divinevtt.com/docs/guides/automation). A sandboxed `present` receives a serializable snapshot instead (`node`, `value`, `entityId`, `entityKind`, `readonly`), because functions cannot cross the frame. To read sibling fields from either side, declare them. A renderer may ship `reads: string[]` beside `present`, and the host snapshots those ids' effective values into `ctx.fields` before every render: ```js api.register.sheetNodeType(api.ids.nodeType("oath-grants"), { reads: ["class_name", "subclass", "level"], present(ctx) { return { lines: [{ text: `${ctx.fields.subclass ?? "No oath"}` }] }; }, }); ``` Same numbers `getField` returns, pushed [effects](https://divinevtt.com/docs/guides/effects) folded in, capped at 64 ids, and `{}` for a node that declares nothing. Inside the sandbox this is the only synchronous sibling read there is; a trusted pack gets it too, so a node written this way behaves the same on both paths. Anything not declared comes from `api.data`. A node that ships `"readonly": true` in its facet JSON receives `ctx.readonly === true` and inert writers. This is required when a node binds a core field id to render a derived view. ## Panels ```js api.register.panel("table.sidebar", { icon: "scroll", present(ctx) { return { title: "Party ledger", lines: [{ text: "Nothing owed." }] }; }, }); ``` A panel uses the same renderer shape as a sheet node but is bound to a host surface rather than a field. The only slot today is `table.sidebar`: every module with a panel there gets its own tab on the table's left rail, named after the module. `icon` picks the tab's icon from a fixed set (`book`, `compass`, `dice`, `flag`, `heart`, `list`, `map`, `music`, `puzzle`, `ruler`, `scroll`, `shield`, `shuffle`, `signature`, `skull`, `sparkles`, `tag`, `timer`, `users`, `vote`, `wand`, and for views `cards`, `club` and `chess`); anything else gets the puzzle piece. The tab already shows the module's name, so the panel's own `title` is not drawn there. Its ctx is the surface's ctx; the field members are inert. At the table it carries `campaignId`, `isGM` and `userId`. `present()` runs when the panel mounts, when the surface's ctx changes (a GM switching to the player view), and again after each of the panel's own actions finishes. While an action runs, the panel's buttons are locked, so a slow roll cannot be started twice by a second press. The host does not know when your own state changes for any other reason. After a relay message, a timer or a tool changes what the panel should say, ask for a redraw (api 1.3): ```js api.relay.on((msg) => { votes.push(msg.vote); api.ui.refreshPanel("table.sidebar"); // or refreshPanel() for all of yours }); ``` A pack has one panel per slot: registering the same slot again replaces it, so re-registering after a change of mode does not stack a second copy. ## Views: drawing your own UI A panel is drawn by the host from a description, which is right for a settings screen and wrong for a hand of cards fanned in an arc or a chess board. For those, a module draws its own screen: a **view** (api 1.5, the `views` permission) is an HTML page in your pack that the host shows in a sandboxed frame, as a rail tab, a floating window or a box over the map. Everything inside it is yours: markup, CSS, animation, pointer handling. ``` my-pack/ pack.json "api": "1.5", "permissions": ["views", "ui"] main.js views/ counter.html counter.css counter.js ``` ```js // main.js - the state lives here, the view draws it export default function register(api) { let count = 0; api.register.view("counter", { title: "Counter", placement: "tab", // "tab" | "window" | "overlay" entry: "views/counter.html", icon: "dice", minHeight: 220, }); const push = () => api.views.post("counter", { type: "state", count }); api.views.on("counter", (msg) => { if (msg?.type === "hello") push(); // a view asks when it starts if (msg?.type === "add") { count += 1; push(); } }); api.register.command("counter", { description: "Open the counter", run: () => api.views.open("counter"), // a command is the person asking }); } ``` ```html
0
``` ```js // views/counter.js - runs inside the frame; window.view is already there const n = document.getElementById("n"); view.on((msg) => { if (msg.type === "state") n.textContent = String(msg.count); }); document.getElementById("add").addEventListener("click", () => view.send({ type: "add" })); view.send({ type: "hello" }); ``` ```css /* views/counter.css - the app's theme tokens are set on :root */ main { font-family: var(--font-ui); color: var(--color-text); display: grid; gap: 8px; padding: 12px; } button { background: var(--color-accent); color: var(--color-text-on-accent); border: 0; border-radius: var(--radius-md); } ``` What to know: - **Where state lives.** Keep the state in the main script and send the view what it should draw. The main script is running whether or not the view is open; a view comes and goes (the person closes the tab, a window is shut), and nothing is queued for a view that is not showing, so a view says hello when it starts and the main script answers. - **`view.api`** is the same api your main script has, minus `register`: `view.api.relay.toGM(...)`, `view.api.dice.roll(...)`, `view.api.hooks.on(...)` all work from the page, each gated by the same permissions. Prefer routing through the main script anyway, so there is one place that decides things. - **Assets.** The frame has no network, so `` pointing at a pack file does not load. Ask for it: `img.src = await view.assets.load("art/king.webp")` (a path under `assets/`, as for `api.assets.url`). The host fetches it once and caches it; `view.assets.preload([...])` warms several before an animation. Stylesheets and scripts the page links with a relative path are inlined when the view starts. A `type="module"` script may import other files in the pack. - **Theme and size.** The app's colours, fonts and radii are CSS custom properties on the page's `:root`, updated when the person switches light and dark (`view.onTheme` hears it). `view.size` is the frame's size and `view.onResize` hears it change; a tab is about 300px wide. - **Overlays and the pointer.** An overlay is a box in the middle of the map. Inside the box your page gets the pointer and the map under it does not; outside it the map works as usual. The page's background is transparent, so draw only what should be seen and keep the box small with `view.resize(width, height)`. - **Opening.** `api.views.open(id)` works only in answer to the person, the same rule as `canvas.activateTool`; calling it from a view the person is using counts. Every view also has a rail button of its own. - **Motion.** Respect `prefers-reduced-motion` in your CSS, animate with transforms, and throttle pointer work to `requestAnimationFrame`: the frame runs on the same thread as the table. ## Action sources Contribute derived, read-only action **groups** into a sheet's actions table. For example, an equipped weapon's attacks appearing inside the wearer's attacks. ```ts api.register.actionSource("attacks", async (ctx) => [ { label: "Longsword +1 (equipped)", sourceId: itemId, actions: [ /* ActionDef[] */ ] }, ]); // ctx: { entityId, profile } (frozen) ``` The slot (`"attacks"`, `"spells"`) is a [semantic binding key](https://divinevtt.com/docs/guides/packages#semantic-bindings), so the source is system-agnostic: the host resolves which actions node the slot maps to. Groups render after the entity's own editable actions and re-query when inventory or any entity's profile changes. Contributed actions are display and automation data. Users edit them on the source, never in the group. [`item-automation`](https://divinevtt.com/docs/examples/item-automation) is the reference. Requires the `grant-actions` permission. ## Chat cards A chat card uses a declarative renderer, so the host owns layout, keyboard activation, drag serialization, and a fallback when the renderer is missing. ```ts api.register.card("attack", { present(ctx) { return { eyebrow: "Attack", title: ctx.payload.name, body: ctx.payload.summary, accent: "#b8935a", badges: ["Action"] }; }, onClick(ctx) { return { type: "open", surface: "sheet", id: ctx.payload.actorId }; }, getDragData(ctx) { return { attackId: ctx.payload.attackId }; }, }); ``` `ctx` is `{ type, payload, message }`, where `message` carries stable message, campaign, author, and timestamp metadata. `present` is required. `onClick` and `getDragData` are independently optional, so a card may be clickable, draggable, both, or display-only. Card messages persist `{ type, payload }` plus fallback text, so history stays readable after a module is removed. Requires the `send-chat` permission. `present` and `onClick` may each return a promise instead of a value. That is what makes a card work from a sandboxed package, whose renderer sits behind a message round trip: the host shows a quiet placeholder for the frame the answer takes, remembers it against the card's type and payload, and redraws. Answer synchronously when you can - one less flicker - but you are not forced to. `getDragData` is the exception: it must be synchronous, because a drag begins on a mousedown that cannot wait. A sandboxed package's drag data is fetched alongside its presentation, so a card that has been drawn can be dragged. ### Action buttons A presentation may add `actions` (api 1.3): small buttons under the card's text. Pressing one calls your `onClick` with `ctx.action` set to that button's id; a click anywhere else never reaches it, because a card with buttons is operated by its buttons and is no longer one big button itself. ```js api.register.card("poll", { present(ctx) { const { question, options, counts, closed } = ctx.payload; return { eyebrow: closed ? "Poll closed" : "Poll", title: question, actions: options.map((label, i) => ({ id: `opt-${i}`, label: `${label} (${counts[i]})`, disabled: closed, })), }; }, onClick(ctx) { if (ctx.action?.startsWith("opt-")) api.relay.broadcast({ vote: Number(ctx.action.slice(4)) }); }, }); ``` | `actions[i]` member | | | --- | --- | | `id` | Handed back as `ctx.action`. Letters, digits, `_ . : -`, up to 64. | | `label` | The button's text, one line. Cut at 40 characters. | | `tone?` | `"primary"`, `"danger"` or `"muted"`; leave it out for the plain button. | | `disabled?` | Drawn but cannot be pressed. | A card draws at most 8 buttons, and only when the renderer has an `onClick` to press them into. They are real buttons, so Tab reaches them and Enter or Space presses them. The presentation is the same for everyone who sees the card (it is cached by type and payload), so a button every viewer sees must check for itself whether this viewer may use it. `onClick` may answer an `open` action for a button exactly as for a click. A server older than api 1.3 draws the card without its buttons, so a package that relies on them declares `"api": "1.3"`. ## Menu items `api.register.menuItem(surface, item)` adds a row to one of the app's menus. `item` is `{ label, onSelect(ctx) }`; `surface` names where it goes: | Surface | Where | ctx | | --- | --- | --- | | `table.token` | Right-click on a token at the table | `surface`, `campaignId`, `tokenId`, `entityId`, `entityKind`, `name`, `isGM` | | `codex.entry` | Right-click a card in the world Codex | `surface`, `worldId`, `entityId`, `entityKind`, `name` | | `library.entry` | Right-click a row in an entity library | `surface`, `worldId`, `entityId`, `entityKind`, `name` | ```ts api.register.menuItem("table.token", { label: "Send to the forge", onSelect(ctx) { api.relay.toGM({ kind: "forge", entityId: ctx.entityId }); }, }); ``` Module rows are drawn after the app's own, behind a separator and under a "From modules" heading, so nothing you add can stand where a built-in action stands. Labels are one line and are trimmed; one package contributes at most 4 rows to a surface, and a surface draws at most 12 across every package. A surface id the app does not have registers and is never shown - pack lint flags it. Requires the `ui` permission. ## Chat commands `api.register.command(name, spec)` adds a slash command to the table's chat box, on the desktop and on a phone. This is the whole of a module that hands out notes as draggable cards: ```js export default function register(api) { api.register.card("paper", { present(ctx) { return { eyebrow: "Paper", title: ctx.payload.name, body: ctx.payload.text }; }, getDragData(ctx) { return ctx.payload; }, }); api.register.command("paper", { description: "Hand the table a note", usage: '"" ""', async run(ctx) { const [name, text = ""] = ctx.argv; if (!name) throw new Error('Say what it is called: /paper "Old map" "A torn corner"'); await api.chat.post("paper", { name, text }, `${name}: ${text}`); }, }); } ``` - `run(ctx)` gets `args` (everything after the name), `argv` (split on spaces, a `"quoted run"` kept whole), `campaignId`, `isGM` and `userId`. - A command only runs at a table, so inside `run` the table-only namespaces (`api.chat`, `api.dice`, `api.sheets`) are there, given the permissions. - Throw, or reject, to tell the person what went wrong. The message is shown to them and their text stays in the box. - `gmOnly: true` hides the command from players. It is not a boundary; what the handler does is checked like any other call. - People type the short name. The app's own commands keep theirs: `roll`, `r`, `gmroll`, `gr`, `me`, `emote`, `ooc`, `check`, `c`, `init`, `help`, `next`, `damage`, `heal`, `time`, `advance`, `weather` and `sound`, listed with what they do on [Chat commands](https://divinevtt.com/docs/reference/chat-commands). A package command with one of those names answers only to `/packId:name`, and pack lint warns about it. Two packages with the same name make it ambiguous; `/packId:name` always works. The [api reference](https://divinevtt.com/docs/reference/api#chat-commands) has the rules. - `args` (api 1.3) turns the usage into hints: once the name is typed, the chat box marks the argument being typed and offers its `options`. A `complete(ctx)` adds suggestions worked out as the person types (`ctx.argIndex`, `ctx.args`, `ctx.partial`). See [Argument hints](https://divinevtt.com/docs/reference/api#argument-hints). Requires the `ui` permission, checked when you register. ## Posting to chat and rolling dice At a table the api carries more namespaces: `api.chat`, `api.dice` and `api.sheets`. They are absent anywhere else, so check before calling from code that also runs in the world editor: ```js if (typeof api.dice === "object") { const roll = await api.dice.roll("1d20+2", { label: "Luck" }); if (roll.total >= 15) await api.chat.post("omen", { total: roll.total }, `A good omen (${roll.total})`); } ``` - `api.chat.post(type, payload, fallback)` posts a card of one of your own registered types. Needs `send-chat`. `api.chat.update(messageId, payload, fallback?)` (api 1.3) rewrites one you posted, which is how a poll card keeps its tally current: the poster's client owns the card and updates it as votes arrive over `api.relay`. - `api.dice.roll(expression, opts)` rolls on the server, posts the roll, and returns it. Decide what the roll means from that result, never from a roll of your own: the table sees the server's dice. Leave `private` out and the person's own "roll privately" switch applies. Needs `roll-dice`. - A roll can be read the way the system reads its own: name an outcome table with `outcomes` and answer its questions with `context` (`{ position: "desperate" }`). The result then carries `outcome`, the band it fell in, so a module reacts to "a partial success" without restating the table's numbers. ```js const r = await api.dice.roll("2d6kh1", { label: "Prowl", entityId, outcomes: "fitd-action", context: { position: "desperate" }, }); if (r.outcome && r.outcome.tone === "fail") { /* the consequence lands */ } ``` - `api.sheets.get(entityId)` and `api.sheets.patch(entityId, patch)` read and change a character's sheet from somewhere with no sheet open, such as a command. A patch may only name fields the sheet declares, and the server applies the person's own grants: a player changes only a character they may edit. Needs `read-world` and `write-character`. ## Rolling on random tables Packages ship random tables as data (the [`rollTables` facet](https://divinevtt.com/docs/guides/packages#random-tables)); `api.tables` (api 1.3) reads them, in the world editor and at a table alike. Rolling stays with you, so the table sees the dice: roll the table's `formula`, then take the row whose `range` holds the total. A row that names another `table` rolls that one too, `count` times. This command does the lot, nested tables included: ```js async function rollOn(id, depth = 0) { const table = await api.tables.get(id); if (!table || depth > 4) return []; const { total } = await api.dice.roll(table.formula, { label: table.name }); // The row whose span holds the total; outside the table, the nearest end. const low = total < table.min; const row = table.rows.find((r) => total >= r.range[0] && total <= r.range[1]) ?? table.rows.find((r) => (low ? r.range[0] === table.min : r.range[1] === table.max)); const lines = row.text ? [row.text] : []; if (row.table) { const times = /^\d+$/.test(row.count ?? "1") ? Number(row.count ?? 1) : (await api.dice.roll(row.count)).total; for (let i = 0; i < times; i++) lines.push(...await rollOn(row.table, depth + 1)); } return lines; } api.register.command("table", { description: "Roll on one of the world's random tables", args: [{ name: "table", description: "Which table" }], async complete(ctx) { const tables = await api.tables.list({ search: ctx.partial }); return tables.map((t) => ({ value: t.id, label: t.name, description: t.description })); }, async run(ctx) { const lines = await rollOn(ctx.argv[0]); api.ui.toast("message", lines.join("; ") || "Nothing on that table."); }, }); ``` The depth limit matters: a table may name itself. Needs `read-world` to read, `roll-dice` to roll and `ui` for the command. The shapes are in [the api reference](https://divinevtt.com/docs/reference/api#api-tables). ## Drawing on the map `api.canvas` lets a module draw on the table map and `api.register.canvasTool` lets it offer a tool that works the map with the pointer: a measuring template, a ping, an area of effect, a better pen. Both need the `canvas` permission, and `api.canvas` exists only at a table. You never touch a canvas. You SET a named layer to a list of plain shapes and the host draws them; your tool RECEIVES pointer events and answers by setting shapes again. That is what lets the same module run sandboxed, where no canvas or Pixi object can reach you. ```js export default function register(api) { if (typeof api.canvas !== "object") return; // not at a table api.register.canvasTool("ping", { label: "Ping", onPointer(e) { if (e.type !== "down") return; api.canvas.draw("ping", [ { kind: "circle", x: e.x, y: e.y, radius: 40, stroke: "#ffdd55", width: 3, pulse: true }, ]); }, onDeactivate() { api.canvas.clear("ping"); }, }); } ``` What to know before you draw: - **Space.** `x`, `y`, radii and points are the focused map's image px, so a shape stays on the thing it marks through any pan or zoom, on every screen. Widths, dashes and text sizes are screen px. The pointer event hands you the grid cell under the pointer (`e.cell`) and `e.scale`, the screen px per image px right now; `api.canvas.view()` gives the map's size and grid, so distances in grid units are `pixels / grid.size * grid.unitsPerCell`. - **One layer, one list.** `draw(layer, shapes)` replaces the whole layer, so a drag redraws its preview on every move; that is cheap, and moves arrive at most once a frame. Use a few named layers ("preview", "placed") rather than one per shape. Layers belong to a map: one drawn on another map waits there until the table comes back. - **Local by default.** Nothing you draw is saved or sent. When the whole table should see a template, broadcast its description over [`api.relay`](#talking-across-clients) and let every copy of your module draw it. The relay's size cap and per-pack delivery apply, and the shape limits apply again on every client that draws. - **Under the fog.** Overlays sit above the map and below the fog of war and the tokens. A shape in a room a player cannot see is hidden from them the way the room is, whatever your module was told. The GM sees everything. - **Tools.** Your button appears in the table's Drawing panel after the app's own. While held, the map's presses and moves come to `onPointer` instead of panning it; Escape, a right-click or another tool puts yours down and calls `onDeactivate`, which is where a preview gets cleared. `gmOnly: true` hides the tool from players; it is not a security check. - **Right button and keys (api 1.3).** `rightClick: true` gives your tool the right button too (a right-click to rotate, or to cancel a drag), so only Escape puts it down. `onKey(e)` hears keys while the tool is held, never while the person types in a field; list the ones you use in `keys` and they stop reaching the table's own shortcuts (`t` opens the token palette and WASD moves a selected token). - **Picking a tool up for the person (api 1.3).** `api.canvas.activateTool(name)` holds one of your own tools, so a `/fireball` command or a "Place" button in your panel can put the template in their hand. It works only in answer to them, within 5 seconds of them using your module (a command, a control, a card or menu click, your map tool, or another module they used calling your exports), and throws anywhere else, so a module can never take the pointer at a moment nobody chose. - **Cleanup is automatic.** Disabling the pack drops its layers and its tools. ```js api.register.canvasTool("template", { label: "Template", rightClick: true, // right button rotates instead of letting go keys: ["r", "Delete"], onPointer(e) { if (e.button === 2 && e.type === "down") rotate(); else drag(e); }, onKey(e) { if (e.key === "Delete") removeLast(); else if (e.key.toLowerCase() === "r") rotate(); }, }); api.register.command("fireball", { description: "Aim a 20 ft sphere", run() { arm({ shape: "circle", size: 20 }); api.canvas.activateTool("template"); }, }); ``` The shapes, their styles and the limits are in [The api object](https://divinevtt.com/docs/reference/api#api-canvas). [Example: circle template](https://divinevtt.com/docs/examples/circle-template) is a whole module, a tool that drags out a circle template and labels its radius. ## Tokens and turns `api.tokens` (api 1.6) reads the tokens on the table's map and moves them; the `token.*`, `combat.*` and `door.changed` hooks say when they change. All of it needs the `tokens` permission and exists only at a table. A module reads what the person at the screen sees, never more: a player's copy of your module gets no exact hp for creatures they do not control, no name for one they cannot name, and nothing at all about a token hidden from them in the dark. Moves and changes go through the table's own routes, so a player's copy can move only their own tokens, and placing, removing and conditions are the GM's. This one follows the active combatant: every screen rings the token whose turn it is, and the ring goes with it when it moves. ```js // pack.json: "api": "1.6", "permissions": ["tokens", "canvas"] export default function register(api) { if (typeof api.tokens !== "object") return; // not at a table let active = null; // the token whose turn it is const ring = (t) => api.canvas.draw("turn", t ? [{ kind: "circle", x: t.gx, y: t.gy, radius: 50, stroke: "#ffd35a", width: 3, pulse: true }] : []); api.hooks.on("combat.turn", async ({ tokenId }) => { active = tokenId; ring(tokenId ? await api.tokens.get(tokenId) : null); // null when this screen cannot see it }); api.hooks.on("token.updated", ({ tokenId, token, to }) => { if (tokenId === active && to) ring(token); }); api.hooks.on("combat.ended", () => { active = null; ring(null); }); } ``` - **Screens.** Every screen at the table runs your module and hears the same hooks. Drawing is local, so each screen drawing its own ring is right. A write (a condition, a move) should come from one screen, usually the GM's: check `api.user.isGM` first. - **Your own writes come back.** `api.tokens.update` answers with the token, and then every screen, yours included, hears `token.updated`. Keep state in the hook handler and you handle both the same way. - **Map, not world.** The list and the hooks are the map this screen is showing. When the table moves to another map, read `api.tokens.list()` again in your `canvas.changed` handler; it waits (a few seconds at most) for the new map's tokens to arrive. The shapes are in [The api object](https://divinevtt.com/docs/reference/api#api-tokens) and the hook payloads in [Hooks](https://divinevtt.com/docs/reference/hooks#tokens-combat-and-doors). ## Talking across clients `api.relay` is present at a table (it needs a campaign). `toGM(payload)` reaches the GM's clients, `broadcast(payload)` reaches everyone at the table, and `on(handler)` receives messages addressed to this pack only. Payloads must be cloneable. This is the seam for anything that needs GM authority or a shared decision: a player's module asks, the GM's copy of the same module answers. Requires the `relay` permission. `toGM(payload, { anonymous: true })` (api 1.3) leaves the sender off: the GM's handler is called with `{ from: "", anonymous: true }` and nothing on the wire says who sent it. That is the X-card case, where the point is that nobody has to explain. Keep your payload anonymous too, and declare `"api": "1.3"` so an older server (which would name the sender) refuses the package instead. Each person may send 30 anonymous messages a minute at a table, counted across every package, so an X-card, a check-in answer and an anonymous vote never crowd each other out; past that the call rejects, and your package should say so rather than retry. ```js api.register.command("x", { description: "Stop the scene, no questions asked", run: () => api.relay.toGM({ card: "x" }, { anonymous: true }), }); api.relay.on((msg, meta) => { if (msg.card === "x" && meta.anonymous) api.ui.toast("message", "Someone tapped the X-card."); }); ``` Every named message also carries `meta.isGM` (api 1.3): the server's own check that the sender may edit the world. Check it, not the payload, before a copy of your module obeys something only a GM should be able to say: ```js api.relay.on((msg, meta) => { if (msg.t === "clear-all" && meta.isGM === true) clearEverything(); }); ``` Two more pieces help a module keep per-person state straight (api 1.3). `api.user` is `{ id, name, isGM }` for the person at this screen, the same `id` the relay names as `meta.from`, so you know which messages are your own. And the `presence.changed` hook says who joined and who left the table, so what a departed person left on screen (their ruler, their templates) can go with them: ```js api.hooks.on("presence.changed", ({ left }) => { for (const { userId } of left) forget(userId); }); ``` ### Between modules that work together A module built on another, a spell pack on a template module, can hear what the other did without polling its exports (api 1.3). The two must be related by `requires`, in either direction: ```js // measure-templates, when a template goes down: api.packs.emit("placed", { id, shape, size, tag }); // a spell module that requires measure-templates: api.packs.on("measure-templates", "placed", (tpl) => { if (tpl.tag === "fireball") rollDamageFor(tpl); }); ``` The payload is plain data, copied, and stays on this client. `emit` answers how many listeners it reached. --- # Effects & the value seam Every computed read of sheet data flows through one seam: ``` base profile value -> summed modifier delta -> effective value ``` A module computes modifiers from source data and pushes them. Computed fields re-render immediately with the effective value. This is how equipped armor raises AC or a belt raises STR without the engine knowing a single rule. ## Pushing modifiers ```ts api.effects.set(entityId, [ { target: "ac", value: 2, label: "Plate armor" }, { target: "str", value: 1, label: "Belt of Giant Strength" }, ]); api.effects.clear(entityId); // or set([]) to remove this package's modifiers ``` Each modifier is `{ target: fieldId, value: number, label? }`. They are package- and entity-scoped, and summed per target across every package that pushes. ## The rules that make it safe - **Never persisted.** Modifiers live in memory only. Rebuild them from source data on mount and clear them on unmount. Disabling the package removes its modifiers at once, and the base data is untouched. - **Top-level fields only.** Modifiers apply to top-level profile fields, never to a list row's local keys. - **Inputs edit the base; computing sees the effective.** A field input still reads and writes the base value, so editing AC edits the base AC, while `computed` nodes and `ctx.getField` see base plus delta. The effective value isn't written back anywhere; it's resolved on read. ## Push only when the value changes `api.effects.set` bumps an internal version that re-renders dependent nodes. If a node recomputes its modifiers in its `update`/render and pushes every time, that re-render triggers another render, and you get an infinite loop. Compare against the last pushed set and only call `set` when it actually changed: ```js const key = JSON.stringify(mods); if (key !== lastPushedKey) { lastPushedKey = key; api.effects.set(entityId, mods); } ``` This is the most common effects bug. The [`dnd5e-automation` class-resolver](https://divinevtt.com/docs/examples/dnd5e-automation) does exactly this for its AC modifiers. ## A reference pattern The [`item-automation`](https://divinevtt.com/docs/examples/item-automation) summary node reads the wearer's equipped items, sums each item's wearer modifiers (and computes worn AC from armor), then pushes the result. On unmount it calls `api.effects.clear`. Equipment changes flow to every computed field that reads those targets, with no persisted state and nothing for the system to special-case. ## Timed effects (reserved) Durations ("+2 STR for an hour", Active-Effects-style modifiers) arrive as `api.effects.add` / `api.effects.remove` with the play-side campaign state, without retrofitting consumers. Runtime effect state will be campaign- and encounter-scoped and never written into the profile. Feature-detect with `typeof api.effects.add === "function"` before relying on it. --- # Automation patterns A custom [sheet node](https://divinevtt.com/docs/guides/scripting#sheet-node-renderers) can make a sheet do things: auto-fill fields, roll dice, run a rest, all from the module, with no rules in the engine. It works through the node context (`ctx`) passed to `mount`/`update`. Before you write any of it, check whether the sheet can already do it on its own. A field that computes itself and can still be typed over, a slider whose ceiling follows another field, a section that appears at a certain level: those are [formulas on the field](https://divinevtt.com/docs/guides/character-sheets#formulas-on-any-field) (`auto`, `minAuto` / `maxAuto`, `visibleWhen`, `readonlyWhen`) and need no script, no permissions and no sandbox. Reach for a node renderer when the behaviour is not a number - granting actions, rolling with a prompt, writing several fields at once from a table your pack ships. ## The node context | Field | What it does | | --- | --- | | `ctx.value` / `ctx.setValue(v)` | The node's own bound value, and a writer for it | | `ctx.getField(id)` | The effective value of any sibling field: the base value (or template default) with pushed [effects](https://divinevtt.com/docs/guides/effects) folded in | | `ctx.fields` | A snapshot of the ids your node type declared in `reads`, taken fresh each render. The one sibling read that works [inside the sandbox](https://divinevtt.com/docs/guides/sandbox#reading-sibling-fields) | | `ctx.setField(id, v)` | Write one sibling field into the live sheet (persists like `setValue`) | | `ctx.setFields(patch)` | Write several fields at once atomically, so no write clobbers another | | `ctx.evalFormula(expr)` | Evaluate a pack formula against the live scope (missing refs read 0; a parse error returns null) | | `ctx.entityKind` | The sheet's kind (`pc`, `npc`, `item`, ...), so a node can behave per kind | | `ctx.roll?(label, expr, opts?)` | Roll into the table chat as this sheet's character, and get the roll back. Present only at the table, absent in the editor, so feature-detect | | `ctx.rest?(kind)` | Refill the sheet's tracked pools for a `"short"` or `"long"` rest. Absent when the sheet has no `tracks` node, so feature-detect | | `ctx.readonly` | True for a derived-view node. `setValue`/`setField`/`setFields` no-op | Declare what you read. A node type may ship a `reads` array beside `present`/`mount`, and the host snapshots those ids into `ctx.fields` before every render: ```js api.register.sheetNodeType(api.ids.nodeType("oath-grants"), { reads: ["class_name", "subclass", "level"], present(c) { const oath = String(c.fields.subclass || ""); return { lines: [{ text: oath ? `Oath: ${oath}` : "No oath sworn", muted: !oath }] }; }, }); ``` For a trusted package this is a convenience over `getField`. For an installed one it is the only synchronous sibling read there is, because functions cannot cross the sandbox boundary: write both against `ctx.fields` and your node behaves the same on either side. A node re-runs `update()` whenever any field or pushed effect changes, so values read through `getField`/`evalFormula` stay live. That re-render model is also the source of the most common bug. See [below](#push-effects-only-when-they-change). ## Auto-fill from other fields Read inputs, write derived fields. The 5e class-resolver reads class, level, and race and fills spell slots, save proficiencies, hit die, speed, and senses: ```js const cls = String(c.getField("class_name") || ""); const level = Math.round(num(c.getField("level"), 1)); const patch = {}; // ...compute from the module's own 5e tables... patch.hd_die = CLASSES[cls].hd; for (let i = 1; i <= 9; i++) patch[`slot${i}`] = slots[i - 1] || 0; c.setFields(patch); // one atomic write ``` Use `setFields` (not repeated `setField`) when several values change together, like a Long Rest resetting HP and every slot, so concurrent writes don't clobber one another. ## Resting Your module owns what a rest means in your rules - hit dice, spell slots, hit points. It does not own the sheet's **tracked pools** (`tracks` nodes: class resources, injuries), whose sizes are formulas evaluated in the sheet's scope. Ask the host to refill those: ```js c.setFields(patch); // your system's half if (typeof c.rest === "function") c.rest("long"); // the pools' half ``` A long rest restores every pool that recovers at all; a short one only those that recover on a short rest. Call it even when nothing was spent - resting is what refills a pool, not spending hit dice. Do this rather than letting the sheet grow a second pair of rest buttons beside yours. Two sets that each do half a rest is worse than either alone: whichever the player presses, something they expected to come back does not. ## Adopt, don't clobber When automation first runs on a sheet, including the moment a GM enables your module on an existing, hand-built character, it must not overwrite manual work. Stamp a signature of the inputs. On first encounter, adopt the current state without writing. Only re-derive when the inputs actually change. ```js const sig = `${cls}|${level}|${race}`; const applied = c.getField("__autosig"); if (applied === undefined) { c.setField("__autosig", sig); return; } // adopt silently if (String(applied) === sig) return; // unchanged // inputs changed, so re-derive and write the patch (including __autosig: sig) ``` ## Click-to-roll `ctx.roll` exists only at the table (there's no chat in the editor), so feature-detect it. It takes a label and a dice expression and posts a roll card on the same path as the core action-to-dice bridge. ```js if (typeof c.roll === "function") { const mod = abilityMod(c.getField("con")) + c.getField("con_save_prof") * c.getField("prof"); c.roll("Concentration (CON save)", `1d20${mod >= 0 ? "+" : ""}${mod || ""}`); } ``` Pass `{ private: true }` as a third argument to post privately (roller plus GM only). Omit it to follow the table-wide "roll privately" toggle. `ctx.roll` resolves to the server's roll: `total`, `terms`, `messageId`, and, when you name one of the system's outcome tables, its reading. Name the table with `outcomes` and answer its questions with `context`, the same options as [`api.dice.roll`](https://divinevtt.com/docs/reference/api#api-dice). Resolve from that roll, never from a second one of your own: ```js const r = await c.roll("Resist with Prowess", "3d6kh1", { outcomes: "fitd-resistance" }); if (!r) return; // the roll could not be made; the person was told const cost = r.outcome?.tone === "crit" ? -1 : 6 - r.total; c.setField("stress", Math.max(0, Number(c.getField("stress") || 0) + cost)); ``` It resolves to `null` when the roll could not be made, after telling the person why. ## Automation with no sheet open A node can only write the sheet it is drawn on. A slash command, or a hook that reacts to a roll, has no sheet at all. For those, [`api.sheets`](https://divinevtt.com/docs/reference/api#api-sheets) reads a character's sheet with the system's derived numbers and changes its own fields under the person's grants, and `action.rolled` carries the roll's outcome reading and a `mine` flag so exactly one client acts on it (see [Hooks](https://divinevtt.com/docs/reference/hooks#reacting-to-a-roll-once)). ## Read-only derived views A node that only displays derived data should ship `"readonly": true` in its facet JSON and never write. The 5e class-features list reads class and level and renders the features the character has. It stores nothing, so it never clobbers the manual "Features & traits" list beside it. ## Push effects only when they change Because a node re-renders on any field or effect change, calling `api.effects.set` unconditionally inside render is an infinite loop (`set` bumps the effects version, which re-renders, which calls `set` again). Compare a serialized key and push only on change: ```js const acKey = JSON.stringify(acMods); if (acKey !== lastAcKey) { lastAcKey = acKey; api.effects.set(c.entityId, acMods); } ``` Clear your effects in the handle's `unmount`. The rest of the seam is in [Effects & the value seam](https://divinevtt.com/docs/guides/effects). ## Behave per entity kind `ctx.entityKind` lets one node type adapt, like a stat block that renders fuller on a `pc` than on an `npc`, without separate registrations. Combine it with [semantic bindings](https://divinevtt.com/docs/guides/packages#semantic-bindings) (`api.system.binding`) and the same automation works across systems: resolve the field id you need rather than hardcoding it. ## Four of these, written for you Studio's code workbench keeps the patterns on this page as script templates. Each one writes the generic shape with your own table left as a marked blank, ticks the permission it needs in the manifest, and then leaves the code alone: nothing regenerates over your edits. - **Resolve a level into the sheet's numbers.** Declares one field in `reads` (a level, a rank, a tier), and writes the numbers your table gives for that value into the sheet's own fields with one `setFields`. It stamps a signature so it adopts a hand-built sheet on first sight and re-derives only when the driving field changes, which is the "adopt, don't clobber" rule above. - **Grant actions from a table.** An `actionSource` over a table of your own: reads the entity's profile, decides which groups apply, and contributes them into the sheet's actions slot. It stores nothing on the sheet, so the rows vanish cleanly when the module is disabled. - **Spend a pool when it is rolled.** A sheet widget that reads a pool and its spent count (`` and `_used`), throws your die into chat through `ctx.roll`, and then takes one from the pool. It refuses when the pool is empty, and it rolls before it writes. - **Answer damage over a threshold.** A handler on `action.damageApplied` that ignores anything under the number you set, reads a result off your own table for anything over it, says so, and records it against the entity through `api.data.flags`. The sheet halves of the same patterns live in the node palette's **Patterns** group, so the layout and the automation are one click each. See [Studio](https://divinevtt.com/docs/guides/studio#the-code-workbench). ## The reference module [`dnd5e-automation`](https://divinevtt.com/docs/examples/dnd5e-automation) ships every pattern here (a class resolver, a derived features list, a conditions tracker, death saves, short and long rest) as a single, readable module. Read it next. --- # Permissions and the sandbox Every pack that is not a first-party built-in runs its script **sandboxed**. This page is what that means for you as an author: what to declare, what the script can and cannot do, and how to write renderers and formulas that work there. Declarative facets are unaffected; they never run code. ## Trust levels - **Trusted**: first-party built-ins, plus community packs an admin has reviewed and granted trust. The script is imported into the app's own realm and may draw into host DOM. - **Sandboxed**: everything else. User packs, imported zips, marketplace packs. Granted trust is how a community module earns a built-in's reach without being first-party. It is not something a pack can ask for: no manifest field sets it, uploading cannot claim it, and it is stored server-side against the pack and written only from the admin console. Write your module to work sandboxed; trust, if it is ever granted, only takes limits away. See [What trust unlocks](#what-trust-unlocks) for what those limits are. A sandboxed script runs in an iframe with `sandbox="allow-scripts"` and no `allow-same-origin`. It has an opaque origin: no app cookies, no localStorage, no credentialed fetch, no host DOM. A strict content security policy blocks all network. The only way out of the box is a message to the host, and every message passes a permission check there. The host holds all authority; the frame holds your code and an `api` proxy whose privileged methods each become one message. Containment is per pack. Each pack gets its own frame. A watchdog pings it; a hung module (a synchronous infinite loop) stops answering and is terminated. An error storm trips a budget. Termination removes the frame and tears the pack down locally, and the rest of the table keeps playing. The GM can disable or report the module. ## Declaring permissions The manifest lists the capabilities the script needs. A sandboxed module is granted exactly these and nothing more. The GM sees them, with the descriptions below, before enabling the module, and confirms again when they change. | Permission | Unlocks | GM sees | | --- | --- | --- | | `read-world` | `data.entities.list/get`, `data.profiles.get`, `data.flags.get`, `sheets.get`, `catalog.list`, `tables.list/get`, `system.*`, `gameplay.*` | Read characters, sheets, and world definitions. | | `write-character` | `data.entities.create/update`, `data.profiles.patch`, `data.flags.set`, `sheets.patch`, `catalog.materialize`, and a sheet node's `setField`/`setFields` | Create or update entities, add compendium entries to the world, and change fields on any character sheet the person using it could edit. | | `push-effects` | `effects.set/clear` | Push temporary modifiers onto sheet values. | | `grant-actions` | `register.actionSource` | Add attacks, spells, and actions into a character's action lists. | | `send-chat` | `register.card`, `chat.post`, `chat.update` | Show cards in the chat log. | | `roll-dice` | `dice.roll` | Roll dice into the table. | | `ui` | `register.sheetNodeType/panel/menuItem/formulaFunction/command`, `ui.toast/confirm` | Show notifications and confirmations and contribute interface elements. | | `relay` | `relay.toGM/broadcast` (an anonymous `toGM` too) | Send messages to the GM or other players' clients at this table. | | `canvas` | `canvas.draw/clear`, `register.canvasTool` (`canvas.view` needs nothing) | Draw shapes on your own view of the table map and offer map tools. Nothing it draws is saved or shown to anyone else by itself. | | `views` | `register.view`, `views.open/close/post/on` (api 1.5) | Show its own tabs, windows and map overlays, drawn by the module in a sealed frame that cannot reach the app, your account or the internet. | | `tokens` | `tokens.list/get/update/create/remove/setCondition`, and hearing the `token.*`, `combat.*` and `door.changed` hooks (api 1.6) | See the tokens on the map the way the person using it does, move the ones they may move, and follow token moves, combat turns and doors. On the GM's screen it can also place and remove tokens and set their conditions. | | `network` | raw outbound network | Contact external services. Flagged: reviewed on every update, restricted on the hosted service. | ```jsonc // pack.json "permissions": ["read-world", "push-effects", "ui"] ``` Some calls need no permission at all: `log`, `settings.get`, `settings.set`, `hooks.on/off`, `packs.get`, `packs.emit/on`, `canvas.view` and `ui.refreshPanel`. `settings.set` (api 1.4) only ever writes this package's own settings for the table it is running at, and the server takes it from that table's GM alone, so there is nothing for a grant to add. Unknown permission names are ignored, not fatal, so a manifest written for a newer host still loads. `tokens` is the one permission that gates hooks as well as calls. The token, combat and door hooks say where every token stands and whose turn it is, which is what `api.tokens.list` reads, so a module could otherwise learn by listening what it is refused by asking. Every other hook stays free. A sandboxed module without `tokens` may still call `api.hooks.on("token.updated", ...)`; the host does not subscribe it, the handler never runs, and the console says so once. What the permission can reach is bounded twice over: reads are only what the map already shows the person at that screen (a player never gets an NPC's hp or a token hidden from them), and every write is a request to the table's own token routes, which let a player move only their own tokens and leave placing, removing and conditions to the GM. ### When a call is refused The call never reaches the world. The host rejects it and the promise your script is awaiting rejects with a message that names the method, the permission it wanted, and the pack that did not ask for it: ``` permission denied: 'data.flags.set' needs the 'write-character' permission, which my-pack does not declare ``` A method the host has never heard of says so instead (`'foo.bar' is not a method this host has`), which is the same rejection an author gets for a typo. Writes a sheet node makes through `setField` and `setFields` are the one exception: a missing `write-character` drops them with a console warning rather than an error, because a render is not a place to throw. The fix is always the same: add the permission to the manifest. Studio's Pack part has the checkbox list, its lint flags a call whose permission is missing, and its Test it harness runs the script under **exactly** the declared set, so a refusal shows up before a GM sees it. ## Scope `"scope": "shared"` (the default) means a GM enables the module for a world or campaign and it affects everyone. `"scope": "local"` means each player turns it on for themselves in their account settings, and it changes only their own client. A local module keeps only `ui`, `read-world`, `push-effects`, `roll-dice`, `canvas` and `views`; anything that writes shared documents or reaches outward is dropped. `canvas` stays because an overlay never leaves the client that drew it: a measuring tool you turn on for yourself is exactly a local module. `views` stays for the same reason: a view is a frame on your own screen. `builtin:matrix-dice` is a local module: a dice tray skin you choose without asking the rest of the table to live in it. `tokens` is dropped: moving a token changes the map for everyone. ## What works sandboxed The `api` surface is the same on both trust levels. A pack behaves identically whether it runs sandboxed or trusted, with these exceptions, which pack lint flags and Studio's script templates never emit: - **Renderers** cannot draw into host DOM, so `mount(el, ctx)` is not available. Ship `present(ctx)` and `onAction()` instead, described below. - **Formula functions** must be the declarative `{ params, expr }` form. A JS function cannot be called on the host's synchronous formula path. - **Chat cards** work, and answer LATE. A sandboxed `present(ctx)` is a message round trip, so the host draws a placeholder for one frame and then the real card. `getDragData` cannot be late, so the host fetches it with the presentation; a card that can be seen can be dragged. A card's action buttons are data in the presentation, and a press reaches your `onClick` in the frame with `ctx.action` set, one round trip like a click. - **Menu items** work, against the app's named surfaces (`table.token`, `codex.entry`, `library.entry`). Your entries are drawn after the app's own, behind a separator, capped per pack. An id the app does not draw registers and is never shown - pack lint flags that. - **Chat commands** work. Your `run(ctx)` stays in the frame and the host replays the command into it when someone sends it, then waits for it to finish (up to 30 seconds) so a failure reaches the person who typed it. A command's `args` travel as data, and its `complete(ctx)` stays in the frame too: the host asks it while someone types and waits at most 1.5 seconds, so a slow answer shows nothing rather than holding up the chat box. - **Drawing on the map** works: shapes are data, so `api.canvas.draw` is one message like any other call, and `api.canvas.view()` is answered inside the frame from a copy the host keeps current, so it stays synchronous. A map tool's callbacks stay in your frame; the host draws its button from the label you gave and replays each activation, pointer and key event to it by name. `rightClick` and `keys` travel as data with the label, so the host takes the right button and your listed keys without asking the frame first. - **Picking up a map tool** (`api.canvas.activateTool`, api 1.3) is judged in the host, not on your word. The host notes the moments the person acts on your pack where the click or key actually lands (a command they typed, a control in your panel or sheet node, your chat card, your menu item, your map tool), and a pickup within 5 seconds of one is allowed. Anything else is refused, so do the pickup inside the handler that answers them, not in a timer or a relay handler. A call to another pack's exports, which the host brokers, lends that pack the same window. A trusted pack may also lean on the browser's own record of a recent click, since its DOM is in the page; a sandboxed one has none to lean on. - **`api.user`** (api 1.3) comes with the load message and the host pushes a change before the `user.changed` hook, so it reads synchronously in the frame. It is absent in Studio's harness, which names nobody. - **Panels** (`api.ui.refreshPanel`, api 1.3) are redrawn by asking: your `present()` runs in the frame and the host draws its answer, so the frame cannot touch the panel itself. The host also presents it again when one of its actions finishes in the frame, and keeps its buttons locked until then. The rail tab's `icon` is a name from the host's fixed list, so it crosses as data with the registration. Registering a panel again replaces it. - **Pack events** (`api.packs.emit` / `on`, api 1.3) cross the frame as messages. `on` keeps your handler in the frame and the host listens for you; which packs may reach you (those related by `requires`) is the host's rule. - **Table settings** (`api.settings.set`, api 1.4) are one message, counted against the rate limit below. The host applies the server's answer on this screen at once, and the `settings.changed` hook reaches your frame on every screen at the table, so `api.settings.get` reads the new value there too. - **Views** (`api.register.view`, api 1.5) work the same on both trust levels, because a view always runs in its own sandboxed frame; see [Views](#views). - **Registering a card, a menu item, a command, a map tool or a view is gated.** `register.card` needs `send-chat`, `register.menuItem` and `register.command` need `ui`, `register.canvasTool` needs `canvas` and `register.view` needs `views`, checked when you register rather than when the host draws, so a missing grant is a console line at load and a "denied" line in Studio's harness. - **`api.chat` and `api.dice`** are present in the frame exactly when they are on the trusted side: at a table, and nowhere else. Studio's harness counts as a table and answers them itself, so a post or a roll there is a console line. - **`api.tokens`** (api 1.6) is present in the frame at a table, like `api.chat`. Every call is one message: the host answers `list` and `get` from the tokens its own screen holds, and sends writes to the table's routes. The token, combat and door hooks reach the frame only under `tokens` (see [Declaring permissions](#declaring-permissions)). Studio's harness has no map, so a call there fails with an unknown-method line; test it at a table. ## Views A view (`api.register.view`, api 1.5) is the one place a module's own markup is drawn at the table, so it gets the main frame's box, made visible: - The frame is `sandbox="allow-scripts"` and nothing else: an opaque origin (no app cookies, no storage, no credentialed request, no host DOM), and no top navigation, popups, forms, modals or downloads. No browser feature (camera, microphone, clipboard, fullscreen...) is delegated to it. - Its content security policy allows **no network at all**: `connect-src`, `frame-src` and `worker-src` are `'none'`, and images, media and fonts may come only from `data:` and `blob:`. The host builds the page from your pack's own files (your `` and `