T3 Wave 5 (T31). New user-facing guide at docs/user/custom-modifiers.md covering: - Opening the editor + 3-column workspace - All 15 effect primitives (state / mechanic / advanced) with one example per primitive - Composing primitives (simple boosted-pawn → medium shield → complex aura king) - Saving + loading from the local library - Using a custom modifier in a profile (panel kind dropdown) - Multi-profile stacking (solo-only T3 limitation noted) - Aura semantics (Chebyshev distance, recompute cadence) - Limits and DoS guards (50 primitives, depth 3, 20 per library, 10 per multiplayer room) - T3 limitations called out inline (trigger primitives seed but don't yet fire; AuraContributions written but not yet consumed)
9.2 KiB
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
- Open the Modifier Profile editor (
+ Modifier Profilefrom the rules drawer, or via the layout's modifier-profile picker). - In the editor's header, click + Custom Modifier. The Custom Modifier editor opens as a nested modal.
- The Custom Modifier editor is a 3-column workspace:
- Left: primitive palette, grouped by category.
- Center: the descriptor's primitive tree (the composition you're building).
- Right: parameter inspector for the selected primitive.
Each descriptor needs a name (1-40 chars) and an optional description (0-200 chars). The header also exposes Save, Load from library, and live validation status.
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 aseed-attributeforShieldCharges=3produces 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 comparisonattr-eq— exact match (string / number / boolean / null)always— runsthennever— runselse(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) andconditionalcurrently 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:
seed-attribute:attr=ShieldCharges, value=3absorb-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:
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):
AuraContributionsare 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 ≤ 3 —
on-turn-startcontainingon-capturecontainingconditionalis 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.