Rules
Rules define project-wide gameplay formulas.
You can change them in the creator via the Game / Rules item in the project tree.
Rules are where the shared game math lives. Instead of repeating the same combat formulas, combat messages, or combat sound logic in every character script, you define them once here and let the engine apply them consistently.
What Rules Are For
Think of rules as the global gameplay math layer.
Scripts should usually decide things like:
- when an NPC attacks
- when it runs away
- when it starts or stops tracking a target
- what event should happen next
Rules should usually decide things like:
- how base combat stats scale with level
- how much damage a hit really does
- how armor reduces damage
- how spells differ from physical attacks
- which combat message should be shown
- which combat sound should play
That keeps character scripts smaller and avoids copying the same combat logic into every NPC.
Format
Rules use TOML.
[progression.damage]
base = 1
gain = "STR * 0.25"
[progression.level]
xp_for_level = "level * level * 50"
[progression.xp]
kill = "defender.LEVEL * 25"
[progression.messages]
xp_key = "progression.xp.gained"
xp_category = "system"
level_up_key = "progression.level_up"
level_up_category = "system"
[combat]
outgoing_damage = "value + source.DMG"
incoming_damage = "value + attacker.STR - defender.ARMOR"
[combat.messages]
incoming_key = "combat.damage.incoming"
incoming_category = "warning"
outgoing_key = "combat.damage.outgoing"
outgoing_category = "system"
[combat.audio]
incoming_fx = "hit"
outgoing_fx = "attack"
[combat.kinds.physical]
outgoing_damage = "value + source.DMG"
incoming_damage = "value + attacker.STR - defender.ARMOR"
[combat.kinds.spell]
outgoing_damage = "value + source.POWER"
incoming_damage = "value + attacker.INT - defender.RESIST"
[combat.kinds.fire]
outgoing_damage = "value + source.POWER"
incoming_damage = "value + attacker.INT - defender.FIRE_RESIST"
Mental Model
Right now, the normal damage flow looks like this:
- A script decides to attack and usually calls
attack(). attack()starts fromprogression.damage.- If
progression.damageis not configured, it falls back to the attacker'sDMGattribute, then to1. - The engine resolves:
- attacker
- defender
- damage kind
- source item, if there is one
outgoing_damageruns first and adjusts the attack before it reaches the defender.incoming_damageruns second and adjusts what the defender finally receives.- The
take_damageevent runs as the reaction hook. - The server applies the final damage automatically.
So:
- scripts decide that an attack happens
- rules decide what that attack means mathematically
take_damageis for reaction logic, not for repeating combat math
Use attack() for normal weapon-style attacks. Keep deal_damage(...) as the explicit low-level escape hatch when you want to send a manual amount or kind.
Formula Syntax
Rules formulas support:
- numbers like
1,2.5,10.0 - variables like
value,attacker.STR,defender.ARMOR,source.DMG +,-,*,/- parentheses:
( ... ) - unary
+and-
Supported helper functions:
min(a, b)max(a, b)clamp(value, min, max)abs(x)floor(x)ceil(x)round(x)
Example:
[combat]
outgoing_damage = "value + source.DMG"
incoming_damage = "value + attacker.STR - defender.armor.ARMOR"
The engine already clamps final damage to >= 0, so you usually do not need to wrap formulas in max(0, ...).
Progression
Progression rules are defined per stat under progression.<stat>.
[progression.damage]
base = 1
gain = "STR * 0.25"
[progression.level]
xp_for_level = "level * level * 50"
[progression.hp]
base = 10
per_level = 2
gain = "VIT * 0.5"
Current supported keys:
base: starting value at level 1per_level: fixed amount added each level after level 1gain: formula added each level after level 1xp_for_level: total experience required to reach a level, used underprogression.level
The current formula is:
base + (level - 1) * (per_level + gain)
Progression formulas can use:
level- any direct character attribute like
STR,INT,VIT
attack() reads its base value from progression.damage.
Leveling Flow
The full progression flow now works like this:
- A script calls
gain_xp(amount). - The server adds that amount to the attribute named by
game.experience. - The server checks
progression.level.xp_for_levelagainst the new total. - If one or more thresholds are reached, it raises the attribute named by
game.level. - For each level increase, the character receives a
level_upevent with the new level.
Example:
[progression.level]
xp_for_level = "level * level * 50"
With that rule:
- level 2 requires
200total XP - level 3 requires
450total XP - level 4 requires
800total XP
If a character has LEVEL = 1, EXP = 180, and gains 25 XP, it reaches EXP = 205 and levels up to 2.
Automatic XP on Kill
You do not need to call gain_xp() manually for normal combat kills.
If progression.xp.kill is configured, the server awards XP automatically when a character kills another character.
[progression.xp]
kill = "defender.LEVEL * 25"
This expression can use the normal combat-style attacker/defender values, so you can base XP on the defeated character.
Progression Messages
Progression can also send automatic localized messages for XP gain and level-up.
[progression.messages]
xp_key = "progression.xp.gained"
xp_category = "system"
level_up_key = "progression.level_up"
level_up_category = "system"
Example locale entries:
[en]
progression.xp.gained = "You gain {amount} XP"
progression.level_up = "You reached level {level}"
Supported placeholders:
{amount}: XP gained in this step{level}: new level for level-up messages{xp_total}: new total experience after the gain
These messages are only sent to player characters.
Combat Values
Available values in combat formulas:
value: the current amount at this rule stageattacker.<attr>: reads an attacker attributedefender.<attr>: reads a defender attributeweapon.<attr>/attacker.weapon.<attr>: sum of the attacker's equipped weapon-slot item attributesdefender.weapon.<attr>: sum of the defender's equipped weapon-slot item attributessource.<attr>/attacker.source.<attr>: attribute of the actual weapon or spell item that caused this hit, when availableequipped.<attr>/attacker.equipped.<attr>: sum of all equipped attacker item attributesdefender.equipped.<attr>: sum of all equipped defender item attributesarmor.<attr>: sum of the defender's non-weapon equipped item attributesattacker.armor.<attr>: sum of the attacker's non-weapon equipped item attributesdefender.armor.<attr>: sum of the defender's non-weapon equipped item attributes
The weapon and armor groups use the configured slot lists from Game / Settings:
game.weapon_slotsgame.gear_slots
Difference Between weapon.* and source.*
This is important:
weapon.<attr>means the sum of all equipped weapons in the configured weapon slotssource.<attr>means the actual item that caused this hit
So:
- use
weapon.<attr>when you want a total from all equipped weapons - use
source.<attr>when you want the sword, bow, or spell item that was actually used
Worked Examples
Example 1: Basic Physical Damage
[progression.damage]
base = 1
gain = "STR * 0.25"
[combat]
outgoing_damage = "value + source.DMG"
incoming_damage = "value + attacker.STR - defender.armor.ARMOR"
If:
LEVEL = 5STR = 4progression.damage = 1 + (5 - 1) * (4 * 0.25) = 5- the current weapon has
DMG = 2 defender.armor.ARMOR = 1
then:
- outgoing damage =
5 + 2 = 7 - final damage =
7 + 4 - 1 = 10
Example 2: Weapon Damage from the Actual Source Item
[combat.kinds.physical]
outgoing_damage = "value + source.DMG"
incoming_damage = "value - defender.armor.ARMOR"
If:
value = 1- the actual sword used has
DMG = 4 defender.armor.ARMOR = 2
then:
- outgoing damage =
1 + 4 = 5 - final damage =
5 - 2 = 3
This is usually a better formula than attacker.weapon.DMG if you want the hit to depend on the weapon that was actually used.
Example 3: Sum of Equipped Weapons
[combat]
outgoing_damage = "value + attacker.weapon.DMG"
incoming_damage = "value - defender.armor.ARMOR"
If the attacker has:
- main hand weapon with
DMG = 4 - off hand weapon with
DMG = 2
then:
attacker.weapon.DMG = 6
This is useful if your game really wants the total from all equipped weapons. If not, use source.DMG instead.
Example 4: Spell Damage by Kind
[combat.kinds.spell]
outgoing_damage = "value + source.POWER"
incoming_damage = "value + attacker.INT - defender.RESIST"
[combat.kinds.fire]
outgoing_damage = "value + source.POWER"
incoming_damage = "value + attacker.INT - defender.FIRE_RESIST"
If a spell item has:
spell_kind = "fire"
POWER = 3
then the engine uses the fire formula instead of the generic spell formula.
Damage Kinds
Kinds let you branch combat rules by damage type.
Common examples:
physicalspellfireicepoison
Behavior:
attack()uses the current weapon'sdamage_kindwhen available, otherwisephysicaldeal_damage(...)defaults tophysical- spells default to
spell - custom kinds like
fireoricecan override the base rule
If combat.kinds.<kind>.outgoing_damage or combat.kinds.<kind>.incoming_damage exists, it overrides the base combat formula for that kind.
Spells are already connected to this system through spell_kind:
- spell items default to
spell_kind = "spell" - changing
spell_kindtofire,ice, or another custom kind uses the matchingcombat.kinds.<kind>rule path - the same kind drives damage formulas, combat messages, and combat audio
Combat Messages
You can also define automatic combat messages in rules so every monster does not need its own take_damage message script.
[combat.messages]
incoming_key = "combat.damage.incoming"
incoming_category = "warning"
outgoing_key = "combat.damage.outgoing"
outgoing_category = "system"
Message timing:
attack()ordeal_damage(...)starts the hit.outgoing_damageandincoming_damagecalculate the final amount.- The server applies that final amount.
- The rules-driven combat messages are sent using the final
amount.
So the message system sits after damage calculation. It reports the resolved hit, not the raw base value.
The message key is looked up in Game / Locales using the active locale from Game / Settings.
[game]
locale = "en"
Example locale entries:
[en]
combat.damage.incoming = "{attacker} hits you for {amount} damage"
combat.damage.outgoing = "You hit {defender} for {amount} damage"
Supported placeholders inside locale strings:
{attacker}{defender}{amount}{kind}{from_id}{target_id}
These placeholders use the final combat context:
{amount}is the final post-rules damage{kind}is the resolved damage kind likephysical,spell, orfire{attacker}and{defender}are resolved display names{from_id}is the attacker entity id{target_id}is the defender entity id
Example
[combat.messages]
incoming_key = "combat.damage.incoming"
incoming_category = "warning"
outgoing_key = "combat.damage.outgoing"
outgoing_category = "system"
[en]
combat.damage.incoming = "{attacker} burns you for {amount} damage"
combat.damage.outgoing = "You burn {defender} for {amount} damage"
If a fire hit resolves to 9 final damage, that is the value inserted into {amount}.
These messages are only sent when a player is involved:
incoming: only if the defender is a playeroutgoing: only if the attacker is a player
If you do not want localization for a rule message, you can still use literal incoming / outgoing strings instead of incoming_key / outgoing_key.
Kind-Specific Messages
You can override messages per damage kind the same way as formulas:
[combat.kinds.fire.messages]
incoming_key = "combat.damage.fire.incoming"
outgoing_key = "combat.damage.fire.outgoing"
If a kind-specific message exists, it takes precedence over the base combat.messages values for that hit kind.
Combat Audio
Rules can also trigger built-in or file-based audio clips during combat.
[combat.audio]
incoming_fx = "hit"
incoming_bus = "sfx"
incoming_gain = 1.0
outgoing_fx = "attack"
outgoing_bus = "sfx"
outgoing_gain = 1.0
These names are played through the normal audio system, so they can point to either:
- generated effects from Audio FX
- regular audio assets loaded through the audio asset system
Kind overrides work the same way as formula overrides:
[combat.kinds.fire.audio]
outgoing_fx = "fire_cast"
Weapon and spell items can override the rules-based audio directly with item attributes:
attack_fxattack_busattack_gainhit_fxhit_bushit_gain
These item-level values take precedence over the global rules audio.
Combat audio is only played when a player is involved:
incoming_fx: only if the defender is a playeroutgoing_fx: only if the attacker is a player
What take_damage Receives
After the server resolves the final amount, take_damage receives:
amount: final incoming damagefrom_id: attacker iddamage_kind: kind stringsource_item_id: weapon or spell item id when availableattacker_name: resolved attacker name
The server applies the final damage automatically after take_damage returns.
So the usual pattern is:
- keep combat math in rules
- use
take_damagefor reaction logic like fleeing, counterattacks, or custom behavior
Recommended Pattern Right Now
For the current system, this is the intended split:
- use scripts to decide when to attack
- use
attack()for normal attacks against the current target - use
deal_damage(...)for explicit custom damage cases - use rules to calculate outgoing and incoming damage
- use
take_damageto react to the hit
This means the following is usually a good script shape:
if event == "attack" {
if target() != "" {
attack()
notify_in(4, "attack")
}
}
and the detailed math should live in rules, not in every NPC script.
For general localization and built-in system.* keys, see Localization.