HomeDeveloper docsSystem package: an editable character sheet
Examples by kind 4 min read

System package: an editable character sheet

A single original system package with actor types, a complete minimal sheet and revision-aware saving.

The original Example Expedition sheet has saved Stamina 17 through the scoped API. Gravewright 0.3.0, English interface.
The original Example Expedition sheet has saved Stamina 17 through the scoped API. Gravewright 0.3.0, English interface. · Open full screenshot ↗

Goal and prerequisites

Create a campaign using Example Expedition. The package declares character and npc actors, an empty gear item type, and replaces the actor sheet with an editable stamina field. It provides no published game rules or catalogs.

Target host: 0.3.0, SDK 1.0.0. Sample interface strings are English. Preserve the requested version when adapting.

Complete files

Download source package ZIP

These are complete unsigned sources. After reviewing the code, test with Install from ZIP on a disposable host. Marketplace publication uses the signature/catalog described in signing and activation. No private signing key is included.

Complete manifest

json
{
  "id": "example.expedition",
  "name": "Example Expedition",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "description": "Original character/NPC sheets with a saved stamina field and an empty gear type.",
  "author": "Gravewright documentation examples",
  "license": "GPL-3.0-only",
  "entry": "main.mjs",
  "system": {
    "actorTypes": [
      {
        "id": "character",
        "label": "Character"
      },
      {
        "id": "npc",
        "label": "NPC"
      }
    ],
    "itemTypes": [
      {
        "id": "gear",
        "label": "Gear"
      }
    ]
  }
}

Example code

main.mjs

javascript
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    const sheet={
      mode:'replace',types:['character','npc'],
      async mount({root,context,api,signal,onDispose}) {
        const doc=root.ownerDocument;
        const form=doc.createElement('form');form.className='example-panel';
        form.setAttribute('aria-label','Example Expedition sheet');
        const title=doc.createElement('h2');
        const label=doc.createElement('label');label.textContent='Stamina (0–100)';
        const input=doc.createElement('input');input.type='number';input.min='0';input.max='100';
        label.append(input);
        const saveButton=doc.createElement('button');saveButton.type='submit';saveButton.textContent='Save';
        const reloadButton=doc.createElement('button');reloadButton.type='button';reloadButton.textContent='Reload server value';
        input.disabled=true;saveButton.disabled=true;reloadButton.disabled=true;
        const status=doc.createElement('p');status.setAttribute('role','status');
        form.append(title,label,saveButton,reloadButton,status);root.append(form);
        let current,dirty=false,pending;
        const token=context.tokenId ? {tokenId:context.tokenId} : {};
        async function load() {
          input.disabled=true;saveButton.disabled=true;reloadButton.disabled=true;
          try {
            const sheet=await api.actors.sheet(context.actorId,token,{signal});
            if(signal.aborted) return;
            current=sheet;title.textContent=sheet.name;
            input.value=String(sheet.data.stamina ?? 10);
            input.disabled=!sheet.canEdit;saveButton.disabled=!sheet.canEdit;
            dirty=false;
          } finally { if(!signal.aborted) reloadButton.disabled=false; }
        }
        function save() {
          if(pending) return pending;
          if(!dirty || !current?.canEdit) return Promise.resolve();
          const stamina=Number(input.value);
          if(input.value.trim()==='' || !Number.isInteger(stamina) || stamina<0 || stamina>100) {
            status.textContent='Enter an integer from 0 to 100.';
            return Promise.reject(new Error('Invalid stamina'));
          }
          const payload={actorId:context.actorId,name:current.name,version:current.version,
            sheetVersion:current.sheetVersion,data:{...current.data,stamina},
            ...(current.tokenId ? {tokenId:current.tokenId} : {})};
          const requestId=crypto.randomUUID();
          input.disabled=true;saveButton.disabled=true;reloadButton.disabled=true;
          pending=(async()=>{
            try {
              await api.actors.saveSheet(payload,{requestId,signal});
              if(signal.aborted) return;
              await load();status.textContent='Saved.';
            } catch(error) {
              if(!signal.aborted) {
                if(error.code==='conflict') {
                  await load();
                  status.textContent='Conflict: server value loaded. Review it before editing again.';
                } else status.textContent=error.code || 'Save failed. Reload before retrying.';
              }
              throw error;
            } finally {
              pending=undefined;
              if(!signal.aborted){input.disabled=!current?.canEdit;saveButton.disabled=!current?.canEdit;reloadButton.disabled=false;}
            }
          })();
          return pending;
        }
        input.addEventListener('input',()=>{dirty=true;},{signal});
        form.addEventListener('submit',event=>{event.preventDefault();void save().catch(()=>{});},{signal});
        reloadButton.addEventListener('click',()=>{
          void load().then(()=>{if(!signal.aborted)status.textContent='Server value loaded.';})
            .catch(error=>{if(!signal.aborted)status.textContent=error.code||'unavailable';});
        },{signal});
        root.closest('.gw-window')?.addEventListener('gravewright:sheet-flush',event=>{
          if(pending || dirty) event.detail.waitUntil(save());
        },{signal});
        onDispose(()=>form.remove());
        await load();
      }
    };
    register('actor.sheet',sheet);
    register('token.sheet',sheet);
  },
  stop() {}
};

styles.css

css
.gw-module-root .example-panel { padding: var(--space-4, 16px); font-size: 14px; }
.gw-module-root .example-panel label { display: block; margin-bottom: var(--space-3, 12px); }
.gw-module-root .example-panel button { margin-right: var(--space-2, 8px); }
.gw-module-root[data-module-id="example.campaign-summary"] .example-panel { position: fixed; left: 16px; bottom: 112px; z-index: 20; max-width: min(360px, calc(100vw - 32px)); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); pointer-events: auto; }

How to run

  1. Review and install the source ZIP through Systems → Install from ZIP on a disposable host; select Example Expedition for a new campaign.

  2. Create a Character or NPC, open its sheet, change Stamina and press Save. Close and reopen the window.

  3. Assign a player read access and then edit access to the actor. Compare disabled/enabled controls and the server response.

  4. Open the same actor in two sessions. Save one, then save the stale one. Review the refreshed server value before editing again.

Expected result and lifecycle

The saved value survives reopening. The payload keeps version and sheetVersion from the same read. Linked tokens use the actor; independent tokens use the returned tokenId snapshot. Pending saves participate in gravewright:sheet-flush.

Errors and limits

A conflict reloads the authoritative sheet and asks the user to review it. Validation rejects blank/noninteger/out-of-range stamina in the sample UI; external system JSON remains subject to host JSON limits and permissions. Add authoritative rule validation only through contracts actually supported by the target host.

How to validate

  • Create/edit/save/reopen both actor types.

  • Verify read-only access cannot write, including a direct attempted call.

  • Test linked and independent tokens separately.

  • Close during a slow save and during an invalid edit; failed flush must keep the window open.

How to use AI with this example

text
Extend Example Expedition with [original fields]. Keep one installable system package, the declared actor/item types and sheet-flush/version behavior. Preserve unrelated data fields. Do not add a Server Rules companion or require a newer host. State unsupported backend requirements explicitly.