Everything below is real, working code — not a stub or a sketch — but gated "testing" inside Core right now, so none of it is live for a real player in a real release build. Build against it, but don't ship a mod that depends on it working for your own users until Core's own release notes say the underlying flag flipped to "enabled".
To actually see one of these running while you develop, see Integration Guide's "Enabling testing mode to try a "testing" feature yourself" section — the short version: run Core's own packaging script with --testing, or set window.BMFT_BUILD_MODE = "testing" from your browser console for a quick local check, but never ship that global-flip approach in anything you distribute (it turns on every "testing"-state feature for every one of your players, not just the one you meant to try).
Preference axes (BMFT.Identity.registerPreferenceAxis)
Status: the registry functions are always live; Core's own dossier display of them is gated "testing" (preference_axes flag, since 2026-08-21 — the design still needs more planning before it's ready for a real release). A preference axis is a named 0-10 value that drifts gradually toward a target instead of jumping to a set value — the same target-clamped-delta idiom the base game's own "adjust reluctance" passage uses (reluctance.twee), generalized so any mod can register its own axis instead of only Core touching $character's "reluctance". Core ships one seed axis (BMFT.Identity.SEED_AXIS_ID, "attraction_to_women") proving the whole pattern end to end; every other axis is expected to come from whichever addon needs it, not from Core guessing at axes in advance.
The engine itself — everything below in this section — is not behind the gate and works exactly as documented regardless of its state, same guarantee this guide's "A guarantee you can rely on" section makes for everything else here. Only Core's own dossier tab cards (Preference axes, Computed orientation) are gated off while "testing"; if you're building your own UI against this engine, that's unaffected either way.
Registering your own axis
window.BMFT.Identity.registerPreferenceAxis({
id: "your_axis_id",
label: "Your Axis Label",
initialValue: 5,
bands: [
{ min: 0, max: 2, text: "Never" },
{ min: 3, max: 4, text: "No" },
{ min: 5, max: 6, text: "Want to try" },
{ min: 7, max: 10, text: "Yes" },
],
});Idempotent — re-registering the same id replaces the previous definition, same as the base game's own GE.tertiaryStats.registerNewStat. bands must cover the value's real range with text shown wherever the axis's qualitative read is displayed (the dossier tab, for instance) — a value that falls outside every band's min/max reads as "Unknown" rather than throwing.
Registration only defines the axis; it doesn't create a save-persisted value for any one character yet. Call window.BMFT.Identity.seedAxisValue("your_axis_id") from your own [initialize init_new]-tagged passage (the same one-time, safe-to-call-every-load pattern Core's own init.twee uses) — idempotent, only creates the entry if one doesn't already exist for that character.
Reading and adjusting a value
(set:$bmft_axis to "your_axis_id")
(set:$set to 8)
(set:$gain to 1)
(display:"bmft adjust preference")Moves the axis's live value toward $set by $gain, clamped so it can never overshoot the target and never move the wrong way — exactly the base game's own "adjust reluctance" semantics, just parameterized by axis id. Fires a notification with the axis's new band text if the value actually moved (0 applied means fully blocked by the target or a lock — no notification in that case). From JS directly:
const applied = window.BMFT.Identity.adjustPreference("your_axis_id", /* target */ 8, /* gain */ 1);Read the current value and band text together (the dossier tab and Core's own worked example both want both at once):
const value = window.BMFT.Identity.currentValue("your_axis_id");
const band = window.BMFT.Identity.currentBandText("your_axis_id");Locking a hard boundary
A lock is a boundary adjustPreference will never move the value past, layered on top of the target-clamp above — Core's own addition, not part of the base game's reluctance system. Declaring a lock doesn't itself move the current value; call adjustPreference afterward if the value should also snap toward the boundary immediately.
(set:$bmft_axis to "your_axis_id")
(set:$bmft_lock_side to "floor")
(set:$bmft_lock_boundary to 3)
(display:"bmft lock preference")$bmft_lock_side is "floor" (value may never drop below the boundary), "ceiling" (may never rise above it), or "" to clear an existing lock. From JS: window.BMFT.Identity.lockPreference("your_axis_id", "floor", 3). Read the current lock state (for display) with window.BMFT.Identity.lockInfo("your_axis_id") — returns { side, boundary }, { side: "" } meaning no lock declared.
What to check after using this
registerPreferenceAxis/seedAxisValuethrow on a missing/malformedidor an emptybandsarray — a real caller bug to fix, not a state to defensively guard against at runtime.adjustPreference/lockPreference/currentValueall throw if you pass an axis id that was never registered (getAxisDef— "was not registered") — register before you seed, and seed before you adjust or read.- This surface has no feature-gate fallback to worry about (unlike Pronouns and the sidebar sticky note, both of which have a documented off-state) — it's unconditionally live, so there's nothing to check besides your own axis id being spelled right.