Skip to main content

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:

typeHandleFields
"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"GroupHandleoptions: a nested schema shown as a drawer.
"modes"ModesHandlemodes, def?: choices with their own options and behavior.
"click-scheduler"ClickSchedulerConfigurableThe host's CPS clicker. See Combat Helpers.
"rotation"RotationHandleThe host's rotation strategy selector. See Combat Helpers.
"target"TargetConfigurableThe host's target-selection drawer. See Combat Helpers.
"raw"the object itselfEscape 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 name is set is a safe refactor. Saved values are kept; only your ctx.options.<key> references change.
  • Changing the display name (or the key when name isn'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.

note

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.

note

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/onDisable hooks 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.

MemberTypeDescription
valueunion of mode keysThe selected mode's key. Assign to it to switch modes, as if changed in the UI.
is(mode)booleanWhether mode is currently selected.
subscribe(listener, notifyFirst?)() => voidWatch for selection changes. Returns an unsubscribe function.
modesrecord of ConfigurableEach mode's configurable, keyed like the schema, with its option handles. modus.modes.Reversal.ticks.value works from anywhere.
optionOption<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.

MemberTypeDescription
valueTThe current value. Read it, or set it to change the option as if edited in the UI.
namestringDisplay name.
defaultValueTThe value reset() restores.
subscribe(listener, notifyFirst?)() => voidWatch for changes. Returns an unsubscribe function. Pass notifyFirst: true to also fire once right away.
reset()voidRestore the default value.
isDefault()booleanWhether the current value is the default.
childConfigurable | undefinedNested options, if declared (typed from the schema on your own handles).
getConfigurableValues()Configurable[]Every child configurable reachable from this option.
get typestringThe 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);
};
Subscriptions vs. ctx.on

option.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:

MemberDescription
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
note

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?) (pass as const to get a literal union)
  • color(name, def)

It can't declare nested children, groups, or modes, so prefer the schema.