HomeDeveloper docsHow to build deck and card tools
Implementation tutorials 13 min read

How to build deck and card tools

Create reusable deck definitions, draw, reveal, move, transfer and recover cards with real ownership.

Installed Harbor card tools module after running its inspect recipe. Gravewright 0.3.0, English interface.
Installed Harbor card tools module after running its inspect recipe. Gravewright 0.3.0, English interface. · Open full screenshot ↗

The project you will build

Build Harbor card tools with original signaling cards. Choose a text-only deck or define a reusable two-card deck with optional uploaded front/back images. Draw to your hand, place/reveal/move a card, take it back, give it to another member, discard/return and finally shuffle/reset/delete the test instance.

Target host: Gravewright 0.3.0. Actions run only when you click Run action, using the signed-in account. The demonstration panel is English. Each recipe specifies inputs and an observable result.

1. Create the files

Create a folder named cards. Save the complete files below as UTF-8, or download the project. manifest.json belongs at the root. No npm dependencies or JavaScript build is required.

text
cards/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Use a disposable campaign, GM account and one player account. A scene is needed for table placement; Inspect returns authorized scene IDs. GM creates definitions/instances and manages decks. A player with a deck read grant can draw to their own hand and control their own cards; knowing a UUID does not grant control.

2. Package, install and activate

From the project folder, run in PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../cards-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../cards-1.0.0.zip

On Linux/macOS, replace python with python3 in the same commands. The archive listing must show manifest.json at its root. As installation owner, open Inside → Modules → Install module → Install from ZIP, select the ZIP and click Install. As GM, open the campaign, select its Gravewright button → Extensions and Activate version 1.0.0 of Harbor card tools. Close that menu and click Harbor card tools. See your first module for screen-by-screen instructions.

The package stays inactive until selected for the table. Test one demonstration extension at a time because their launchers share a position. For changed bytes, increment version to 1.0.1, build another ZIP, install and activate that version; do not edit installed files.

3. Run each recipe

Find decks, your hand and scene cards

Run without a scene first. Copy a scene UUID from scenes, run again with it, and keep the IDs/versions from hand or cards.

javascript
async ({api,signal},values) => {
  const maps = await api.maps.state({}, {signal});
  const cards = await api.cards.state(values.sceneId ? {sceneId:values.sceneId} : {}, {signal});
  return {scenes:maps.maps.map(row=>({id:row.id,name:row.name})),...cards};
}

Expected result: Authorized decks, this account's hand and cards on the chosen scene. Private faces remain filtered.

How to verify: Compare recipient and nonrecipient accounts. A deck count does not reveal concealed front images.

Create a text-only deck

Run once as GM. This route creates an instance directly, without image assets or a reusable definition. Copy id as your deck UUID.

javascript
async ({api,signal}) => {
  return api.cards.create({name:"Harbor signals",cards:["Blue lantern","Return to harbor","Hold position"]}, {signal});
}

Expected result: A three-card deck instance. Another explicit run creates another deck.

How to verify: Inspect draw_count 3 in the state recipe and find the native deck list.

Define and instantiate a deck with optional artwork

Choose original PNG/JPEG/WebP images or leave both files empty. The host uploads real File objects, defines two cards and creates an instance. Keep deckDefinitionId to instantiate another copy with the same public method.

javascript
async ({api,host,signal},values) => {
  const front = values.front ? await host.call("card.asset.upload", {purpose:"card_front",file:values.front}, {signal}) : null;
  const back = values.back ? await host.call("card.asset.upload", {purpose:"card_back",file:values.back}, {signal}) : null;
  const definition = await api.cards.define({name:"Reusable harbor signals",default_back_asset_id:back?.assetId ?? null,cards:[
    {name:"Blue lantern",front_asset_id:front?.assetId ?? null},
    {name:"Hold position",front_asset_id:front?.assetId ?? null},
  ]}, {signal});
  const instance = await api.cards.instantiate({deck_definition_id:definition.deck.id}, {signal});
  return {deckDefinitionId:definition.deck.id,deckId:instance.id};
}

