controller.toml
Controller (GUI gamepad) configuration lives in its own
controller.toml — separate from keybinds.toml
so a controller setup can be shared or version-controlled as one file,
and a malformed edit here cannot take keyboard input down with it.
Everything below is edited in the .controller editor (GUI) — hand-editing
is never required.
Global and per-character layers
There are two layers, base first:
global/controller.toml— the shared setup, used by every character.profiles/<character>/controller.toml— that character’s overrides.
At load time the two are merged, character over global: binds override
per button, named wheels override per name, and the whole-value sections
(the default wheel ring, the overlay list, [controller_rumble],
[controller_tuning]) are replaced wholesale by the character’s copy when
present. A character file therefore holds only the diffs — a swashbuckler
and a wizard can drive the same pad differently while sharing a global base,
and a character with no override file just uses global.
In the .controller editor a Save to: switch at the top picks where
edits land — Global (all characters) or This character. It
routes every save (binds, wheels, rumble, tuning, overlay); loading always
merges, so switching scope changes only where new edits are written. The
character option is disabled until a character is active.
Installs that predate the keybinds/controller split are migrated
automatically on first run: the [controller*] tables are moved out of
keybinds.toml into global/controller.toml (both files backed up), so
existing controller setups carry over untouched.
Bindings
The [controller] table maps gamepad buttons to the same actions and
macros as [user]. Edit with .controller in the GUI. Buttons: south,
east,
north, west, dpad_up/down/left/right, l1, r1, l2,
r2, l3, r3, select, start, guide.
[controller]
start = "interact_mode"
dpad_up = { macro_text = "n\r" }
Inside interact mode and context menus the d-pad, South, and East are fixed navigation keys; bindings apply outside those modes.
Controller-specific actions: controller_shift (hold: buttons use
[controller_shift], a second bank in the same format),
controller_wheel / controller_wheel:<name> (hold: radial command
wheel — default ring in [[controller_wheel]], named rings in
[controller_wheels.<name>]; slices take label, command, optional
color, optional span and inner (geometry), and
nested slices for folders — the phone client renders these same
wheels for its wheel binds; the name portals is reserved:
controller_wheel:portals (r3 by default — clicking l3 would nudge the
movement stick into stray steps, while the aim stick’s click settles
harmlessly) builds its slices from the current room’s noun exits at
open time — the same list .portal resolves — shadowing any static
wheel of that name),
and controller_overlay
(toggle the binding legend — curated by [controller_overlay] buttons, with shift/<button> entries for the shift bank). Rumble
lives in [controller_rumble]: a pattern per event (off, the
built-ins short/long/double, or the name of one of your own),
plus custom patterns under [[controller_rumble.patterns]] — each has
a name, strength (0–1), pulse_ms (length of each buzz), pulses
(how many), and gap_ms (silence between them). The editor’s Rumble
tab has a row per pattern with a Test button that plays it on the
pad immediately, unsaved edits included. Custom patterns are also
selectable on any highlight rule, so any text match can buzz the pad —
see highlights.toml. Built-in names win if a
custom pattern reuses one. All of it is edited in the .controller
editor’s tabs — hand-editing is never required.
Interact mode and popup-menu navigation are configurable too.
interact_select activates the interact focus (walk an exit, open a
creature/object menu) and confirms a menu item; menu_up / menu_down /
menu_left / menu_right move a menu selection; menu_cancel closes a
menu. By default these live on the d-pad (navigate), south (select), and
east (cancel) — binding one to another button moves the role there and
frees the physical default. Two escape hatches are always guaranteed no
matter how you rebind: the interact_mode button always exits interact
mode, and east always cancels a menu.
Input feel lives in [controller_tuning] (Tuning tab). movement_stick
("left"/"right") chooses which stick walks the compass; the other
stick aims the wheel and scrolls the story. The radial wheel is
dwell-driven: aim a slice, rest on it, and it commits — leaves
after aim_dwell_ms, folders and the reserved Back slice after
nav_dwell_ms (folders auto-descend, Back auto-ascends). Releasing the
wheel button fires the committed leaf; returning the stick to center
before releasing cancels. Sweeping across the ring never commits the
slices you pass through, so a far slice is safe to reach. Inside a folder
a Back slice is reserved at the back_slice screen anchor
(up/down/left/right and the four diagonals) — or back_slice = "none" drops the reserved seat entirely and you back out with East.
deadzone (percent)
is how far the stick must deflect before a slice registers; a 0 dwell
means instant commit. fire_debounce_ms suppresses double-fires and
release_grace_ms keeps a still-deflected stick from walking as the
wheel closes. south/east remain optional accelerators while the wheel
is up (fire/descend now, back up now).
fire_mode chooses how a committed leaf fires (folders always descend
on dwell and are never fired by these modes; cancel is unchanged):
"release"(default) — the behavior above: dwell to commit, fire when the wheel button comes up."edge"— fire the instant deflection crossesedge_threshold(percent), no dwell. Fastest on sparse wheels. The re-arm-until-center guard means it fires once per hold, not repeatedly as you sweep the ring."retract"— dwell to commit, then fire as soon as deflection dropsretract_delta(percent) below its peak — a small inward flick, without waiting for a full return to center. Best when recenter-based firing feels sluggish.
Both thresholds are exposed so you can tune the feel. Every field is optional and defaults to the shipped feel. Fire modes apply to the native controller (they read the analog stick); the phone client’s touch wheel is dwell/release only.
Wheel slices resolve <target_id>/<target_noun> against the interact
focus, exactly like bound interact macros — so a combat wheel slice such
as cast at <target_id> fires at the creature currently selected in
interact mode. Slices without a placeholder are sent as-is; a slice that
needs a target with nothing focused is dropped (not sent literally) with
a note.
Each wheel declares, in the Wheels tab, which button opens it
and which stick aims it (stored in [controller_wheels_meta.<name>]).
The Wheels tab is the single place to set a wheel’s button — the
controller_wheel / controller_wheel:<name> actions are no longer in
the Base tab’s action dropdown to avoid two sources of truth. The reserved
portals wheel has its own permanent, non-deletable Wheels-tab entry
(portals (dynamic)): it exposes the same Opens with and Aim
stick fields, but no slice list, because its slices are generated from
the room every time it opens.
The button field is a convenience: saving it writes the matching
[controller] entry, which remains the runtime authority — so if the two
ever disagree, [controller] wins and a note says which button really
opens the wheel, and two wheels claiming one button are flagged. When a
wheel’s meta doesn’t record a button (e.g. it was bound before the Wheels
tab existed), the editor back-fills Opens with from [controller] so
you always see the real key. The
stick field overrides the global movement_stick while that wheel is
open: name the movement stick and walking is silenced for the wheel’s
duration; name the other and movement stays live (e.g. an exits wheel
aimed with the right stick while you keep walking on the left). Left
unset, a wheel aims with the non-movement stick as before.
Wheel geometry
Wedges don’t have to be even. Per-slice span fixes a wedge’s width
in degrees; whatever remains of the 360° splits evenly among the
span-less slices, so a wheel with no spans keeps the classic even ring.
Bad numbers are never rejected — spans below 30° clamp up and a ring
that doesn’t close rescales to fit, with a warning at load and in the
editor telling you what was adjusted. Per-slice inner sets that
slice’s aim floor as a percent of full stick deflection: below it the
slice can’t be aimed or committed, so a destructive command can demand a
deliberately deep throw. Unset slices use the global deadzone, and the
editor caps the value below the fire thresholds so a slice always has
travel left to fire. A per-wheel start in
[controller_wheels_meta.<name>] rotates the whole ring (degrees, 0 =
up, clockwise). Folder rings anchor to their Back seat instead and
ignore start — unless back_slice = "none", in which case they rotate
with it too. The portals wheel always keeps an even ring, since its
slices are rebuilt per room.
Inside a folder, the “go up a level” seat is normally synthesized for
you and pinned at the back_slice anchor set globally in
[controller_tuning] (the Tuning tab). That anchor is one of the eight
compass directions, or none to drop the synthesized Back from
every folder — you then ascend with the East/B button. To place the
seat yourself on one ring instead, mark a slice back = true: it
becomes a real slice you can position, size, color, and floor like any
other — dwelling it still ascends instead of firing, and it never sends
a command (any command on it is ignored). A folder with an explicit
Back uses your ring verbatim: the synthesized seat, the back_slice
anchor, and the anchor rotation all step aside for that folder only. A
back slice on the top ring does nothing (there’s no level to go up
to) and is flagged; only one Back per ring is useful.
All of this is drawn and edited live in the Wheels tab’s Visual designer — see the GUI chapter — or typed exactly in its Numeric view; both edit the same wheel. On the wheel itself (designer and live alike), a slice’s colored fill covers only its activation zone — from its aim floor out to the rim — so the empty ring inside the fill is exactly the stick travel that does nothing.