RPG Script
RPG Script (.rpgs) is a compact language for defining your system's stats, mechanics, and views.
It compiles into the same JSON format the app uses internally, saving you from writing verbose raw JSON by hand.
RPG Script covers:
basedeclarations — static default values for stats.calcdeclarations — computed stats defined by a formula expression.mechanicdeclarations — event-driven effects (e.g. restore actions on turn start).- Views — UI components used inside mechanic pop-ups and other interactive elements.
File structure
A .rpgs file contains a list of stat declarations:
basedeclarations define default values.calcdeclarations define a formula (an expression) that compiles into a formula JSON tree.
Declarations
Base stat
Syntax:
base <type> <id>(<meta args>) = <literal>;
Example:
base integer level(name="Level") = 1;
base string character_name(name="Name") = "";
base bool is_npc(name="NPC?") = false;
Important: base defaults are literal-only (numbers, strings, bool, null, list literals).
Types
Supported type forms:
stringintegerdecimalboolresource<resource_type>(e.g:resource<spell>)- Arrays:
T[](e.g.integer[],resource<spell>[])
Examples:
base resource<spell> favorite_spell(name="Favorite Spell") = null;
base resource<spell>[] prepared_spells(name="Prepared Spells") = [];
Comments
- Line comments:
// ... - Block comments:
/* ... */
Expressions
Calculated stat expressions compile into StatFormulaComponent JSON.
Literals
"text"123,12.34true,falsenull- List literals:
[1, 2, 3],["a", "b"],[]
Identifiers
An identifier is treated as a stat reference:
strength + proficiency_bonus
Compiles as
{
"type": "add",
"components": {
"type": "list",
"components": [
{
"type": "stat",
"stat": "strength"
},
{
"type": "stat",
"stat": "proficiency_bonus"
},
]
}
}
As with regular RPG JSON, Identifiers may include
.and?inside the name. Names starting with$are reserved for lambda params / special variables
Operator precedence
From lowest → highest:
if ... then ... else ...||&&??==,!=<,<=,>,>=+,-*,/,%- unary
!, unary- - grouping
(...), calls, literals
Special expressions
Null coalescing: ??
a ?? b
Compiles into:
{
"type": "defaultIfNull",
"components": <a>,
"default_components": <b>
}
If/then/else
if condition then x else y
This is lowered to a single when component with clauses, including a final true fallback.
When expression
when {
cond1 -> value1,
cond2 -> value2,
else -> fallback
}
This compiles to:
{
"type": "when",
"clauses": [
{ "condition": <cond1>, "components": <value1> },
{ "condition": <cond2>, "components": <value2> },
{ "condition": { "type":"constant","value_type":"bool","value":true }, "components": <fallback> }
]
}
Function / component calls
A call compiles to a component JSON object:
contains(haystack=spells, needle="Fireball")
Compiles to:
{ "type": "contains", "haystack": <...>, "needle": <...> }
Positional first argument sugar
The first argument may be passed without a name — it becomes components = <expr>:
add( [1, 2, 3] ) // sugar for add(components=[1,2,3])
After the first argument, all arguments must be named.
Lambdas (map / reduce / filter / sortBy / findFirst / flatMap)
Lambdas are only valid as arguments to supported components.
Lambda forms: (x, y) => expr, (z) => expr2
Operator behavior notes
+ chooses between add and concat
- If any operand is a
listliteral,+becomesconcat. - If any operand is a
stringliteral,+becomesconcat. - If all operands are
numericliterals,+becomesadd. - Otherwise (stats / calls / mixed), it defaults to numeric semantics (
add) and the evaluator decides validity.
- becomes n-ary subtract
a - b - c
→ subtract(list(a,b,c))
*, &&, || are flattened to n-ary
a * b * c -> multiply(list(a,b,c))
a && b && c -> and(list(a,b,c))
a || b || c -> or(list(a,b,c))
Practical examples
Proficiency bonus (rounded up)
calc integer proficiency_bonus(name="Proficiency Bonus", abbreviation="PB") =
1 + divide(components=[level, 4], rounding_method="up");
Conditional stat (if → when)
calc integer speed =
if has_armor then 25 else 30;
Defaulting null
calc string title = nickname ?? character_name;
Complex example
calc integer classes_hp_mods(name = "Classes HP mods") =
max(
[
0,
(level * max(
map(
classes,
mapper = ($class) => add(
map(
($class.class.selected_archetype?.actual_hp_modifiers ?? $class.class.actual_hp_modifiers),
mapper = ($mapValue) => metaStat(meta_stat = concat([$mapValue, "_modifier"])),
),
),
),
)),
],
);
Mechanics
A mechanic declaration defines an event-driven effect — something the app executes automatically when a specific event fires.
Syntax
mechanic <id>(<meta_args>) = <effect_expression>;
Meta arguments (all optional except where noted):
| Argument | Type | Description |
|---|---|---|
name | string | Human-readable name for the mechanic (documentation only). |
description | string | Description of what this mechanic does (documentation only). |
event_names | string | Comma-separated event names that trigger this mechanic. |
calculated_event_names | formula → string[] | Formula returning additional event names. Appended to event_names. Use eventName() here if targeting another resource's mechanic. |
revert_event_names | string | Comma-separated event names that cause this mechanic to revert its reversible effects (e.g. setStat). |
calculated_revert_event_names | formula → string[] | Formula returning additional revert event names. Appended to revert_event_names. |
revert_on_remove | formula → bool | If true, reversible effects are automatically undone when the resource owning this mechanic is removed from the character. Defaults to false. |
The body after = is an Effect expression.
When you write an event name directly as a string literal (e.g. event_names = "my_event"), the app automatically appends the resource instance's unique id at runtime, so only that specific instance reacts to it. This prevents unintended cross-instance triggering (e.g. casting one spell shouldn't fire all spells).
If you fire an event from a formula (e.g. in fireEvent) and want to target a specific instance's mechanic, use eventName(stat = "id", event_name = "my_event") to build the fully-qualified name.
Example: restore actions at turn start
mechanic reset_actions_on_turn_advanced(
name = "Reset actions on turn advanced",
event_names = "turnAdvanced",
) =
when {
$event?.combatant?.id? == id -> sequence(effects = [
setStat(stat = "actions_remaining", new_value = 3, aggregation_type = "set"),
setStat(stat = "reaction_available", new_value = 1, aggregation_type = "set"),
]),
};
See the Combat Events reference for the full list of events and their payloads, and the Mechanic reference for advanced usage including lifecycle events.
Views
Views are UI components used in pop-ups displayed by mechanics (via showPopUp) or defined directly in the system JSON for interactive character sheet elements, resources (display, edit, list, search), etc. They let you build rich, dynamic UI driven by stat formulas.
Every view has an id field. Inside mechanic bodies or other formulas, you can read input values from a view using $view.<view_id>.
Syntax
Views are called like functions inside effect expressions:
showPopUp(
pop_up_title = "Edit HP",
pop_up_view = column(children = [
stepper(
id = "hp_input",
label = "Current HP",
value = current_hp,
min = 0,
max = max_hp,
),
]),
display_save_button = true,
on_save = setStat(stat = "current_hp", new_value = $view.hp_input, aggregation_type = "set"),
)
View types
See the Views reference for the full list of available view types and their individual argument docs (text, stat, ticker, popUpButton, resourceArray, select, etc.).
Tip: Install the RPGScript VS Code Extension to get full IntelliSense, hover docs, and tab-stop snippets for every view type and its arguments directly in your editor.
Macros (define)
define lets you name and reuse any expression fragment — a compile-time substitution that produces identical JSON output to writing the body inline. Macros work in stat formulas, view definitions, and mechanic bodies.
Zero-parameter macro (constant alias)
define ability_modifier(score) = divide([score - 10, 2], rounding_method = "down");
calc integer strength_modifier(name = "Strength modifier") = ability_modifier(score = strength);
calc integer dexterity_modifier(name = "Dexterity modifier") = ability_modifier(score = dexterity);
// ... 4 more — without define this would be 24 lines of repeated divide([])
Zero-parameter macro (constant)
define base_ac = 10;
calc integer armor_class(name = "AC") = base_ac + dexterity_modifier;
Parameterized macro
Arguments are named at the call site (same style as all other RPGScript calls):
define ability_modifier(score) = divide([score - 10, 2], rounding_method = "down");
calc integer str_mod(name = "Str") = ability_modifier(score = strength);
Macros in views
Macros expand in view bodies too. View component id fields must be string literals (not formula expressions), so when a macro generates multiple child components you need to pass each id as a string argument — or use compile-time string interpolation (see below):
// Without interpolation — explicit IDs (more verbose, always valid)
define labelValueRow(cid, lid, vid, label, value) = composite(
id = cid,
subviews = [
text(id = lid, text = concat([label, ": "]), style = "text", bold = true),
text(id = vid, text = value, style = "text"),
],
);
labelValueRow(cid="spell_ct_c", lid="spell_ct_l", vid="spell_ct_v", label="Casting Time", value=display_casting_time),
// With interpolation — cleaner call site
define labelValueRow(prefix, label, value) = composite(
id = "${prefix}_row",
subviews = [
text(id = "${prefix}_lbl", text = concat([label, ": "]), style = "text", bold = true),
text(id = "${prefix}_val", text = value, style = "text"),
],
);
labelValueRow(prefix = "spell_casting_time", label = "Casting Time", value = display_casting_time),
// Expands ids to: "spell_casting_time_row", "spell_casting_time_lbl", "spell_casting_time_val"
Compile-time string interpolation
Inside a macro body, "${param}" in any string literal is replaced with the param's value at expansion time. The param must be a string literal at the call site.
define sectionHeader(section, icon) = composite(
id = "${section}_header",
subviews = [
icon(id = "${section}_icon", name = icon, size = "medium"),
text(id = "${section}_title", text = section, style = "title"),
spacer(id = "${section}_spacer"),
],
);
// Usage:
sectionHeader(section = "skills", icon = "ic_skills"),
sectionHeader(section = "combat", icon = "ic_combat"),
This also works for the defined(stat = ...) pattern — useful for "view input or committed value" stats:
define viewOrCommitted(view_path, view_value, committed) = when {
defined(stat = "${view_path}") -> view_value,
else -> committed,
};
calc integer effective_slot_qty(name = "Effective slot quantity") = viewOrCommitted(
view_path = "$view.slot_qty_edit",
view_value = $view.slot_qty_edit,
committed = slot_quantity,
);
Interpolation rules:
- Only works in string literal positions (inside
"..."in a macro body). - The interpolated param must be a string literal at the call site — passing a stat reference throws a compile error.
${name}wherenameis not a param of the current macro is left unchanged in the string (transparent passthrough).- Multiple placeholders in the same string all expand:
"${a}_and_${b}". - Interpolation is transitive — a macro that calls another macro can pass interpolated strings as arguments.
Rules
defineis compile-time only — no runtime cost, no new JSON shape.- Macros are file-scoped unless exported via
import(see below). - Duplicate macro names in the same file are an error.
- A file containing only
definedeclarations is valid; it acts as a macro library.
Importing macro libraries (import)
import pulls define declarations from another .rpgs file into the current file. Only macros are imported — stats, views, and mechanics in the imported file are ignored.
import "stdlib/dnd_common.rpgs";
calc integer str_mod(name = "Str") = ability_modifier(score = strength);
Path resolution
Paths are relative to the current file:
// In: systems/my_system/stats/character_stats.rpgs
import "../macros/dnd5e_common.rpgs"; // → systems/my_system/macros/dnd5e_common.rpgs
import "local_helpers.rpgs"; // → systems/my_system/stats/local_helpers.rpgs
Priority
The importing file's own define declarations take priority — importing a file that defines a macro with the same name as one you've already defined locally is silently ignored (your local definition wins).
Transitive imports
Imported files can themselves import other files. Cycles are detected and silently broken.
Recommended layout
my_system/
system/
macros/
dnd5e_common.rpgs ← ability_modifier, proficiency_bonus_from_level, …
view_components.rpgs ← labelValueRow, sectionHeader, editLabelField, …
character_stats.rpgs
resources/
…
Sharing macros across systems
Since import paths are relative to the importing file, you can share a macro library across multiple systems by placing it outside the individual system folders and using ../ paths to reach it.
Single-repo multi-system layout
my-systems-repo/
shared/
macros/
dnd_common.rpgs ← ability_modifier, proficiency_bonus_from_level, …
view_components.rpgs ← labelValueRow, sectionHeader, editLabelField, …
systems/
5e/
system/
character_stats.rpgs ← import "../../shared/macros/dnd_common.rpgs";
resources/…
5e2024/
system/
character_stats.rpgs ← import "../../shared/macros/dnd_common.rpgs";
resources/…
The build_systems tool is pointed at the systems/ folder as usual, and the import paths escape out to shared/macros/ via ../../.
What to put in a shared library
A shared macro file should contain only define declarations — no base, calc, view, or mechanic statements. Stats and views always belong to the individual system.
Good candidates for shared libraries:
| Pattern | Example macros |
|---|---|
| Ability score math | ability_modifier(score), proficiency_bonus_from_level(level) |
| View row templates | labelValueRow(...), sectionHeader(...), editLabelField(...) |
| Mechanic templates | rollAndDismiss(dice, bonus, label), toggleCondition(condition) |
| String formatting | signed(n), asPercent(n) |
Version-locking a shared library
If two systems in the same repo need to diverge (e.g. 5e vs 5e2024 use slightly different proficiency formulas), use the priority rule: import the shared library and then define a local override with the same name — your local definition always wins.
import "../../shared/macros/dnd_common.rpgs"; // defines proficiency_bonus_from_level
// 5e2024 uses a different formula — local define overrides the import:
define proficiency_bonus_from_level(level) =
1 + divide([level + 3, 4], rounding_method = "down");
Annotations
@party_visible
Place @party_visible before a base or calc declaration to mark that stat as visible to other party members during a live session. These stats appear on the party view screen so the GM and other players can see them.
@party_visible
base integer current_hp(name = "Current HP") = 10;
@party_visible
calc integer armor_class(name = "AC") = 10 + dexterity_modifier;
If no stats are marked @party_visible, the app falls back to the stats defined in character_indexed_stats.