Buff
"All effects are buffs. Some are just shitty."
Buffs modify stats, abilities, or behavior. They have durations, can stack, and come from various sources. Curses are just negative buffs — no separate system.
Characters Only
Buffs only target characters. This is a design constraint, not a limitation.
- •Single closure signature:
(world, subject, verb, object) - •
subjectis always a character — no type checking needed - •Rooms that need buffs get a "room spirit" character
# Room needs to be "haunted"? Create its spirit.
character:
id: dark-cave-spirit
name: "Spirit of the Dark Cave"
location: room/dark-cave
buffs:
- ref: buff/haunted
Structure
buff:
name: "Caffeinated"
source: "Espresso"
effect: { energy: +2, focus: +1 }
duration: 5 # simulation turns
stacks: false
| Field | Purpose |
|---|---|
name | Display name |
source | What granted this buff |
effect | Stat mods OR semantic prompt |
duration | How long it lasts |
stacks | Can multiple instances exist? |
max_stacks | If stacking, limit |
decay | How it ends (time, action, condition) |
Buff Types
Numeric
Traditional stat modifiers:
buff:
name: "Caffeinated"
effect: { energy: +2, focus: +1 }
duration: 5
Semantic
Arbitrary effect prompts interpreted by the LLM — not predefined stats, just vibes:
- •"feeling lucky"
- •"cats seem to like you today"
- •"slightly cursed"
- •"radiating calm energy"
- •"shadows feel watchful"
How it works:
Buff: "cats seem to like you today" Action: PAT TERPIE LLM: Gives bonus, narrates extra warmth
Mixed
Combine numeric and semantic:
buff:
name: "Terpie's Blessing"
effect:
calm: +2
vibe: "cats trust you more"
duration: "a while"
Standard Properties Buffs Affect
Player/NPC Stats (Sims-Style Needs)
# Numeric needs — decay over time, restored by actions needs: hunger: 80 # 0=starving, 100=full energy: 65 # 0=exhausted, 100=rested social: 45 # 0=lonely, 100=connected hygiene: 90 # 0=filthy, 100=clean bladder: 30 # 0=desperate, 100=empty fun: 55 # 0=bored, 100=entertained comfort: 70 # 0=miserable, 100=cozy
Mind-Mirror Stats (Cognitive/Emotional)
# Mental state — affects decision-making and narration mind: focus: 75 # Concentration (0-100) mood: 20 # Emotional valence (-100 to +100) stress: 35 # Anxiety level (0-100) creativity: 60 # Creative capacity (0-100) confidence: 50 # Self-assurance (0-100) patience: 40 # Frustration tolerance (0-100) curiosity: 80 # Exploration drive (0-100)
Room Spirit Stats
Room spirits are characters whose stats affect the room they haunt:
character:
id: forge-spirit
name: "Spirit of the Forge"
location: room/blacksmith-forge
# These stats affect everyone in the room
production_speed: 120 # +20% crafting speed
error_rate: 8 # 8% chance of mistakes
mood_influence: +5 # Slight pride boost
comfort_bonus: -10 # Hot and uncomfortable
discovery_chance: 15 # Sometimes find rare materials
danger_level: 25 # Burns, sparks, accidents
buffs:
- id: master-craftsman-blessing
source: "Pleased the forge spirit"
effect: { production_speed: +30, error_rate: -5 }
duration: "until you leave"
| Spirit Stat | What It Does | Example Buff Effect |
|---|---|---|
production_speed | Work/craft rate | Blessing: +30% faster |
error_rate | Mistake probability | Curse: +20% more errors |
mood_influence | Mood granted to visitors | Haunting: -15 mood |
comfort_bonus | Comfort modifier | Cozy: +20 comfort |
discovery_chance | Finding hidden things | Mysterious: +25% |
danger_level | Hazard intensity | Cursed: traps more deadly |
Sources
| Source | Example |
|---|---|
| Interactions | Petting a cat grants joy |
| Consumables | Coffee grants energy |
| Locations | Being in pub grants comfort |
| Items | Lit lamp grants grue immunity |
| Relationships | High friendship grants trust |
| Personas | Wearing persona grants themed buffs |
Lifecycle Hooks
Three hooks control buff behavior, written as natural language and compiled to JS:
| Hook | → Compiles To | Purpose |
|---|---|---|
start | start_js | Runs when buff activates |
simulate | simulate_js | Runs each tick while active |
is_finished | is_finished_js | Returns true → buff ends |
Example: Poison Buff
buff:
id: poison
name: "Poisoned"
tags: [curse, damage-over-time, dispellable]
# Natural language prompts (author writes these)
start: "Mark character as poisoned, turn them slightly green"
simulate: "Reduce HP by 1, chance of groaning sound"
is_finished: "Return true after 5 ticks OR if HP drops below 10"
# Compiled by buff compiler (generated)
start_js: |
subject.poisoned = true;
subject.tint = 'green';
simulate_js: |
subject.hp -= 1;
if (Math.random() < 0.3) world.emit('*groan*');
is_finished_js: |
return subject.poisonTicks >= 5 || subject.hp < 10;
Closure Signature
All compiled hooks use the same signature:
(world, subject, verb, object) => { ... }
- •
world— shared game state (never null) - •
subject— the character with the buff (never null for buffs) - •
verb— context-dependent (may be null) - •
object— context-dependent (may be null)
Body-only in YAML: Write just the code body, engine wraps it.
Buff Interactions
Buffs can look up and modify other buffs by tag:
| Interaction | Effect | Example |
|---|---|---|
cancels | Remove buffs with these tags | Antidote cancels [poison] |
boosts | Multiply/extend buffs with tags | Fire spell boosts [fire] x2 |
replaces | Remove old, add this | Drunk replaces [tipsy] |
merges_with | Combine into new buff | Rage + Focus → Battle Trance |
blocked_by | Can't apply if these exist | Poison blocked by [immunity-poison] |
counters | Weaken/shorten these buffs | OJ counters [hangover] |
countered_by | These weaken/shorten this | Couch-lock countered by [citrus] |
Cancel Example
buff: id: cleanse name: "Cleanse" tags: [holy, dispel] cancels: [curse, poison, disease] # Remove all matching start: "Holy light purges dark afflictions"
Boost Example
buff:
id: fire-attunement
name: "Fire Attunement"
boosts:
tags: [fire]
multiplier: 2.0
extend_duration: 5
start: "Fire spells burn twice as hot"
Merge Example
buff:
id: rage
name: "Rage"
tags: [combat, aggression]
merges_with:
tags: [focus, discipline]
result: battle-trance # Creates new combined buff
buff:
id: battle-trance
name: "Battle Trance"
tags: [combat, legendary]
effect: { damage: +50%, focus: +30, pain_immunity: true }
start: "Fury and focus unite — you become a weapon"
Blocked By Example
buff: id: poison name: "Poisoned" tags: [poison, damage-over-time] blocked_by: [immunity-poison, divine-protection] # Won't apply if target has these tags
Weight Trees (ML-Style Mixtures)
Buffs can form hierarchical weighted mixtures, like neural network layers:
Blend → Strains → Terpenes → Effects ↓ ↓ ↓ ↓ weights weights weights final ↑ ↑ ↑ ↑ BUFFS BUFFS BUFFS BUFFS ← Each stage can be modified by buffs!
Meta-buffs can modify the weight tree itself:
| Buff | Affects | Example |
|---|---|---|
tolerance | Strain weights | Regular use → diminishing returns |
sensitivity | Terpene weights | First time → effects amplified |
synergy-boost | Effect weights | Entourage → all effects +20% |
citrus-clarity | Specific terpenes | Limonene effects doubled |
indica-affinity | Strain category | Indica strains hit harder |
Tolerance Relationships
Tolerances use the character relationship map — same system as NPC friendships:
character:
id: player
name: "Don"
# Relationships include people AND substances
# Terpenes are unidirectional — they don't have feelings back
relationships:
# NPCs (bidirectional)
bob: { trust: 45, friendship: 60 }
alice: { trust: 80, friendship: 75 }
# Terpene tolerances (unidirectional — no reciprocal)
terpene/myrcene: { tolerance: 45 } # Couch-lock less effective
terpene/limonene: { tolerance: 12 } # Citrus hits hard
terpene/pinene: { tolerance: 30 }
terpene/linalool: { tolerance: 5 } # Lavender knocks you out
terpene/caryophyllene: { tolerance: 60 } # Need more for pain relief
terpene/humulene: { tolerance: 20 }
terpene/terpinolene: { tolerance: 8 } # Full creative boost
terpene/ocimene: { tolerance: 3 } # Maximum effect
Key difference from NPC relationships:
| Aspect | NPC Relationship | Terpene Relationship |
|---|---|---|
| Direction | Bidirectional | Unidirectional |
| Reciprocal | Bob likes you back | Myrcene has no feelings |
| Tracked on | Both characters | Player only |
| Decay | Neglect hurts both | Time heals tolerance |
Tolerance mechanics:
| Tolerance | Multiplier | Experience |
|---|---|---|
| 0 (virgin) | 1.5x | "Whoa, this is intense" |
| 25 (light) | 1.2x | "Nice, I feel it" |
| 50 (moderate) | 1.0x | "Standard effect" |
| 75 (heavy) | 0.7x | "Need more than usual" |
| 100 (maxed) | 0.4x | "Barely feel anything" |
Tolerance changes:
# Each use increases tolerance on_use: tolerance_gain: 2-5 points per use # Tolerance decays over time (T-break!) on_rest: tolerance_decay: 1 point per day of abstinence # Full reset after extended break t_break: duration: 2 weeks effect: "Reset to 50% of current tolerance"
Effective weight calculation:
def get_effective_terpene_weight(character, terpene, base_weight):
tolerance = character.tolerances.get(terpene, 0)
# Convert tolerance to multiplier
if tolerance < 25:
multiplier = 1.5 - (tolerance / 50) # 1.5x → 1.0x
elif tolerance < 75:
multiplier = 1.0 - ((tolerance - 50) / 100) # 1.0x → 0.75x
else:
multiplier = 0.75 - ((tolerance - 75) / 100) # 0.75x → 0.5x
return base_weight * multiplier
Layer 1: Terpenes → Effects
Each terpene has weighted effects:
myrcene-blessing:
effects_weighted:
relaxation: { value: +30, weight: 1.0 } # Full effect
pain_relief: { value: +20, weight: 0.8 } # 80%
sedation: { value: +25, weight: 0.9 } # 90%
Layer 2: Strains → Terpenes
Each strain is a weighted mixture of terpenes:
strain-og-kush:
terpene_profile:
myrcene: 0.35 # 35% of profile
limonene: 0.25 # 25%
caryophyllene: 0.20
linalool: 0.10
humulene: 0.10
Layer 3: Blends → Strains
Blends mix multiple strains:
blend-wake-and-bake:
strain_mixture:
sour-diesel: 0.50 # Half the blend
jack-herer: 0.30 # 30%
pineapple-express: 0.20
Computing Final Effects
# Blend → Strain → Terpene → Effect propagation
def compute_blend_effects(blend):
final_terpenes = {}
# Layer 3→2: Blend weights × Strain terpene profiles
for strain_id, strain_weight in blend.strain_mixture.items():
strain = get_strain(strain_id)
for terpene, terpene_weight in strain.terpene_profile.items():
final_terpenes[terpene] += strain_weight * terpene_weight
# Layer 2→1: Terpene amounts × Effect weights
final_effects = {}
for terpene, amount in final_terpenes.items():
terpene_buff = get_terpene_buff(terpene)
for effect, config in terpene_buff.effects_weighted.items():
final_effects[effect] += amount * config.weight * config.value
return final_effects
Example Calculation
Wake & Bake Blend:
├── Sour Diesel (50%)
│ ├── limonene: 0.30 × 0.50 = 0.15
│ └── pinene: 0.15 × 0.50 = 0.075
├── Jack Herer (30%)
│ ├── limonene: 0.20 × 0.30 = 0.06
│ └── pinene: 0.25 × 0.30 = 0.075
└── Pineapple Express (20%)
├── limonene: 0.30 × 0.20 = 0.06
└── pinene: 0.25 × 0.20 = 0.05
Final limonene: 0.15 + 0.06 + 0.06 = 0.27
Final pinene: 0.075 + 0.075 + 0.05 = 0.20
Then: limonene × mood_boost, pinene × focus → final character effects
This is essentially a mini neural network where:
- •Weights are terpene profiles and strain mixtures
- •Activations are effect values
- •Forward pass computes final buff effects
Buff Orchestration (Simulation Loop)
The orchestrator runs buff rounds during simulation ticks:
1. Scan Phase
Orchestrator collects all active buffs across all characters:
# Orchestrator builds active-buff manifest
active_buffs:
- character: player
buff_ref: "skills/buff/buffs/INDEX.yml#caffeinated"
remaining: 6
stacks: 2
- character: player
buff_ref: "skills/buff/buffs/INDEX.yml#high"
remaining: 8
stacks: 1
- character: bob-npc
buff_ref: "skills/buff/buffs/INDEX.yml#drunk"
remaining: 4
stacks: 1
2. Event Generation
Create buff-tick events with pointers:
buff_round:
tick: 42
events:
- type: buff-simulate
character: player
buff: caffeinated
simulate_js: "subject.energy_effective += 20; subject.focus_effective += 15;"
- type: buff-simulate
character: player
buff: high
simulate: "Deep thoughts about random topics, food cravings"
simulate_js: "if (Math.random() < 0.3) world.emit('*ponders existence*');"
- type: buff-simulate
character: bob-npc
buff: drunk
simulate: "Occasional slurred speech, may say embarrassing things"
3. LLM Simulation Prompt
Orchestrator instructs LLM to enumerate and simulate:
prompt: |
BUFF ROUND — Tick 42
Enumerate and simulate each active buff:
1. PLAYER — Caffeinated (6 ticks remaining, 2 stacks)
Effect: +20 energy, +15 focus per stack
Simulate: Apply effects, note jitteriness if 2+ stacks
2. PLAYER — High (8 ticks remaining)
Effect: -25 stress, +20 creativity, +30 hunger
Simulate: "Deep thoughts about random topics, food cravings"
→ Narrate any random musings or munchie urges
3. BOB — Drunk (4 ticks remaining)
Effect: +30 confidence, -25 focus, -30 judgement
Simulate: "Occasional slurred speech, may say embarrassing things"
→ Decide if Bob says something regrettable this tick
For each buff:
- Apply stat modifications to _effective values
- Run simulate behavior (chance-based events)
- Check is_finished conditions
- Decrement remaining duration
- Remove expired buffs, trigger spawns_after
Return updated character states and any narration.
4. Buff Lifecycle Per Tick
┌─────────────────────────────────────────────────────────────────┐ │ BUFF TICK │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ For each character: │ │ For each active buff: │ │ │ │ 1. APPLY EFFECTS │ │ stat_effective += buff.effect × buff.stacks │ │ │ │ 2. RUN SIMULATE │ │ Execute simulate_js OR let LLM interpret simulate │ │ (chance-based events, narration, random behaviors) │ │ │ │ 3. CHECK IS_FINISHED │ │ If is_finished_js returns true → mark for removal │ │ If remaining <= 0 → mark for removal │ │ │ │ 4. DECREMENT DURATION │ │ remaining -= 1 │ │ │ │ 5. HANDLE EXPIRATION │ │ If marked for removal: │ │ - Remove buff from character │ │ - Trigger spawns_after buffs (with delay/chance) │ │ - Emit buff-expired event │ │ │ │ 6. HANDLE INTERACTIONS │ │ Check for cancels, boosts, replaces, merges │ │ Apply buff-on-buff effects │ │ │ └─────────────────────────────────────────────────────────────────┘
5. Compiled vs Interpreted
| Mode | When | How |
|---|---|---|
| Compiled | simulate_js exists | Engine evals cached closure directly |
| Interpreted | Only simulate text | LLM reads prompt, narrates behavior |
| Hybrid | Both exist | JS runs effects, LLM narrates flavor |
buff:
id: drunk
# LLM interprets this for narration
simulate: "Occasional slurred speech, may say embarrassing things"
# Engine runs this for mechanics
simulate_js: |
if (Math.random() < 0.2) {
world.emit(subject.name + " slurs something incomprehensible");
}
6. Attention Concentration (Time-Slicing)
The event-based design concentrates LLM attention on specific tasks:
┌──────────────────────────────────────────────────────────────────┐ │ LLM ATTENTION TIME-SLICING │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Instead of: "Simulate everything at once" (diffuse attention) │ │ │ │ We do: Series of focused micro-tasks │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Buff 1 │ → │ Buff 2 │ → │ Buff 3 │ → │ Buff 4 │ │ │ │ PLAYER │ │ PLAYER │ │ BOB │ │ ROOM │ │ │ │ caffein │ │ high │ │ drunk │ │ haunted │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ ↓ ↓ ↓ ↓ │ │ [focused] [focused] [focused] [focused] │ │ attention attention attention attention │ │ │ └──────────────────────────────────────────────────────────────────┘
Why this works:
| Problem | Solution |
|---|---|
| LLM loses track with many buffs | One buff at a time, clear context |
| Effects get confused/merged | Each buff isolated in its own slice |
| Hard to debug | Each event is traceable, logged |
| Inconsistent simulation | Same prompt structure every time |
Iteration Pattern:
# Orchestrator feeds LLM one task at a time iteration_1: focus: "PLAYER's Caffeinated buff" context: [player_state, buff_definition, tick_number] task: "Apply effects, check finish condition, narrate if needed" output: [updated_state, narration, events] iteration_2: focus: "PLAYER's High buff" context: [player_state, buff_definition, tick_number] task: "Apply effects, chance of munchies event, narrate thoughts" output: [updated_state, narration, events] # ... and so on
Benefits:
- •Focused attention — LLM only thinks about one buff
- •Predictable structure — Same input/output format each time
- •Debuggable — Can trace exactly which buff caused what
- •Parallelizable — Independent buffs can run in parallel
- •Interruptible — Can pause/resume between iterations
- •Cacheable — Compiled
_jsbuffs skip LLM entirely
Speed-of-Light Compatible:
This fits the speed-of-light pattern — many focused micro-operations in a single LLM call, or batched across calls:
# Single call, multiple focused tasks prompt: | Process these buff events in sequence: [1/4] PLAYER — Caffeinated → Apply: energy +40, focus +30 (2 stacks) → Check: is_finished? No (6 remaining) → Output: state changes only [2/4] PLAYER — High → Apply: stress -25, creativity +20, hunger +30 → Simulate: "Deep thoughts" — roll for musing → Output: state + optional narration [3/4] BOB — Drunk → Apply: confidence +30, focus -25 → Simulate: "Slurred speech" — roll for embarrassment → Output: state + optional narration [4/4] LIBRARY-SPIRIT — Haunted → Apply: error_rate +15, mood_influence -20 → Simulate: "Poltergeist activity" — roll for book fall → Output: room effects + optional event
Stacking
- •Same source: Doesn't stack — refresh duration instead
- •Different sources: Stack additively up to category limit
Category Limits
terpene_effects: 3 charm_effects: 5 consumable_effects: 4 negative_effects: 3 # 3+ same negative = LEGENDARY
Synergies
Some buffs COMBINE into stronger effects:
- •Myr + Lily = "Sedation Stack"
- •Lemon + Pine = "Focus Boost"
- •All 8 kittens = "ENTOURAGE EFFECT" (legendary)
Negative Buffs (Curses)
Curses are just shitty buffs. Same structure, negative effects.
buff:
name: "Scratched"
source: "Failed BELLY RUB"
effect: { hp: -1, visible_marks: true }
duration: "Until healed"
Persistent Curses
Long-term negative buffs with lift conditions:
buff:
name: "Curse of Darkness"
effect: { lamp_efficiency: -25% }
duration: conditional
lift_condition: "Light 3 dark places"
reward_on_lift: "LIGHT-BEARER title"
Duration Types
| Type | Example |
|---|---|
| Turns | duration: 4 |
| Conditional | duration: until you eat |
| While present | duration: while in pub |
| Permanent | duration: forever |
| Natural language | duration: a few minutes |
| Probabilistic | duration: 25% fade chance per turn |
Natural Language Durations
We're not tracking real time — the LLM interprets and makes its best guess:
- •"forever"
- •"5 minutes"
- •"a day"
- •"until sunset"
- •"randomly 50%"
- •"a while"
- •"briefly"
- •"until you forget"
See time/ for full natural duration examples.
Decay
When LLM judges turn(s) have passed:
- •Decrement duration on timed buffs
- •Remove buffs that hit 0
- •Apply new buffs from current turn
Effective Derived Values: Flags Edition
This is the effective derived values protocol for booleans.
| Type | Base | Modifiers | Effective |
|---|---|---|---|
| Numeric | energy: 5 | buff +2 | effective_energy: 7 |
| Boolean | in_darkness: false | room.lit=false, has_lamp=false | effective_in_darkness: true |
Same pattern:
- •Numeric: base + sum(modifiers) = effective
- •Boolean: base OR any(conditions) = effective flag
Push / Pull / Latch
The LLM can handle any combination:
| Mode | Pattern | Example |
|---|---|---|
| Pull | Compute on demand | in_darkness derived from lamp + room state |
| Push | Source sets flag | Buff explicitly sets urgent_situation: true |
| Latch | Stays until cleared | has_visited_room_a: true persists |
# PULL — derived on demand, not stored in_darkness: (room.lit == false) AND (has_lamp == false) # PUSH — buff explicitly sets buff: sets_flags: [urgent_situation] # LATCH — persists in state until cleared player: visited_rooms: [room-a, room-b] # grows, never shrinks
Traditional reactive systems pick one mode. The LLM does all three simultaneously — it sees the whole context and figures out which pattern applies.
Tweening and Animation
Values don't have to snap — they can interpolate over time:
| Type | Instant | Tweened |
|---|---|---|
| Numeric | energy: 5 → 7 | energy: 5 → 7 over 3 turns |
| Boolean | lit: false → true | lit: fading in over 2 turns |
| Position | room-a → room-b | walking through hallway |
buff:
name: "Warming Up"
effect: { warmth: +3 }
tween: ease-in # Gradual increase
duration: 5
animation:
entering_room:
from: hallway
to: pub
frames: [approaching, at_door, stepping_in, arrived]
The LLM narrates intermediate states. "You feel yourself warming up..." not just "You are warm now."
Velocity
Any reactive variable can have a rate of change:
energy: value: 5 velocity: -1 # Draining 1 per turn trust: value: 45 velocity: +3 # Building rapport mood: value: "content" velocity: "improving" # Semantic velocity works too
| Variable | Value | Velocity | Meaning |
|---|---|---|---|
energy | 5 | -1 | Tired and getting worse |
trust | 45 | +3 | Relationship strengthening |
position | room-a | north | Moving northward |
mood | anxious | calming | Settling down |
The LLM reads velocity to predict and narrate: "You're running low on energy and fading fast..." vs "Low energy but recovering."
Physics Simulation
Extend to full 2D/3D cartoon physics:
thrown_ball: position: [5, 3] velocity: [2, 4] # Moving up-right acceleration: [0, -1] # Gravity pulling down bouncing: elasticity: 0.8 # Loses 20% on bounce cartoon_physics: hang_time: true # Pause at apex squash_stretch: true # Deform on impact delayed_fall: true # Look down first, then fall
The LLM narrates physics with cartoon timing:
The ball arcs gracefully upward... hangs for a moment at the peak... then plummets, SQUASHING flat against the floor before bouncing back slightly less enthusiastically.
Works for:
- •Thrown objects (ball, inventory items)
- •Character movement (jumping, falling, knockback)
- •Environmental effects (swinging doors, rolling boulders)
- •Looney Tunes logic (run off cliff, pause, look down, THEN fall)
- •Temperature cooling or warming (ice cream melting, water freezing)
Commands
| Command | Effect |
|---|---|
BUFFS or STATUS | List active buffs with remaining duration |
EXAMINE [buff] | Full details of buff source, effect, duration |