Skip to content

architecture stage manager

github-actions[bot] edited this page Oct 30, 2026 · 26 revisions

Stage Manager Architecture

Audience: Contributors to the Blacksmith codebase. There is no public API yet; the panel and the setting are the whole surface.

The Stage Manager is a Tool window, the GM's and, read only, the player's (see What a player sees), that lists the scenes in use as cards, each holding the people on it as chips. Dragging a chip onto another card sends that player there, and the arrangement is remembered. scripts/manager-stage.js is the data and the actions, scripts/window-stage.js is the panel, styles/window-stage.css holds the little layout that is its own, and the controls are the shared blacksmith-tool-* set.

Three facts from Foundry that shape it

  • Where a person is comes from Foundry. Every client keeps user.viewedScene for every user, kept current by the userActivity socket event. The panel reads it and tracks no locations of its own. A person who is not connected is shown on the scene they are held on, if any.
  • A pull is not remembered. Scene#pullUsers(users) makes a player's client view a scene, but a player who reloads lands on the active scene. So what the GM decided is stored and re-applied.
  • The player applies it. A non-GM client reads the stored assignment as it finishes loading and views that scene itself (StageManager.applyOwnAssignment, from initialize in ready, after a short delay; if the canvas is still loading it retries once a second). It does not wait for canvasReady: Foundry calls ready only after the first scene draw, so a hook registered there misses that draw. It needs no GM connected and cannot race the player's own canvas load, because only that client knows when it is ready. A scene the player's Permissions do not allow is not in their game.scenes at all, so a move to a GM-only scene silently does nothing; the Lobby Mode switch warns the GM when that is the case. It runs once per load, not on every draw, so a scene the GM pulled a player to with core's own tools is not undone.

State

One world setting, stageState, registered hidden in settings.js: { assignments: { userId: sceneId }, extras: [sceneId], order: [sceneId], favorites: [sceneId], sets: [{ id, name, scenes: [sceneId] }], lobbyScene: sceneId | null, lobby: boolean }. assignments is who is held where. extras are scenes the GM added to the panel by hand, for a stage nobody is on yet. order is the order the GM arranged the cards in. lobbyScene is the one scene the GM marked as the waiting room (see Lobby and speaking). favorites always sort above the other cards. sets are named groups of scenes (see Sets and favorites). normalizeState drops junk and duplicates on every read. World scope so every client can read its own assignment at load; only a GM writes it.

Assigning a player to the active scene clears the hold, since the active scene is where everyone lands anyway.

Actions

