StatCodeGuide - JakeJamesDev/formamorph GitHub Wiki
This guide explains Formamorph's stat code: a small JavaScript script attached to a stat. It can set the stat's value from other stats, set the stat's own bounds, write another stat, pin a placeholder, or switch a trait. Each stat has two script boxes, one on each side of the AI's turn. In a world file they are scripts with the timings "before" and "after" in the stat's scripts list (Scripts). The World Format page describes the rest of a stat.
- In the World Editor, select Advanced in the mode switch. The Code tab shows in Advanced mode only.
- Open the Stats tab and select the stat.
- Open the stat's Code tab. Only a number or percentage stat has it. It shows Dynamic Value Calculation with two boxes, Before the AI and After the AI.
- Type your script into one box. For a value the AI should read this turn, use Before the AI. For a reaction to the AI's change, use After the AI.
- Select Test Code under the box. Read the result, any error, and every write the run made.
- Open the stat's Code tab.
- Select Templates beside the box's Test Code button.
- Pick a template. Each box lists only the templates written for its timing.
- Fill in the inputs the template asks for.
- Edit the inserted code as you like.
Select the Variable button in the toolbar of a code box on a stat's Code tab. The menu opens on This Stat, Stats, Traits, Entities, Persona, Placeholders, Dictionaries and Clock. In the box of an entity's stat or a Blueprint stat, an Owner group follows This Stat. Stats always lists the global stats. Each group opens level by level: a list of your world's own names, then a name's fields, and for an entity, its Traits and Placeholders. Select a field to insert its whole path at the caret. To reach entities.Mira.traits.Wounded.enabled, open Entities, Mira, Traits, Wounded, and then select enabled. The path runs as inserted, with no name to type.
- Open the stat's Code tab.
- In After the AI, read the AI's ask from
self.delta.ai.value. - Clamp the ask.
- Set
self.valuefromself.previous.value, the clamped ask andself.delta.regen.value. See Reading This Turn for an example. - Select Test Code.
- Open the Code tab of the stat that decides the change. This stat is the hub.
- In its code box, write the field of the stat to move:
stats.Health.value = stats.Health.value / 2. To move an entity's stat, name the entity:entities.Mira.stats.Trust.value -= 5. Use the After the AI box to react to this turn's roll or ask. - Select Test Code. It lists every write to another stat, such as
stats.Health.value = 30, with the owner of each target stat. - In Advanced mode, open the Test Bench and read Issues. It warns when two boxes write one field.
See Writing to Other Stats for the four fields and the rules.
- Open the stat's Code tab.
- In Before the AI, call
pinon the placeholder, such asplaceholders.Mood.pin('calm'). A pin from this box is in the prompt for this turn. - Select Test Code. It lists each placeholder the run pinned.
- Select Test Code to see the error and every write.
- Add
console.log()lines. - Open your browser's developer console. Each line shows there.
- In the World Editor's Test Bench, read the Issues list for writes to unknown names and for two boxes that write one stat field.
Stat code runs in a sandbox on every turn (see When Your Code Runs). It can:
- Derive a value from other stats (e.g., carrying capacity based on strength)
- Combine stats (e.g., defense calculated from armor + agility)
- React to thresholds (e.g., speed penalties when health is below 30%)
- Follow time: how long a turn took, or what time of day it is (see The Story Clock)
-
Set its own Min, Max, or Regen (see Writing to
self) - Write another stat, global or owned by any entity, so one event can move several stats (see Writing to Other Stats)
- Shape what the AI asked for before it lands (see Reading This Turn)
- Pin a placeholder to any text (see Placeholders)
- Switch a trait on or off (see Traits)
- Read or switch an entity's traits, and read its placeholders (see Persona and Entities)
-
Find the entity that owns the stat with
owner(see Owner) - Read a dictionary's placeholders (see Dictionaries)
- Each stat has two optional JavaScript boxes: Before the AI and After the AI
- On every turn, each box runs in a safe environment at its own point in the turn
- Each box reads every stat, the story clock, the world's placeholders and traits, and the entities and dictionaries in the game
-
return <number>sets the stat's value, clamped to its range. Writes toself,stats,owner,placeholders,traits,persona,entitiesanddictionariesapply after the run - A script that throws or times out changes nothing
A turn runs your code twice, once on each side of the AI:
| Step | What happens |
|---|---|
| 1 | Before the AI runs, on the state the turn started in |
| 2 | The prompt is built and the AI answers |
| 3 | The AI's stat changes apply |
| 4 | Regen applies |
| 5 | After the AI runs |
Before the AI runs before the prompt is built. A value it sets, a placeholder it pins, or a trait it switches is in the prompt for that turn. A value it sets shows on the bar while the AI is still writing.
After the AI runs where a single box always ran, so a world written before the split keeps its meaning with no edits. It reads the AI's change, this turn's regen, and the values the before box set.
⚠️ The before box has no turn behind it yet.previousreads as the stat itself,delta.ai,delta.regen,delta.totalanddelta.actualall read zeros, and the clock reads turn start, soclock.deltaHoursis0. Code that scales the AI's change belongs in the after box.
Bounds carry across both boxes. A self.max the before box sets persists through an after box that never writes it. Emptying a box, or both, leaves the bounds as they are.
Your code runs on every turn. There is no schedule to choose and no option to enable. Both boxes run on the opening turn and on a turn with no AI stat change.
💡 Both boxes run even when the stat request is off, or when it fails. On such a turn
delta.aireads zeros in the after box too, so code that scales an ask leaves the value where it stood.
Stat code can use only these objects. A word in angle brackets stands for a name from your world, <n> for a number, and <text> for text. A name with a space needs brackets: stats["<Stat>"].
| Object | Members | Read | Write |
|---|---|---|---|
self |
id, name, type, description, enabled, min, max, value, regen, previous, delta
|
self.value |
self.value = <n> |
stats |
id, name, type, description, enabled, min, max, value, regen, previous, delta
|
stats.<Stat>.value |
stats.<Stat>.value = <n> |
owner |
id, name, type, pronouns, inScene, traits, placeholders, stats
|
owner.stats.<Stat>.value |
owner.stats.<Stat>.value = <n> |
clock |
day, daypart, deltaHours, elapsedHours, previous
|
clock.day |
Read-only |
traits |
enabled, acquired, id, name, mode, available, group, playerToggle
|
traits.<Trait>.enabled |
traits.<Trait>.enabled = true |
persona |
id, name, type, pronouns, inScene, traits, placeholders, stats
|
persona.traits.<Trait>.enabled |
persona.traits.<Trait>.enabled = true |
entities |
id, name, type, pronouns, inScene, traits, placeholders, stats
|
entities.<Entity>.stats.<Stat>.value |
entities.<Entity>.traits.<Trait>.enabled = true |
placeholders |
id, name, value, values, text, roll(), pin(), unpin()
|
placeholders.<Placeholder>.text |
placeholders.<Placeholder>.pin(<text>) |
dictionaries |
id, name, placeholders
|
dictionaries.<Dictionary>.placeholders.<Placeholder>.text |
dictionaries.<Dictionary>.placeholders.<Placeholder>.pin(<text>) |
console |
log() |
— | console.log(<text>) |
Follow these rules:
-
A stat compares through
.value. Writestats.<Stat>.value > <n>.stats.<Stat>andselfalone are whole entries, not numbers. -
A trait switches through
.enabled. Writetraits.<Trait>.enabled = true. The editor underlinestraits.<Trait> = true. -
A trait sits under its owner.
traitsholds the global traits only. A trait on the entity that owns the stat is underowner.traits. A trait on the played persona is underpersona.traits. A trait on another entity is underentities.<Entity>.traits, the Custom Persona under a library persona included. -
statsis the global stats in every box. It means the same in an entity's stat code as in a global stat's code, astraitsdoes. It never holds the entity's own stats. -
An entity's stat code reads its own entity's stats through
owner.stats.owneris the entity that owns the running stat, soowner.stats.<Stat>.valueis the entity's own stat. In a Blueprint stat's code,owneris the entity the run is for. In a global stat's code, it's an empty entry: reads are blank, and a write is ignored and reported. -
Another entity's stats are under
entities.<Entity>.stats. The played persona's are underpersona.stats. -
"After N days" is
clock.day > <n>.clock.daystarts at 1. For timing in hours, useclock.elapsedHours. The time of day isclock.daypart. -
selfand everystatsentry take writes tovalue,min,maxandregen. So does every stat entry underowner.stats,entities.<Entity>.statsandpersona.stats. Their other fields are read-only.
Your code is plain JavaScript. Return a number to set the stat's value. The code has access to stats, a map of the global stats keyed by name, and to self, the stat the code belongs to. It also has owner, the entity that owns the stat: empty for a global stat, and the entity the run is for in a Blueprint stat (see Owner).
// Example: Return a fixed value
return 50;A script does not have to return anything. One that only writes self, a placeholder, or a trait leaves the value to the AI and regen.
self is the stat the code belongs to, in every box. It has the same members as a stats entry. In a global stat's code, it is the same entry as stats.<own name>. In an entity stat's code, it is the same entry as owner.stats.<own name>. Four of its fields take writes (see Writing to self).
stats is a map of the global stats keyed by name, in every box. An entity's stat code gets the same map as a global stat's code, as traits does. It never holds the entity's own stats: those are under owner.stats. Each entry has the same members as self, and takes writes to value, min, max and regen (see Writing to Other Stats). To read a stat by name, see Accessing Other Stats.
owner is the entity that owns the running stat. It has the same members as an entity entry: id, name, type, pronouns, inScene, traits, placeholders and stats. So code reaches the entity's own stats as owner.stats, its own traits as owner.traits, and its own placeholders as owner.placeholders.
| Box | What owner is |
|---|---|
| A stat on an entity's Stats tab | That entity |
| A Blueprint stat | The entity the run is for. The code runs once for each owner |
| A global stat | An empty entry |
In an entity stat's code, self is the same entry as owner.stats.<own name>, so a write through either route is one write.
// On a stat of The Creature: the Creature's Rage grows while it is Hunted.
if (owner.traits.Hunted.enabled) owner.stats.Rage.value += 2;Code that uses owner never throws in a global stat's box. Every read is blank, and every trait reads as off. A write through it is ignored and reported.
ℹ️ Two entities can share a code name. The later one is the entry in
entities.owneris still the owner's own entry, so a same-name entity never hides it.
Read another stat by its name:
// Example: Return the value of another stat
const health = stats.Health.value;
return health;A name with a space needs brackets: stats["Hit Points"].value. A name the world does not have reads as a blank entry with every number 0, so a typo never throws.
To read the entity's own stats in an entity's stat code, use owner.stats. To read another entity's stats, use entities.<Entity>.stats:
// In The Creature's stat code: read a global stat and the Creature's own stat.
const alert = stats["Alert Level"].value;
return owner.stats.Rage.value + alert / 2;Rename a stat, a trait or a placeholder that code names, and Update Code References asks whether to change the name in that code too. Leave Code keeps the code as it is.
ℹ️ A stat name with a placeholder chip in it reads in code as the placeholder's own name. A stat named
{{Beast}} Powerisstats["Beast Power"]in every playthrough, whatever the chip rolled. The player still sees the rolled name.
Each stat in the stats map, self included, exposes the following properties. So does each stat under owner.stats, entities.<Entity>.stats and persona.stats:
| Property | What it is |
|---|---|
id |
Unique identifier |
name |
Code name. The authored name with each chip replaced by its placeholder's name |
type |
Type of stat ('number' or 'percentage') |
description |
Text description |
min |
Minimum value |
max |
Maximum value |
value |
Current value, with this turn's AI change and regen applied |
enabled |
False while a trait's stat toggle switches the stat off. Read-only |
regen |
Regen per story hour, with traits applied |
previous |
The full stat at the start of the turn: id, name, type, description, min, max, value, regen. Read-only |
delta |
Every change this turn made, by source: ai, regen, total, actual. Read-only |
A stat that a trait switched off still reads as a real entry. Its name, value and bounds read as usual, with enabled false. A write to it is ignored and reported. A live stat wins a code name over a switched-off one. Between stats in the same state, the later one wins.
ℹ️ Only these fields are passed into the sandbox. A stat's own
codeanddescriptorsare not available from inside a script.
self is the stat the code belongs to. In a global stat's code, it is the same entry that sits in stats, so self.value and stats[self.name].value read the same. In an entity stat's code, it is the same entry that sits in owner.stats, so self.value and owner.stats[self.name].value read the same. A write through either path is one write. Four of its fields take writes:
| Write | Effect |
|---|---|
self.value = n |
Sets the value this turn, clamped to the range. Same as return n
|
self.min = n |
Sets the floor to n now, but never below the authored min. Later changes move it from n
|
self.max = n |
Sets the cap to n now. Later changes move it from n
|
self.regen = n |
Sets regen per story hour to n now. Later changes move it from n
|
A field you do not write keeps what the turn gave it. So a script can move the cap and leave the value to the AI:
// Max grows with Level. The value still moves as the AI narrates.
const level = stats.Level.value;
self.max = 50 + level * 10;A bound write works like a value write. After self.max = 20, the cap reads 20 when the run lands, whatever a trait switch in the same run moved. Every later change moves it from 20:
| Later change | Effect on the cap |
|---|---|
| A trait that adds 10 to the max switches on | 30 |
| That trait switches off again | 20 |
| The AI raises the max by 5 | 25 |
The code writes self.max = 40
|
40 |
No code bound hides the traits or the AI under it. Deleting the code leaves the bounds as they are. Writing null to value, min, max or regen fails the run, as any other non-number does. Every other field of self is read-only. To write a stat other than self, see Writing to Other Stats.
Code can write any stat in the game, by the route that reaches it. One box can move several stats, so an event needs no reaction code in each target stat. A stat whose code writes other stats is a hub.
| Route | Reaches |
|---|---|
stats.<Stat> |
A global stat |
owner.stats.<Stat> |
A stat of the entity that owns the running stat |
entities.<Entity>.stats.<Stat> |
A stat of that entity |
persona.stats.<Stat> |
A stat of the played persona |
// Roll a d20. A 1 halves Health. A 20 adds 1 to Luck.
const roll = Math.floor(Math.random() * 20) + 1;
if (roll === 1) stats.Health.value = stats.Health.value / 2;
if (roll === 20) stats.Luck.value += 1;
return roll;An entity's stat can move a global stat too. This code runs on a stat of The Creature:
// The Creature's Rage pushes the world's Alert Level.
stats["Alert Level"].value += owner.stats.Rage.value / 10;Every stat entry on every route takes writes to the same four fields as self: value, min, max and regen. They work as they do on self. A value clamps to the target's range. A bound sets the number now, and later changes move it from there. A min never goes below the authored min. stats.Health.max = 20 makes the cap 20 now, and a trait that adds 10 later makes it 30. null fails the run.
| Rule | What it means |
|---|---|
| Owner order, last write wins | Two writes to one field land in owner order, and the later write wins. The order is the global stats first, then the world's entities in the order you authored them, then the library entities in the order the player picked them. The played persona sits in that order like any entity, so a library persona lands last |
| Stat order inside one owner | Writes from one owner's stats land in the order of that owner's stat list. A write through self and a write through any route count alike. The target's own box is one of the writers |
| One snapshot per run | Every box of every owner reads the snapshot the run started from. A hub's write to Health isn't in Health's own box on that run, or in its delta.actual. It's in Health's previous on the next turn |
| Blueprint stats | A Blueprint stat's code runs once for each owner. In it, owner is the entity the run is for, and owner.stats.Mana.value lands on that owner's own copy of Mana. A write through stats lands on the global stat |
| Unknown or switched-off stats | A write to a name the target doesn't have, or to a stat a trait switched off, is ignored and reported. The editor underlines an unknown name |
| An entity not in the game | A write through entities.<Entity>.stats to an entity that isn't in the game is ignored and reported |
| Fields that stay read-only |
id, name, type, description, previous and delta never take a write, on any route |
| A whole entry |
stats.Health = 5 is ignored. Write stats.Health.value = 5
|
A stat that a hub moved shows the change on its bar, as any code change does. The turn log gets no new line. A failed or stopped turn rolls the write back with the rest of the turn.
Test Code lists each write to another stat, such as stats.Health.value = 30, and applies none of them to your world. A write to an entity's stat shows with the owner, the stat, the field and the number. Every target stat starts at its starting value. In a Blueprint stat's box, owner.stats holds only the stats of that Blueprint's group.
The Test Bench warns when more than one box writes the same field of one stat. The finding names the field and each writer's box. A writer on another owner than the target shows with its stat and owner. It shows in Advanced mode.
- It counts a box that assigns the field, such as
self.max = 10orstats.Health.value -= 5. It counts the target's own box too. The Before the AI and After the AI boxes of one stat count as two. - It doesn't count
return. A hub that writesstats.Health.valueand a Health box that only returns a number raise no finding. - It reads literal names only:
stats.Health.value,stats["Health"].value,owner.stats.Rage.value,entities.Mira.stats.Trust.valueandself.max. It skips a computed name such asstats[name].value. It skips a variable that holds a stat entry too. - Two routes that reach one stat count as one target.
owner.stats.Rage.value,entities.Creature.stats.Rage.valueandpersona.stats.Rage.valueare one field when they reach the same stat. -
persona.statsreaches every entity that the player can play, because the world doesn't know which one the player picks. - A Blueprint stat's box counts once for each owner. Each owner's own copy of the stat is judged on its own, so two owners don't clash on their own stats. A Blueprint box that writes a global stat's field gets the warning when it has two or more owners (see A Note on Accumulating Stats).
A warning isn't an error. Owner order, then stat order, already settles which write wins. The finding shows two boxes that may overwrite each other by mistake.
Every stat carries the turn's state before the code ran. previous holds the full stat, every field self has, at the start of the turn. In the before box the turn has done nothing yet, so previous reads as the stat itself and every delta below reads zero. This section is about the after box.
delta holds every change the turn made to the stat, by source. Each member has the same four fields: value, min, max, and regen. A field that a source cannot move reads 0.
| Member | What it is |
|---|---|
delta.ai |
The change the AI asked for, raw: before flags and the range. The AI asks for value and max only |
delta.regen |
What regen did this turn. Only value moves |
delta.total |
Every source added up: what the turn asked of the stat, before flags and the range |
delta.actual |
Current values minus previous. A bound a trait changed since the turn started shows here |
previous and delta are read-only. A write to them is ignored and reported. Together they let a script clamp or scale an ask:
// The AI may lower Sanity by at most 10 per turn, and never raise it.
const ask = Math.max(-10, Math.min(0, self.delta.ai.value));
self.value = self.previous.value + ask + self.delta.regen.value;On a turn with no ask, every field of delta.ai is 0.
total - actual is what flags and the range took from the turn. A noIncrease flag that blocks a gain still shows the gain in total, so the loss shows in the difference.
// A gain the cap cut off stretches the cap instead.
const lost = self.delta.total.value - self.delta.actual.value;
if (lost > 0 && self.value === self.max) {
self.max += lost;
self.value += lost;
}placeholders holds the world's own placeholders, by name. A name with a space needs brackets: placeholders["Hair Color"]. A placeholder that an entity or a dictionary owns is not in it. Reach that one through its owner (see Owned Placeholders). Each entry has:
| Member | What it is |
|---|---|
id |
Unique identifier. Read-only |
name |
The placeholder's name. Read-only |
values |
Every authored value as text, in authored order. Values with weight 0 are included |
value |
The current value. One text on a Wildcard or a Variable, a list on an Object |
text |
value as one string: exactly what the prompt sees. A list joins with ", ". Read-only |
roll() |
One draw with the author's weights. The draw is not kept |
pin(x) |
Pin the placeholder. Takes what value reads on that entry |
unpin() |
Remove the pin code set. The next pin in rank, or the roll, applies again |
Code reaches a placeholder the way the editor names it. A placeholder that holds parts carries them as members, to any depth:
placeholders.Hair.Shade // the Shade that the world's Hair holds
entities.Molly.placeholders.Hair // the Hair that Molly owns
entities["Old Molly"].placeholders["Eye Color"] // brackets, at any depth| You write | You reach |
|---|---|
placeholders.Hair |
The world's own Hair
|
placeholders.Hair.Shade |
The Shade that the world's Hair holds |
entities.Molly.placeholders.Hair |
Molly's Hair
|
Each placeholder has one path. A part's bare name doesn't reach it, and neither does an owner's name under placeholders.
Each placeholder has all the members above, so a part named value or roll is shadowed by the member. The editor warns on the part's name field.
An entity or a dictionary can own placeholders. Code reaches them only through the owner's entry:
entities.Molly.placeholders.Hair // the Hair that Molly owns
persona.placeholders.Hair // the played persona's Hair
dictionaries.Weather.placeholders.Sky // the Sky that the Weather dictionary ownsAn owned placeholder has the same members as a world placeholder, and pin lands on that placeholder. A name the owner doesn't have reads as a blank entry, and a pin through it is ignored. Test Code and the Test Bench report it.
values is what the author wrote. value is the current value. text is what the prompt sees.
A Wildcard draws one value, so its value is one text and its text is that same text. An Object shows every value at once, so its value is the list of current values and its text joins that list with ", ". Compare against narration wording with text, never with value. text reads the same on either kind.
// Works on either kind: the prompt's own words.
if (placeholders.Hair.text.includes('gray')) self.value -= 1;pin(x) pins the placeholder until the code changes it again. Assigning value is equivalent. pin is the documented form, and the last of pin, value or unpin in a run wins. The pin ranks above the roll and every other pin and never replaces them, so unpin() restores the next pin in rank or the roll. Any text is allowed, on the list or off it:
// Mood follows Sanity's band.
placeholders.Mood.pin(self.value < 20 ? 'furious' : self.value < 50 ? 'wary' : 'calm');pin takes the type value reads. A Wildcard takes one text. An Object takes a list, and one text handed to an Object pins a one-item list:
// The prompt then reads "gray, cropped short".
placeholders.Hair.pin(['gray', 'cropped short']);A list handed to a Wildcard, or anything that is not text, fails the run, and every write that run made is discarded.
A write to an unknown placeholder name is ignored. Test Code and the Test Bench both report it.
traits holds every trait in the world's own trait list, by name, whether the player has it or not. Blueprint items are in it. A trait that an entity owns is not. Each entry has:
| Member | What it is |
|---|---|
enabled |
True when the player has the trait and it is on. Write it to switch the trait |
acquired |
True when the player has the trait, on or off. Read-only |
id |
Unique identifier. Read-only |
name |
Code name. Read-only |
mode |
'optional', 'alwaysOn' or 'hidden'. Read-only |
available |
True when the trait's requirements hold for its owner now. Read-only |
group |
The code name of the trait's group, or '' when it has none. Read-only |
playerToggle |
True when the player can switch the trait during the game. Read-only |
enabled and acquired read the player's state only. An entity that holds the same trait does not change them. Use mode, available and group to see why a switch had no effect.
Writing enabled switches the trait after the run, with the same effect as the player's checkbox. Switching on disables its siblings in an Up to One group. Code never switches an Automatic or Hidden trait, and it ignores pick counts. Switching on a trait the player never took acquires it. The switch persists until the player, the AI, or a later run switches it again. Code ignores In Game, so a script can switch a trait the player cannot toggle.
// Cursed while Sanity is on the floor.
traits.Cursed.enabled = self.value <= 0;A write to an unknown trait name is ignored. Test Code and the Test Bench both report it. A write to acquired, or to any other read-only field, is ignored, and Test Code says so.
ℹ️ A trait name with a placeholder chip in it reads in code as the placeholder's own name. A trait named
{{Beast}} Furyistraits["Beast Fury"]in every playthrough, whatever the chip rolled. The player still sees the rolled name, and the turn log still writes it.
persona is the entity the player plays: the picked persona, or the Custom Persona entity when the player picks None.
| Member | What it is |
|---|---|
name |
The persona's code name, never the name the player typed under None. Read-only |
traits |
The persona's own traits by name, owned or linked |
placeholders |
The placeholders the persona owns. See Owned Placeholders |
stats |
The persona's own stats by name, as an entity's stats. Entries take writes |
persona also has the id, type, pronouns and inScene of an entity entry. inScene is always true for the played persona. Each entry in persona.traits has the same members as a traits entry, for the persona's state. Writing enabled switches the persona's own trait by the same rules as traits.
// Lose a point each turn while the persona is Scarred.
if (persona.traits.Scarred.enabled) self.value -= 1;
// Read the persona's own placeholder.
if (persona.placeholders.Hair.text.includes('gray')) self.value -= 1;persona.traits and traits are separate. A global trait and a persona trait can share a name, and each map reads its own.
Writing value, min, max or regen on an entry in persona.stats moves the persona's stat, by the rules in Writing to Other Stats.
When the player plays no entity, persona is an empty entry. Its name is '', every trait reads as off, and a switch or a stat write through it is ignored and reported. Code that reads persona.traits.X.enabled never throws. Test Code runs with no persona, so it reports each persona write as ignored.
⚠️ Under a library persona,personais the library persona, not the Custom Persona. Code reaches the Custom Persona entity's stats throughentities[...], not throughpersona.
ℹ️ The editor offers the traits of every entity that can be played. A library persona can bring traits and placeholders the world doesn't have, so an unknown name after
persona.traitsorpersona.placeholdersis a warning, not an error.
entities holds every entity in the game by its code name: the world's cast, the played persona, and the library entities the player added at Enter World. A name with a space needs brackets: entities["Old Mira"]. Each entry has:
| Member | What it is |
|---|---|
id |
Unique identifier. Read-only |
name |
The entity's code name. Read-only |
type |
The entity's type, or '' when it has none. Read-only |
pronouns |
The entity's pronouns, or '' when it has none. Read-only |
inScene |
True when the entity is in this turn's scene. Read-only |
traits |
The entity's own traits by name, owned or linked |
placeholders |
The placeholders the entity owns. See Owned Placeholders |
stats |
The entity's own stats by name. Entries take writes |
Each entry in an entity's stats has the same members as a stats entry, previous and delta included. Code writes value, min, max and regen on an entry, by the rules in Writing to Other Stats. A write to any other field is ignored and reported. An entity's stats run every turn, whether or not the entity is in the scene. A stat the entity has switched off reads with enabled false, and a write to it is ignored and reported.
Each entry in an entity's traits has the same members as a traits entry, for that entity's state. Writing enabled switches the entity's own trait. The other traits in its groups follow, as after a manual switch. A write to any other field is ignored.
// Mira's wound costs the party a point each turn.
if (entities.Mira.traits.Wounded.enabled) self.value -= 1;
// Switch Mira's own trait. Her other traits in the group follow.
entities.Mira.traits.Calm.enabled = self.value > 50;
// React while Mira is in the scene.
if (entities.Mira.inScene) self.value += 1;
// Lose a point when Mira's Trust dropped this turn.
if (entities.Mira.stats.Trust.delta.actual.value < 0) self.value -= 1;
// Drain Mira's Stamina when this stat runs dry.
if (self.value <= 0) entities.Mira.stats.Stamina.value -= 10;-
personais the played persona's entry, sopersona === entities[persona.name]when the persona entity has a code name. A persona entity with no code name still plays aspersona, but it isn't inentities. - An entity's
traitslists only that entity's own set. A name outside it reads as a blank entry:enabledandacquiredare false, and a switch through it is ignored and reported. - An entity the narrator invents during the game is not listed. Neither is a persona-only entity the player didn't pick, the Custom Persona entity under a world persona, or an entity with no code name.
- Of two entities that share a code name, the later one is the entry. The played persona always keeps its own name.
- An entity's descriptions, aliases, locations and media are not in the entry.
A name no entity in the game has reads as a blank entry. Its name and id are '', and every trait reads as off. A switch or a stat write through it is ignored and reported. Check entities.Mira.name to test whether Mira is in the game.
Test Code lists every authored entity, with no trait chosen and each stat at its starting value. A switch or a stat write it makes is reported and never applied.
dictionaries holds every dictionary in the game by its code name. Each entry has:
| Member | What it is |
|---|---|
id |
Unique identifier. Read-only |
name |
The dictionary's code name. Read-only |
placeholders |
The placeholders the dictionary owns. See Owned Placeholders |
// The sky follows Sanity's band.
dictionaries.Weather.placeholders.Sky.pin(self.value < 20 ? 'storm' : 'clear');- The list holds the world's dictionaries that the player left on at Enter World, then the library dictionaries the player picked there. Of two that share a code name, the later one is the entry.
- A dictionary the player turned off reads as an unknown dictionary.
- A name no dictionary in the game has reads as a blank entry. Its
nameandidare'', and every placeholder under it reads as blank. A pin through it is ignored. - The editor warns on a dictionary name it doesn't know, because a library dictionary can bring more.
Each box is a separate run. Within one run every stat's code reads the same snapshot, so no script sees another stat's writes from that run. After each run, effects apply in this order: trait switches, then bounds, then values, then placeholder pins. A bound a stat writes lands at the written number, after the run's trait switches have moved the bounds. When two stats write the same placeholder, trait, or stat field in one run, owner order decides first, then the later stat in the list wins. See Writing to Other Stats.
Each run covers the global stats and every entity's stats over that one snapshot. Writes land in owner order, then stat order within one owner. The last write to a field wins. See Writing to Other Stats for the order.
The two runs are ordered against each other, though: everything the before box wrote is already in place when the after box reads.
clock is a read-only object that describes the story time. A write to any of its fields is ignored and reported.
| Field | What it is |
|---|---|
clock.deltaHours |
Story hours this turn consumed |
clock.elapsedHours |
Total story hours so far, counting this turn |
clock.day |
Day number (1-based) at the end of the turn |
clock.daypart |
Time of day at the end of the turn |
clock.previous.day |
Day number at the start of the turn |
clock.previous.daypart |
Time of day at the start of the turn |
clock.daypart and clock.previous.daypart are one of six words: night, dawn, morning, midday, afternoon, evening.
Why start and end are both given. A turn spans time. An eight-hour sleep that begins at 15:00 has clock.previous.daypart === 'afternoon' and clock.daypart === 'night'. Neither reading alone describes the turn.
⚠️ With the clock off,clock.deltaHoursis always the Time Per Turn setting (1unless the player changes it), and every turn advances the story by that much. Your code works either way; it just gets a flat number instead of a measured one. The setting is Measured Clock, under Settings → Output → Time, in Advanced mode.
Calculate a stat as a percentage of another stat:
// Make Stamina 75% of Health
const health = stats.Health.value;
return health * 0.75;Calculate a stat as the average of every stat, iterating with Object.values:
// Make Morale the average of every global stat, this one included
const all = Object.values(stats);
return all.reduce((sum, stat) => sum + stat.value, 0) / all.length;stats holds the global stats in every box, so this code gives the same average on an entity's stat. To average the entity's own stats, iterate owner.stats instead:
// On an entity's stat: the average of the entity's own stats, this one included
const own = Object.values(owner.stats);
return own.reduce((sum, stat) => sum + stat.value, 0) / own.length;Calculate a stat differently based on conditions:
// Make Speed depend on Health
// Full speed when Health > 50, otherwise reduced
const health = stats.Health.value;
const baseSpeed = 100;
if (health > 50) {
return baseSpeed;
} else {
// Reduce speed by up to 50% as health approaches 0
const healthPercent = health / 50;
return baseSpeed * (0.5 + (healthPercent * 0.5));
}Use more complex formulas for game mechanics:
// Calculate Damage based on Strength, Weapon Skill, and a random factor
const strength = stats.Strength.value;
const weaponSkill = stats["Weapon Skill"].value;
// Base damage from strength
const baseDamage = strength * 0.8;
// Skill multiplier (1.0 to 2.0 based on skill)
const skillMultiplier = 1.0 + (weaponSkill / 100);
// Random factor (±20%)
const randomFactor = 0.8 + (Math.random() * 0.4);
return baseDamage * skillMultiplier * randomFactor;💡 Each stat's code draws its own
Math.random()numbers. The sandbox seeds from the clock, and two stats' code in one turn starts at different times, so two dice stats roll independently.
Implement diminishing returns for stat scaling:
// Calculate Dodge Chance with diminishing returns
const agility = stats.Agility.value;
// Diminishing returns formula
// First 50 points give full value, after that diminishing returns
let dodgeChance = 0;
if (agility <= 50) {
dodgeChance = agility * 0.5; // 0.5% per point
} else {
// First 50 points give 25% dodge
// Additional points give less and less
const baseChance = 25;
const diminishedPoints = agility - 50;
const diminishedChance = 25 * (1 - Math.exp(-diminishedPoints / 50));
dodgeChance = baseChance + diminishedChance;
}
// Cap at 75%
return Math.min(dodgeChance, 75);Scale a change by how long the turn actually took, so a night's sleep costs more than a short conversation:
// Thirst rises 2 per story hour
const current = stats.Thirst.value;
return current + (2 * clock.deltaHours);React to when the turn happened rather than to another stat:
// A vampire's Power climbs at night and fades by day
const current = stats.Power.value;
const rate = (clock.daypart === 'night' || clock.daypart === 'evening') ? 4 : -4;
return current + (rate * clock.deltaHours);Calculate resource consumption based on other stats:
// Calculate Hunger Rate based on activity and size
const activityLevel = stats.Activity.value;
const size = stats.Size.value;
// Base consumption rate
const baseRate = 1;
// Activity multiplier (1.0 to 3.0)
const activityMultiplier = 1.0 + (activityLevel / 50);
// Size factor (larger characters consume more)
const sizeFactor = size / 50;
return baseRate * activityMultiplier * sizeFactor;💡 Prefer the
regenfield for plain regeneration. A stat that simply drifts at a fixed rate already scales with story hours without any code at all. Useclock.deltaHourswhen the rate itself depends on something: the time of day, another stat, a threshold.
- Keep it simple: Complex code can be hard to debug and may impact performance
-
Trust the zero default: a stat name not in the world reads as a blank entry, every number
0, so a lookup never throws - Stay within min/max: The system will automatically clamp your result to the stat's min/max range
- Avoid infinite loops: Do not create circular dependencies between stats
- Write only what you mean to change: A field, placeholder, or trait you leave alone keeps the turn's own result
- Test your code: Use the box's own "Test Code" button to validate that box before saving
- Pick the right box: put a write the AI should read this turn in Before the AI, and a reaction to what the AI asked in After the AI
- Add comments: Document your code for future reference
- Code execution has a timeout of 1 second to prevent infinite loops
- The code cannot access external resources (network, files, etc.)
- Circular dependencies between stats may cause unexpected behavior
- The code runs in a sandboxed environment with limited JavaScript features
- Code writes
value,min,maxandregenonly. Every other field of a stat entry is read-only on every route - A write to an entity that isn't in the game, to an unknown stat, or to a stat a trait switched off is ignored and reported
-
Test Code runs one box with no player traits, so it cannot preview a long turn or a different daypart. Before the AI runs as the opening turn, where
clock.deltaHoursandclock.elapsedHoursare both0; After the AI runs as a one-hour turn on day one. It shows a trait switch and never applies it to the world - Each box's Templates menu lists only the templates written for that box
Most stat code is a formula: it reads other stats and returns an answer, and running it twice gives the same result. Code that adds to its own current value (return current + …) is a running total. It depends on running exactly once per turn.
Formamorph runs it once per turn. A re-roll of a turn's stat changes runs it again by design: the re-roll rebuilds the turn from its starting values, so the total is not counted twice. A running total is more fragile than a formula. Prefer a formula where one will do.
A Blueprint stat's write to a global stat moves the stat once, not once for each owner. The code runs once for each owner, but every run reads one snapshot. Two owners that each write stats.Alert.value += 5 both write the snapshot's Alert plus 5. The later write wins, so Alert rises by 5, not by 10. To count each owner, write to owner.stats, where each owner has its own copy. The Test Bench warns on a Blueprint box that writes a global stat's field when two or more owners run it.
If your code does not work as expected:
- Check for typos in stat, placeholder, and trait names (they are case-sensitive)
- Ensure your code returns a number, or writes a field instead
- Verify that every stat you reference exists
- Use the "Test Code" button to see any error messages, every field, placeholder, and trait the run wrote, and every write it ignored
- Add
console.log()statements to debug your code (output appears in browser console)
// Scale Health based on Level and Constitution
const level = stats.Level.value;
const constitution = stats.Constitution.value;
// Base health
const baseHealth = 50;
// Level scaling (10 health per level)
const levelBonus = (level - 1) * 10;
// Constitution scaling (2 health per point)
const constitutionBonus = (constitution - 10) * 2;
return baseHealth + levelBonus + constitutionBonus;// Calculate Fatigue based on recent actions and Stamina
const stamina = stats.Stamina.value;
const staminaMax = stats.Stamina.max;
const actions = stats["Recent Actions"].value;
// Base fatigue from actions
const actionFatigue = actions * 5;
// Recovery from stamina (higher stamina = less fatigue)
const staminaFactor = 1 - (stamina / staminaMax);
// Final fatigue value (0-100)
return Math.min(actionFatigue * staminaFactor, 100);// Calculate Carrying Capacity based on Strength
const strength = stats.Strength.value;
// Base capacity
const baseCapacity = 50;
// Linear scaling for first 50 points (2 units per point)
let capacity = baseCapacity;
if (strength <= 50) {
capacity += strength * 2;
} else {
// First 50 points add 100 capacity
// After that, diminishing returns
capacity += 100;
capacity += Math.sqrt(strength - 50) * 10;
}
return capacity;// Calculate Magical Power based on Intelligence, Wisdom, and current Mana
const intelligence = stats.Intelligence.value;
const wisdom = stats.Wisdom.value;
const mana = stats.Mana.value;
const maxMana = stats.Mana.max;
// Base power from intelligence
const basePower = intelligence * 1.5;
// Wisdom bonus (diminishing returns)
const wisdomBonus = Math.sqrt(wisdom) * 5;
// Mana percentage factor (more effective with higher mana)
const manaFactor = 0.5 + (0.5 * (mana / maxMana));
return (basePower + wisdomBonus) * manaFactor;