houserules/docs/user/custom-modifiers.md
Joey Yakimowich-Payne 8605a22530
feat(ui): AttrCombobox declare vs. consume semantics
Attr-string fields in the Custom Modifier Editor now differentiate
between declare-sites (seed-attribute.attr) and consume-sites
(add-to-attribute.attr, multiply-attribute.attr, add-aura.targetAttr,
absorb-damage-with-attribute.attr):

- Consume-sites hide the illustrative 'User-defined examples' group
  (ArmorPlates/BloodStacks/ManaPool) because those names are fiction
  unless something seeds them.
- Consume-sites surface a new 'Seeded in this descriptor' group
  populated from seed-attribute primitives elsewhere in the current
  tree (including inside trigger children via childPrimitives).
- Badge states: emerald 'seeded' when the typed value matches an
  in-tree seed; red 'not seeded' when it's a non-schema name with no
  backing seed; amber 'user-defined' only in declare-mode for
  off-catalog names.

Declare-mode behaviour is unchanged — inventing ShieldCharges there
still works without warnings.
2026-04-21 13:07:27 -06:00

12 KiB
Raw Permalink Blame History

Custom Modifiers — User Guide

Houserules ships with six built-in modifiers (HP Bonus, Range Bonus, Direction Additions, Capture Flags, Promotion Override, Damage Resistance). Custom modifiers let you author your own from a fixed catalog of 15 reusable effect primitives — no programming required.

A custom modifier is a named, reusable bundle of primitive effects that any modifier profile can reference, exactly the same way it references a built-in.


Opening the editor

  1. Open the Modifier Profile editor (+ Modifier Profile from the rules drawer, or via the layout's modifier-profile picker).
  2. In the editor's header, click + Custom Modifier. The Custom Modifier editor opens as a nested modal.
  3. The Custom Modifier editor is a 3-column workspace:
    • Left: primitive palette, grouped by category. Hover any entry for a full tooltip with the primitive's long description and a worked example.
    • Center: the descriptor's primitive tree (the composition you're building).
    • Right: parameter inspector for the selected primitive. The top of the inspector shows an in-editor docs panel — a longer behaviour explanation plus one or more concrete example configurations you can copy. Click Hide docs & examples to collapse it when you want only the form.

Each descriptor needs a name (1-40 chars) and an optional description (0-200 chars). The header exposes Templates, Load, Save, and live validation status.

Templates — starter recipes

The Templates button opens a picker with pre-composed recipes drawn from the examples below (Boosted Pawn, 3-Charge Shield, Aura King, Vampire, Low-HP Fortress). Picking a template replaces the current draft with a fresh copy — the id is regenerated so you can save the loaded template as your own library entry without collisions.

Attribute-name autocomplete

Primitives that target an attribute (seed-attribute, add-to-attribute, multiply-attribute, absorb-damage-with-attribute, add-aura) render their attr / targetAttr field as a combobox with grouped suggestions:

  • Core numeric attributes — Hp, HpBonus, RangeBonus, DamageResistance, ReflectDamagePercent, AbsorbDamageRate, HalfmoveClock, FullmoveNumber. Safe targets for add/multiply/aura/absorb primitives.
  • Core non-numeric attributes — CaptureFlags, DirectionAdditions, PromotionOverride, BlockedMoveTypes, AbsorbDamageAttr, HasMoved. Seed-only for most; avoid add/multiply.
  • User-defined examples — ShieldCharges, ArmorPlates, BloodStacks, ManaPool. Illustrative names matching the recipes; invent your own.

The combobox is free-form — type any name and hit Enter to accept it, even if it isn't in the list. Numeric-context primitives (add, multiply, aura, absorb) rank numeric suggestions first.

