Options
Options are the controls your module shows in the menu: toggles, sliders,
dropdowns, text fields, color pickers, and nested trees of them. You declare
them as a schema (the second argument to defineModule). The live handles
then show up, fully typed, on ctx.options in your
setup function.
defineModule(
{ name: "Example" },
{
enabled: { type: "boolean", name: "Particles", def: true },
count: { type: "number", def: 8, min: 1, max: 64, step: 1 },
mode: { type: "enum", values: ["Spin", "Pulse", "Wave"] },
color: { type: "color", name: "Tint", def: [255, 0, 0, 255] },
},
(ctx) => {
ctx.on("tick", () => {
if (!ctx.options.enabled.value) return;
render(ctx.options.count.value, ctx.options.mode.value, ctx.options.color.value);
});
},
);
The schema — OptionsSchema
A schema is a plain object:
- Keys become the handle names on
ctx.options, and the default display names in the menu. - Values describe each option.
TypeScript infers everything from the object literal. Above,
ctx.options.mode.value is typed "Spin" | "Pulse" | "Wave" without any
as const.
Node kinds
Every node has a type field that picks its kind:
type | Handle | Fields |
|---|---|---|
"boolean" | Option<boolean> | def? (default false), child? |
"trigger" | Option<boolean> | A button. subscribe to the handle to react to presses. |
"number" | Option<number> | def, min, max, step?, displayResolution?, child? |
"number-range" | Option<NumberRange> | Like number, but def is { min, max } and the handle holds a pair. |
"string" | Option<string> | def? (default ""), child? |
"string-list" | Option<string[]> | def? (default []), child? |
"enum" | Option<union of values> | values, def? (default values[0]) |
"multi-enum" | Option<Set<union of values>> | values, def? (default empty), child? |
"color" | Option<Color> | def as [r, g, b, a], each channel 0–255; child? |
"comment" | — | A text line, display only. The text is name (or the key). icon?: "info", "warning", "error", "suggestion", "advanced". |
"group" | GroupHandle | options: a nested schema shown as a drawer. |
"modes" | ModesHandle | modes, def?: choices with their own options and behavior. |
"click-scheduler" | ClickSchedulerConfigurable | The host's CPS clicker. See Combat Helpers. |
"rotation" | RotationHandle | The host's rotation strategy selector. See Combat Helpers. |
"target" | TargetConfigurable | The host's target-selection drawer. See Combat Helpers. |
"raw" | the object itself | Escape hatch: embed a pre-built host Option or Configurable. See Combat Helpers. |
For number and number-range, displayResolution is how many decimal places
the UI shows. It defaults to the number of decimals in step (or 2 if step
isn't set), so you'll rarely need it.
Keys, name, and saved config
Any node can take an optional name to override its display name:
attackRange: { type: "number", name: "Attack Range", def: 4, min: 1, max: 8 },
// menu shows "Attack Range"; code reads ctx.options.attackRange
The display name (name, or the key if name isn't set) is also what
saved config is keyed on. That means:
- Renaming a key while
nameis set is a safe refactor. Saved values are kept; only yourctx.options.<key>references change. - Changing the display name (or the key when
nameisn't set) loses that option's saved value.
Hidden options — the # prefix
If a display name starts with #, the option is hidden from the menu but
still works normally. You can read and write .value, subscribe to it, and it
is saved in the config like any other option:
{
calibration: { type: "number", name: "#calibration", def: 0, min: -10, max: 10 },
}
Only use this for internal values that should persist across client launches, like a calibration figure or a cached choice. It's not general storage.
Hidden options persist through the config system, so values are only written and restored when the user saves or loads the config. Think of it as "part of the saved config, without a widget", not as a database. For state that only needs to last one session, use per-session state instead.
Nested options — child
Every value-bearing node (boolean, number, number-range, string,
string-list, multi-enum, color) accepts a child schema. The nested
options appear under the parent in the menu, and the parent handle exposes them
as .child:
{
rotate: { type: "boolean", def: true, child: {
smoothing: { type: "number", def: 10, min: 1, max: 20, step: 1 },
} },
},
(ctx) => {
ctx.on("tick", () => {
if (ctx.options.rotate.value) {
applyRotation(ctx.options.rotate.child.smoothing.value);
}
});
}
handle.child gives you the typed handles (child.smoothing) and is a
Configurable, so child.getOption("smoothing"),
child.options(), and child.subscribeOptions(...) also work. Children can
nest as deep as you like.
Nesting is only visual and organizational. A child's value isn't gated on the parent toggle. If the child should only apply while the parent is on, check the parent's value yourself, as above.
Drawers — group
A group is a visual drawer around a nested schema. Its handle is the set
of child handles; it has no value of its own:
{
visuals: { type: "group", options: {
color: { type: "color", def: [255, 0, 0, 255] },
width: { type: "number", def: 2, min: 1, max: 10, step: 1 },
} },
},
(ctx) => {
ctx.options.visuals.color.value; // Color
ctx.options.visuals.width.value; // number
}
Modes
Sometimes each choice in a dropdown needs its own options and behavior.
Think of a Velocity module with Vanilla and Reversal modes. For that,
declare a modes node instead of an enum.
Each mode can have an options schema and a setup function, both optional. A
mode with setup is executable:
- Its setup runs per session, just like the module's own.
- Its listeners and
onEnable/onDisablehooks only fire while the module is enabled and that mode is selected.
Wrap modes in the mode(options, setup) helper so ctx.options is typed inside
the mode's setup. A plain { options, setup } object also works, but untyped.
import { defineModule, mode } from "@protohax/userscript";
defineModule(
{ name: "Velocity" },
{
modus: { type: "modes", def: "Reversal", modes: {
// no setup: just a choice (with or without its own options)
Vanilla: {},
// setup only: behavior without options
Cancel: mode((ctx) => {
ctx.onPacket("set_entity_motion", (packet) => { packet.isCancelled = true; });
}),
// options + setup: the full shape
Reversal: mode({
ticks: { type: "number", def: 2, min: 0, max: 5, step: 1 },
}, (ctx) => {
// ctx.options here are the MODE's options
ctx.on("movement_tick", (state) => applyReversal(state, ctx.options.ticks.value));
ctx.onEnable(() => { /* module on AND Reversal selected */ });
}),
} },
},
(ctx) => {
// the module's own setup still runs, for module-wide logic
ctx.onEnable(() => console.log("velocity on, mode:", ctx.options.modus.value));
},
);
Switching modes while the module is enabled fires onDisable on the old mode
and onEnable on the new one. It's the same lifecycle as the module toggle,
just scoped to the selection.
Each setup (the module's and every mode's) is its own closure. To share
per-session state between them, use
utils.sessionLocal.
ModesHandle
The handle for a modes node works with keys. Display-name overrides never
show up in code.
| Member | Type | Description |
|---|---|---|
value | union of mode keys | The selected mode's key. Assign to it to switch modes, as if changed in the UI. |
is(mode) | boolean | Whether mode is currently selected. |
subscribe(listener, notifyFirst?) | () => void | Watch for selection changes. Returns an unsubscribe function. |
modes | record of Configurable | Each mode's configurable, keyed like the schema, with its option handles. modus.modes.Reversal.ticks.value works from anywhere. |
option | Option<unknown> | The raw host option behind the selector. |
Reading & observing — Option<T>
Every scalar node's handle is a live Option<T>. Options belong to the module
definition, not the session, so the handles are shared across sessions:
ctx.options gives you the same objects every time.
| Member | Type | Description |
|---|---|---|
value | T | The current value. Read it, or set it to change the option as if edited in the UI. |
name | string | Display name. |
defaultValue | T | The value reset() restores. |
subscribe(listener, notifyFirst?) | () => void | Watch for changes. Returns an unsubscribe function. Pass notifyFirst: true to also fire once right away. |
reset() | void | Restore the default value. |
isDefault() | boolean | Whether the current value is the default. |
child | Configurable | undefined | Nested options, if declared (typed from the schema on your own handles). |
getConfigurableValues() | Configurable[] | Every child configurable reachable from this option. |
get type | string | The option kind, e.g. "boolean", "number". |
Reading
Read option.value inside a listener. It always matches what's in the menu
right now:
ctx.on("tick", () => {
if (ctx.options.speed.value > 0) applySpeed(ctx.options.speed.value);
});
Writing
Setting option.value applies the change and notifies subscribers, exactly as
if the user had moved the slider:
ctx.onEnable(() => { ctx.options.speed.value = 1.5; });
Subscribing
To react the moment an option changes, instead of checking every tick:
(ctx) => {
const color = ctx.options.color;
const unsub = color.subscribe(() => rebuildPalette(color.value), true);
ctx.onDisable(unsub);
};
ctx.onoption.subscribe isn't tied to whether the module is enabled. It fires
whenever the value changes. If you only care while the module is on, either
unsubscribe in onDisable (as above) or just read option.value inside a
ctx.on listener.
Configurables
Nested handles (child, group, and each entry of ModesHandle.modes) are
Configurables, the same type as a module's own option container:
| Member | Description |
|---|---|
options() | Iterate over the configurable's options. |
getOption(name) | Look up an option by its display name. |
subscribeOptions(callback) | Watch every option in the subtree, including nested children and enum values. callback gets the option and its path. Returns an unsubscribe function. |
On your own module you won't need these, since the typed handles do the same
job. They're useful for walking another module's option tree through the
moduleManager.
Definition-scoped wiring
In the schema form, defineModule returns the handles record.
Because setup runs per session, a subscribe inside it registers once per
session. For wiring that should happen only once, subscribe on the returned
handles instead:
const opts = defineModule({ /* meta */ }, { /* schema */ }, (ctx) => { /* ... */ });
opts.speed.subscribe(() => console.log("speed:", opts.speed.value)); // once, ever
For ModuleMode.Atomic modules, the returned record belongs only to the first
instance. Copies spawned later each get their own options.
The legacy builder
The original imperative form still works (and is typed), but it's deprecated:
defineModule(meta, (options) => {
const speed = options.number("Speed", 1, { min: 0, max: 5, step: 0.1, displayResolution: 1 });
return (ctx) => { /* close over `speed` */ };
});
options is a ModuleOptions with one method per scalar kind:
boolean(name, def?)number(name, def, props)string(name, def?)enum(name, values, def?)(passas constto get a literal union)color(name, def)
It can't declare nested children, groups, or modes, so prefer the schema.