Skip to content

v0.122.0 — Map building: hidden means hidden, Object Edit Mode, and the Library as Assets

Built on program/map-building/milestone-6: map building's first three milestones, released together (the owner's choice, 2026-10-16). M1 shipped phases 1–4 (hidden means hidden, objects with parts, stateful objects, use), M2 Object Edit Mode, and M6 the Library as Assets. The program's documents are in docs/programs/map-building/.

The Library as Assets (M6)

The Library becomes a view over everything you've made and imported: tokens, Objects, maps and characters wherever they are, plus textures and kept artwork. Detailed tasks: docs/programs/map-building/m6-library-as-assets.md. Cut from v0.123.0, which ships first.

  • [x] The map building program moved into the platform (docs/programs/map-building/), and the milestone's shared types landed (libraryAssets.ts).

  • [x] An Object's drawing parts and token parts stack by its layers: a drawing part takes a depth slot among its Object's token parts (utils/objectStack.ts), drawn and clicked in that order; drawings that aren't parts are unchanged. Checked in the browser by e2e-live/objectLayerInterleave.spec.ts.

  • [x] Stored files get unguessable names: 128 random bits from crypto in place of a timestamp and Math.random().

  • [x] Tokens and maps record who made them (created_by, migration 0069), backfilled from history; a token can be on no map and a map in no campaign (0070), its maker's alone until placed; tokens carry a fingerprint of what they are (0071), so identical copies can be shown as one; and Library Records and staged imports get their tables (0072).

  • [x] The Library's server side: one listing across tokens, Objects, maps, characters, textures and artwork (/api/library/assets), with filters, name search (FTS5 and Postgres full-text), copies rolled up by fingerprint, facets and paging; each entry shows only to who may see it, hidden means hidden; tags, Remove from map, a usage report for delete, delete with fork, and Copy to my Library.

  • [x] Importing into the Library (/library/import): pick a folder or a zip, say what it is (guess, all one type, or by folder) and how to tag it, and watch it run. Every image is processed and staged, classified with a confidence, tagged and checked for duplicates; the review groups it by type and confidence, with defaults per tier you can save. Approving makes real Tokens, Objects and Maps (with no map or campaign until placed), Textures and Artwork; rejecting deletes the files. A PDF goes to the character importer and is never stored.

  • [x] An Object's drawn parts can be off the map with it (migration 0074), so a room built of floors and walls can be copied into your Library and kept there.

  • [x] The new Library. Your Library (/library) holds everything you've made or imported, wherever it is, with filters for origin, type, map, campaign, tags, name and dates, and copies grouped into one card you can open. Each campaign has a Campaign Library (/campaign/:id/library) and a strip of its newest on the campaign's page. On a map, the Library sidebar switches between This map, Campaign and Mine; drag a card onto the map, or Insert into map, to place a copy, Objects with all their parts. Each card can be tagged, removed from its map, copied to your Library, or deleted with a warning that says what goes with it. New Token, New Object and New Map start from the Library.

  • [x] The old Library is gone (migrations 0075–0076): library_items is dropped, as the spec decided, and today's Library is cleared rather than migrated; files stay in storage to be re-imported. The module Library API keeps its shape over the new Records; textures, the map generator, Choose From Library and the storage quota read the new Library; the self-hosted folder import stages its folder as import batches. Gone with it: Retouch from the Library page (token and character art are still retouched in the artwork editor), the review queue and reclassify (import asks first now), Library undelete, and the admin Library pages.

  • [x] The map's inner ring (the Drawing, VBL and Fog palettes, the snap bar) has its own background, distinct from the panels around it: a fade from a lighter tone next to the outer ring to a darker one next to the map, in dark and light colour schemes.

  • [x] The Drawing palette's stroke and fill chips sit side by side instead of one over the other, each with its own ×.

  • [x] Stroke width, wall width and arc radius are a slider and a px | grid toggle: the −/+ buttons and the text box are gone. The slider shows its value while you hover, drag or focus it, and with it focused the arrow keys step the width (Shift by 10, Ctrl/⌘ by 100; in grid units 0.01, 0.1 and 1 square).

  • [x] The Drawing tools' snap settings (snap to objects, and the From / To grid snap) moved from the Drawing palette to the snap bar on the map's right edge, which shows while the Drawing tools are open.

  • [x] Mode indicators (targeting, measuring, deleting, heal/harm, impersonating, placing, picking, drawing a wall) float again at the top centre of the map, just under the map's name, stacked when more than one is on, instead of sitting on the map's bottom edge.

  • [x] An image imported as a Map no longer also shows in the Library as a token you made, named like the map: the token that carries the map's image is part of the map, has the import's Record, and isn't listed on its own. Maps imported before this are fixed at boot, and a module storing a map, or a type changed in review, behave the same.

  • [x] The Library's type and origin filters (Tokens, Objects, … and Made, Imported, Built-in) are toggle switches, each with its count, instead of chips; the map's Library sidebar uses the same switches for its types.

  • [x] Hovering a token, Object or character in the Library shows a card with its portrait art, its token art and a few facts: size, faction, linked character, a handful of stats, conditions, tags and the start of its notes, clipped to stay small. It opens after a short pause, fetches only then, and never on a touch screen.

  • [x] A map's thumbnail shows the map as a player sees it, though the GM's browser takes it: no token, Object part or drawing hidden from players, nothing on a hidden layer, the fog opaque where it isn't revealed, and none of the GM's overlays (selection, VBL, measuring, targeting, grid adjust, hover). It is framed on what players can see, taken only while the map is at rest, and the GM's view doesn't flicker while it is.

  • [x] A token or Object hidden from players shows its art to the GM at 40% again, instead of only its faction ring: the art's alpha cut-off now scales with its opacity, so art drawn fainter than half (hidden, dim light, a faded Object layer, the drag ghost) is no longer thrown away whole. Broken since hiding arrived in v0.20.0.

  • [x] Security: token writes are authorized. PUT/PATCH /api/tokens/:id checked only that you could see the token, so any player could rename, re-art, resize, move or rewrite any token, the GM's included, with a crafted request; most per-token routes (actions, auras, conditions, artwork, mount, delete, bulk move) had no check either. Now Edit on the token (the GM, editors, its owner, its character's owner) writes anything; a player who controls it (a grant on the token or its character, or a token allow) may move and turn it and change its HP, bars, conditions, auras, artwork choice and chat colour, and gets a 403 naming any other field; anyone else gets a 403. Placing a token needs play on the map, and removing one its owner or Edit on the map. A token's map can't be changed by PATCH (409: move it by pasting or placing it), which left an Object's parts behind. A guard test fails any token write route without a check. Players no longer drag tokens they don't control.

  • [x] Security (CodeQL, PR #80): a Library import's upload takes only bytes: a JSON body (an object or an array) or a repeated path header is refused before anything reads its length, in the route and again in the import service.

Object Edit Mode (M2)

Map building tools phase 5 (planning/roadmap/2026-Q4/map-building-tools.md#object-edit-mode), release M2 of the map building program. Cut from version/0.122.0 while its gate waits; main is merged in once v0.122.0 ships.

  • [x] Release bookkeeping: M1, M2 and M6 ship together as v0.122.0 (the owner's choice, 2026-10-16), so M1's own bookkeeping folds into this one.

Nomenclature

  • [x] tokens.is_object (migration 0066): what makes a row an Object is explicit (MB-D22). Set by New Object, Convert to Object, Make Object, placing an object type, the wall tool and paste of an Object; backfilled from today's Objects (a type, parts, a frame or a wall). A shared isObject predicate replaces isCreature's guess wherever the UI names a thing. objectFlagFor (shared) is what a new row gets when the caller doesn't say: a type, a frame or a wall makes it an Object — so an older clipboard's Objects paste as Objects. Double-click enters an Object or a part of one; Edit Parts and the context menu's Target / Impersonate / Delete this Object label go by the flag. isCreature stays for what it decides: whether a new token inside an entered Object becomes its part.
  • [x] The copy sweep: Object means only the new multi-layer Object; a free-size token is a Token. Sizing Mode reads Grid-sized / Freeform, and Freeform gets Snap to grid (position and size; snapToGrid, every layer). What an Object offers is an Interaction in user-facing copy (the menu, chips, Nearby, the action editor). The layer modal's default sizing uses the same two names; a Grid-sized token now always snaps (one with snapToGrid off moved freely); the Snap to grid switch shows for any Freeform token, where it showed only on the background layer. The action editor's checkbox is Make this an Interaction. Left as it is: the Library's Objects type (classified images), which the Library Objects release joins rather than renames (MB-D13).

The nub and its breadcrumbs

  • [x] In Object Edit Mode the nub reads Campaign / Map / Object (users) n [Done]: one crumb per entered level, each a way back out to it, Done inside the nub. The strip under the nub goes. Outside edit mode it reads Campaign / Map (slashes now). In edit mode the nub is a row rather than one button: the campaign and map crumbs leave edit mode, an Object's crumb steps out to it, the count still opens the map switcher, and Done leaves. ObjectBreadcrumbs is gone.
  • [x] Crumbs shorten when the nub doesn't fit: kept by priority alternately from the ends (most specific, least specific, parent of the most specific, child of the least…); the hidden middle is one … whose tooltip shows the whole chain, wrapping. A pure function of the widths, unit-tested. utils/crumbs.ts: crumbPriority and fitCrumbs (crumbs are kept in priority order until the next doesn't fit — never skipping to a shorter one, so the hidden crumbs are always one run; the most specific always shows); measureLabel measures on a canvas at the app's font. The edit-mode nub uses it within its maximum width. The collapsed nub's two crumbs still truncate with CSS: the rule would drop the campaign first, which truncation already does.

The Edit Object sidebar

  • [x] Entering an Object opens a left sidebar tab, Edit Object (tabler package), header Edit Object, with the Object's name (AsyncTextInput). It exists only while an Object is entered; leaving puts back the left context that was open. A new context type, editObject, and a left tab, object, first in the strip, which visibleTabs shows only while an Object is entered (the bar's column widens by one then). useEditObjectSidebar (mounted by the map) opens it on entering and remembers what the left sidebar showed; stepping into a sub-Object keeps it, and leaving closes it and reopens that. The phone has no left sidebar; the tab is desktop only.
  • [x] Object layers (migration 0067): metadata.layers ([{ id, name }], bottom to top; a new Object has Layer 1) and object_layer_id on tokens and drawings (null = the first). New parts go on the selected layer. Rendering subdivides the Object's z slot by its layers, recursively for sub-Objects. Shared: objectLayersOf, objectLayerIndex, nextLayerName. The edit store keeps the selected layer per entered Object (activeLayer, enteredLayerFor); a token or drawing made inside goes on it. utils/objectLayerOrder.ts orders a tree's members — parent first, by layer, then their own order, recursively — in the slots the tree already held, so the Object keeps its place on the map layer; the token layer and the drawing layer each apply it. Fixed: an object placed by type inside an entered Object (Place Object ▸ Wooden Door, or Add to Map's Objects) went on the map, not the Object. Sent with only its type, it has no layer until the server gives it one, so isCreature read it as a token on the token layer — a creature, which is never a part. An Object, or anything placed from an object type, is never a creature now.
  • [x] (Carried to map building M6 as E1, and done there.) A drawing part and a token part interleave by their Object's layers. Drawings render in their own depth band (0.9, between the objects and tokens layers), so today an Object's drawings are all above its object-layer tokens or all below its token-layer ones, whatever their layers say; drawing an Object's drawing parts in its own depth slot fixes it. Deferred: drawings also carry fixed render orders (10 and 11) that three.js sorts before depth, so moving them in z alone wouldn't put one under a token; it needs the drawing layer's render order reworked against the token stack, and a visual check on the map that this session can't make. Next release, with a screenshot test.
  • [x] The Layers section: cards top to bottom with counts (tokens, Objects, drawings, interactive Objects, light sources; zeros hidden); New layer on top named the next Layer N, its name field focused with the text selected; rename; drag to reorder; delete, asking to move the contents to another layer or discard them (skipped when empty), one commit. Server: /api/tokens/:id/layers — POST adds on top, PATCH /:layerId renames, PUT reorders (bottom to top, every layer once), DELETE /:layerId with ?moveTo= moves the direct parts or, without, deletes them (tokens with their own parts); one commit each, Edit on the map, an Object keeping one layer at least. Client: useObjectLayerActions, utils/objectLayerCounts.ts (direct parts by layer; interactive is an Object whose type offers something or with an action made an Interaction; a light is a part with a light source type), and ObjectLayersSection (dnd-kit cards; a click selects the layer new parts go on).
  • [x] Show background while editing: Yes (default) — the map behind a translucent white screen; No — the Object in a black void. Per user. One more screen per sub-Object level; in the void, everything from the parent up is hidden. utils/objectEditLevels.ts: a thing's level is how deep into the entered chain it sits; outside the edited Object it shows at 0.3 per level out (none in the void), and ObjectEditBackdrop washes the foundation white — stronger a level deeper — or covers it in black. A screen made of fades and a wash, not a plane over the map: the placed layers' render orders would draw some things above a plane whatever its depth. The switch is the sidebar's Options, kept in this browser. Fixed after the owner's first try: the backdrop's mesh carried data-testid, which react-three-fiber reads as object.data.testid — it threw while the canvas rendered and took the map down on entering a new Object. It is name now, and r3fDataProps.guard.test.ts refuses any data-* prop on a three.js element.

The Palette in Object Edit Mode

  • [x] The layer selector lists the entered Object's layers; Edit Layers scrolls the sidebar's Layers section into view and highlights it instead of opening the map's layer modal. useObjectLayerPalette: the Object's layers top to bottom, the selected one (the edit store's activeLayer) shown and picked — the inline control, the overflow menu and the phone's drawer alike. Edit Layers opens the Edit Object tab if it isn't showing and bumps layersHighlight; the section scrolls in and outlines itself for a moment.
  • [x] Target and Fog of War are hidden; VBL is locked to the entered Object's attached VBL (a wall says its VBL comes from its run); Edit Grid warns that it changes the map's grid. The VBL tool opens the token-VBL editor on the entered Object (setTokenVBLEditMode(true, id)), and turning the tool off leaves both VBL modes; a wall answers with a note and stays off. Edit Grid's warning is a note, and the grid edit goes ahead.
  • [x] Find a token by name searches only the entered Object and its sub-Objects, with small print saying to leave edit mode to search the whole map. The search popover scopes itself (subtreeOf the entered Object), so the Palette's bar, its overflow menu and the phone's drawer all do.
  • [x] Undo, Redo and History are scoped to the Object: a server-side partial restore puts back only the subtree's rows as of the chosen commit (one new commit, MB-D20); History lists only the commits that touched the subtree. Server (services/history/objectRestore.ts): the subtree is the Object, its tokens at any depth, their drawings, and those tokens' grants and permissions, taken from both the target and the current snapshot (a part added since goes; one deleted since comes back); the merged map is written through commit, so it's one ordinary commit on the current timeline, its reason object restored: …. A commit touched the Object when its stored change list names a subtree row; GET /api/history/map/:id/objects/:objectId lists those (from the map's newest 500), POST …/restore restores, refusing a commit from before the Object existed. Client: HistoryRef.objectId scopes the History panel (list, restore, no cascade); useObjectUndo steps through the Object's edits (its own restores aren't edits), and the keyboard shortcuts and the Palette's buttons use it while an Object is entered. Outside the Object, the map's Undo treats a scoped restore as any other commit.

Sub-Objects

  • [x] Double-clicking a sub-Object, or making one with the Add Object tools while editing, enters it; the nub gains its crumb. Phase 2's double-click already enters the part of the entered Object that holds what was clicked, and New Object made inside is entered with its whole chain; this release's nub and backdrop make the levels visible. Pinned by hooks/subObjects.contract.test.ts. Placing an object type inside (a door, a lantern) makes it a part without entering it: a type has no parts to make.

Turning and walls

  • [x] A turned Object's selection outline and resize handles turn with it. They were drawn square to the map while the Object, and its hit area, turned. A corner drag of a turned Object resizes in its own frame and holds the corner across from the handle where it is drawn (anchoredOffset takes the rotation; the resize popover's anchors stay in unturned space, D4).
  • [x] Turn a selected Object with the mouse: a handle above its top edge, dragged around its centre, snapped to 15° (Shift: free), one PATCH on release, Escape to abandon. Shown on one selected Object that turns with its rotation (facingLogic: 'rotate'); Shift/Alt+arrow and Shift+wheel still set facing. utils/rotateHandle.ts.
  • [x] A wall drawn inside an entered Object is its part, on the layer selected in it. The wall tool builds through POST /api/tokens/wall, not createToken, so it never took the entered Object as its parent: walls drawn while editing a room landed on the map as Objects of their own, and dragging them (their hit area lay over the room's) looked like the room's walls tearing loose. buildWall fills the parent and layer as createToken does; the route takes parentId/objectLayerId and checks the parent (parentProblem).
  • [x] An Object's frame fits its parts exactly. Leaving an Object fits its frame to its parts (partsFootprint), which rounded every edge out to whole squares: float noise from many moves (a floor edge at -14.000000000000004) pushed a room's frame a square past its walls, and parts off the grid got a frame up to a square bigger on each side. That frame is the gray outline, the selection and the hit area, so they showed apart from the room ("a ghost outline at an offset"). Edges on a grid line stay on it, others hug the parts, and a turned part counts as drawn. Reproduced and checked on the dev server with pnpm test:live.
  • [x] A frame Object's footprint follows its parts as they change, not only when it is left. A New Object kept the one square it was made with until the GM left it, so a room built in one took the pointer only in that square (its locked Floor and Walls layers take none of their own); one built here stayed 1×1 for a quarter of an hour. Now the map fits a frame whenever anything its fit reads changes, whoever changed it (a part made, moved, resized, turned, redrawn or removed, an undo, another GM): utils/frameRefit.ts gives each frame its parts as one string (framePartSignatures), and useFrameRefit posts /fit for the frames whose string changed, once per burst (300 ms), sub-Objects first. The server still computes the footprint; a fit writes only the frame, so it can't set off another. Only for who may edit the map.
  • [x] On the map, a part has no hover of its own: hovering a room's wall or door shows the room's hover, over the room (inside an entered Object, the part of it that holds what is hovered), as a click and a drag already act on it.
  • [x] Map loads read their rows by an index (migration 0068). tokens, drawings, fog_of_war_shapes, vbl_shapes and map_layers are all read by map_id on every map load, and none had an index on it in either database (only scripts/sync-prod-db.ts made one, for tokens), so each read scanned the whole table. <table>_map_idx, declared in dbSchema.ts for both dialects and created in sqlite's DDL. Found while specifying the Library as Assets (planning library-assets.md, task 1).
  • [x] An Object's Interaction chips beside a token no longer come and go as the map pans. drei sets an <Html> overlay's CSS z-index each frame from the camera's distance across zIndexRange; the camera sits at z=1000 against a far plane of 1000, so the chips' [MAP_UI, 0] went negative a few squares from the camera's centre and the chips dropped behind the canvas. Every map <Html> now has a range with both ends equal: the chips, the measure readout, remote cursors' names, and a remote drag's name label (which had drei's default, floating over modals too). components/mapHtmlZIndex.contract.test.ts holds every <Html> in the app to it.
  • [x] The Drawing a Wall banner sits below the drawing toolbar instead of over its stroke and wall settings. It measures the toolbar (belowToolbarTop) and follows it as it wraps.