Declare vs. consume sites. The combobox distinguishes between primitives that declare a new attribute and primitives that consume an existing one:

  • seed-attribute.attr is a declare-site — any name is fine, including inventing brand new counters. The illustrative "User-defined examples" group is surfaced for inspiration. Typed values outside both the schema and the examples catalog carry an amber user-defined badge.
  • add-to-attribute.attr, multiply-attribute.attr, absorb-damage-with-attribute.attr, and add-aura.targetAttr are consume-sites — they read an attribute that must already exist. The illustrative examples group is hidden because those names are fiction until something seeds them. Instead, a "Seeded in this descriptor" group surfaces attrs that seed-attribute primitives elsewhere in the current tree actually declare. Typed names that aren't built-in and aren't seeded carry a red not seeded warning; names that are seeded carry an emerald seeded confirmation.

The 15 effect primitives

Primitives are atomic, composable, and pure data. Click any palette entry to add it to the current descriptor's tree.

State primitives

These mutate facts on the piece at apply time.

seed-attribute

Seeds a fact { attr, value } on the piece. Overwrites any existing value.

Example: attr=Hp, value=5 — gives the piece 5 HP regardless of the baseline.

add-to-attribute

Reads the existing numeric value of attr (treats absent as 0) and writes existing + delta.

Example: attr=HpBonus, delta=2 — adds +2 to whatever HpBonus is already there.

multiply-attribute

Reads the existing numeric value of attr (no-op if absent) and writes existing * factor.

Example: attr=Hp, factor=2 — doubles HP.

add-direction

Appends named directions into DirectionAdditions. Composes additively with the built-in Direction Additions modifier — both write to the same fact and deduplicate by direction name.

Example: directions=[backward] — adds backward movement.

set-capture-flag

ORs a CaptureFlag bitflag into the piece's CaptureFlags.

Example: flag=CANNOT_BE_CAPTURED — makes the piece untargetable.

Mechanic primitives

These plug into the engine's existing pipelines (damage, movement, promotion).

absorb-damage-with-attribute

Seeds { AbsorbDamageAttr, AbsorbDamageRate }. Each incoming damage point consumes rate of attr instead of HP, until attr is exhausted.

Example: attr=ShieldCharges, rate=1 — paired with a seed-attribute for ShieldCharges=3 produces a 3-charge shield.

reflect-damage

Seeds ReflectDamagePercent. When this piece takes damage, percentage% is reflected back to the attacker.

Example: percentage=50 — half-reflective armour.

modify-movement-range

Composes additively with the built-in Range Bonus.

Example: delta=1 — adds +1 to the piece's range.

block-move-type

Filters out moves matching the given type (capture / step / slide).

Example: moveType=capture — pacifist piece, can move but not capture.

override-promotion

Sets PromotionOverride to a target piece type. Mirrors the built-in Promotion Override modifier.

Example: target=knight — pawns promote to knights only.

Advanced primitives

These compose other primitives.

add-aura

Seeds an AuraSpec entry. Every piece within radius (Chebyshev / king-move distance) gets delta added to targetAttr.

Example: radius=2, targetAttr=HpBonus, delta=1 — every piece within 2 squares gets +1 HP.

Auras recompute after every move. A piece moving INTO range picks up the contribution; a piece moving OUT loses it on the next pass.

on-turn-start

Wraps a list of nested primitives that run when this piece's color begins a turn.

Example: primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}] — heals 1 HP each turn.

on-capture

Wraps a list of nested primitives that run when this piece captures another.

Example: primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}] — vampire piece, heals on capture.

on-damaged

Wraps a list of nested primitives that run when this piece takes damage.

Example: primitives=[{kind: 'reflect-damage', params: {percentage: 25}}] — auto-reflects on damage.

conditional

Branches on a condition and runs the matching primitive list.

Supported condition types:

  • attr-lt / attr-gt — numeric comparison
  • attr-eq — exact match (string / number / boolean / null)
  • always — runs then
  • never — runs else (or no-op if no else)

Example:

