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.
Hosts — a buff attaches to whatever the effect belongs to
A Sim gets caffeinated. A tavern gets haunted. A lantern burns down. Silver, as a material, harms anyone who wields it. Bob is smitten with Alice, whatever Alice thinks. In Fluxx, the rules themselves get a new one. All of those are buffs, and each belongs to a different kind of thing:
# a character
buff:
name: "Caffeinated"
host: character/mrs-crumplebottom
effect: { energy: +2, focus: +1 }
expires: { after: 5, unit: turns }
# a room — and note it reaches the people inside it, not just the room
buff:
name: "Poisoned Air"
host: room/lower-mine
effect: { breathable: false }
radiates: { to: occupants, effect: { health: -1 }, while: present }
expires: { when: "ventilation restored" }
# an object, carrying its own countdown
buff:
name: "Burning Down"
host: object/brass-lantern
effect: { light_radius: -1 }
tick: { every: 1, effect: { fuel: -1 } }
expires: { when: "fuel <= 0" }
# a prototype — every instance inherits it, including ones made later
buff:
name: "Cursed Silver"
host: prototype/material/silver
effect: { harms_wielder: true }
# a relationship — the edge, with a direction
buff:
name: "Smitten"
host: relationship/{ from: bob, to: alice }
effect: { romance: +80 }
expires: { after: 40, unit: turns }
# the ruleset
buff:
name: "Everyone Draws Two"
host: ruleset/current-game
effect: { draw_per_turn: 2 }
expires: { when: "superseded by another Draw rule" }
Nothing in the buff's structure changes with the host. Same fields, same clock, same conflict resolution.
Rooms and objects can simply be characters
MOOLLM has no rigid entity classes to work around. Everything is a prototype, and
anything can delegate to character — so a room that needs to
want things, take turns, and narrate becomes animate by saying so:
room:
id: the-forge
delegates_to: [room, character] # it is a place AND someone
wants: { be_tended: 8, be_admired: 3 }
buffs:
- { name: "Well Banked", effect: { production_speed: +30 }, expires: { after: 12, unit: turns } }
Rooms already ADVERTISE in the same auction characters bid into, so
an animate room is a small step rather than a special case. A forge that resents
neglect is a character who is a place, and it holds its buffs itself.
This is also why the host list above is not a type system. It is a list of things
that turn out to want modifiers, and host: accepts any of them — plus whatever
else a world invents.
Restricting a buff to certain hosts
Most buffs are host-agnostic and should say nothing. Where a buff genuinely only makes sense somewhere, it says so:
buff:
name: "Haunted"
hosts: [room, object] # a place or a thing, not a mood someone has
buff:
name: "Waterlogged"
# nothing declared — anything can get wet
effect: { weight: +1, flammable: false }
hosts: omitted means any; be liberal in what you accept
(postel). A world may also narrow it for a whole game, which is
where a ruleset's own restrictions belong:
world:
buff_policy:
allowed_hosts: [character] # this game keeps effects on people
on_violation: "refuse and explain"
Three things a host has to answer
The structure is the same everywhere, but every host must have an answer to who narrates, when it ends, and how far it reaches:
| Host | Who narrates its tick | Ends when |
|---|---|---|
| character | the character | death, scene end, dispel |
| room | the room if it is animate; otherwise nobody, and it ticks silently | an explicit condition — see below |
| object | the object if animate, else its holder | destruction, or leaving play |
| prototype | nothing — a prototype does not tick; its instances do | the buff is removed from the prototype |
| relationship | either party, or the narrator | either party leaves play, or dispel |
| group | any member, or the narrator | per on_member_leave: — dissolve, shrink, or persist |
| ruleset | the narrator | repeal, or supersession by a later rule |
Places and materials outlive everything. A timer keyed to the host's own death never fires on a room, a material, or a ruleset. Those hosts need a real end condition — a turn count, an event, or a predicate:
expires: { after: 12, unit: turns } # good
expires: { when: "ventilation restored" } # good
expires: { with: host } # never fires on a room
radiates: is how a buff reaches beyond its host. Buffing a room and
buffing everyone in it are different claims, and a buff that means both says so:
buff:
name: "Sweltering"
host: room/the-forge
effect: { comfort: -10 } # the room's own property
radiates:
to: occupants
effect: { energy: -1, mood: -2 } # everyone inside
while: present # leaving ends it
The same field carries auras from a character, contagion from an object, and
region effects from a place. to: accepts occupants, holder, wearer,
nearby: <radius>, or a query.
Relationship hosts — the matrix cell is the host
Some effects belong to neither party. Cupid, in Don's SimProv playset for The Sims 1, makes Bob love Alice — and whether Alice loves Bob back is a separate value the player chooses to set or not. The effect is not on Bob and not on Alice. It is on the edge, and the edge has a direction.
The relationship matrix already is that host. A character's relationships: map is keyed by target and holds an open mapping per target, so a pair-hosted buff needs no new storage — it goes in the cell:
# in bob.yml
relationships:
alice:
feeling: "Can't stop thinking about her. This is new."
daily: 45
lifetime: 30
buffs:
- name: "Smitten"
source: "object/cupid"
effect: { romance: +80 }
expires: { after: 40, unit: turns }
tick: "he finds a reason to walk past her"
host: relationship/{ from: bob, to: alice } names that cell. The direction is
not a field on the buff — it is where the buff is stored, which is why the
asymmetry maintains itself. Bob's file says he is smitten; Alice's file is
untouched, and stays untouched unless someone sets it.
Why the cell, rather than two coordinated buffs on two characters:
- Nothing keeps two halves consistent. A buff on Bob referencing Alice plus another on Alice referencing Bob gives two objects that expire, dispel, and get edited independently, with no invariant tying them.
- Dispel wants to name the edge. "End whatever is between those two" is one operation on one host, not a search for buffs whose payload mentions the other party.
- Asymmetry is the normal case. Unrequited feelings are the interesting ones, which is why the storage is per-direction in the first place.
The matrix is YAML Jazz, so the dimensions are open
The Sims 1 matrix holds a fixed numeric tuple per directed pair. MOOLLM's holds whatever the world needs, in whatever notation fits, with comments carrying meaning the way they do everywhere else. So a relationship buff is not confined to the dimensions someone declared in advance:
relationships:
marieke:
owes_her: "three coffees and an apology" # invented for this world, needs no schema
buffs:
- name: "Awkward Since the Thing"
effect:
eye_contact: -3
# not a stat anybody registered — the guard reads the prose
guard: "while neither of them has mentioned it"
expires: { when: "one of them mentions it" }
Two consequences worth naming. The daily/lifetime pair inherited from the
Sims chunk format is one convention among many, not the schema — it stays for
beaming to and from The Sims, and a world that does not
care about it simply omits it. And an effect can be purely semantic, with no
numeric footprint at all, because the cell is prose-capable and the guard is
compiled from English (BUFF-IN-TIME-COMPILER.md).
Pairs of anything, including yourself
The relationships map already points at characters, objects, and places — so pair-hosted buffs come along for free:
# a person and an object
relationships:
object/grandfathers-watch:
buffs:
- { name: "Can't Bear to Sell It", effect: { will_trade: false } }
# a person and a place
room/the-old-library:
buffs:
- { name: "Homesick For It", effect: { pull: +4 }, expires: { when: "she visits" } }
# a person and themselves — `self:` is a relationship like any other
self:
buffs:
- name: "Ashamed"
effect: { confidence: -20 }
expires: { when: "she tells someone what happened" }
That last one is the case a character-hosted buff models badly and a relationship-hosted one models exactly: shame is not a property of a person, it is a stance toward oneself, and it ends when the stance changes.
The same shape covers grudges, trust, debts, jealousy, and disguises — a disguise's effect lives in the observer-wearer pair and belongs to neither (buffopedia/systems/equipment-inventory/). In Korz terms this is a slot whose coordinates include both parties, which is why SELF-KORZ.md wants the receiver dropped.
⚠ Reciprocity is about existence, not values. If A has a cell for B, B should
have a cell for A — but the contents must be free to disagree, or Cupid is
unimplementable. See the note in ../coherence-engine/.
Bulk operations over the matrix
Cupid edits one directed cell. Super Cupid — make everyone love everyone, or hate, or go neutral — operates on a whole region of the matrix at once, and it needs three fields:
operation:
scope: { among: everyone } # or: a lot, a family, a named set
topology: complete_symmetric # who ends up pointing at whom — see below
write: { romance: 100 }
Neutral is a different kind of operation from love and hate, and the difference is the base/effective split (EFFECTIVE-VALUES.md):
| Layer written | Behavior | What "neutral" means there |
|---|---|---|
| base | permanent; prior values are gone | social amnesia — history destroyed, nobody remembers the feud |
| modifier (a buff per cell) | temporary; originals return on expiry | a truce — everyone is civil for an hour, then remembers |
That is one object with two wildly different characters, and the buff version is only possible because base values are preserved underneath. A party where everyone adores each other for an hour and then the grudges come back is the whole reason the split exists.
buff:
name: "Universal Adoration"
host: relationship/{ among: everyone } # applied per cell in scope
effect: { romance: +100 }
expires: { after: 60, unit: minutes } # base values return, feuds intact
Topology is the interesting parameter, because it turns one object into several:
| Topology | Shape | What it makes |
|---|---|---|
complete_symmetric |
everyone ↔ everyone | a commune |
complete_asymmetric |
random per direction | unrequited chaos |
star_inbound |
everyone → one | a cult — adoration flows one way |
star_outbound |
one → everyone | the doting leader |
perfect_matching |
disjoint pairs | mass pairing, the guru assigning couples |
clique_partition |
love inside, hate across | factions — a schism generator |
star_inbound and clique_partition are worth calling out: one is how a cult's
affection graph actually looks, and the other manufactures rival factions in a
single gesture, which is otherwise hours of hand-editing.
Group hosts — one buff over a set, not a mesh of pairs
Some bonds are held by three or more parties at once: a marriage of three, a band, a crew, a conspiracy, a cult. The host is the set:
buff:
name: "Married"
host: group/{ members: [bob, alice, chandra] }
effect: { household: shared, inheritance: equal }
buff:
name: "In On It"
host: group/{ members: [marieke, henk] } # two is just the common case
effect: { may_discuss: the-thing }
guard: "only when no one else is present"
A group host is a hyperedge, and it is not reducible to the pairwise cells in the relationship matrix:
- The bond is one thing, not its pairs. Three people in one marriage is a single commitment among three, not three couples. Storing it as pairs asserts something false about its structure.
- Dissolution is one operation. Ending a group bond means removing one buff from one host. Pairwise, it is n(n−1)/2 removals that can partly fail, leaving a marriage that half exists.
- Membership can change without the bond ending. Members join and leave a crew that persists. A pair bond, by contrast, dies when either party leaves — which is correct for pairs and wrong for groups.
So use a relationship host when the asymmetry matters (Bob's feelings for Alice, which Alice need not return) and a group host when the togetherness is the thing being modified. They coexist: three people can share one marriage while each holding a different private opinion of the others.
Two questions a group-hosted buff must answer, because the pair case answered them implicitly:
| Question | Options |
|---|---|
| A member leaves | dissolve · shrink · block the departure |
| Membership is edited | new buff · amend in place (a Fluxx New Rule played onto the host) |
buff:
name: "The Crew"
host: group/{ members: [a, b, c] }
on_member_leave: shrink # dissolve | shrink | block
min_members: 2 # dissolve if it would drop below
Worked example — an officiant that summons a participant list and marries it,
whoever and however many they are:
life-events-playset.md.
Prior art, in Don's own code, is written up at buffopedia/systems/simprov/. What The Sims itself eventually did about relationship-hosted effects is in buffopedia/systems/sims-4/.
Prototype hosts inherit for free
Attaching a buff to a prototype means everything delegating to it is affected, because that is what delegation does:
buff:
name: "Cursed Silver"
hosts: [prototype]
attached_to: material/silver
effect: { harms_wielder: true }
# every object made of silver now harms its wielder, including ones made later
Most engines cannot do this at all; Dwarf Fortress is the notable exception, and MOOLLM gets it from the object model rather than as a feature. See buffopedia/registry.yml under the syndrome family.
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 Stats
A room carries its own stats and its own buffs, directly. No spirit required:
room:
id: blacksmith-forge
name: "The Forge"
# The room's own stats
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: well-tended
source: "Someone banked the fire properly"
effect: { production_speed: +30, error_rate: -5 }
expires: { after: 12, unit: turns }
radiates:
to: occupants
effect: { mood: +5 }
while: present
| Room 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 |
Note expires: rather than a duration tied to the host's lifetime — a room does
not die, so a room buff that waits for its host to expire waits forever.
A forge that remembers who banked the fire, wants to be tended, and can be
pleased or offended is a room that also delegates to character — see above. It
holds its own buffs either way; delegating to character is what lets it want
things and act on them.
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"
Pending — issued, but not yet in force
A buff can exist without being in effect. The model has active and absent, and
the missing third state is issued and awaiting ratification:
buff:
name: "Married"
host: group/{ members: [bob, alice] }
status: pending # exists, contributes nothing yet
pending_expires: { after: 90, unit: days } # the licence lapses if unused
ratified_by: officiant # who can flip it to active
Two clocks, not one. The pending window is how long the offer stands; the
buff's own expires: does not start until ratification. A lapsed pending buff was
never in force at all, which is a different outcome from one that expired.
Why it earns its place:
- Prior art is everywhere. A marriage licence is issued, then solemnized, then recorded. A statute is enacted, then commences on a later date. A contract is signed, then takes effect. Enactment and force are separate events in every system that takes records seriously.
- It makes batch ratification possible. Pending buffs are a work queue, so one event can ratify all of them — a mass wedding is a scheduler over every unratified licence present.
- The gap between issue and ratification is where the stories are. Someone assigned and never showing up is only representable if the assignment can exist unfulfilled.
status: omitted means active on application, which is the ordinary case.
Removal policy — who may end this, at what cost, leaving what
The table above matches on tags: what cancels what. It says nothing about who is permitted to remove a buff, what removal costs, or what it leaves behind — and those three questions are where most of the interesting cases live. A cursed item you cannot drop, a marriage you can end cheaply or properly, a contract that needs a specialist: all three are the same missing field.
buff:
name: "Married"
host: object/marriage-certificate # the artifact is the host
removal:
- by: host_owner # throw the certificate out
cost: 0
unwinds: false # record gone, entanglement remains
residue:
relationship: { bitter: +40 }
room: "a lighter rectangle on the wallpaper"
- by: service/divorce-attorney # hire someone to do it properly
cost: { simoleons: 3000 }
unwinds: true # pairwise state, property, paperwork
residue: {}
Three fields, each answering one question:
| Field | Question | Prior art |
|---|---|---|
by: |
who is permitted to remove it | D&D's remove-curse-requires-a-caster; cursed items you cannot unequip |
cost: |
what removal takes | hiring a specialist as an economy rather than a spell list |
unwinds: |
whether the state is cleaned up or just the record deleted | the orchestrator as unwind handler (SELF-KORZ.md § phase extent) |
A cheap removal destroys the record. An expensive one unwinds the state. The
difference is residue:, and residue is where the stories are — the ex who is
still owed money, the rectangle on the wall where the certificate hung.
A buff with no removal: block behaves as it always has: it expires on its own
terms and cancels: can clear it.
Second-order: a buff that edits another buff's removal
Because removal: is data on the buff, another buff can modify it. A
prenuptial agreement does not change what being married does — it changes what
divorce costs and what it leaves:
buff:
name: "Prenuptial Agreement"
host: object/marriage-certificate # rides on the same artifact
modifies_removal_of: buff/married
set:
- { by: service/divorce-attorney, cost: { simoleons: 500 }, unwinds: true, residue: {} }
That is a modifier whose target is another modifier's dispel policy, which the
tag-matching table cannot express at all. Worked design, with the object family
it belongs to:
life-events-playset.md.
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 — Caffe
…(truncated)