Foundation and wall drawing

  • [x] A map's foundation can be a flat colour instead of a texture: Foundation ▸ Texture | Color in the Edit Map sidebar and the Edit Map modal (FoundationPicker). The colour is stored in maps.texture as its hex, which no texture id or URL can be, so nothing migrates; the WebGL map draws it flat and the Konva fallback fills with it. A colour has no texture scale. The colour picks live and saves (one undo step) when picked.
  • [x] Wall corners join: a wall is drawn as one shape (utils/wallOutline.ts, WallMesh), its corners mitred by default (two beams joined at 90°, bevelled past a 4× miter), rounded (round ends too), or left as separate segments (the old look, with the corner notches). Corners on the Drawing Palette in Wall mode; saved in the drawing's geometry (join, which the drawings route now keeps) and on a wall object's spec, so its drawn runs keep it. A wall that names none (every wall drawn before) is mitred. A run that doubles back on itself is outlined as two runs, or the outline crosses itself and fills a wedge beside the wall.
  • [x] Walls have a width and a fill: Wall width on the Drawing Palette in Wall mode (the stroke width's control, StrokeWidthControl with a name), apart from the stroke width. A wall with a width is a body that wide filled with the fill colour (no fill: hollow), and its stroke outlines the whole of it — both sides, its ends, a closed room's inner edge — centred on the edge, joined as the wall is (WallMesh). The width is saved in the geometry (width); a wall drawn before it is its stroke, as it was. Selected walls follow a width change. A wall object is built at its width, its outline (wallStrokeWidth) and fill (wallFill) kept on its metadata so its redrawn runs keep them; hit-testing and the selection outline span the wall's whole drawn width. A fill texture does not fill a wall yet (colour only).

Walls and Object Edit Mode, round two

  • [x] A wall filled with a texture shows it (a rectangle did; a wall drew no fill). WallMesh fills its body with TexturedFillMesh, as any shape is filled, the texture over the colour; a wall object keeps its texture and tile size (wallFillTexture, wallFillTextureScale) for its drawn runs.
  • [x] A drawing that is a part of an Object (a room's floor) moves when it is dragged inside the Object, as its other parts do. Three things stood in the way (reproduced on a scratch map with pnpm test:live): a wall object's pick plane was its run's whole bounding box, so a room's walls took every press on its floor (utils/wallHit.ts: a wall object takes the pointer on its wall only, in the token raycast and the map's hit tests); the entered Object's own footprint shielded the drawings in it (the map's hit tests now count only tokens that take the pointer there); and a drawing dragged only under the drawing toolbar's select tool (a press on a drawing now drags it, reading the drag live so a quick press is not lost, and a click that moves nothing writes nothing).
  • [x] While a drawing tool is active, the map stands down: no selection outline, no hover or drag on tokens and drawings — a click is the tool's (starting a wall on a selected floor's corner). drawingToolActive (any tool but Select, toolbar open): drawings show no selection, preview-selection or hover, the selected one's text editor and delete button stand down, tokens take no pointer and show no selection ring or resize/rotate handles. The Drawing a Wall banner now lets clicks through to the map (save its Done button): it sat over a room's top corner, and a click there started nothing.
  • [x] With the select tool, the palette is the selected drawing's: a selected wall shows Wall width and Corners, and changes them. Selecting a wall lends its width and corners to the palette, as its colours always were; Wall width and Corners show for the wall tool or a selected wall, and a change reaches every selected wall. Giving a wall drawn before widths a width keeps its look: its stroke colour becomes its fill, with no outline (wallWidthPatch). A selected wall object is shown in the palette too (useWallObjectPalette, wallObjectStyle), and its stroke, fill, width and corners restyle the wall object itself — POST /api/tokens/:id/wall-style, one commit, its drawn runs remade — so a redraw (a door placed in it) keeps them; a wall from before outlines takes its stroke colour as its fill on its first restyle.
  • [x] A selected wall's outline follows its outer edge, not a wide band along the run, wherever it is clicked. A wall object's drawing was selected bare from its outer half (the drawing toolbar's select tool took it as a drawing) and as the wall object from its inner half. Now the select tool takes a part as its Object, as the map's own click does, and a wall object is hit anywhere on its wall (its own ray test: its outer half lies outside its footprint plane). Its highlight is the barber pole round the wall's own outline (WallSelectionOutline: every ring, a room's inner edge too) — for a wall object and a wall drawing alike, hover and drag-select preview too — with no footprint rectangle or resize handles on a wall object. Checked on the scratch map: either half, with or without the toolbar, selects the one wall object.
  • [x] Object layers have a lock (nothing on the layer takes the pointer) and a visibility toggle (the layer and what is on it hidden); Shift-click toggles every other layer instead; Lock all / Unlock all. A layer in metadata.layers may be locked and hidden, set by PATCH /api/tokens/:id/layers (several at once, one commit). A part takes its layer's flags, and a sub-Object's contents its (utils/objectLayerFlags.ts): hidden, it is not drawn and takes no pointer; locked, it is drawn and takes none — no hover, press, selection or box selection, on tokens and drawings alike. Shift-click sets every other layer, or clears them once they all have it (utils/objectLayerToggles.ts). Hiding is for building: what a hidden layer blocks for sight it still blocks, and players' clients still receive it (hiding from players is Visible to players).
  • [x] Drawings snap to the edges and corners of other Objects and drawings, not only the grid. Within 12 screen pixels of another thing's corner a point snaps to it, else onto its edge; else the grid's snap, as before (utils/snapFeatures.ts). The corners and edges are tokens' footprints (turned as drawn; not creatures; a wall object's own faces, not its footprint), rectangles, and line and wall runs (a wall with a width, its outline too) — nothing on a hidden Object layer. Starting a shape, each point, a rectangle's far corner and a vertex edit snap so; a drag keeps the grid's. The palette's magnet turns it off (on by default). Checked on the scratch map: a wall started 10 px from an off-grid rectangle's corner starts on it.
  • [x] Entering an Object selects nothing, and its frame outline stands down while it is edited. The double-click that enters an Object selected it, and inside, the whole Object can't be acted on; enter now clears the selection (tokens, the current token, drawings). An Object's frame fits its parts only on the way out, so inside it showed a stale outline (an 11×8 box behind an 11×6 room); the frame outline of the Object being edited, and of those holding it, is hidden until you leave.

Edit Object and the Library

  • [x] A selected Object's outline stays with it after a drag. Not reproduced on the scratch map (dragged by its floor, by its wall, twice in a row), so this closes the gap it must come from: a drag writes a token's 3D group position by hand, and React re-applies the position prop only when it changes, so a group left where a drag put it stayed there until a reload. Whenever it isn't being dragged, following a drag or swinging, the group is put where the token is.
  • [x] The Edit Object sidebar opens on the right, so the Library can be open on the left beside it. Its tab is the right bar's first (shown only while an Object is entered); entering an Object opens it there and leaving puts back what the right sidebar showed (the chat, say); Edit Layers in the Palette opens it there too.
  • [x] The Library's Objects view can be narrowed to the built-in object types (Wooden door, Iron door, …). The sidebar Library's Objects tab has Library | Built-in: the Library's object pictures, as before, or the built-in object types — the presets and the system's, not the campaign's own — with their art, the search narrowing them by name (LibraryBuiltInObjects). A card's + places one in the middle of the view; inside an entered Object it is its part, on the selected layer. The GM's, as placing an object of a type is.
  • [x] Drag from the Library onto the map. A built-in object type is placed where it is dropped (inside an entered Object, as its part on the selected layer); a Library picture opens the new-token dialog at the dropped square, as its + does. Cards carry what they are under their own MIME type (utils/libraryDrag.ts), so the map tells them from a file dragged from the desktop, which goes as before.

Arcs, hover layers and door swings

  • [x] The palette warns when a drawing may not show: a stroke too thin to see at this zoom, with no fill. Under 1.5 screen pixels at the current zoom (or no stroke at all), and no fill on a tool that fills, a yellow warning beside the stroke width says so, and why (utils/drawingVisibility.ts). Select and the brush (which paints its nib's width) never warn.
  • [x] Arcs: a drawing tool that draws a section of a circle of a set radius, from a first click (a cardinal tangent point of the circle — left, top, right or bottom, changeable between the clicks) to the point of the circle nearest the cursor; the whole circle shows faintly while drawing. The Arc tool's palette has its radius and its mount (First click is the circle's Left / Top / Right / Bottom); the end follows the cursor round the circle, unwrapped, so it can pass 180° and go round a whole turn either way; a second click (or tap) finishes it, Esc cancels. Saved as type: 'arc' (center, radius, startAngle, sweep; utils/arcGeometry.ts); it selects, drags, rotates with its Object and rescales with the grid like the other shapes. The tool grid is five across now (nine tools).
  • [x] Object layers have an opacity (a slider on hover, its % otherwise), for everyone. metadata.layers[].opacity (0–1, absent is 1), set by the layers PATCH; a part's opacity is its layers' product, a sub-Object's contents taking theirs. A translucent part still takes the pointer (only a hidden or locked one doesn't).
  • [x] Object layers have a hover state: their own visibility and opacity while the pointer is over what is on the layer (its things' areas, not the whole Object). metadata.layers[].hover ({ hidden?, opacity? }, each falling back to the layer's; null clears it). The row's hand button switches the eye and the opacity to editing the hover state (an × clears it). The map tests the pointer against each hover layer's things as it moves — a drawing's area (an arc's whole sector, a door's swing), a token's footprint — hidden or not, so a hidden layer can show on hover (hoveredLayerKeys, layerHoverStore); for players too. Checked on the scratch map: a walls layer hidden on hover vanished under the pointer and came back.
  • [x] Built-in doors show the arc they swing through, on hover.

The map's rings, and placing Objects

  • [x] The map has rings. Outside in: the outer ring (the play bar, the rail and the sidebars, the footer — a phone's top band and bottom bar), the inner ring (the map's tools, as nubs: square where they meet the outer ring, rounded over the map, only as long as they need and never the whole edge — 2rem short of it at most, collapsing their contents first), and the map view (the nub, Mini-Me and the target, the hover UI, the Timeline, End Turn). utils/mapRing.ts is the geometry; MapRingHost (beside the canvas) publishes the outer ring as --vtt-map-* and the inner ring's thickness on each edge as --vtt-ring-*, and holds the edge rows nubs sit in (RingNub); an inner-ring element reports its size (useRingSlot, mapRingStore). The mode indicators — Targeting, Heal/Harm, Impersonate, Delete, Measure, Drawing a Wall, the object pick — are nubs on the bottom edge, side by side, instead of banners over the top of the map; the Timeline, and everything that stands on it (aboveBottomChrome), stands on them. The map nub, Mini-Me, the target, the hover portrait and End Turn add the inner ring to where they were.
  • [x] The Drawing, VBL and Fog of War palettes are the inner ring's upper left corner: the tools a strip down the left edge, the active tool's properties a strip across the top, fused into one shape over the corner. CornerPalette (in components/MapRing): the tools wrap into another column before they would come within 2rem of the bottom edge's nubs (stripRows), the properties wrap onto another line 2rem short of the map view's right; one drop shadow over both, so they read as one shape, with a concave fillet where they meet. The pencil's and brush's nib tray is among the properties now rather than hung under the palette. The palette no longer sits centred below the play bar, over the map; the Drawing a Wall nub no longer measures it (belowToolbarTop is gone), and the stacking rule for narrow screens (toolbarLayout.ts) went with the old strip.
  • [x] Snapping an Object: a snap bar, a nub on the right edge, while the GM has an Object selected (on the map and in Object Edit Mode). What it snaps to — grid centres, grid corners, gridlines (doors, windows: onto the nearer line, free along it), other things' edges, centres and corners — and which point of it snaps, a 3×3 of its centre, corners and edge midpoints. A drag of an Object snaps by them: another thing's corner or centre within 12 screen px first, then its edge, else the nearest grid target that is on, else where it was put. utils/objectSnap.ts (snapObjectRect, snapObjectDelta), stores/objectSnapStore.ts (kept in this browser; default: grid corners and other things' corners and edges, by the top left — today's grid snap), components/UI/SnapBar. The other things are what a drawing already snaps to (snapFeaturesOf, which now gives footprints' and rectangles' centres too), less the Object and its own parts. A part dragged on the map snaps by its whole Object's footprint; inside an entered Object, by the part being moved. A token that is not an Object keeps the grid's snap, and a turned Object snaps by its unturned box. Touch drags still round to the grid.
  • [x] Placing an Object follows the cursor: New Object and Place Object ▸ a type, from the Palette's Add to Map or the map's context menu, put the Object under the cursor, translucent — its picture in its default state, else a dashed outline (a dashed square for a new, empty Object) — held by its centre and snapped by the snap bar, which shows while it is placed. A click places it there (a new Object is entered at once, as before; inside an entered Object either is its part); Escape, a right click or the Placing … nub's × cancels. On a phone the ghost follows the finger and lifting it places. stores/objectPlacementStore.ts (start, place, cancel; the map registers what a placement makes), utils/objectSnap.ts placementAt, components/PlacementGhost.tsx (in the canvas), components/UI/PlacementBanner (a bottom nub; takes Escape in the capture phase so an entered Object isn't left too). While placing, the map's other clicks, tokens' presses and hovers and the context menu stand down. Both used to drop the Object at once: the context menu's where it was opened, the Palette's at the map's grid origin.
  • [x] Dragging an item from the Library sidebar onto the map places it there, snapped by the snap bar, which shows while the drag is over the map. Merged onto Drag from the Library onto the map above (built-in object types placed at once, pictures opening the new-token dialog — kept as they are): while a card is over the map its ghost — an object type at its size, in its art; a picture at its picture's size, or one square on the token layer — follows the cursor as a placement's does, and the drop goes where the ghost was, held by its centre and snapped (placementAt), instead of the plain grid square under it (dropSquare is gone). The map can't read a drag's data before the drop, so a card also puts what it carries in objectPlacementStore from its dragstart to its dragend (startLibraryDrag / endLibraryDrag; libraryDrag, libraryDragOver); libraryGhostSize is its size. The full Library page has no map, so its cards don't drag.

