-
Notifications
You must be signed in to change notification settings - Fork 1
Recipes
A collection of helpful snippets and copy-paste solutions for frequently used RPG mechanics. These patterns work out-of-the-box and can be customized for your system.
New to ISDL? The concept pages explain the building blocks these recipes use: Fields covers every field type, Basic Logic covers actions and dice, Advanced Logic covers functions and loops, and Interactivity covers prompts and events. If you're looking for something specific, the How Do I� page routes you directly to the right answer.
β Related: attribute, parent<type>, roll(), chat
Many D20 systems follow the form of d20 + Attribute mod for their attack. We can calculate the roll by storing per-equipment what Attribute it uses. Referencing this value will automatically get the parent's value and grab the mod by default for Attributes.
This example takes advantage of the powerful HTML editor to display rolls and other effects that occur on a hit, but more data modeling could be done to replace that with properties instead.
actor PC {
attribute Strength(min: 1, max: 30, mod: {
return (self.Strength - 10) / 2
}
attribute Dexterity(min: 1, max: 30, mod: {
return (self.Dexterity- 10) / 2
}
table<Equipment> Equipment
}
item Equipment {
parent<Attribute> Attribute
html OnHit
action Roll(icon: "fa-solid fa-dice-d20") {
fleeting attack = roll(d20 + self.Attribute)
chat AttackRoll {
attack
self.OnHit
}
}
}Often players get granted boons / banes which modify the roll. We can prompt either the current User (no target)
item Equipment {
parent<Attribute> Attribute
html OnHit
action Roll(icon: "fa-solid fa-dice-d20") {
fleeting attack = roll(d20 + self.Attribute)
fleeting userAnswer = prompt {
boolean HasAdvantage
number Bonus
}
if (userAnswer.HasAdvantage) {
attack = roll(2d20kh + self.Attribute + userAnswer.Bonus)
}
else {
attack = roll(d20 + self.Attribute + userAnswer.Bonus)
}
chat AttackRoll {
attack
self.OnHit
}
}
}or the GM (target: GM)
item Equipment {
parent<Attribute> Attribute
html OnHit
action Roll(icon: "fa-solid fa-dice-d20") {
fleeting attack = roll(d20 + self.Attribute)
fleeting gmAnswer = prompt(target: gm) {
boolean HasAdvantage
number Bonus
}
if (userAnswer.HasAdvantage) {
attack = roll(2d20kh + self.Attribute + gmAnswer.Bonus)
}
else {
attack = roll(d20 + self.Attribute + gmAnswer.Bonus)
}
chat AttackRoll {
attack
self.OnHit
}
}
}Reading target data is available to non-GM's, and allows you to calculate if an attack should hit. target will resolve to the first target a user has, and if none are selected the code inside the target check won't run. Checking the document type of the target is a required step, as it helps to ensure safe access to the data - a Monster and a Structure might have very different data, for instance.
actor NPC {
number ArmorClass
}
item Equipment {
parent<Attribute> Attribute
html OnHit
action Roll(icon: "fa-solid fa-dice-d20") {
fleeting attack = roll(d20 + self.Attribute)
if (target is NPC) {
if (attack.total >= target.ArmorClass) {
chat AttackRoll {
attack
self.OnHit
}
}
}
else { // Do something else }
}
}β Related: tracker, visibility:, Visibility.locked
Many spells and talents can only be used so often, making it helpful to keep track of uses left automatically and block usage if no uses are left. tracker is a good element for this as it allows the max to be controlled by the user, or optionally a max can be set in the syntax. Trackers also have built-in UI to refill / empty them. number can also be used, although it doesn't allow a user editable max.
item Skill {
tracker UsesLeft(style: slashes)
action Use(visibility: {
if (self.UsesLeft <= 0) return Visibility.locked // Locked or readonly will disable the Action
// If uses are left, the default visibility will be used
})
{
self.UsesLeft -= 1
// Do something
}
}β Related: tracker, on turnStart, Combat.nextTurn()
Many systems track Action Points (AP), and when you're out, your turn is up. Like skill uses, a tracker is a good element to use for this. This example takes advantage of Combat.isMyTurn to lock the Action to only be available when it's that character's turn
actor PC {
tracker AP(style: slashes, max: 3)
on turnStart {
self.AP = 3
}
table<Skill> Skills
}
item Skill {
tracker UsesLeft(style: slashes)
number ApCost
action Use(visibility: {
if (self.UsesLeft <= 0) return Visibility.locked
else if (Combat.isMyTurn) {
return Visibility.default
}
else {
return Visibility.locked
}
})
{
self.UsesLeft -= 1
// Do something
if (parent is PC) {
fleeting remainingAp = parent.AP - self.ApCost
parent.AP = remainingAp
if (remainingAp <= 0) {
Combat.nextTurn()
}
}
}
}β Related: resource, on preApplyDamage, tracker
A health resource already comes out of the box with support for temporary values, useful for things like basic shields or overflow health. Damage is applied to a sheet via a chat card driven UX.
For more advanced setups, you can use the on preApplyDamage to adjust the amount of damage about to be applied.
actor PC {
tracker Shields(style: icons, icon: "fa-shield-halved")
on preApplyDamage (damage) {
if (self.Shields > 0) {
self.Shields -= 1
damage = 0
chat ShieldAbsorb {
flavor "Shield Absorbed"
"The shield absorbed the damage."
}
}
}
}actor PC {
tracker Armor(style: slashes)
on preApplyDamage (damage) {
if (self.Armor > 0) {
// Order is important
fleeting remainingArmor = self.Armor - damage
damage -= self.Armor
self.Armor = remainingArmor
}
}
}ISDL doesn't currently support attaching damage types to damage, but for now you can do basic flat damage resistances
actor PC {
number DamageResistance(min: 0, max: 100) // 0 would resist no damage, 100 would resist all damage
on preApplyDamage (damage) {
damage *= 1 - (self.DamageResistance / 100)
}
}β Related: tracker, on turnStart, hidden
This example takes advantage of ISDL's custom Active Effect mode "Add Once". Hidden numbers aren't rendered on the sheet, but can be impacted by Active Effects. Setting up an effect with 3 current Burn AddOnce would add to the Burn when the Effect is added to the sheet.
actor PC {
hidden tracker BurnAmount
on combatStart {
self.BurnAmount = 0
}
on turnStart {
if (self.BurnAmount > 0) {
self.Health -= self.BurnAmount
self.BurnAmount -= 1
chat Burn {
flavor "Hot hot hot"
"You take " + self.BurnAmount + " π₯ burn damage."
}
}
}
}β Related: each loops, value: (calculated), tracker
If your system cares about things like "how much can I carry", you can use a calculated value to help show that.
actor PC {
tracker WeightLimit(style: bar, min: 0, max: 100, value: {
fleeting total = 0
each loot in self.Loot {
total += loot.Weight
}
return total
})
table<Loot> Loot
}
item Loot {
number Weight(min: 0)
}β Related: choice<string>, visibility:, Visibility.hidden
A common pattern in class-based RPGs: picking a class (Barbarian, Mage, Rogue) should change the character β adding stats, resources, or actions that other classes don't have. ISDL doesn't have a class-as-mixin construct, but you can get the same effect with the visibility system: define every possible field on the actor, and use visibility: to hide the ones that don't apply.
actor Hero {
choice<string> Class(choices: ["Barbarian", "Mage", "Rogue"])
health resource HP
// Barbarian-only: rage tracker shown only when Class is Barbarian
tracker Rage(max: 6, style: segmented, visibility: {
if (self.Class equals "Barbarian") return Visibility.default
return Visibility.hidden
})
// Mage-only: spell points
resource Mana(max: { return self.Level * 5 }, visibility: {
if (self.Class equals "Mage") return Visibility.default
return Visibility.hidden
})
// Rogue-only: sneak-attack die
die SneakAttackDie(initial: "d6", visibility: {
if (self.Class equals "Rogue") return Visibility.default
return Visibility.hidden
})
}When the player picks "Barbarian", they see HP and Rage. Picking "Mage" hides Rage and shows Mana. Hidden fields don't render on the sheet at all, so the UI stays clean.
The same pattern works for actions:
action Rage(visibility: {
if (self.Class equals "Barbarian" and self.Rage > 0) return Visibility.default
return Visibility.hidden
}) {
self.Rage -= 1
chat rage { flavor "RAAAAAH!" }
}For larger systems, consider modeling each class as a Class item with a choice<Class> Class(global: true) field on the actor. Class items can carry their own data (description, level table, etc.), and your visibility checks become if (self.Class.Name equals "Barbarian"). See the tutorial's "What's next?" for the Class-as-item pattern.
β Related: number, resource, action, visibility:
actor Hero {
number Level(min: 1, max: 20, value: 1)
number Experience
resource HP(max: { return self.Level * 10 + self.CON })
action LevelUp(visibility: self.Experience >= (self.Level * 1000) ? Visibility.default : Visibility.hidden) {
eternal costPerLevel = 1000
fleeting requiredXP = self.Level * costPerLevel
self.Experience -= requiredXP
self.Level += 1
fleeting hpGain = roll(1d8) + self.CON
chat LevelUp {
"LEVEL UP! Now level " + self.Level
"Gained " + hpGain + " maximum HP"
tag self.Level
tag self.HP.max
}
}
}actor Character {
number AttributePoints(value: 27)
number Strength(min: 8, max: 15, value: 8)
number Dexterity(min: 8, max: 15, value: 8)
number Constitution(min: 8, max: 15, value: 8)
number Intelligence(min: 8, max: 15, value: 8)
number Wisdom(min: 8, max: 15, value: 8)
number Charisma(min: 8, max: 15, value: 8)
readonly number UsedPoints(value: {
fleeting total = 0
fleeting attributes = [self.Strength, self.Dexterity, self.Constitution, self.Intelligence, self.Wisdom, self.Charisma]
each attr in attributes {
if (attr <= 13) {
total += attr - 8
} else {
total += (attr - 8) + (attr - 13) // Higher costs for 14-15
}
}
return total
})
readonly number RemainingPoints(value: {
return self.AttributePoints - self.UsedPoints
})
}β Related: roll(), value: (calculated), on preApplyDamage
actor Character {
number BaseAC(value: 10)
attribute Dexterity(mod: { return Math.floor((self.Dexterity - 10) / 2) })
boolean WearingArmor
number ArmorBonus
boolean HasShield
readonly number AC(value: {
fleeting total = self.BaseAC + self.Dexterity.mod
if (self.WearingArmor) {
total += self.ArmorBonus
}
if (self.HasShield) {
total += 2
}
return total
})
}The crit: parameter checks the natural die face, so a crit is a real natural
20 β not just a 20+ total after bonuses. The chat card also auto-highlights green on
a crit (red on a fumble).
action Attack {
fleeting attackRoll = roll(d20 + self.AttackBonus, crit: 20, fumble: 1)
if (attackRoll.fumble) {
chat AttackResult {
"Critical miss!"
tag attackRoll
}
}
else if (attackRoll >= self.Target.AC) {
fleeting damage = roll(self.WeaponDamage + self.STR)
if (attackRoll.crit) {
damage = damage * 2
self.CriticalHits += 1
}
self.Target.HP -= damage
chat AttackResult {
attackRoll.crit ? "CRITICAL HIT!" : "Attack hits!"
"Damage: " + damage
tag attackRoll
tag self.Target.HP
}
} else {
chat AttackResult {
"Attack misses!"
tag attackRoll
tag self.Target.AC
}
}
}World of Darkness / Storyteller style β roll a pool, count dice at or above a target, with 1s subtracting.
action ChannelMagic {
fleeting pool = roll((self.Willpower)d10, success: >= 8, failure: 1)
chat MagicResult {
"Rolled " + pool.successes + " success(es)"
if (pool.successes <= 0) { "The ritual backfires!" }
tag pool
}
}Keep-highest pools drop dice, but contains/count still see the dropped faces β
so a 1 on a discarded die still fizzles the spell.
action CastSpell {
fleeting cast = roll((self.SpellDice)d6 kh1)
if (cast.contains(1)) {
chat SpellResult { "The spell fizzles β a 1 was rolled!" }
}
else if (cast >= self.Difficulty) {
chat SpellResult { "Success! (" + cast.total + ")" }
}
else {
chat SpellResult { "The spell fails to take hold." }
}
}on preApplyDamage(number amount, string damageType) {
fleeting finalDamage = amount
// Check resistances
if (damageType equals "Fire" and self.FireResistance) {
finalDamage = Math.floor(amount / 2)
chat Resistance {
"Fire resistance reduces damage!"
"Damage reduced from " + amount + " to " + finalDamage
}
}
else if (damageType equals "Cold" and self.ColdImmunity) {
finalDamage = 0
chat Immunity {
"Cold immunity negates all damage!"
}
}
else if (damageType equals "Poison" and self.PoisonVulnerability) {
finalDamage = amount * 2
chat Vulnerability {
"Poison vulnerability doubles damage!"
"Damage increased from " + amount + " to " + finalDamage
}
}
// Apply the modified damage
amount = finalDamage
}function makeSkillCheck(string skillName, number difficulty) returns boolean {
fleeting skillBonus = parent[skillName].mod
fleeting roll = roll(d20) + skillBonus + self.Level
fleeting success = roll >= difficulty
chat SkillCheck {
success ? "Skill check succeeded!" : "Skill check failed."
skillName + " check: " + roll + " vs DC " + difficulty
tag roll
}
return success
}
action PerformSkill {
fleeting roll = roll(d20) + self.SkillMod
eternal easy = 10
eternal medium = 15
eternal hard = 20
if (roll >= hard) {
chat SkillResult {
"Exceptional success!"
tag roll
}
self.Experience += 100
}
else if (roll >= medium) {
chat SkillResult {
"Good success!"
tag roll
}
self.Experience += 50
}
else if (roll >= easy) {
chat SkillResult {
"Basic success."
tag roll
}
self.Experience += 25
}
else {
chat SkillResult {
"Failed attempt."
tag roll
}
}
}function rollWithAdvantage(string diceExpression, boolean hasAdvantage, boolean hasDisadvantage) returns number {
if (hasAdvantage and !hasDisadvantage) {
fleeting roll1 = roll(diceExpression)
fleeting roll2 = roll(diceExpression)
fleeting result = Math.max(roll1, roll2)
chat AdvantageRoll {
"Rolling with advantage"
"Rolled: " + roll1 + " and " + roll2
"Using: " + result
}
return result
}
else if (hasDisadvantage and !hasAdvantage) {
fleeting roll1 = roll(diceExpression)
fleeting roll2 = roll(diceExpression)
fleeting result = Math.min(roll1, roll2)
chat DisadvantageRoll {
"Rolling with disadvantage"
"Rolled: " + roll1 + " and " + roll2
"Using: " + result
}
return result
}
else {
return roll(diceExpression)
}
}β Related: tracker, function, each loops
actor Spellcaster {
section SpellSlots {
tracker Level1Slots(max: { return Math.max(1, Math.floor(self.Level / 2)) })
tracker Level2Slots(max: { return Math.max(0, Math.floor(self.Level / 4)) })
tracker Level3Slots(max: { return Math.max(0, Math.floor(self.Level / 6)) })
}
function canCastSpell(number spellLevel) returns boolean {
if (spellLevel equals 1) {
return self.Level1Slots > 0
}
else if (spellLevel equals 2) {
return self.Level2Slots > 0
}
else if (spellLevel equals 3) {
return self.Level3Slots > 0
}
return false
}
function castSpell(number spellLevel) returns nothing {
if (spellLevel equals 1) {
self.Level1Slots -= 1
}
else if (spellLevel equals 2) {
self.Level2Slots -= 1
}
else if (spellLevel equals 3) {
self.Level3Slots -= 1
}
}
action RestoreSpellSlots {
self.Level1Slots = self.Level1Slots.max
self.Level2Slots = self.Level2Slots.max
self.Level3Slots = self.Level3Slots.max
chat SpellSlotsRestored {
"All spell slots restored!"
}
}
}actor Character {
number CarryingCapacity(value: { return self.STR * 15 })
readonly number TotalWeight(value: {
fleeting weight = 0
each item in self.Equipment {
if (item.Weight exists) {
weight += item.Weight * item.Quantity
}
}
return weight
})
readonly string EncumbranceStatus(value: {
fleeting ratio = self.TotalWeight / self.CarryingCapacity
if (ratio <= 0.33) {
return "Light Load"
}
else if (ratio <= 0.66) {
return "Medium Load"
}
else if (ratio <= 1.0) {
return "Heavy Load"
}
else {
return "Overloaded"
}
})
readonly boolean IsOverloaded(value: {
return self.TotalWeight > self.CarryingCapacity
})
}β Related: boolean, on turnStart, status
actor Character {
section StatusEffects {
boolean IsStunned
boolean IsBlinded
boolean IsCharmed
boolean IsPoisoned
number PoisonDuration
number StunDuration
}
action ApplyStun(number duration) {
self.IsStunned = true
self.StunDuration = duration
chat ConditionApplied {
self.Name + " is stunned for " + duration + " rounds!"
}
}
on turnStart {
// Process ongoing conditions
if (self.IsStunned and self.StunDuration > 0) {
self.StunDuration -= 1
if (self.StunDuration <= 0) {
self.IsStunned = false
chat ConditionEnded {
self.Name + " is no longer stunned."
}
}
}
if (self.IsPoisoned and self.PoisonDuration > 0) {
self.HP -= roll(1d4)
self.PoisonDuration -= 1
chat PoisonDamage {
self.Name + " takes poison damage!"
}
if (self.PoisonDuration <= 0) {
self.IsPoisoned = false
chat ConditionEnded {
self.Name + " recovers from poison."
}
}
}
}
}actor Character {
section TemporaryEffects {
number StrengthBonus
number DexterityBonus
number ConstitutionBonus
number TemporaryHP
}
function applyBuff(string statName, number bonus, number duration) returns nothing {
if (statName equals "Strength") {
self.StrengthBonus += bonus
}
else if (statName equals "Dexterity") {
self.DexterityBonus += bonus
}
else if (statName equals "Constitution") {
self.ConstitutionBonus += bonus
}
chat BuffApplied {
statName + " increased by " + bonus + " for " + duration + " minutes!"
}
// Schedule buff removal
wait duration minutes
if (statName equals "Strength") {
self.StrengthBonus -= bonus
}
else if (statName equals "Dexterity") {
self.DexterityBonus -= bonus
}
else if (statName equals "Constitution") {
self.ConstitutionBonus -= bonus
}
chat BuffExpired {
statName + " boost expires."
}
}
action CastBullsStrength {
if (self.canCastSpell(2)) {
self.castSpell(2)
self.applyBuff("Strength", 4, 10)
}
}
}actor Character {
section Currency {
number Copper
number Silver
number Gold
number Platinum
}
readonly number TotalCurrency(value: {
return self.Copper + (self.Silver * 10) + (self.Gold * 100) + (self.Platinum * 1000)
})
function payAmount(number copperAmount) returns boolean {
if (self.TotalCurrency >= copperAmount) {
// Convert and pay from largest denominations first
fleeting remaining = copperAmount
fleeting platinumNeeded = Math.min(Math.floor(remaining / 1000), self.Platinum)
self.Platinum -= platinumNeeded
remaining -= platinumNeeded * 1000
fleeting goldNeeded = Math.min(Math.floor(remaining / 100), self.Gold)
self.Gold -= goldNeeded
remaining -= goldNeeded * 100
fleeting silverNeeded = Math.min(Math.floor(remaining / 10), self.Silver)
self.Silver -= silverNeeded
remaining -= silverNeeded * 10
self.Copper -= remaining
return true
}
return false
}
action BuyItem(number cost) {
if (self.payAmount(cost)) {
chat Purchase {
"Purchase successful!"
"Paid: " + cost + " copper"
}
} else {
chat InsufficientFunds {
"Not enough money!"
"Need: " + cost + " copper"
"Have: " + self.TotalCurrency + " copper"
}
}
}
}β Related: value: (calculated), string, boolean
actor Character {
number QuestsCompleted
number TotalQuests(value: 20)
readonly number QuestProgress(value: {
return Math.min(100, Math.floor((self.QuestsCompleted / self.TotalQuests) * 100))
})
readonly string ProgressDescription(value: {
fleeting percent = self.QuestProgress
if (percent >= 100) {
return "All quests complete!"
}
else if (percent >= 75) {
return "Nearly finished"
}
else if (percent >= 50) {
return "Halfway there"
}
else if (percent >= 25) {
return "Making progress"
}
else {
return "Just getting started"
}
})
action CompleteQuest {
self.QuestsCompleted += 1
chat QuestComplete {
"Quest completed!"
"Progress: " + self.QuestProgress + "%"
self.ProgressDescription
}
}
}β Related: roll(), function, each loops
function explodingRoll(string baseDice, number explodeOn) returns number {
fleeting total = 0
fleeting keepRolling = true
while (keepRolling) {
fleeting thisRoll = roll(baseDice)
total += thisRoll
if (thisRoll >= explodeOn) {
chat ExplodingDie {
"Die exploded! Rolled " + thisRoll + ", rolling again..."
}
} else {
keepRolling = false
}
}
return total
}
action ExplodingAttack {
fleeting damage = self.explodingRoll("d8", 8) + self.STR
self.Target.HP -= damage
chat ExplodingAttack {
"Exploding damage roll: " + damage
}
}action RandomEncounter {
eternal encounters = [
"Bandits attack!",
"Friendly merchant offers trade",
"Ancient ruins discovered",
"Wild animals block the path",
"Mystical fog rolls in",
"Treasure chest found!"
]
fleeting roll = roll(d6) - 1 // Convert to 0-based index
fleeting encounter = encounters[roll]
chat RandomEncounter {
"Random Encounter:"
encounter
tag roll + 1
}
}