GUI Editor#
The GUI Editor builds every on-screen panel that isn't the room itself — the always-on HUD
bar, the Options and Save/Load screens, the inventory popup, a verb-coin, or any custom modal
your project needs (a keypad, a phone, a dialog choice list). A GUI is a descriptor: shape-only
data listing an id, an optional rect/backdrop, and an ordered array of widgets. There is
no hand-coded UI here — computeLayout() and hitTest() (core/guis/layout.js) turn that data
into screen geometry and click targets, both pure functions with no canvas or DOM access; the
actual pixels are painted by drawGui() in shell/canvas_render.js. It saves into the project's
guis.json (the HUD bar as mainBar/mainGui, everything else under modals).
You reach it from the Hub (dev server running). The GUI dropdown in the toolbar picks which descriptor is loaded; the canvas in the middle is a live, editable preview — there's no separate preview tab, you drag and resize widgets directly on it — and the right-hand panel shows the properties of whatever's selected. Zoom the canvas with the toolbar 🔍 / 🔍− buttons or the scroll wheel, ⊞ Fit frames the whole GUI in the preview area, and you can pan the view to work on a crowded corner up close; the zoom stays purely visual, so widget coordinates never change as you zoom.
Anatomy of a descriptor#
- id — the GUI's key. The HUD bar resolves through
mainGuiId()/getMainGui()(core/guis/index.js): a descriptor can opt in explicitly withrole: 'main', or fall back to the conventionalmainBar/mainGuiid. Everything else is opened byOPENGUI:<id>and closed withCLOSEGUI, gated bystate.activeGui— while a modal is open, room input stops responding. - rect — the panel's own bounds, mostly for backdrop/debug bookkeeping.
- backdrop — an optional full-screen dim painted before the widgets (used by modals).
- widgets — the ordered list described below. Order matters for both painting (later widgets
paint on top) and hit-testing (
hitTest()walks the list in reverse, so the topmost widget under the pointer wins).
Widget kinds#
Every widget can carry a shared set of properties regardless of kind: visibility (isVisible),
enabled state (isEnabled), fill (bgColor/bgOpacity), text color/font/alignment, and an
ordered onClickAction list of effect tokens. isLocked is editor-only bookkeeping — the pure
runtime ignores it. The kinds:
- panel — a colored rect or circle, usually placed first (behind everything) as a background or a modal's dim backdrop. Can carry a sprite overlay and an optional linked animation.
- label — text drawn from up to four sources, resolved every frame in priority order:
a conditional message (
condMsgs— a list of{condition, message}rows; the first row whose condition holds wins, built with the same condition picker as puzzle and hotspot rules), then a bound value (bindat a state path likeparty.countorsettings.musicVol), then a self-rotating ticker (a list of messages that cycle on their own against the FX clock —secsper message,gapSecsof blank between them, and a blank entry is a valid pause beat; no rules or timers needed), and finally the static text, which doubles as the placeholder shown when nothing above applies. Ticker and conditional messages are authored monolingually like all content, each backed by its own lid for the Translation Editor. - sentence — the SCUMM sentence line ("Usar llave con puerta…"). Its content isn't authored
here at all — it's computed every frame by
core/guis/sentence.jsfrom the hovered target and armed item. Its visibility follows what the line is for: with a modal open (the inventory, say) it stays readable over the modal's dim, because that's the feedback for the item you're about to pick; while a dialog is running it hides along with the verb buttons, because the choice list takes over that strip and the sentence would only be showing what you were doing before the conversation started. - button — the general-purpose control, specialized by which ref it carries (see below).
- sprite — an image, with optional inline frame animations (
anims) so a shipped build never needs to list a folder to animate it. - inventory — a
cols × rowsgrid that fans out into one leaf cell per slot at layout time (autoFitderives slot size from the widget's rect); cells reflectstate.inventorylive and arm/un-arm/combine items on click. Cell painter picks how a slot is drawn: icons (the default) or text names, the SCUMM v5 look used by theclassic9legacybar. It changes the drawing only — the same cells, the same click targets, the same armed state. In the icon painter, Show names under the icon turns the caption on or off, so you can have an icons-only grid; it is on unless you say otherwise. The toggle is not offered for the text painter, where the name is the cell. - list — rows of text +
onClickAction, used for things like a dialog choice list reused as a GUI. - textbox — an engine-bound text field (
bind,placeholder,maxLength); used for the save-name field on the Save screen. Focus and typing are routed byshell/main.js(state.guiFocus), not by this widget itself. - slider — an engine-bound
min/max/stepcontrol bound to a state path (used for the Options screen's volume/brightness/contrast sliders); drag handling lives inshell/main.js. - saveSlots — fans out into
Nsave-slot cells at layout time; display content comes frompersistence.listSlots()at draw time, not from the descriptor.
Button kinds#
A button widget's behavior comes from which reference field it carries — the editor's kind
dropdown switches between them and clears the others so exactly one applies:
- verbRef — resolves label/sprite from the
VERBSregistry; default actionSELECTVERB:<id>. This is how the classic 9-verb bar is built — nine buttons, eachverbRefpointing at one verb. - charRef — resolves name/sprite from the
CHARACTERSregistry; default actionSWITCHCHAR:<id>. - partyRef — a party-roster toggle (add/remove a character from the active party); default
action
PARTYTOGGLE:<id>. If the character you pick isn't in the project's character pool (Hub Config → Project), the editor warns right there — a button that offers someone the pool never holds back at boot means they can end up on-screen before anyone chooses them. - partySlotRef — a committed-party slot button. It carries no character id at all — at
runtime it resolves to
chosenOrder(state)[N-1](the Nth character in current selection order) and dispatchesSWITCHPARTYSLOT:<N>.computeLayout()is pure and can't resolve "who's in slot 2 right now," so that resolution happens at draw/click time in the shell. - toggleRef — flips an arbitrary game flag; default action
TOGGLEFLAG:<flag>. The flag field is an autocompleted free-text input (flags are an open, author-defined namespace, so it can't be a closed picker), listing every flag already used in the project plus the reserved engine flags — names the engine maintains itself, which no project scan can find and you would otherwise have to know by heart. - scrollRef —
'up'/'down'inventory paging. Its action is locked (INVENTORY_UP/INVENTORY_DOWN) — the editor won't let you override it. - plain — no ref at all: freeform label plus whatever
onClickActiontokens you attach through the shared effect-token widget (the same one used by hotspot reactions, dialog choices, and cutscenes — every ID-bearing argument is a typed picker, never free text).
Layout and positioning#
Widget coordinates are absolute logical pixels at the project's canvas resolution (1920×1080
by default), not percentages — core/guis/constants.js holds the shared geometry (panel top,
verb-grid cell size, inventory slot size…) that both the built-in templates and the renderer read
from. A project at a non-default resolution applies it once at boot through
applyCanvasResolution(), which moves those constants and rebuilds the four engine modals
(Options, Save/Load, quit confirmation, inventory) centred on the real canvas — the editor surfaces
them the same way, so materializing one never writes a widget off the edge of your game. The bar
gets the same treatment through a different route: every bar the engine ships declares the canvas it
was drawn for (designResolution) and is scaled to yours by one uniform factor, then docked to the
bottom — which is how a single classic9legacy descriptor authored at 320×200 is also a 1280-wide
bar at 1280×1024. A descriptor you authored is left exactly as you drew it, unless it carries a
designResolution of its own — that field is how a materialized template keeps scaling after it
becomes your content. It works on your modals too, where it means "re-centre me on the real canvas"
(and shrink me only if I wouldn't fit): a dialog centred for a 1920 canvas is not too big for a
1280 one, it's in the wrong place, and that's a distinction only the declared canvas can make.
Everything else you re-position by hand here. computeLayout()
takes a descriptor and returns a flat list of leaf nodes (each with a resolved rect) — container
widgets like inventory, list, and saveSlots fan out into one leaf per cell/row/slot with a
composite id (${parentId}.${index}). hitTest() walks that flat list back-to-front and returns
the first rect containing the click point, skipping anything invisible or disabled.
Button and verb labels auto-fit their cell the same way: a widget's fontSize is a literal in
the renderer, so unlike the geometry above it doesn't scale on its own — an unscaled label at a
small canvas can print wider than the cell that holds it (the classic 9-verb bar at 640×400 used
to read "DARGARRUSAR"). fitFontSize() shrinks a label down until it fits its box on both axes,
and ellipsizes rather than shrink past a readable floor; a label that already fits is left at its
authored size, so a project that already looks right is unaffected. Verb cells fit as a group,
not independently — the bar takes the smallest size that fits every cell sharing a base size, so
you never end up with one verb readable at 22px next to another cramped at 15px; a cell an author
gave a deliberately different fontSize sits outside that group. The sentence line (what the
player's about to do) gets the same box-fit, measured against the font's own line-height rather
than the actual ink, so its size doesn't shift every time the hovered description's length does.
Wiring widgets to game logic#
Every clickable widget's behavior is an ordered onClickAction array of the same effect tokens
used everywhere else in the engine (SETFLAG, GIVEITEM:<item>|<char>, SAY…), run through
applyEffects(). The GUI runtime layers a few of its own: OPENGUI:<id> / CLOSEGUI open and
close a modal (gated by state.activeGui), SELECTVERB/SWITCHCHAR/PARTYTOGGLE/
SWITCHPARTYSLOT/TOGGLEFLAG are the default actions the ref-kinds above derive automatically,
and SETLANG:es/SETLANG:en (used by the Options screen) switch the active language. A widget's
own onClickAction, if set, always wins over a kind's derived default — the editor only re-syncs
the default when the current action still is that default, so a genuine custom override is
never clobbered.
Text and localization#
GUI widget text is authored monolingually, in the project's defaultLocale — the same
content-i18n rule as everywhere else. There's no inline {es, en} object to fill in here (a few
of the engine's built-in descriptors, like save.js and options.js, still carry legacy inline
lang-maps from before this migration — the editor reads them fine via L(), but any edit through
the editor's text field rewrites them as a plain string). What actually happens on save:
- The literal you type is written into the descriptor as a plain string (the
defaultLocalesource of truth — this is what a locale falls back to if no translation exists). - The editor also upserts an address-derived lid into the project's
strings.json—guiTextLid(guiId, widgetId)for a widget's maintext, and matching per-property lids for placeholders, tooltips, and list-row text. Verb-button labels are a special case: they overlay through the verb's own lid (verb_<id>_label) instead, so translating a verb once covers every bar/coin that references it. - At runtime,
shell/canvas_render.js's_guiText()resolves through that lid first (falling back to the literal, then toL()), so the drawn text is never the raw descriptor string once a translation exists. - Those lids show up under the GUI category in the Translation Editor, where the actual Spanish/English (or any other configured locale) pairs get filled in — never inline, here.
GUI widget sprites follow the same overlay idea for art: a per-locale image variant
(<base>.<lang>.<ext>) is preferred over the base PNG when one exists, for images that bake text
into the pixels.
Button states#
A button can look different depending on what the player is doing with it: sitting there, hovered, held down, or selected (the verb currently chosen, the character being played, a party member, a toggle that's on). Two things can change per state, and they stack:
- Art — one PNG per state. Fill in only the ones you need; a state you leave blank falls back to the default image, so a button with nothing but a default sprite is perfectly valid.
- Color — a background fill and a text color per state, under colors by state in the properties panel. Same rule: blank means "keep the base color".
The auto button next to a hover or clicked color derives that shade from the base color — a
lighter one for hover, a darker one for pressed — and writes it into the field, where you can still
tune it by hand afterwards. It needs the base color to be a hex value (#rrggbb) to have something
to work from.
The editor's canvas is for authoring, so it always draws buttons at rest. The 👁 next to a state draws the selected widget in that state instead — art and colors together — so you can check how a hover looks without launching the game. Click it again to go back.
The selected state only exists for buttons that can actually be selected: verb, character, party, and flag-toggle buttons. A plain button never enters it, so those fields aren't offered for one.
An inventory widget takes the same colors, authored once for the whole grid — each cell then picks its own state as the player moves around it: hovered is the cell under the cursor, pressed is the cell being held, and selected is the cell holding the item they picked up and haven't used yet. In the text names cell painter the colors are the whole story, since that mode has no box to tint — the name itself changes color for each state.
Animations#
A widget (sprite, button, or panel) can link an animation authored in the Anim Editor
under its own GUI category (assets/guis/<guiId>/animations/<animName>/, tracked in
animations.json). The linked animation's current frame overrides the widget's static PNG at
draw time; clearing the link falls back to the static sprite. The toolbar's Animate toggle
plays every linked animation live in the editor's canvas so you can check timing without leaving
the tool.
Workflow#
- New GUI… and choose an id, a kind (modal / panel / coin), and a starting
template — empty, or a clone of a built-in (
classic9the 9-verb bar,modern4,coinHud, orclassic9legacy, the same nine verbs with no chrome at all: plain text, drawn for 320×200 and scaled up from there). A panel kind becomes the project's HUD bar; a modal kind gets a backdrop forOPENGUI; a coin kind materializes the radial verb-coin as editable content. - + Add widget… and drag/resize it directly on the canvas; Snap aligns drags to a 4px grid.
- Set kind-specific fields in the right panel — a
verbRef/charRef/partySlotRef/toggleReffor a button,bindfor a label/textbox/slider,cols/rows/autoFitfor an inventory grid. - Wire clicks through the shared onClickAction token list — the same typed-picker widget used by hotspot reactions and dialog choices.
- Author widget text once, in your project's default language; translations are filled later in the Translation Editor.
- Link an animation from the Anim Editor's GUI category if the widget needs to play frames.
- Save to write the project's
guis.json.
Deleting a GUI#
A single button in the toolbar handles removing a GUI, and it reads the currently-loaded descriptor to decide which of two very different things it does:
- On one of the engine's five default slots (the main bar, the verb-coin, and the
inventory/save/options modals) it reads Revert to default: taking it out of
guis.jsondoesn't remove it from the game — those five rebuild themselves from the engine's own templates the moment the key is absent, so the bar or modal is never gone, only your saved changes to it are (and that loss can't be undone). - On a modal of your own it reads Delete GUI, in red, because there's no built-in
fallback behind it: deleting it is permanent, and any
OPENGUI:<id>left pointing at it is left dangling (the token is null-safe — it quietly does nothing — but the confirm dialog lists exactly which reactions, watchers, or GUI buttons still name it, so you can fix them first instead of finding out in play). - On a virtual entry — a default being offered for editing, or a brand-new GUI you haven't saved yet — the button is disabled: there's nothing on disk yet to remove. Save once and it activates.
Saving a main bar can offer to move the playable area#
roomViewport in project.json is where the room area stops — the band above the bar that
rooms are drawn into. The wizard sets it to the bar's top edge when it creates a project, but
nothing kept the two in step afterwards: move or materialize a main bar and the project's playable
area still describes the old one, while the runtime believes the project file. Either the room
draws under the bar and loses its bottom band, or a strip belongs to nobody — the room can't use
it and the bar doesn't cover it.
So saving a panel (a main bar) compares the bar's top edge against the playable area, and if
they disagree by more than a couple of pixels it offers to line them up, naming the numbers: this
bar starts at y=740, but the room area runs to 1024 — the bar covers the last 284 px of every
room. It is an offer, never automatic: leaving it alone is the default, and the check runs
after the GUI is safely written, so declining costs you nothing. Accepting patches only
roomViewport's height — an authored x/y/width stays exactly as it is — and once the two agree the
offer stops appearing.
Widget ids are load-bearing for
partySlotRefand every other ref field — but theiditself is just a label the widget owns, not a stable content key like a hotspot or item id. The values that actually get referenced elsewhere are the ref fields:partySlotRef's slot number resolves at runtime againstchosenOrder(state), not against any fixed character — rename the character roster and the button keeps working. The one id that is load-bearing is the GUI's own id, sinceOPENGUI:<id>and the main-bar resolution both key off it directly; renaming a GUI after other content already opens it withOPENGUI:<oldId>breaks that link silently (the token is null-safe — it just does nothing — so the failure is quiet, not a crash).