Ignitor Docs ← getignitor.com
🇺🇸 EN 🇪🇸 ES

Puzzle Editor#

The Puzzle Editor authors the room's self-driven logic — rules that fire on their own as state changes, rather than in response to a verb-click (that's a hotspot reaction, covered by the Room Editor) or as an ordered non-interactive sequence (that's a cutscene, covered by the Cutscene Editor). It writes into the same room-scoped rules file as both of those tools — reactions, watchers, and globalWatchers all live together in one <roomId>.rules.js, and this is the dedicated editor for the latter two.

You reach it from the Hub (dev server running). The toolbar has a Room dropdown — every room in the project, plus a synthetic 🌐 Global (cross-room) entry at the top for the shared _global.rules.js bucket (the same one the Cutscene Editor exposes) — a Name field, New, and Save. A strip of room thumbnails sits under the toolbar for jumping between rooms without going back to the dropdown — the Global bucket gets its own card too; it comes in three sizes and collapses away, and your choice is remembered. Below that, five tabs: Reactions, Room Watchers, Global Watchers, GUI Puzzles, and Graph. The right panel holds Validate and Preview (an Export tab exists in the markup, hidden, kept for a future escape hatch).

Reaction and watcher cards render collapsed by default, showing just their key/id so a room with a lot of logic stays scannable — click a card's header (or the arrow) to open it, or use the collapse all / expand all buttons in the top-right corner of the tab bar to act on every card in the current tab at once. Adding a new reaction or watcher always expands and scrolls to the card it just created. The + Add Watcher modal asks for the watcher's id up front, the same way the reaction modal does — so a watcher is named and keyed before it exists, never left sitting on a placeholder id you forget to change.

The Puzzle Editor screen — a room's watchers and global watchers.
Puzzle Editor — a room's watchers and global watchers.

Watchers: the piece unique to this editor#

A watcher is { id, when, do } — a condition and an effect list, with no hotspot and no verb attached. Every frame, the rule engine's tickRules() checks each active watcher's when against current state; the moment it evaluates true, do fires once, and the watcher is marked in state.firedWatchers so it never fires again on its own. This is edge-triggered, fire-once semantics — the opposite of a hotspot reaction (which only ever evaluates because the player clicked a verb on something) and of a cutscene (an author-ordered step list that plays start to finish). A watcher instead says "the moment this condition becomes true — whenever, however it happens — do this," with no player action as the trigger.

{ p: 'timerExpired', a: 'estufa' } firing a burn message when a stove's countdown lapses, or { p: 'npcSharesRoomWith', ... } catching the player off-screen, are typical watcher jobs: things that should happen because state changed, not because of a click.

CLEARWATCHER re-arms it#

state.firedWatchers is the only thing standing between a watcher and firing again. The CLEARWATCHER:<id> effect token (available from the same shared effects widget everywhere else in the engine, autowired against this room's own watcher + global-watcher ids) deletes the id from that set, so the next frame the condition is checked fresh. Without an explicit CLEARWATCHER, a watcher that has fired is permanently spent for the rest of that play session (or until save/load resets state) — this is by design, not a bug, but it's the single most common point of confusion when a watcher "stops working" after the first time.

Room watchers vs. global watchers#

Both tabs edit the exact same { id, when, do } shape; the only difference is where they're registered at runtime:

  • Room Watchers (rules.watchers[]) tick only while this room is the active one. Leave the room and the watcher stops being checked; come back and it resumes (still respecting firedWatchers if it already fired).
  • Global Watchers (rules.globalWatchers[]) are still authored per-room — you add one from whichever room's rules file makes sense to declare it in — but on that room's very first enter, shell/room_enter.js's _onRoomEnter() copies each one into state.registeredGlobalWatchers (deduped by id) and it stays there for the rest of the session, ticking on every frame regardless of which room the player is currently standing in. core/ruleEngine.js's tickRules(state, dt, watchers) doc-comments this plainly: watchers passed in each frame is "active room's watchers PLUS all registered global watchers." A cross-room watcher that doesn't belong to any single room has a dedicated home: select 🌐 Global (cross-room) from the Room dropdown and author it straight into _global.rules.js, the shared bucket where cross-room catch watchers and globally-guarded NPC reactions also live.

The authoring docs frame the choice as one question: if the player walks away, should this keep running? Yes → global (a pursuing NPC, a world-level consequence that must land no matter where the player wandered). No → local (a kitchen stove burning is a kitchen event — it shouldn't resolve off-screen). Getting this wrong in either direction is the classic gotcha: a global watcher authored for a one-room puzzle keeps ticking (and can fire) long after the player has left that room entirely, which reads as "why did this fire in a room I was never in" until you remember it was registered once and never unregistered — there's no per-room teardown for a global watcher, only CLEARWATCHER to stop it from firing again.

The condition language#

when is built with the same visual condition tree used for hotspot reaction guards: null (always true), a single predicate { p, a, a2 }, or all/any/not composites nesting further conditions. core/ruleEngine.js's evalCond() is the single source of truth for what predicates exist; the table below is generated from the picker's real list, so it's always current. Every ID-bearing argument (item, flag, room, region, hotspot, NPC…) is a typed picker, never free text. Where the picker shows a friendlier name than the engine id, it's noted (in parentheses).

Predicate Arguments What it checks
flag flag name Is this remembered switch on?
hasItem item id, owner (player/char) Does the player (or a specific character) carry this item?
npcInRoom npc id, room id Is this NPC currently in that room?
npcSharesRoomWith npc id, meets: npc/@party Does this NPC share a room with that character — or with anyone in the chosen party (@party)?
playerInRoom room id Is the player in this room?
isRoomLit room id (opt) Is the room's light on? (no room given = the current one)
isOpen hotspot id Is this openable hotspot (door, chest…) currently open?
isSwitchedOn hotspot id Is this hotspot switched on (e.g. its looping sound is playing)?
isWalkableOn walkable area id, room id (opt) Is this walkable area currently enabled?
isRegionOn region id, room id (opt) Is this walk-over trigger zone currently enabled?
isLightZoneOn light zone id, room id (opt) Is this light zone currently switched on?
timerExpired timer name Has this countdown already run out?
timerRunning timer name Is this countdown still running (started, not yet run out)?
behaviourFinished npc id, behaviour id Has this NPC finished that routine?
counterGte counter name, value Has this counter reached at least this value?
valEq (valueEquals) flag/value name, value Is this stored value exactly equal to this text? (string compare)
cmp (compare) flag/counter, number Numeric comparison against a stored value, with an operator you pick: <, <=, >, >=, ==, !=.
touching (touching (hitbox)) npc id, target:, / char / npc id, margin px (opt), depth px (opt) Are this NPC's and its target's hitboxes touching right now?
partyHas (in party) character Is this character in the chosen party? (no party chosen = everyone counts)
soundPlaying (sound playing) sound kind, sound file (opt) Is audio of this kind (music, ambient, sfx, voice) playing right now? (no clip named = any clip of that kind)
allItemsCollected (has all items) items (set) Have ALL the listed items been collected? (achievements condition builder)
allRoomsVisited (visited all rooms) rooms (set) Has the player visited ALL the listed rooms? (achievements condition builder)
allNpcsTalked (talked to all npcs) npcs (set) Has the player talked to ALL the listed NPCs? (achievements condition builder)

touching: margin and depth#

touching asks whether two actors are close enough to count as touching. It compares their visible silhouettes — the sprite's actual pixels, with the PNG's empty padding trimmed off — not the whole image rectangle. Two optional numbers tune what "close enough" means, and they answer different questions:

  • marginhow close on screen. At 0 the silhouettes have to genuinely overlap. Raise it and you add that many pixels of slack all around, so "almost touching" counts too. Margin only ever makes the test more forgiving — it can never make it stricter, so you can't tighten a catch by shrinking it.
  • depthhow close on the floor. On its own the test is flat: two characters standing on completely different depth planes still overlap on screen, so simply walking in front of an NPC registers as a touch. Set depth and their feet must also land within that many pixels of each other along the room's depth axis (the foot line). Leave it blank and there's no depth test at all — the classic, overlap-only behaviour.

The rule of thumb: margin widens a touch, depth demands they share the same floor. A chase that should grab the player as soon as the monster is near wants a generous margin and no depth. A "standing right at the counter" check wants a small margin plus a depth, so someone crossing the foreground doesn't trip it.

do is an ordered effect-token list, authored through the same manifest-driven effects widget the Room and Cutscene editors use — the identical dispatch that runs everywhere (applyEffects()). Nothing about a watcher's effect list is special; only how it gets evaluated is.

Timer names autowire like flags do. The timerExpired/timerRunning predicates and the STARTTIMER/CLEARTIMER effects all autocomplete from a project-wide list of every timer already referenced anywhere in the project — a timer is often started in one place and watched in another, so this surfaces the id you meant instead of asking you to retype it from memory. Timers are an open set, so free text still creates a new one; the suggestions just close off the classic silent bug where a mistyped timer id makes a watcher that quietly never fires.

Reactions bucket#

The Reactions tab is this editor's home for guarded reactions — the { when, do } rule lists keyed "<hotspotId>.<verb>" (or "<hotspotId>.<verb>_<itemId>" for use-item-on-target) that resolveReaction() checks before falling back to a hotspot's flat reactions[verb] string from the Room Editor. A dedicated + Add Reaction modal picks a target — hotspot, NPC or region — a verb, and optionally an item for a composite key, previews the resulting key string, and warns if it already exists (with a Go to it shortcut instead of creating a duplicate). Pick region and the modal narrows itself to what a region can actually do: the target list is filled from that room's regions, the verb locks to walkover (the only event a region fires — you don't look at one), the composite "+ on item" option disappears, and the key previews as <regionId>.walkover. Each reaction card holds an ordered list of { when, do } rules — first match wins — plus an optional puzzleTag free-text field used purely for filtering/grouping (see Graph, below). This tab is a convenience for authoring the guard logic that determines a hotspot's outcome; the Room Editor's hotspot list still owns the hotspot itself.

You don't have to come here to reach a guarded reaction, either: in the Room Editor, a verb row with guarded rules behind it shows a small puzzle-rule badge — clicking it opens the same condition/effect editing in a modal, right there, without leaving the Room Editor.

GUI Puzzles bucket#

Not every puzzle lives in a hotspot reaction or a watcher. A GUI opened from a room — say, OPENGUI:digitlock on a hotspot's use verb — can carry its own logic in the buttons' click actions: a keypad that appends digits to a code, an ENTER button that checks it and unlocks something. That logic is authored in the GUI itself (see the GUI Editor), so it's easy to lose track of it once it's tucked behind a room's hotspot. The GUI Puzzles tab closes that gap: it scans this room's reactions and watchers for OPENGUI tokens (including ones buried inside a conditional effect, and GUI-to-GUI menu chains) and gives each one a card.

A GUI only earns a card here if it actually behaves like a puzzle — some button sets a flag, writes a value, grants an item, starts a timer, or branches on a condition. Purely visual or navigation GUIs (a menu that just closes itself or slides to another screen) are left out of the card list and instead summarized in a single dimmed line, so they're still discoverable without cluttering the bucket with things that aren't actually puzzles. A GUI your rules try to open but that doesn't exist in the project still gets a card, flagged as a mistake worth fixing.

Each card shows where the GUI is opened from, and lists every button with a click action: its visible label ("ENTER", "1", "CLEAR") leads, with the button's internal id shown dimmed beside it. A conditional button — one that checks something before acting — is marked, and its condition and branches (IF / THEN / ELSE) are shown as readable rows, the same token coloring used elsewhere in this editor (say lines green, flag/value writes purple, item grants amber).

Editing a button's action without leaving the room#

Click the pencil next to any button to edit its click action right there, using the same condition and effect builder as the rest of this editor — typed pickers, IF blocks, nothing you haven't already used in a reaction or a watcher. Save writes just that button back to the GUI; Cancel discards the edit. This is a genuinely separate save from the room's rules file: the puzzle editor's toolbar Save never touches a GUI, and this button's Save never touches the room's rules. A caution is shown while editing: if you also have the GUI open in the GUI Editor at the same time, whichever one saves last wins — reload the other one afterwards so it doesn't overwrite your change.

Graph tab#

A read-only dependency visualization: nodes for items/hotspots/NPCs referenced by this room's rules, edges labeled by relationship (requires, validates, waits for, depends, opens) — drag nodes, scroll-zoom, drag the canvas to pan. A Cross-room toggle loads every room in the project and adds edges that cross room boundaries (useful for spotting a globalWatchers chain that reaches into another room).

Puzzle GUIs from the GUI Puzzles bucket join the same graph as their own nodes (shown in amber): a widget's flag/value writes and item grants produce resources just like a reaction's effects do, its condition consumes them the same way, and the hotspot or watcher that opens the GUI gets an opens edge pointing at it — so soda_machine.useopens → the digitlock GUI shows up as a normal step in the graph, and a code that a keypad writes and its ENTER button later checks shows as the GUI validating its own flag.

The panel is explicit about its own gaps: edges cover flags and values, items, timers, walkables/regions, and GUI opens — but a location gate (the player or an NPC being in a given room), a touching check, an audio-state check like soundPlaying, or an NPC behaviour has no edge here. That's expected, not a bug; those conditions aren't resource-shaped in a way a dependency graph can represent.

Preview tab#

Runs the real engine functions — evalCond, applyEffects, resolveReaction imported straight from core/ruleEngine.js — against an in-editor scratch state, the same "real runtime, not a simulation" approach as the Cutscene Editor's Preview tab. You set flags, inventory, the player's room, and NPC positions (id:room pairs) in a small form; the panel then auto-lists every reaction key, every watcher (room + global), and every puzzle GUI button from the GUI Puzzles bucket in the loaded room as clickable chips, grouped under collapsible Reactions / Watchers / GUI widgets headers so a room with a lot of cases stays easy to scan. You can also type a hotspot/NPC id and verb manually and Run Reaction.

A flag can be set to a plain value (puzzle_done) or given one (lock_code=1234), so a GUI puzzle built around a code or a written value — not just a plain on/off flag — is testable here too. Running a watcher chip evaluates its when against your scratch state and, if true, applies its do and prints the resulting tokens; running a GUI button chip applies its click action directly — a button doesn't have a when gate of its own, so any condition it checks lives inside the action itself, and the preview reports every flag, value, or item change that comes out the other side, including ones buried inside a branch it took (so clicking the ENTER button on a keypad with the right code shows the flag it sets).

The state form and case chips scroll in their own area; the manual runner (target/verb inputs, Run Reaction, and the result box) stays pinned at the bottom so it's always in view, no matter how long the case list gets. The one caveat, called out directly in the tool: predicates that read live runtime state can't be simulated here. Spatial ones like touching have no x/y in the scratch state, and audio ones like soundPlaying have no actual audio playing in the editor, so both always read false — the preview warns rather than lying about the result.

Author notes#

Every reaction and watcher card carries an Author note field — the place for why this rule exists, which the rule itself can't say. What you type is written into the .rules.js as a // comment directly above that entry, and a note already in the file loads back into the field, so the rules file and the editor are two views of the same text.

Notes are anchored by name — to a reaction key, a watcher id, or a whole section — not by position, which is what makes them survive the save. Reordering rules never drifts a note away from what it describes; renaming a reaction key or a watcher id carries its note along; and deleting a reaction takes its note with it instead of leaving it dangling over the next rule.

Workflow#

  1. Pick a Room (every room in the project, whether or not it has a rules file yet — one is created on first save) or New.
  2. Reactions tab: + Add Reaction to key a hotspot, NPC or region + verb (+ optional item), then build its ordered { when, do } rule list.
  3. Room Watchers tab: + Add Room Watcher for logic that should only run while this room is loaded.
  4. Global Watchers tab: + Add Global Watcher for logic that must keep running no matter which room the player wanders into — ask "if the player walks away, should this keep running?"
  5. GUI Puzzles tab: check whether any GUI this room opens (a keypad, a combination lock) carries its own puzzle logic, and edit a button's action in place if it needs a tweak.
  6. Check Graph to sanity-check what a reaction/watcher/GUI puzzle actually depends on, especially with Cross-room on if you suspect a global watcher reaches beyond this room.
  7. Walk every case in Preview — click the auto-listed chips (collapse a section you're not working on to keep the list manageable) or drive it manually — before trusting it in-engine.
  8. Keep an eye on Validate — beyond missing/malformed reaction keys, it also blocks Save on any effect token missing a required argument (an empty SETFLAG: and the like — checked recursively inside if/then/else blocks too), since the engine would otherwise run that token on an empty key no predicate could ever read back. Save writes the room's rules file. Author-only // comments you write between reactions/watchers survive the round-trip, anchored to the entry they sit next to — the exception is the cutscenes: block, where a comment loses its anchor and Save warns before dropping it.

Validate does more than count missing keys: it also reads every effect token — in reaction rules, in watchers, and inside if blocks — against the effect manifest, and reports any required argument left blank. SETFLAG: with no flag name used to save happily and then set the empty flag at runtime, one no predicate can ever read back; now it is an error, and an error blocks Save until you fix it. Arguments that are legitimately optional stay quiet — a PICKUP with no item grants the hotspot's own id, SHOWTEXT with no colour draws white, and the speed/options tails may all be empty.

Validate also warns — it never blocks — when a chain's own order risks a stall. A chain only ever parks on a WAIT, a WAITFOR, or a blocking walk; without one of those it runs start to finish in a single pass and none of this applies. With one, anything earlier in the chain that takes over the frame leaves the rest hanging: opening an engine modal that pauses the world (the inventory bag, Options, Save/Load, the quit confirmation) and then parking on a wait freezes for good, because the modal's own close button can't run while the chain is parked either. A scene, cutscene, arcade stage or another CUTSCENE takes over the frame instead — usually fine, since the queue resumes once it ends, but a scene with a hold-for-input end never ends on its own. And a token that rebuilds the world (GOTOBLOCK, LOADGAME, QUITGAME…) drops everything queued after it, silently. The check walks the chain's real if/then/else structure, not the flattened token list, so it still knows which branch a given step is actually in. It's shared by this editor, the Reactions Editor, and the Room Editor's own reaction modal — the three places that author chains; the Cutscene Editor is deliberately left out, since its steps run on their own per-frame runner and can't stall this way.

A watcher that has fired stays fired until CLEARWATCHER says otherwise — and a global watcher keeps ticking in rooms the player isn't standing in. These are two faces of the same gotcha: watchers are easy to reason about in isolation ("when X, do Y") but their lifetime is not obvious from reading the rule itself. Before authoring a puzzle you expect to reset or repeat (a switch you can flip back and forth, a chase that should be re-triggerable), decide up front whether you need a CLEARWATCHER somewhere in the effects that undo it — and before making a watcher global, confirm the event really does belong to the world and not just to this room, since there's no per-room teardown, only the fire-once guard.

Saving rewrites the rules file, and two kinds of comment behave differently. The header block at the top of the file is preserved as-is — that's still the home for file-level notes. Notes attached to a node ride along as described above. What the round-trip can't carry is a comment written inside a block the serializer re-emits from the live object — today, cutscenes:. Both Save and the export panel's save stop and list exactly those lines before writing, so you can cancel and move them somewhere that survives.