Skip to main content

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:

  • base declarations — static default values for stats.
  • calc declarations — computed stats defined by a formula expression.
  • mechanic declarations — 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:

  • base declarations define default values.
  • calc declarations 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:

  • string
  • integer
  • decimal
  • bool
  • resource<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.34
  • true, false
  • null
  • 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:

  1. if ... then ... else ...
  2. ||
  3. &&
  4. ??
  5. ==, !=
  6. <, <=, >, >=
  7. +, -
  8. *, /, %
  9. unary !, unary -
  10. 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 list literal, + becomes concat.
  • If any operand is a string literal, + becomes concat.
  • If all operands are numeric literals, + becomes add.
  • 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):

ArgumentTypeDescription
namestringHuman-readable name for the mechanic (documentation only).
descriptionstringDescription of what this mechanic does (documentation only).
event_namesstringComma-separated event names that trigger this mechanic.
calculated_event_namesformula → string[]Formula returning additional event names. Appended to event_names. Use eventName() here if targeting another resource's mechanic.
revert_event_namesstringComma-separated event names that cause this mechanic to revert its reversible effects (e.g. setStat).
calculated_revert_event_namesformula → string[]Formula returning additional revert event names. Appended to revert_event_names.
revert_on_removeformula → boolIf 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.

Event name scoping

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} where name is 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

  • define is 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 define declarations 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.

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:

PatternExample macros
Ability score mathability_modifier(score), proficiency_bonus_from_level(level)
View row templateslabelValueRow(...), sectionHeader(...), editLabelField(...)
Mechanic templatesrollAndDismiss(dice, bonus, label), toggleCondition(condition)
String formattingsigned(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.