Expected result: Reusable definition plus a separate two-card instance. Images belong to the campaign/uploader and are referenced by assetId.

How to verify: Draw a card and compare private hand versus revealed scene presentation. Image limits are 10 MiB and 16 megapixels; the native server converts valid images to WebP.

Find a recipient for a transfer

Use this if the host has the ready-check/lobby feature enabled. Copy a real member userId for Draw recipient or Give. If disabled, obtain the authorized member UUID from your controlled test fixture; do not guess IDs.

javascript
async ({api,signal}) => {
  const lobby = await api.table.lobby({signal});
  return lobby.members.map(row=>({userId:row.user_id,name:row.name,role:row.role}));
}

Expected result: Current campaign member IDs and names, without account credentials.

How to verify: Give to an actual participant. A UUID from another campaign must fail.

Draw cards into a real destination

Use a deck instance UUID, count 1 and destination hand for the first run. scene/table need a scene UUID. chat deliberately publishes a card event and discards the drawn cards; use it only when that is the intended action. Only GM can choose another recipient.

javascript
async ({api,signal},values) => {
  return api.cards.draw({deck_instance_id:values.deckId,count:values.count,destination:values.destination,
    ...(values.sceneId ? {sceneId:values.sceneId} : {}),...(values.recipient ? {recipient:values.recipient} : {}),
    face_state:"face_up",x:140,y:140}, {signal});
}

Expected result: The selected count leaves the draw pile and enters the requested destination. A direct hand draw remains private to its owner.

How to verify: Inspect draw_count and hand/cards as the recipient; insufficient cards fail instead of returning a partial draw.

Play, move, reveal and transfer a controlled card

After Draw to your hand, paste its card UUID and a scene UUID. Run place, then move, flip and take one at a time. Give needs a real recipient; switch to that account to continue. Discard sends to discard; return sends back to draw.

javascript
async ({api,signal},values) => {
  const state = await api.cards.state(values.sceneId ? {sceneId:values.sceneId} : {}, {signal});
  const card = [...state.hand,...state.cards].find(row=>row.id===values.id);
  if (!card?.canControl) throw Object.assign(new Error("Card is not controllable in this view"),{code:"permission_denied"});
  const payload = {id:card.id,version:card.version};
  switch (values.action) {
    case "place": return api.cards.place({...payload,sceneId:values.sceneId,x:values.x,y:values.y,reveal:true,scale:1,z_index:0}, {signal});
    case "move": return api.cards.move({...payload,x:values.x,y:values.y,rotation:45,scale:1,z_index:1}, {signal});
    case "flip": return api.cards.flip(payload, {signal});
    case "take": return api.cards.take(payload, {signal});
    case "give": return api.cards.give({...payload,recipient:values.recipient}, {signal});
    case "discard": return api.cards.discard(payload, {signal});
    case "return": return api.cards.return(payload, {signal});
    default: throw new Error("Choose a documented card action");
  }
}

Expected result: Each click reads the current card version and applies exactly one action. Move requires a scene card; ownership and face visibility remain server-enforced.

How to verify: Follow hand → scene → hand → another participant, then discard/return a controlled card. Read fresh state after every action. Another player cannot control your hand.

Shuffle, gather or delete the test deck

As GM use the instance UUID. shuffle randomizes the draw pile; reset gathers all cards, clears owners/scenes/faces and shuffles; deleteDeck removes the instance.

javascript
async ({api,signal},values) => {
  const deck = (await api.cards.state({}, {signal})).decks.find(row=>row.id===values.deckId);
  if (!deck) throw new Error("Deck unavailable");
  const payload={deck_instance_id:deck.id,version:deck.version};
  switch(values.action) {
    case "shuffle": return api.cards.shuffle(payload, {signal});
    case "reset": return api.cards.reset(payload, {signal});
    case "deleteDeck": return api.cards.deleteDeck(payload, {signal});
    default: throw new Error("Choose shuffle, reset or deleteDeck");
  }
}