moveUser stores the assignment and pulls the player now if they are connected. A GM cannot be held: the GM's own chip only moves the GM's own view (as viewStage does), and another GM is left alone. releaseUser forgets a hold and leaves the player where they are. returnEveryone clears every hold and pulls every connected player to the active scene. addStage and removeStage edit extras. activateStage makes a scene the active one (Scene#activate), which moves everyone as the scene navigation does. All are GM-only and throw otherwise.

Activating a different scene moves everyone as Foundry does and leaves the holds in place; the held players go back to their held scene the next time they load.

Sets and favorites

A set is a named group of scenes (a dungeon's floors, a city's districts); a scene may be in any number of them, and being in one puts it on the panel like extras does, so stages() includes every set member. StageManager.createSet, renameSet, deleteSet and setInSet edit stageState.sets; a name is trimmed to 40 characters and must be unique ignoring case. Deleting a set deletes no scene. removeStage takes a scene out of extras, favorites and every set.

Which set the panel shows is not stored: it is StageWindow._setFilter, a way of looking that belongs to that window and starts at All scenes each time. stages({ setId }) filters to exactly the set's scenes: a scene outside the set is hidden even with people on it, so a player on such a scene is not on the panel until All scenes is shown. In the dropdown, a set that holds the active scene is marked with a dot after its count. A set that has been deleted drops the filter on the next render. setOrder receives only the cards on screen, so it writes them into the places the shown cards already held in order and leaves every hidden card where it was; saving the visible list as the whole order would forget the rest.

The row above the cards (_setsBar) is one line: a dropdown that fills the width (All scenes, then each set with its count), and a pencil that opens a small menu: New Set first (always there; it asks for a name through DialogV2.prompt and shows the new set), then Rename and Delete, which act on the shown set and are left out on All scenes. There is no separate New button. A scene joins a set from its card's "..." menu, which lists each set ticked when the scene is in it, or by dragging a scene out of Foundry's sidebar onto the window while that set is shown: the window's drop handler (_addScene) then adds it to the shown set as well as the panel. A native <select> is not a drop target, so there is no drag-onto-a-set; an earlier build had a chip per set for that and it was removed as clutter.

Favorites are stageState.favorites. A favorite sorts above every other card in every view, and the GM's drag order holds within the favorites and within the rest. The card's title carries a heart (the favorite heart used elsewhere in the module), and the card's "..." menu toggles it. Favorites and sets are the GM's: the player's panel (playerStages) shows neither.

Lobby and speaking

Away is not the Stage Manager's. Whether a player is away is the module's own Away status (documentation/architecture/architecture-away.md), a flag on the user. The Stage Manager only shows it: stages() puts away on each person, the chip carries a small clock that marks them away and back (AwayManager.set and clear, which a GM may call for anyone), and a chip that is away is outlined in the brand orange and its clock is light blue (an orange icon vanished on warm portraits and on the active card's orange frame). It is a mark and a message and nothing more: an away player is still moved, still returned by Return Everyone, still ticked for a toast, and still sent to a hold or the lobby on load. The window re-renders on updateUser so a change from the player's own menubar shows at once. An earlier build kept its own Away and Do Not Disturb marks in stageState.statuses; both are gone and normalizeState drops a stored statuses field.

Lobby scene and lobby mode. lobbyScene is one scene, marked from the door item in its card's menu (setLobbyScene; pressing it again clears it), and it is always listed as a stage. lobby is a switch in the action bar (setLobby and lobbyOn; it needs a lobby scene, the button is disabled until one is marked, and clearing the lobby scene turns it off; a lobby scene that has been deleted reads as none). While it is on, a non-GM client that loads in goes to the lobby scene instead of its stored assignment (applyOwnAssignment, the same once-per-load step as a held player). Lobby wins over a stored hold because it is a mode the GM turned on for the whole table; turning it off is how a player leaves it. Turning it on also pulls every connected non-GM player to the lobby scene at once (setLobby, with pullUsers), which holds nobody; each non-GM client toasts "Lobby" itself, locally and with no socket, when it sees the stored switch turn on (a setting-change callback comparing against the last value it saw) and when it loads in under lobby mode (applyOwnAssignment); turning it off sends each connected non-GM player to their own hold, else to the active scene, so the table is released together (setLobby(false); the activate prompt passes release: false, because activating pulls everyone itself). The lobby scene's card carries a Lobby badge while it is on (blacksmith-badge-info, a blue status badge, so it is never mistaken for the orange Active badge). The Lobby Mode switch sits in the footer's left zone (toolFooterLeft), with Return Everyone alone at the right. Making another scene active while it is on (the bullseye, or a double-click on a card) asks first, since the activation pulls everyone out of the lobby and the mode would send a reloading player straight back: turn lobby mode off and activate, leave it on and only view the scene, or cancel. Activating the lobby scene itself asks nothing.

Talking to a stage. The "Send a toast to the players here" item in a card's menu opens the Send Toast window (openToastSendWindow({ recipients })) with the connected non-GM players whose viewedScene is that scene already ticked (StageManager.talkRecipients). The Stage Manager builds and sends nothing itself: the look, the sound, the size and the delivery are the toast system's (sendToastToUsers), and the GM can tick anyone else. The item is disabled on a card with nobody connected. Called on an already-open Send Toast window, it ticks those players there instead of opening a second window; the party box is unticked so it does not override them.

The panel

StageManager.stages() is the view model: the active scene, the scene the GM is viewing, every scene someone is on, every held scene and every hand-added scene, each with its people, whether it is active, how many combats are started on it, and whether it was added by hand. Cards follow the GM's own order; a stage not in it goes after the ones that are, the active scene first and then by name, so a world that has never been arranged shows the active scene first.

Each stage is three parts: the title row, the content area, and the drop zone inside the content area. The whole card (.blacksmith-stage-card) uses the scene's thumbnail (scene.thumb) as its background through a 40% wash of the theme's own --blacksmith-tool-scrim: cream on Light, near-black on Dark and Glass, so the picture runs behind the title row and the content area alike. The title row gets a further 30% of the scrim so the name stays readable over a busy part of the map. The content area (.blacksmith-stage-content, the card body) has generous padding, which is the visible margin of picture around the drop zone. The drop zone is not drawn at rest: it is only the margin around the people, with no border, wash or padding, so the portraits get the room. While anything is dragged over the window (the panel's own chips or a drag out of Foundry's sidebar) the window gets is-dragging and every zone shows a dashed edge and a light wash, going solid orange under the pointer; its border is transparent rather than absent and its height fixed, so nothing shifts when it appears. An empty stage keeps a short row and shows its hint only while dragging. Each person is a solid chip in the theme's raised surface holding a 44px square portrait with no name on it (the name is in the tooltip), so a face reads over any part of the map. Using the theme's own variables means the text colours stay readable on all three, and the title row, the badges and the buttons are the shared controls with no colours of their own. scene.thumb is the small generated thumbnail, not the full map, so it is soft when stretched; the full map would load a large image for every card. The path is rooted with foundry.utils.getRoute before it goes into the custom property, because a url() inside a custom property resolves against the stylesheet that uses the variable, not the page: a bare world path would otherwise land under this module's styles folder and 404. Each stage is a static blacksmith-tool-card whose body is a blacksmith-tool-dropzone. A chip is a draggable blacksmith-tool-chip; players and the GM's own chip are draggable, and another GM's chip is not. Dropping the GM's own chip moves only the GM's own view, exactly what the eye button does. Native HTML drag and drop: dragover toggles is-over, drop resolves who was dropped with StageManager.usersFromDrop and calls moveUser for each. A drop is understood from the panel's own chips (a user id) and from a drag out of Foundry's sidebar: a User, an Actor, or a Token. An Actor or Token means the players it belongs to: those whose assigned character it is, else those who own it. Another GM is never matched, and a drop that matches nobody warns instead of failing. A held person's chip carries a pin icon and a remove button that releases the hold. A chip shows the player's character portrait, or their user avatar, or an icon when neither exists. A person who is not connected is dashed and dimmed.

Below the people each card has a token row, so the GM can see whether anything needs adding to the scene. It is one row of portraits: the party's player characters, then the player-owned NPCs (familiars, summons, companions). Every stage has the row, whether or not anyone is on it: each character either has a token on that scene or does not, so a stage with no row would read as broken. The row is omitted only when the world has no party characters and no player-owned NPCs at all. Each one shows the actor's portrait, never the token's own image, which can be a wildcard path or top-down art that does not read at this size. StageManager._tokenStatus marks each one present or missing with getPartyActorIdsOnCanvas, the same test the deploy action uses, so what the row shows is what deploying would add. A present token is a round portrait; a missing one is the same portrait dimmed with a dashed edge, nothing drawn over it, and is a button. Pressing it runs StageManager.deployActor, which takes the GM to that scene (the placement click needs it on the canvas) and calls Blacksmith's own deployParty with the only option naming that one actor and the sequential pattern, so there is no formation picker and one click places it. Deploy Party in the card's "..." menu runs StageManager.deployPartyTo, the full deployParty for the stage with its formation picker and its choice to include the player-owned NPCs.

A card is reordered by dragging its grip (the dotted handle at the left of the title) above or below another card, with a bar in the brand orange showing where it will land; only the grip drags, so the buttons and the name stay clickable. The drag carries its own type (application/x-blacksmith-stage), so the window never mistakes it for moving a person, and the drop zones stay hidden while it is in progress. The new order of every card on screen is saved with StageManager.setOrder. A scene is added by dragging it from Foundry's sidebar or scene navigation onto the window, anywhere (StageManager.sceneFromDrop reads the payload, StageManager.addStage stores it in extras). The window accepts every drag except a card reorder on the way over, because only a drag's types can be read until it drops; on the drop, a drop zone that took the payload (a person) has already prevented the default and the window leaves it, and a drop zone that sees a scene leaves it for the window. A scene that already has a card is left alone and the GM is told. A scene from a compendium is not a world scene and is ignored. The left of the action bar is only a reminder of this.

The lower right corner of each card carries a small disc showing the scene's own permission, the Permissions setting on the Basics tab of Scene Configuration: a lock for GM only (ownership.default of 0), the people icon for all players (observer or above). It is an indicator only, and it follows the scene because the window refreshes on updateScene.

Each card has three controls: the bullseye makes the scene active (disabled and lit on the one that already is), the eye looks at it yourself (lit on the one you are viewing), and a vertical "..." opens Blacksmith's own context menu (MenuBar.showMenubarContextMenu) with what is done to a scene: edit its configuration without moving anyone's view, favorite it or not, join or leave each set, mark it as the lobby scene or clear that, deploy the party, send a toast to the players there, and, only for a scene the GM put on the panel (by hand or into a set) that is empty and not active, Remove it. The menu is built when it is opened, from stages(), so what it says is current. The first two buttons mirror the scene navigation. A card shows the pointer cursor because double-clicking it does what the bullseye does; a double-click on a button, a person, the grip or the token row is theirs and is ignored, as is one on the scene that is already active.

The window re-renders when Foundry does something that changes who is where: renderSceneNavigation (which core fires when anyone changes scene), userConnected, scene and combat create, update and delete, a token created or deleted and an actor created, updated or deleted (for the token row), and a change to stageState. Movement (updateToken) is deliberately not watched. All of them are registered through HookManager. A redraw is skipped while a chip is being dragged, since it would drop the chip the GM is holding. Hooks are registered through HookManager under one context and disposed on close.

What a player sees

A player gets a read-only version of the same window, and the menubar icon only when there is a scene they may use (visible on the tool is a function; a hook on scene create, update and delete redraws the menubar when their permissions change). StageManager.playerScenes is the active scene plus every scene Foundry itself would list for them (scene.visible, Limited or better), and playerStages builds the cards: the navigation name, else the scene name; the connected non-GM players on each as portraits; the Active badge; and one eye to go there. Everyone may see where the other connected players are, so a scene a player is on that this player cannot use is listed too: under its name if their client holds the scene, else as "Another scene" (Foundry sends a player only the scenes their Permissions show, so the name is not theirs to know), with the eye disabled. viewPermitted is the only action and refuses a scene they may not use. A player sees no holds, offline people, tokens, permissions, lobby, menu or drag handles, and nothing in _bindDrag runs for them; double-clicking a card goes to that scene. An away person shows the same orange outline and clock, not pressable. The GM-only actions still call _requireGM, so a console call from a player throws. The window is not reopened on load for a player.

Reopening

The window reopens on load if the GM left it open, through the shared mechanism in scripts/manager-tool-windows.js: StageWindow.open records stage as open and _onClose records it closed, in a per-user flag, and restoreToolWindows opens it again from ready. Only a GM ever records it, and the opener checks that the user is a GM.

What it does not do

Audio, chat, the world clock and pause are global, whichever scene a player is on. Combat is per scene, but Blacksmith's combat tooling (statistics, the turn and planning timers, the movement-mode switch) still assumes one combat at a time, so a split party running two fights at once is not yet safe. The panel's combat badge only counts started combats on each scene.

Clone this wiki locally