Map building tools, phases 1–4 (M1)

  • [x] Release bookkeeping: v0.121.0's notes move to docs/help/changelog/v0.121.0.md, with a line under Released; v0.121.0 merged into the work (its drawing changes kept beside phases 1–3's: Hide from players writes the whole selection, as every toolbar write now does).

The first four phases of map building tools (planning/roadmap/2026-Q4/map-building-tools.md), built on feature/map-building-tools and shipped together as release M1 of the map building program (planning/roadmap/2026-Q4/map-building-program.md).

Phase 1 — Hidden means hidden

The epic is planning/roadmap/2026-Q4/map-building-tools.md; phase 1 is its MB-D9. Today a token hidden from players is sent to every player and drawn at alpha 0, drawings can't be hidden at all, and anyone with the network tab open sees both. "Can see hidden" is the campaign owner (checkCampaignAccess().isOwner), the same test as a hidden map and the client's GM; player view stays a client-side mode.

  • [x] Token reads filter hidden rows for players: GET /api/tokens (by map and by character), GET /api/tokens/:id and its riders (404, not 403), and GET /api/map-objects/:id. One module decides who sees hidden rows and what is hidden (services/hiddenRows.ts); hiddenRows.tokens.test.ts asks each route as the GM and as a player with read on the campaign.

  • [x] Token writes: a player can't change a hidden token (404) or set visibleToPlayers on any token. middleware/hiddenTokens.ts: a router.param('id') handler answers every /:id… route on /api/tokens and /api/map-objects with 404 for a hidden row, and only the GM may change visibility (echoing the stored value back is not a change). Bulk-move won't drag a hidden token for a player, and a hidden rider that moves with its mount isn't echoed back to them.

  • [x] Hidden tokens with attached VBL reach players as anonymous blockers — position, size, rotation and VBL, no name, art, actions or metadata — so vision still stops at a secret door's wall. The map list sends them (rowsForViewer, marked _blocker); a hidden token without VBL isn't sent at all. A player's map neither draws nor picks a hidden token: WebGLTokenLayer skips it, and the camera controller's tap, drag and marquee hit-tests get only the tokens the viewer may pick (they saw hidden ones before).

  • [x] Per-audience socket emission: a GM room per campaign (campaign:<id>:gm, joined with the campaign room by who sees its hidden rows); a hidden token's row goes to it only, and the rest of the campaign gets its anonymous blocker (attached VBL) or a token:deleted — which is how a token the GM hides leaves the players' maps. Revealing is the visible row's token:updated, which clients upsert. TokenEvents and MapObjectEvents do this (events/hiddenAudience.ts), so the routes, the character fan-out and the restore all follow it. A hidden row is never sent to everyone on an audience-cache miss: it waits for its campaign, and concurrent lookups for one map now share one query instead of the second getting nothing.

  • [x] drawings.visible_to_players (migration 0062, additive, default visible): reads, writes and events filtered as tokens are (a hidden drawing is 404 to a player; only the GM hides or reveals one; its pushes go through emitRowByAudience); the drawings GET stops returning soft-deleted rows. The GM toggles it from the drawing toolbar (an eye beside Metadata, for the selected shape — the map's context menu has no drawing entries) and sees hidden drawings at 0.4 opacity, and not at all in player view. drawing:updated now upserts, so a revealed drawing appears for players without a reload.

  • [x] The GM's client doesn't relay drags, targeting previews or initiative movement of hidden tokens. The server only relays these, so the sender stops: tokenDrag:start/move/end are behind relaysDrag in WebGLTokenLayer, the targeting preview isn't active while its acting token is hidden, and useInitiativeSync skips initiative:movement for a hidden token.

  • [x] History (snapshot, diff, blame), .cat and map-zip exports, and the maps list's token counts and thumbnails leave hidden rows out for players. A map commit's snapshot drops hidden tokens, drawings and objects (and the grants and permissions on hidden tokens); its change lists — the commit's own diff included — drop every change whose path names a hidden row, hidden in the snapshot or now; a diff drops rows hidden at either end, and blame those hidden now. Commit summaries ("1 token moved") are counts and stay. The campaign .cat also leaves out maps hidden from players, which it used to include.

  • [x] Guard test: every route that reads tokens or drawings and every token or drawing emit goes through the hidden filter. hiddenRows.guard.test.ts: each server source that reads either table uses the hidden filter or is listed with why its reads never reach a player as rows (ids, counts, writes, permission checks); a new reader fails until it is one or the other. No token:, drawing: or mapObject: created/updated event is emitted except through emitRowByAudience.

  • [x] Hidden creatures are hidden in the Timeline. timelineForViewer (shared): a player's initiative order leaves hidden combatants out — a group keeps its visible members, led by the first visible one when its lead is hidden — and while a hidden combatant acts, the turn stays on whoever acted visibly before it (the round before, when it leads the round), so nobody is told it's their turn early or that something unseen is acting; with every combatant hidden, initiative isn't running for them. The timeline route, the map row (GET /api/maps/:id and the list), history (the snapshot's row and row.timeline changes, dropped when a player would see no difference) and the timed effects on the strip (none borne by a hidden creature, no links to one) all apply it. An editor who doesn't see hidden combatants can't write the order back without them (409) while any is hidden.

  • [x] Hidden creatures' chat messages are hidden from players. A message records the token it speaks as (user.tokenId); while that token is hidden, a player doesn't read the message — the campaign's chat, a channel's messages, the message by id (404) — and once it's revealed they do (on their next read; nothing re-sends it). Its pushes go to the channel room except the campaign's new players room (campaign:<id>:players, joined by who doesn't see hidden rows), and one message's created and updates queue behind each other, since each now looks its speaker up.

Left for later, outside this phase: hidden tokens' ids still appear in the impersonatable list and a character's effects (ids, not rows); the GM's player view still shows hidden combatants on the Timeline strip (the server filters what players receive; the preview is client-side), and a GM's socket that joined before becoming owner isn't in the GM room until it rejoins.

Phase 2 — Objects with parts

After phase 1. An object is anything placed on a map that isn't a drawing; a token is an object that is a creature (MB-D1). A part is an ordinary tokens or drawings row with a parent, stored in world coordinates; a parent moves, rotates, deletes and copies its whole subtree on the server in one commit (MB-D2, the mount-group precedent). Parts are ordered within their parent's map layer (MB-D4). Clicking a part selects its top-level object; the GM enters an object to edit its parts.

  • [x] tokens.parent_id and drawings.parent_token_id (migration 0063, two nullable columns): a part's parent is a live object on the same map, never itself or its own descendant, and a creature is never a part (services/objectParts.ts parentProblem, on the token and drawing create and update routes: 400 with the reason). isCreature in shared (a part never is; a sheet always is; else the token layer drawn as a token); the MapObject alias beside IToken in the web app.
  • [x] Moving an object moves its parts: a PATCH of its position, and bulk-move, translate every descendant token and drawing in the same commit. moveParts (services/objectParts.ts): descendant tokens by grid squares in one statement, drawings' geometry by the same distance in pixels (the map's grid size), each pushed as an update through its audience. A resize moves the object's corner, not the object, so its parts stay — the mount-group rule.
  • [x] Rotating an object rotates its parts about the object's centre, positions and rotations alike (drawings' geometry too), in the same commit. rotateParts: by the change in the object's rotation, as attached VBL turns, after any move in the same PATCH. A drawing has no rotation of its own, so a rectangle turns exactly by multiples of 90° and otherwise orbits the centre and stays axis-aligned; points, centres and a cone's angle turn exactly.
  • [x] Deleting an object deletes its parts (soft, one commit), and restoring it restores them. softDeleteToken marks every descendant token and drawing with parent_delete_id = the object (the map-delete cascade's mark) and the route pushes their deletes; restoreToken brings back what was deleted with it, not a part deleted on its own before; a part deleted with its object can't be restored on its own.
  • [x] Make an object from a selection, and break one apart: POST /api/tokens/group makes a parent object around the selected tokens and drawings; ungroup clears their parent. Group makes a frame object (no art, drawn as an object, sized to the selection's bounds) and makes the selection's top-level rows its parts; they must share a parent or none, which the frame takes, so grouping inside an object stays inside it; creatures are refused. POST /api/tokens/:id/ungroup hands the object's direct parts to its own parent and removes it if it is only a frame (an object with art stays). Both need Edit on the map and are one commit. The GM's multi-select bar has Make an object (shown for two or more selected things, drawings included, with no creature); the context menu has Break Apart on an object with parts.
  • [x] Select the ancestor: a click, drag or marquee on a part selects its top-level object, and the drag shows the whole object moving. utils/objectTree.ts maps every member of an object tree (token parents and drawing parents, across layers) to its top. A click on a part — a token, or a drawing outside the drawing toolbar — selects the top object; a drag of any member publishes the tree's root to the group-drag store, every token and drawing in the tree follows live, and the release moves the top object once (the server moves the rest); the marquee takes top objects, once, and a phone's touch drags the whole object. A frame (an object with no art in a tree) draws nothing but takes the pointer, and the GM sees its outline.
  • [x] Group selection across tokens and drawings: the marquee takes drawings with tokens, and a group drag moves both. A selected token leads the drag, the selected drawings follow live, and bulk-move takes drawingIds and moves them by the same distance in the same commit — for a player, only drawings that are theirs and visible. Map VBL stays single-select: inside objects VBL is attached to the object (MB-D2), so it already moves with it; a group selection of map VBL is left for later.
  • [x] The clipboard copies objects with their parts, and drawings, and pastes them in one commit. Copying an object takes its whole subtree (tokens and drawings) and the drawings selected with it, each entry with its source ref and its parent's; POST /api/tokens/paste creates them parents first and wires each part to its new parent, in one commit (a part naming something outside the paste, a creature as a part, or a player pasting something hidden is refused). On paste only top-level copies spread; parts and drawings move as far as their object did. Duplicate (Ctrl-D) of an object with parts goes the same way. A copy with no parts or drawings pastes as before.
  • [x] Enter and exit: the GM double-clicks an object (or Edit parts) to enter it — everything else dims, the palette's tools make parts of it, the map nub shows breadcrumbs; Escape, a breadcrumb or a click outside exits. stores/objectEditStore.ts holds the entered object's ancestry. Inside, a click, drag or marquee acts on the part that holds what was hit (selectableFor); everything outside is at 0.3 opacity and takes no pointer; a token (never a creature) or drawing made on that map becomes the entered object's part (useTokens/useDrawings create). Double-clicking a part steps further in; Escape steps out one level before anything else Escape does; breadcrumbs under the nub (Map › Cottage › Ground floor, and Done) step out to any level; a click outside the object's footprint, or another map, leaves it. Creatures still impersonate on double-click.
  • [x] The copy sweep: Sizing mode: Token / Object becomes Round, grid-sized / Free size; "token" only where a creature is meant; the context menu's delete label (Delete {name || layerId === "tokens" ? ...}) fixed. Both Sizing Mode selects share SIZING_OPTIONS (and the layer-default line names the choice); the context menu says Target / Impersonate this Object for one that isn't a creature, and Delete names the thing, or says Token or Object by isCreature — by precedence it called every named entity a Token.
  • [x] map_objects retired: its routes, events, hook and export path go; the table stays until a later migration drops it. /api/map-objects (whose list read the dead table and whose writes wrote tokens) and MapObjectEvents are gone, and so is useMapObjects with the hit-test fallbacks that read it in the map and the legacy renderer; the legacy map zip neither exports the table nor imports an old archive's mapObjects/ (never drawn). The map generator's cleanup and history snapshots still read the table until it is dropped.

Phase 3 — Stateful objects

After phase 2. An object type says what a kind of object is — its states, size, art, VBL, stats and light — and a placed object records its type, its state and what it overrides. Changing state is one server write that sets the object's art, its VBL's blocking, its light, whether players see it, and a hinged leaf's swing. Decisions at their recommendations: object types on the light-source dual-scope pattern (MB-D12); shuttered blocks both, glass movement only, open neither (MB-D10); the wall tool makes wall objects that block, with a decorative toggle (MB-D11); HP 0 is a broken state (MB-D16); a door's swing animates ~200 ms for everyone (MB-D17).

  • [x] object_types (migration 0064): platform presets, system rows and editable campaign copies; tokens.object_type_id, object_state, object_overrides; the shared IObjectType and objectStatePatch — what a state does to an object. A state sets the object's art, what its VBL blocks (none carries no VBL), its light, whether players see it, and whether a hinged leaf stands swung (swungPose turns it about its hinge; the closed pose is kept to swing back to). Also: a token's attached VBL keeps its blockingMode — the route's schema had none, so zod stripped it and every token's VBL blocked vision only.
  • [x] Presets and materials: door, window, portcullis, secret door, chest and lantern, and a material table (wood, stone, iron, glass) with HP and hardness by size; seeded at boot; SystemFixture.objectTypes for a system's own. OBJECT_TYPE_PRESETS (shared): wooden and iron doors, a window, a portcullis, a secret door, a chest and a lantern, each with simple SVG art per state so it draws as what it is; materialStats reads OBJECT_MATERIALS. The presets are upserted at boot (read-only: a campaign copies one to change it); a fixture's objectTypes are seeded with its other traits, namespaced by system.
  • [x] Object type routes: list (presets, the system's, the campaign's), create, update and delete the campaign's, and copy a preset or system type into the campaign. /api/campaigns/:campaignId/object-types: anyone in the campaign reads; writes need Edit on it, are commits of the campaign, and announce objectType; a type's states are validated (unique keys, a default among them). Presets and system types answer a change with 404 — copy one in first.
  • [x] Place an object of a type: a create with objectTypeId takes its size, art, VBL, stats and light from the type, in its default state. objectPlacement (shared) is what a new object starts as — stats into its metadata, a light as lightSourceType/lightEnabled (a lantern with none of its own finds the campaign's, or its system's, light named Lantern); it lands on the map's default objects layer; anything the caller sends wins, save its type and state. A type that starts hidden (the secret door) is the GM's to place.
  • [x] Change an object's state: POST /api/tokens/:id/state, one commit — art, VBL blocking, light, visibility, and a hinged leaf swung about its hinge (the closed pose kept, to swing back to). Needs Edit on the map for now (phase 4 offers states to players); a state that hides or reveals the object is the GM's; a light's other metadata is kept; a swung leaf's own parts move and turn with it; the push goes through the hidden-aware audience, so a door hidden again leaves players' maps as phase 1 says.
  • [x] The GM's context menu sets an object's state, and places objects by type; the door's swing animates. State ▸ lists the target's type's states, the current one checked; Place Object ▸ on an empty spot lists the campaign's types (useObjectTypes) and places one on that square. A state change that moves an object animates over 200 ms for everyone (a drag doesn't); its VBL changes when the state does, not at the end of the swing — MB-D17 asked for the end, which needs vision to read the animated pose. ContextSubmenu is the menu's submenu, written once. Fixed in the M1 gate: a placed preset (a Wooden Door) was invisible. Its art was an SVG with a viewBox and no width or height, which Chrome uploads to WebGL as an empty image (texImage2D INVALID_VALUE, checked in headless Chromium), so its material discarded every pixel. The presets' SVGs are sized now, and the token loader sizes any viewBox-only SVG data URL (utils/svgDataUrl.ts), so objects already placed with the old art show too.
  • [x] Broken: an object whose HP falls to 0 breaks (its type's broken state: open, can't close, its own art); the GM repairs it by setting a state. A token PATCH that takes an object's HP from above 0 to 0 or below puts it in its type's broken state (or its first final one) in the same commit, and answers with it broken; a type with neither is left alone. services/objectState.ts holds the state route's work, shared by both.
  • [x] Secret doors: hidden, they reach players as an anonymous wall (phase 1's blocker) and are not called doors; found, they are. The secret door preset starts hidden; a player's map list carries it as phase 1's blocker — its VBL, no name, type, state, art or metadata — and once the GM finds it, the door itself. (That it looks like wall is the wall's job: hosted in one, a hidden door cuts no gap in its drawing — the hosted-door task.)
  • [x] Chests: opening one opens its Treasury holding. Setting a chest-kind object to Open runs the same path as Contents (v0.103.0): its holding as a loot bucket, then the Treasury — the left context on a desktop, the Me drawer on a phone. (Players opening one themselves is phase 4's, as an offered verb.)
  • [x] The wall tool makes wall objects: a run with a thickness, its VBL computed from the run, drawn as its part; decorative only draws without blocking. POST /api/tokens/wall (Edit on the map, one commit) makes a frame object on the default objects layer whose metadata.wall is the run relative to its centre and its openings; its attached VBL is the run's solid stretches (blocking both), and its drawn stretches are wall drawing parts — so moving or turning the wall moves and turns all of it. The geometry is shared/objects/walls.ts. The GM's wall tool builds one; the drawing toolbar's Decorative only switch makes it the plain drawing it always was.
  • [x] Doors and windows hosted in walls: placed on a wall they cut an opening in its VBL and drawing; moving one along the wall moves the gap; deleting it closes the wall. POST /api/tokens/:wallId/host places an object of a type in the wall nearest a point, lying along it, as its part (metadata.hostedIn), and adds the opening; a position PATCH slides it along the wall (an open door slides by its closed pose and swings again there); deleting it removes the opening; a hidden host (the secret door) cuts the wall's VBL — its own blocks there — but not its drawing, so it looks like wall until found, when the drawing opens. The GM's context menu on a wall has Place in Wall ▸ (doors, windows, portcullises, secret doors).

Placing and making objects (asked for after phase 3):

  • [x] Place Object ▸ ends with a separator and New Object: an empty object on that spot, entered at once to make its parts. Saving it (Done) or leaving it with nothing inside — no drawing, token or object — deletes it; leaving a frame object's edit mode fits its footprint to its parts. The new object is a frame (metadata.frame, drawn as an outline for the GM until it has parts); whatever way the GM leaves it — Done, Escape, a breadcrumb, a click outside, another map — POST /api/tokens/:id/fit fits it to its parts, and its 404 (none) deletes it. Objects made of a selection are frames too, and fit the same way.
  • [x] The Add to Map dialog has an Objects group: New Object, and each of the campaign's object types (Wooden Door, Lantern…) placed where it was opened. The GM's (not for a dropped picture); each type's card shows its own art, since the dialog's game icons are a vendored, licensed set. New Object there is the context menu's (useNewObject, shared).
  • [x] Convert to Object: a selected drawing's context menu, and a Palette button, make an object with the drawing inside it, in the drawing's place. Right-clicking a selected drawing offers it (the whole selection, if several are selected); the Palette shows the button — bar and phone drawer — to the GM while a drawing is selected. It is Make Object on those drawings: a frame sized to them, one commit; the new object is selected.

Phase 4 — Use

After phase 3. The object offers, the actor acts: a door offers Open, and a token someone controls performs it, in range. The common cases are native verbs — state changes the platform performs and the server validates, so doors work with no code and a locked door can't be opened from a console. Decisions at their recommendations: two hit-tests, select and use (MB-D5); chips at the end of a move, Nearby on a phone, never a modal (MB-D6); anyone with a controlled token in range, per offer, the GM always (MB-D7); requirements answered by the actor, the GM, the system or the Treasury (MB-D19).

  • [x] Range is edge to edge with the campaign's diagonal rule, and getDistanceTo uses it. edgeDistance (shared) counts squares between two footprints — the squares each covers, so a token beside a 1×0.25 door is 1 from it and beside an ogre is 1 from its near edge; Euclidean is rounded to whole squares (a diagonal neighbour is 1). tokenFootprint turns an object by its rotation but not a creature. Scripts' getDistanceTo measured Euclidean between top-left corners; it now measures this way with the campaign's rule.
  • [x] Offers on object types (migration 0065): native verbs (open, close, lock, unlock, light, snuff, break) as from-state → to-state, each with a range (1 by default), who it's offered to (controllers or the GM) and requirements; the presets carry theirs. ObjectOffer (shared offers.ts): a key, a verb, a label, the states it's offered in and the one it leaves; offersInState is what an object offers a viewer now. Doors and chests open, close, lock and unlock with their key (holds key:<object id>), pick the lock (thieves' tools, DC 15) and are forced (Strength) into broken; a window opens and closes its glass and its shutters; a portcullis is raised by the GM, or lifted (Strength 20); a found secret door opens (a hidden one offers nothing); a lantern is snuffed, and lit by an actor who says they have a flame. The type routes validate offers against the type's states.
  • [x] POST /api/tokens/:id/use: perform an offered verb as a token — the server checks the actor is the user's, in range, the object in the verb's from-state, and the requirements met; players may; one commit, announced in chat. { offer, actorId }: a GM-only offer is the GM's (403); the object must be in one of the offer's states (409, "The Wooden door is closed"); the actor must be on the map and one the user may impersonate (impersonation's rule — a viewer plays none), and within the offer's range edge to edge by the campaign's rule (409 with the distance). The GM may leave out the actor, as the State menu does. The actor speaks the chat line ("Thorin uses Open on the Wooden door."), so a hidden actor's is hidden with them. An offer with requirements is refused until the next task checks them; a hidden object 404s as phase 1 says.
  • [x] Requirements: holds (the actor's Treasury holds an item tagged key:<object id>), attest (the actor answers; the answer is logged), gm-approve (the GM is asked in their room and answers), check (the system's check, or a d20 against a DC). In that order, so a refused use costs no roll. holds reads the items the actor's token or linked character holds for one whose properties.tags has the tag. attest unanswered answers 409 with needs (the question) for the client to ask; the yes goes into the chat line. gm-approve answers 202 with pending and asks the GM's room; a persistent notification (Allow / Deny) answers POST /api/tokens/use-requests/:id, which performs it as the player asked (all else checked again) in one commit, and tells the player in their room. Requests live in the server's memory for ten minutes. check rolls a d20 on the server plus the actor's property the check names, by name or alias (an ability score as its modifier), against the DC, and the roll goes into chat, pass or fail. Leftover: nothing makes a key yet but the Treasury (an item with properties.tags: ["key:<id>"]); a system module can't yet say what a check means beyond a property.
  • [x] The interactables index and the use hit-test: a player clicking a door they can use opens its offers beside it, whatever layer it's on; select still finds the ancestor. buildInteractables (web utils/interactables.ts) is every object offering this user something in its state, with each offer's nearest acting token (impersonated, then selected, then — a player — owned) and whether it's in range; the map feeds it once into interactablesStore. A press on an indexed object opens the offers menu at the pointer instead of arming a drag — for a player on any layer, for the GM off the layer they're arranging; targeting and the other pick modes, and the delete tool, still win. The menu names who'd do each offer, greys one out of reach with the distance, asks an attest question itself (Yes / No) and sends the answer; a GM-approved offer says it asked the GM. A player opening a chest opens its contents, as the GM's Open does.
  • [x] Proximity chips: when a controlled token ends a move within range of something it can use, chips beside it offer the verbs; per user, never a modal. A move's end is a new position for one of the user's acting tokens (impersonated, selected, or — a player — owned), so a drag in progress isn't one; the chips ("Open · Wooden door", four at most, nearest first, and a ×) sit beside that token inside the canvas, so they follow the camera, in the map-overlay band. They stay until it moves again, one is used or they're dismissed. A chip whose offer asks a question opens the offers menu on that question; a chest's opens its contents. Fixed in v0.123.0: they never showed. drei's <Html> renders into a React root of its own, so Mantine's providers didn't reach the chips' Group, which threw "MantineProvider was not found" on every move's end (the console filled with it, no other symptom). MantineBridge reads Mantine's contexts outside the <Html> and provides them again inside it.
  • [x] On a phone, the Actions drawer's Nearby section lists what the token can use. First in the drawer, from the interactables index's in-range offers (the impersonated token's, when there is one); a tap closes the drawer and uses it, a question opens the offers menu, a chest its contents. The Map tab's Actions is enabled when something is in reach, not only when the token has actions. What the map does after a use (a chest's contents) is now the interactables store's onUsed, shared by the menu, the chips and Nearby.
  • [x] IAction.offer: a token's custom script action offered to others in range, run with actor (the performing token) and token (the object) in scope. { to: 'controllers' | 'gm', range? }, set in the action editor (Offer to others nearby: range, GM only) and kept by the token and character routes. The interactables index carries a token's offered actions beside its native verbs (any token, typed or not), so the menu, the chips and Nearby offer them; useOfferRunner runs a script one as the token, speaking as the actor, and sends a native one to the server. Scripts run in the user's browser, so their range is the index's — doors are native verbs for that reason. The code editor's completions know actor. Also: the 4.4 check test's "can't fail" token could (+15 against DC 20); it's +20 now.