condition: { type: 'attr-lt', attr: 'Hp', value: 2 }
then:     [{ kind: 'set-capture-flag', params: { flag: 'CANNOT_BE_CAPTURED' }}]

— when low on HP, becomes invulnerable.

Note (T3 limitation): Trigger primitives (on-turn-start, on-capture, on-damaged) and conditional currently SEED their hook facts on the piece but the engine pipeline that fires them at the corresponding game phase is not yet wired in T3. The data is correct; the runtime trigger evaluation ships in a follow-up.


Composing primitives — simple to complex

Simple: a "boosted pawn"

One add-to-attribute primitive with attr=HpBonus, delta=2. Save as "Boosted Pawn". Reference from a profile's per-type entry to give every white pawn +2 HP.

Medium: a "shield"

Two primitives:

  1. seed-attribute: attr=ShieldCharges, value=3
  2. absorb-damage-with-attribute: attr=ShieldCharges, rate=1

Pieces start with 3 shield charges; each damage point depletes one charge before HP.

Complex: an "aura king"

One primitive:

  1. add-aura: radius=2, targetAttr=HpBonus, delta=1

Apply to the king's per-type entry. Every piece within 2 squares of the king gets +1 HP.


Saving and loading

The editor's Save button writes the current descriptor to your local custom modifier library (storage key houserules:custom-modifiers:v1). Load from library opens a picker to recall any saved descriptor.

The library is local to your browser. To share, paste the JSON shape of a saved descriptor — or use the modifier profile sharing flow (which embeds the custom descriptor in the share payload).

Library limits

  • 20 descriptors per library — same FIFO eviction rule as profiles (oldest non-starred entry is evicted; star to protect).
  • 10 descriptors per multiplayer room — a room can register up to 10 custom descriptors. Servers reject the 11th.

Using a custom modifier in a profile

Open the Modifier Profile editor, add a Per-Type or Per-Instance entry (left or center panel), and pick your custom descriptor from the Kind dropdown. Custom descriptors appear in the dropdown under a Custom (from library) group.

Custom modifiers don't take a per-instance value — the descriptor's primitive list is the entire payload. The editor displays a small summary card ("Custom modifier — N primitives") instead of a value input.


Multi-profile stacking

The lobby's profile picker stays single-select for the primary profile. Below it, a Stacked (solo only) list lets you append additional profiles in order. Use the up/down arrows to reorder.

When multiple profiles are active, contributions stack across profiles per the same per-kind rules used within a single profile. The "priority-wins" rule (promotion override, capture flags) gives the LAST profile in the list precedence.

Limitation (T3): Multi-profile stacking is solo-only on the wire. Multiplayer rooms still send one profile per room.create. Wire-level stacking is a follow-up.


Aura effects in detail

add-aura measures distance with the Chebyshev metric (king-move distance): two squares are within radius R when max(|file_a file_b|, |rank_a rank_b|) ≤ R. Radius 1 covers the 8 neighbours; radius 2 covers a 5×5 box minus the source square.

Auras recompute after every successful move. If a source piece moves OUT of range of a target, the contribution is retracted on the next compute. Multiple auras to the same targetAttr accumulate additively.

Self-application is skipped — an aura's source piece never affects itself.

Limitation (T3): AuraContributions are written correctly, but most attribute consumers (HP/Range read points) don't yet layer the aura contribution on top of the directly-seeded value. The data is there; consumer wiring is a follow-up.


Limits and DoS guards

  • 50 primitives total per descriptor (counted recursively across nested trees).
  • Recursion depth ≤ 3on-turn-start containing on-capture containing conditional is the maximum nesting depth allowed.
  • 20 descriptors per library with starred-aware FIFO eviction.
  • 10 descriptors per multiplayer room, server-enforced.
  • Name 1-40 chars, description 0-200 chars — descriptor metadata.

The editor's footer shows live validation against all of these. A descriptor that fails any check is rejected on Save with the failing rule highlighted.