Expected result: A version-checked deck operation. Definitions and reusable image assets have their own lifetime.

How to verify: Draw/discard, then reset and confirm all cards return to draw. Delete only your disposable deck and verify it disappears.

4. Understand and adapt the complete files

main.mjs renders the launcher and form. recipes.mjs owns behavior: change the chosen recipe’s run function while keeping its signature. The form passes real number, boolean and File values, blocks overlapping requests and renders using textContent. It does not evaluate user-provided code. api, host, assets and storage belong to the mount; signal cancels work on deactivation. The modal stays open during pending work and is released when the mount aborts. Each business function remains responsible for revisions, permissions and transaction sequencing.

manifest.json

json
{
  "id": "tutorial.cards",
  "name": "Harbor card tools",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Create reusable deck definitions, draw, reveal, move, transfer and recover cards with real ownership.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {recipes} from './recipes.mjs';
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
      const doc=root.ownerDocument;
      const button=doc.createElement('button');
      button.type='button';button.className='tutorial-launcher';
      button.textContent="Harbor card tools";
      root.append(button);
      for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
      let modal,pending=false;
      button.addEventListener('click',event=>{
        event.stopPropagation();
        if(modal?.isConnected) { modal.focus();return; }
        const panel=doc.createElement('section');
        panel.className="tutorial-workbench-cards";
        panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
        panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
        const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
        const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
        for(const recipe of recipes) {
          const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
        }
        const form=doc.createElement('form'),fields=doc.createElement('div');
        const description=doc.createElement('p');
        const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
        const close=doc.createElement('button');close.type='button';close.textContent='Close';
        const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
        form.append(description,fields,run,close,output);panel.append(choose,form);
        function renderFields(){
          const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
          description.textContent=recipe.description;
          for(const field of recipe.fields||[]) {
            const label=doc.createElement('label');label.textContent=field.label;
            const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
            if(field.type==='number')input.step=String(field.step??'any');
            if(field.type==='checkbox')input.checked=!!field.value;
            else if(field.type!=='file')input.value=String(field.value??'');
            input.required=!!field.required;
            if(field.accept)input.accept=field.accept;
            label.append(input);fields.append(label);
          }
          output.textContent='';
        }
        choose.addEventListener('change',renderFields,{signal});renderFields();
        form.addEventListener('submit',async event=>{
          event.preventDefault();if(pending)return;
          const recipe=recipes.find(item=>item.id===choose.value),values={};
          for(const input of fields.querySelectorAll('input')) {
            values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
          }
          pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
          for(const input of fields.querySelectorAll('input'))input.disabled=true;
          output.textContent='Running…';
          try {
            const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
            if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
          } catch(error) {
            if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
          } finally {
            pending=false;
            if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
          }
        },{signal});
        close.addEventListener('click',()=>void modal.close(),{signal});
        modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
      },{signal});
      onDispose(()=>{button.remove();if(modal)void modal.close();});
    });
  },
  stop() {}
};

recipes.mjs

