How to create a scene tools module
Build a working module to inspect scene layers, edit metadata, manage folders, draw a native door and broadcast a test scene.

The project you will build
Create Scene workshop, a browser module with a native workbench modal. Follow a concrete sequence: inspect an existing disposable scene, rename it, organize its folder, create/edit/operate a door and deliberately broadcast it. Delete the test data only after the token and combat tutorials.
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 maps-workshop. 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.
maps-workshop/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtIn a disposable campaign, create an empty scene at least8×8 cells using the native Scenes → New scene → Map Builder flow, then Generate scene. Start with no valuable scene content. The public browser domain API has no maps.create method for a new raster/Builder project. Use Map Builder for authored projects; native source generation remains separate from the commands below.
2. Package, install and activate
From the project folder, run in PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../maps-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../maps-workshop-1.0.0.zipOn 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 Scene workshop. Close that menu and click Scene workshop. 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
Inspect scenes, folders and native layers
Run with the Scene ID blank first. Copy the test scene ID, enter it and run again to inspect walls, images, lights, fog, lighting and their returned versions.
async ({api,context,signal},v) => {
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const directory=await api.maps.state({},read);
const layers=v.mapId.trim()?await api.maps.layers({mapId:v.mapId.trim()},read):null;
return {directory,layers};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: Authorized scene/folder IDs, activeMapId and navigatingMapId; selected native layer state when supplied.
How to verify: Compare the GM scene directory with a player account. Player state must omit private scenes, regions and private graph data.
Rename the test scene and set grid visibility
Use a disposable scene. Name and gridVisible are supplied settings; other scene settings remain unchanged. Do not send the entire projected scene as settings.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const before=await api.maps.get(v.mapId,{},read);
await api.maps.update({mapId:before.id,version:before.version,settings:{name:v.name,gridVisible:v.gridVisible}},write());
return await api.maps.get(before.id,{},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The same scene ID with the chosen name, grid visibility and a new integer version.
How to verify: Open native scene settings and verify name/grid while checking that imageScale, gridSize and measurement units remain as before.
Create a scene folder
Enter an original folder label. Copy created.folderId for the next two recipes.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const created=await api.maps.createFolder({label:v.label,color:'#b49a5a',parentId:null},write());
return {created,folders:(await api.maps.state({},read)).folders};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: A folder UUID and the updated scene folder list.
How to verify: Find Workshop scenes in the native Scenes directory.
Rename or reparent a scene folder
Use the folder created above. Blank Parent puts it at the root. The server rejects cycles, foreign folders and nesting deeper than its supported limit.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
await api.maps.updateFolder({folderId:v.folderId,label:v.label,color:'#b49a5a',parentId:v.parentId.trim()||null},write());
return (await api.maps.state({},read)).folders;
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The retained folder ID with its new label/parent.
How to verify: Expand the scene tree and verify nesting. Try the same folder as its own parent and confirm rejection.
Move the scene into the tutorial folder
Paste the test scene and folder IDs. This command uses groupId; it is not the journal folder_id contract.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
await api.maps.get(v.mapId,{},read);
await api.maps.move({mapId:v.mapId,groupId:v.folderId.trim()||null},write());
return await api.maps.get(v.mapId,{},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The scene groupId references the chosen folder, or null at the root.
How to verify: Locate the same scene under Workshop rooms. The scene ID and native objects are unchanged.
Draw a native test door through updateObjects
Use a scene at least 8 cells wide. Coordinates are scene pixels, not the cell coordinates used by token.move. The endpoints must differ. This creates a plain native door with no blueprint or image dependency.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
await api.maps.get(v.mapId,{},read);
const result=await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'create',data:{x1:v.x1,y1:v.y1,x2:v.x2,y2:v.y2,kind:'door',door_state:'closed'}},write());
return {created:result.wall,layers:await api.maps.layers({mapId:v.mapId},read)};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: A wall entry with kind door, an ID and version. Copy its ID.
How to verify: Open Walls in the native scene and find the horizontal door. Inspect its projected endpoints.
Adjust a door endpoint with its object version
Use the door created above. Only x2 changes; the native validator retains its other fields. Scene version and object version are different.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const layers=await api.maps.layers({mapId:v.mapId},read);
const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
if(!door) throw new Error('Choose a door from this scene');
await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'update',data:{wall_id:door.id,version:door.version,x2:v.x2}},write());
return (await api.maps.layers({mapId:v.mapId},read)).walls.find(row=>row.id===door.id);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: A longer/shorter door, same ID, incremented object version.
How to verify: Verify that x1/y1/y2, presentation and barrier channels are preserved. An interleaved native object edit must cause conflict.
Open or close the plain test door
Enter open, run, then enter closed and run again. This recipe deliberately uses a plain door. On a Smart Object the same interaction may execute authored graphs instead of applying a simple toggle.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!['open','closed','locked'].includes(v.state)) throw new Error('Use open, closed or locked');
const layers=await api.maps.layers({mapId:v.mapId},read);
const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
if(!door||door.blueprint) throw new Error('Choose the plain tutorial door without a blueprint');
return await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'door',data:{wall_id:door.id,door_state:v.state}},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The plain door opens/closes in the native scene.
How to verify: Observe geometry and blocking. Test player interaction separately in the real native controls, including locked-door refusal.
Broadcast the prepared test scene
Choose the disposable prepared scene and explicitly check the broadcast box. activate makes the scene player-visible and changes the campaign broadcast.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the test scene broadcast');
await api.maps.get(v.mapId,{},read);
await api.maps.activate({mapId:v.mapId},write());
return await api.maps.state({},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: activeMapId equals the scene ID; connected players switch to it.
How to verify: Use a second player account. Verify the broadcast changes only after Run action with the checkbox selected.
Delete only the tutorial door
Use only the door created in this tutorial and confirm. A Smart Object deletion can have authored destruction effects; the tutorial plain door has none.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the disposable door');
const layers=await api.maps.layers({mapId:v.mapId},read);
const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
if(!door) throw new Error('Door not found');
await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'delete',data:{wall_id:door.id,expected_version:door.version}},write());
return await api.maps.layers({mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The selected door disappears and other walls remain.
How to verify: Inspect walls before/after and confirm only the copied door ID was removed.
Remove the test folder while keeping scenes
Use the folder created here. recursive:false moves contained scenes/child folders to the parent instead of deleting the scene tree.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the disposable folder');
await api.maps.deleteFolder({folderId:v.folderId,recursive:false},write());
return await api.maps.state({},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The folder is absent, and its scenes still exist.
How to verify: Find Workshop chamber at its new parent location.
Delete the disposable scene last
Run last on the disposable scene only, after the token/combat tutorials. Scene deletion also removes its scene-owned content.
async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the disposable scene');
await api.maps.get(v.mapId,{},read);
await api.maps.delete({mapId:v.mapId},write());
return await api.maps.list({},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}Expected result: The selected scene is absent from the authorized scene list.
How to verify: Confirm other scenes and campaigns remain present. Do not use this on an existing campaign map.
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
{
"id": "tutorial.maps-workshop",
"name": "Scene workshop",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Build a working module to inspect scene layers, edit metadata, manage folders, draw a native door and broadcast a test scene.",
"author": "Gravewright documentation",
"license": "MIT"
}main.mjs
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="Scene workshop";
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-maps-workshop";
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
export const recipes=[
{
id:"inspect",
label:"Inspect scenes, folders and native layers",
description:"Run with the Scene ID blank first. Copy the test scene ID, enter it and run again to inspect walls, images, lights, fog, lighting and their returned versions.",
fields:[
{
"name": "mapId",
"label": "Optional scene ID",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,context,signal},v) => {
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const directory=await api.maps.state({},read);
const layers=v.mapId.trim()?await api.maps.layers({mapId:v.mapId.trim()},read):null;
return {directory,layers};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"metadata",
label:"Rename the test scene and set grid visibility",
description:"Use a disposable scene. Name and gridVisible are supplied settings; other scene settings remain unchanged. Do not send the entire projected scene as settings.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "name",
"label": "Scene name",
"value": "Workshop chamber",
"type": "text",
"required": true
},
{
"name": "gridVisible",
"label": "Show grid",
"value": true,
"type": "checkbox",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const before=await api.maps.get(v.mapId,{},read);
await api.maps.update({mapId:before.id,version:before.version,settings:{name:v.name,gridVisible:v.gridVisible}},write());
return await api.maps.get(before.id,{},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"folder-create",
label:"Create a scene folder",
description:"Enter an original folder label. Copy created.folderId for the next two recipes.",
fields:[
{
"name": "label",
"label": "Folder label",
"value": "Workshop scenes",
"type": "text",
"required": true
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const created=await api.maps.createFolder({label:v.label,color:'#b49a5a',parentId:null},write());
return {created,folders:(await api.maps.state({},read)).folders};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"folder-update",
label:"Rename or reparent a scene folder",
description:"Use the folder created above. Blank Parent puts it at the root. The server rejects cycles, foreign folders and nesting deeper than its supported limit.",
fields:[
{
"name": "folderId",
"label": "Folder ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "label",
"label": "New label",
"value": "Workshop rooms",
"type": "text",
"required": true
},
{
"name": "parentId",
"label": "Parent folder ID, blank for root",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
await api.maps.updateFolder({folderId:v.folderId,label:v.label,color:'#b49a5a',parentId:v.parentId.trim()||null},write());
return (await api.maps.state({},read)).folders;
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"move",
label:"Move the scene into the tutorial folder",
description:"Paste the test scene and folder IDs. This command uses groupId; it is not the journal folder_id contract.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "folderId",
"label": "Folder ID, blank for root",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
await api.maps.get(v.mapId,{},read);
await api.maps.move({mapId:v.mapId,groupId:v.folderId.trim()||null},write());
return await api.maps.get(v.mapId,{},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"door-create",
label:"Draw a native test door through updateObjects",
description:"Use a scene at least 8 cells wide. Coordinates are scene pixels, not the cell coordinates used by token.move. The endpoints must differ. This creates a plain native door with no blueprint or image dependency.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "x1",
"label": "Start x in scene pixels",
"value": 70,
"type": "number",
"required": true
},
{
"name": "y1",
"label": "Start y in scene pixels",
"value": 70,
"type": "number",
"required": true
},
{
"name": "x2",
"label": "End x in scene pixels",
"value": 210,
"type": "number",
"required": true
},
{
"name": "y2",
"label": "End y in scene pixels",
"value": 70,
"type": "number",
"required": true
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
await api.maps.get(v.mapId,{},read);
const result=await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'create',data:{x1:v.x1,y1:v.y1,x2:v.x2,y2:v.y2,kind:'door',door_state:'closed'}},write());
return {created:result.wall,layers:await api.maps.layers({mapId:v.mapId},read)};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"door-update",
label:"Adjust a door endpoint with its object version",
description:"Use the door created above. Only x2 changes; the native validator retains its other fields. Scene version and object version are different.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "doorId",
"label": "Door ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "x2",
"label": "New end x in scene pixels",
"value": 280,
"type": "number",
"required": true
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
const layers=await api.maps.layers({mapId:v.mapId},read);
const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
if(!door) throw new Error('Choose a door from this scene');
await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'update',data:{wall_id:door.id,version:door.version,x2:v.x2}},write());
return (await api.maps.layers({mapId:v.mapId},read)).walls.find(row=>row.id===door.id);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"door-operate",
label:"Open or close the plain test door",
description:"Enter open, run, then enter closed and run again. This recipe deliberately uses a plain door. On a Smart Object the same interaction may execute authored graphs instead of applying a simple toggle.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "doorId",
"label": "Door ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "state",
"label": "Door state: open, closed or locked",
"value": "open",
"type": "text",
"required": true
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!['open','closed','locked'].includes(v.state)) throw new Error('Use open, closed or locked');
const layers=await api.maps.layers({mapId:v.mapId},read);
const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
if(!door||door.blueprint) throw new Error('Choose the plain tutorial door without a blueprint');
return await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'door',data:{wall_id:door.id,door_state:v.state}},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"activate",
label:"Broadcast the prepared test scene",
description:"Choose the disposable prepared scene and explicitly check the broadcast box. activate makes the scene player-visible and changes the campaign broadcast.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "I intend to broadcast this test scene to players",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the test scene broadcast');
await api.maps.get(v.mapId,{},read);
await api.maps.activate({mapId:v.mapId},write());
return await api.maps.state({},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"door-delete",
label:"Delete only the tutorial door",
description:"Use only the door created in this tutorial and confirm. A Smart Object deletion can have authored destruction effects; the tutorial plain door has none.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "doorId",
"label": "Door ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "I selected disposable tutorial data",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the disposable door');
const layers=await api.maps.layers({mapId:v.mapId},read);
const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
if(!door) throw new Error('Door not found');
await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'delete',data:{wall_id:door.id,expected_version:door.version}},write());
return await api.maps.layers({mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"folder-delete",
label:"Remove the test folder while keeping scenes",
description:"Use the folder created here. recursive:false moves contained scenes/child folders to the parent instead of deleting the scene tree.",
fields:[
{
"name": "folderId",
"label": "Folder ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "I selected disposable tutorial data",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the disposable folder');
await api.maps.deleteFolder({folderId:v.folderId,recursive:false},write());
return await api.maps.state({},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
},
{
id:"delete",
label:"Delete the disposable scene last",
description:"Run last on the disposable scene only, after the token/combat tutorials. Scene deletion also removes its scene-owned content.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "I selected the disposable scene and its test contents",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,context,signal},v) => {
if(context.role!=='gm') throw new Error('GM required');
const read={signal};
const write=()=>{
if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
return {signal,requestId:crypto.randomUUID()};
};
try {
if(!v.confirm) throw new Error('Confirm the disposable scene');
await api.maps.get(v.mapId,{},read);
await api.maps.delete({mapId:v.mapId},write());
return await api.maps.list({},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
throw problem;
}
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.maps-workshop"] .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-maps-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-maps-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-maps-workshop input, .tutorial-workbench-maps-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-maps-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-maps-workshop button { margin-right: var(--space-3); }LICENSE.txt
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
Every write is an explicit Run action. Reads use recipient projections; writes keep native permissions and fresh operation UUIDs. Scene update, object update, token move/configuration and combat use their real revision fields. Some native selection commands have no expected revision parameter in this release; the examples do not invent one. On conflict the recipe rereads current state and stops. Review it, then intentionally run a new operation. Multiple API calls are separate transactions: a completed earlier call is not rolled back if a later read fails. Deactivation aborts scoped work; it cannot undo a server commit.
Test GM/player scene filtering, one stale scene revision, one stale door revision, denied folder cycles and deactivation during a read. Activation explicitly broadcasts to players. Map Builder boundary: this package does not author Blueprint JSON or call private Builder routes. Use the real Smart Object editor, Simulate and Test behavior to create automation, then Generate/Update scene. Four Blueprint kinds, 42 complete node examples and execution/data connections provide complete graph recipes.
Public operations covered by recipe
| API | Panel recipe |
|---|---|
| api.maps.update | metadata |
| api.maps.move | move |
| api.maps.delete | delete |
| api.maps.activate | activate |
| api.maps.updateObjects | door-create, door-update, door-operate, door-delete |
| api.maps.createFolder | folder-create |
| api.maps.updateFolder | folder-update |
| api.maps.deleteFolder | folder-delete |
How to use AI to extend this project
Extend Scene workshop on Gravewright0.3.0 to [a concrete scene task]. Inspect frontend.json, maps/services.py and maps/objects.py for exact payloads and revisions. Preserve unrelated settings and object fields. Use public api.maps only in the module. For Builder source/Blueprint editing, give actual editor steps rather than private route calls. Return full code, a disposable setup, exact inputs and GM/player/conflict/abort checks.