javascript
export const recipes=[
{
  id:"inspect",
  label:"Find decks, your hand and scene cards",
  description:"Run without a scene first. Copy a scene UUID from scenes, run again with it, and keep the IDs/versions from hand or cards.",
  fields:[
  {
    "name": "sceneId",
    "label": "Scene UUID, optional",
    "value": "",
    "type": "text",
    "required": false
  }
],
  run:async ({api,signal},values) => {
  const maps = await api.maps.state({}, {signal});
  const cards = await api.cards.state(values.sceneId ? {sceneId:values.sceneId} : {}, {signal});
  return {scenes:maps.maps.map(row=>({id:row.id,name:row.name})),...cards};
}
},
{
  id:"create",
  label:"Create a text-only deck",
  description:"Run once as GM. This route creates an instance directly, without image assets or a reusable definition. Copy id as your deck UUID.",
  fields:[],
  run:async ({api,signal}) => {
  return api.cards.create({name:"Harbor signals",cards:["Blue lantern","Return to harbor","Hold position"]}, {signal});
}
},
{
  id:"define",
  label:"Define and instantiate a deck with optional artwork",
  description:"Choose original PNG/JPEG/WebP images or leave both files empty. The host uploads real File objects, defines two cards and creates an instance. Keep deckDefinitionId to instantiate another copy with the same public method.",
  fields:[
  {
    "name": "front",
    "label": "Optional original front image",
    "value": "",
    "type": "file",
    "required": false
  },
  {
    "name": "back",
    "label": "Optional original back image",
    "value": "",
    "type": "file",
    "required": false
  }
],
  run:async ({api,host,signal},values) => {
  const front = values.front ? await host.call("card.asset.upload", {purpose:"card_front",file:values.front}, {signal}) : null;
  const back = values.back ? await host.call("card.asset.upload", {purpose:"card_back",file:values.back}, {signal}) : null;
  const definition = await api.cards.define({name:"Reusable harbor signals",default_back_asset_id:back?.assetId ?? null,cards:[
    {name:"Blue lantern",front_asset_id:front?.assetId ?? null},
    {name:"Hold position",front_asset_id:front?.assetId ?? null},
  ]}, {signal});
  const instance = await api.cards.instantiate({deck_definition_id:definition.deck.id}, {signal});
  return {deckDefinitionId:definition.deck.id,deckId:instance.id};
}
},
{
  id:"members",
  label:"Find a recipient for a transfer",
  description:"Use this if the host has the ready-check/lobby feature enabled. Copy a real member userId for Draw recipient or Give. If disabled, obtain the authorized member UUID from your controlled test fixture; do not guess IDs.",
  fields:[],
  run:async ({api,signal}) => {
  const lobby = await api.table.lobby({signal});
  return lobby.members.map(row=>({userId:row.user_id,name:row.name,role:row.role}));
}
},
{
  id:"draw",
  label:"Draw cards into a real destination",
  description:"Use a deck instance UUID, count 1 and destination hand for the first run. scene/table need a scene UUID. chat deliberately publishes a card event and discards the drawn cards; use it only when that is the intended action. Only GM can choose another recipient.",
  fields:[
  {
    "name": "deckId",
    "label": "Deck instance UUID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "count",
    "label": "Count",
    "value": 1,
    "type": "number",
    "required": true
  },
  {
    "name": "destination",
    "label": "Destination: hand, scene, table or chat",
    "value": "hand",
    "type": "text",
    "required": true
  },
  {
    "name": "sceneId",
    "label": "Scene UUID for scene/table",
    "value": "",
    "type": "text",
    "required": false
  },
  {
    "name": "recipient",
    "label": "Recipient UUID, blank for yourself",
    "value": "",
    "type": "text",
    "required": false
  }
],
  run:async ({api,signal},values) => {
  return api.cards.draw({deck_instance_id:values.deckId,count:values.count,destination:values.destination,
    ...(values.sceneId ? {sceneId:values.sceneId} : {}),...(values.recipient ? {recipient:values.recipient} : {}),
    face_state:"face_up",x:140,y:140}, {signal});
}
},
{
  id:"card-actions",
  label:"Play, move, reveal and transfer a controlled card",
  description:"After Draw to your hand, paste its card UUID and a scene UUID. Run place, then move, flip and take one at a time. Give needs a real recipient; switch to that account to continue. Discard sends to discard; return sends back to draw.",
  fields:[
  {
    "name": "id",
    "label": "Controlled card UUID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "action",
    "label": "Action: place, move, flip, take, give, discard or return",
    "value": "place",
    "type": "text",
    "required": true
  },
  {
    "name": "sceneId",
    "label": "Scene UUID, needed for scene cards",
    "value": "",
    "type": "text",
    "required": false
  },
  {
    "name": "recipient",
    "label": "Recipient UUID for give",
    "value": "",
    "type": "text",
    "required": false
  },
  {
    "name": "x",
    "label": "Scene x",
    "value": 140,
    "type": "number",
    "required": true
  },
  {
    "name": "y",
    "label": "Scene y",
    "value": 140,
    "type": "number",
    "required": true
  }
],
  run:async ({api,signal},values) => {
  const state = await api.cards.state(values.sceneId ? {sceneId:values.sceneId} : {}, {signal});
  const card = [...state.hand,...state.cards].find(row=>row.id===values.id);
  if (!card?.canControl) throw Object.assign(new Error("Card is not controllable in this view"),{code:"permission_denied"});
  const payload = {id:card.id,version:card.version};
  switch (values.action) {
    case "place": return api.cards.place({...payload,sceneId:values.sceneId,x:values.x,y:values.y,reveal:true,scale:1,z_index:0}, {signal});
    case "move": return api.cards.move({...payload,x:values.x,y:values.y,rotation:45,scale:1,z_index:1}, {signal});
    case "flip": return api.cards.flip(payload, {signal});
    case "take": return api.cards.take(payload, {signal});
    case "give": return api.cards.give({...payload,recipient:values.recipient}, {signal});
    case "discard": return api.cards.discard(payload, {signal});
    case "return": return api.cards.return(payload, {signal});
    default: throw new Error("Choose a documented card action");
  }
}
},
{
  id:"deck-actions",
  label:"Shuffle, gather or delete the test deck",
  description:"As GM use the instance UUID. shuffle randomizes the draw pile; reset gathers all cards, clears owners/scenes/faces and shuffles; deleteDeck removes the instance.",
  fields:[
  {
    "name": "deckId",
    "label": "Deck instance UUID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "action",
    "label": "Action: shuffle, reset or deleteDeck",
    "value": "shuffle",
    "type": "text",
    "required": true
  }
],
  run:async ({api,signal},values) => {
  const deck = (await api.cards.state({}, {signal})).decks.find(row=>row.id===values.deckId);
  if (!deck) throw new Error("Deck unavailable");
  const payload={deck_instance_id:deck.id,version:deck.version};
  switch(values.action) {
    case "shuffle": return api.cards.shuffle(payload, {signal});
    case "reset": return api.cards.reset(payload, {signal});
    case "deleteDeck": return api.cards.deleteDeck(payload, {signal});
    default: throw new Error("Choose shuffle, reset or deleteDeck");
  }
}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.cards"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-cards { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-cards label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-cards input, .tutorial-workbench-cards select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-cards pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-cards button { margin-right: var(--space-3); }

LICENSE.txt

text
MIT License

Copyright (c) 2026 Gravewright contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5. Permissions, failures and final test

Follow the recipes in order and inspect fresh state after each click. Use the definition route and verify its instance has two cards; use direct create for three text-only cards. Compare the two accounts before and after Give. Test hidden fronts, face-down scene cards, wrong ownership, stale card/deck versions and insufficient draws. A card operation checks the card version; shuffle/reset/delete checks the deck version; draw is a native serialized draw action. Each repeated successful Draw removes another card, and Import-like creation actions are not a refresh. After an unknown write result inspect state before running again. Uploads may have completed even when a later define fails; use only original disposable artwork.

How to use AI to extend this project

text
Adapt tutorial-cards for an original [deck idea]. Inspect cards/services.py and card.asset.upload schemas. Preserve instance versus definition IDs, card/deck revisions, File upload, ownership and front visibility. Do not return another player's hand or make private faces visible in logs. Provide the full package and two-account draw/place/give/reset checks.

All tutorials · API