This guide is intended to be an introduction to the scaling methods for developers of ToME (including both official content and addons). It describes the math and functional support recently added to ToME (previously used in the Infinite 500 add-on) which enables the character level limit to be increased from 50 to more than 500. Most of this material can be applied directly to other modules as well.
The balance considerations needed to accomplish this are pretty simple. From most to least important they are:
- I. Don't break anything. (Keep the game running.)
It's no good if it doesn't work. For any conceivable range of talent levels, stats, and other character attributes, you don't want the game to crash. The most common problems of this sort are things like negative talent cool-downs, speeds, or other properties that have to be positive, divide by zero errors (in lua, you usually get inf or nan, which just pushes the error away from where the problem originates), or bad scaling that can overpower or obviate core game mechanics and result in strange behavior. The t-engine and the ToME module are pretty robust and tolerant of many coding errors, but nonsense results must still be avoided.
II. Maintain fairness. (Provide a consistent challenge.)
Make sure that individual talents or combinations of abilities don't become over- or under-powered at various points in the game. ToME accommodates a wide range of play styles because of the diverse range of abilities that are balanced with respect to each other. While there is considerable variation in effectiveness of various builds (race/class combinations) and the talent selections that go with them (which also supports more play styles), it is possible, if not easy, to clear the entire Maj'Eyal campaign with virtually any of them.
To make this happen, the effectiveness of core game mechanics must scale consistently. That is, things like damage output, resistances, health, resources (and recovery), etc. must be of similar effectiveness across a variety of builds from level 1 onwards. This means, among other things, that an archmage and a berserker of similar level, using similar quality equipment, and played with equivalent skill can produce fairly similar amounts of damage (even if the mage is better at AOE and the berzerker is better at focused damage) and have similar survivability (the berserker is tougher and can recover more quickly, but the mage is better at avoiding damage).
III. Provide continuous improvement. (Reward character progress.)
Character development is a tenet of RPG game design. It's what makes games like ToME so engaging (addictive) and have great replayability. ToME excels at this because of all of the choice available to the player on how to develop a character including 26 classes, over 1,000 individually programmed unique talents, and strongly player-directed character development.
To maintain this choice going forward, the game must support character improvment with investment in as wide a range character attributes as possible. For example, the Weapon Mastery talent steadily improves damage at all talent levels. On the other hand, the Hide In Plain Sight and Unseen Actions talents previously (ToME 1.0.4) had a flat % chance of success that increased in equal increments with talent level. 100% success chance was reached at about talent level 9 or 10, and then there there was no room for further improvement. The scaling changes replace this mechanic with an opposed skill system that rewards talent investment with a skill multiplier that is countered by the various detection and sensing abilities of enemys. As a result, investment in these skills is useful far past the previous limits.
To provide consistent scaling, scaling factors (formula exponents) were chosen with which to guide improvement of various characteristics with character level and to stay consistent with existing game code and values. This applies equally to both players and NPC's, since all actors in ToME use the same game mechanics. Consistent with a diminishing returns philosophy, none of these factors are greater than 1.0:
Code: Select all
Scaling factors (with character level)
Attribute Power
Health/Damage* 1.0 (consistent scaling here is the objective of the other scaling factors)
Duration 0.5
Range/Radius 0.5
Damage Multipliers 0.5 (some exceptions)
Crit multiplier 0.75
Armour/APR 0.5-0.75 (except armor training @ 1.0)
Attack/Defense** 0.75-1.0 (some exceptions)
Raw Saves/Power** 0.75-1.0
Speed 0.75
Stat bonus/penalty 0.5-1.0 (0.75)
Resistances/reduction 0.5 (except thick skin, note caps and penetration)
Stealth/Invis 1.0 (includes detection)
Effect removal log (includes talents limited by the number of targets)
Crit rate/penalty 0.5-0.75
Resource recovery 0.75
** These directly opposed values have an extra scaling step (rescaleCombatStats) designed to keep values from diverging too much.
The core of this system requires that both character health (and most resources in general) and offensive ability (damage) scale more or less in proportion to character level. That is, advancing a character to twice its level will result in it having around twice as many hit points and it will deal approximately twice as much damage (everything else being equal).
The duration of most talent effects, on the other hand, is proportional to the square root (0.5 power) of character level. So, if a character keeps investing in a typical talent while going from level 10 to level 40 the talent duration will increase by a factor of 2x. There are exceptions to this, of course.
Tiered scaling:
Some of the scaling factors have a range of powers. These characteristics are scaled in tiers in order to maintain competitive play balance between opposed abilities. For example, armor from the Armor Training skill, which almost every class has access to, scales in proportion to character level (1.0 power with talent level) in order to keep pace with melee damage, which also scales in proportion to character level. Other abilities that affect this balance (addional armor talents like Stoneskin (0.5 power with talent level) or armor piercing talents like Deadly Strikes (0.5 power with Cunning)) scale more slowly than the base because they can provide a strong differential advantage for or against this balance and are not available to all characters.
Reference Characters:
The new scaling changes are designed to maintain the previous game balance as much as possible. It is assumed that a character invests as heavily as possible in the appropriate talent and stats and takes into account a few properties of ToME's leveling system. Once higher character levels are reached, the maximum (raw) talent level that can be learned is level/10 and the maximum (base) stat that can be trained is level + 10. So talent levels and stats are both proportional to character level also.
When matching the new formulas to the old, two standard reference characters were chosen:
Code: Select all
STARTING CHARACTER HIGH LEVEL CHARACTER
Level 1 Level 50
Primary stats 10 Primary stats 100
Talent Mastery 1.0 Talent Mastery 1.0
Talent level 1.0 (effective) Talent level 5.0 (effective)
As an example, using the original formula for stealth (power = 4 + 0.1 * Cunning * Talent Level), the hypothetical starting reference character would have a stealth power of 5, and the high level character would have 54. The old formula calculated stealth power in proportion to the square of character level, since both maximum Cunning and maximum Talent Level are proportional to character level and they are multiplied together. The new formula (computed in a lua call like power = combatScale(Cunning * Talent Level, 5, 1, 54, 50), see the expanation of the scaling functions below) takes the square root of Cunning * Talent Level (so 1.0 power with character level) and scales this value so that when Cunning * Talent Level = 1, stealth power is 5 and when it's 50, stealth power is 54.
Limits:
In a number of places, characteristics are subject to limits. Limits are needed primarily to keep certain characteristics within bounds, like preventing total invulnerability to status effects, or to limit talent effects that could break game scaling from other sources. Some of the more important issues include (with some examples):
- Speed reduction cannot create negative speeds. (Cripple, Thorn Grab. There are also some hard limits that prevent negative speeds as well to prevent game breaking bugs.)
No negative cooldowns. (Supercharge Golem, Rush - can get to zero with other talents and equipment, but has a substantial stamina cost.)
Most absolute status immunities, like stun, confusion, pinning, knockback, etc., are limited to less than 100%, at least for a single talent. (Shield Wall, Relentless)
Some activated abilities with a lasting effect cannot have a duration longer than the cooldown. These include most instant use talents (many racial abilities) since otherwise they could be kept up continuously like a sustained skill, and certain very powerful effects that can unbalance the game if they are kept up continuously (Unstoppable, Paradox Clone, Adrenalin Surge).
Resistance penetration (from a single talent) is limited to less than 100%. (Wildfire, Vulnerability Poison)
Core stat multipliers. (Battle Shout <50% extra life, Greater Weapon Focus <100% chance, effectively a damage multiplier)
Healing and damge penalties are limited to less than < 100% to prevent overpowering too many NPC's, which may not have an offsetting bonus. (Curse of Impotence, Insidious Poison)
Function Support for Scaling:
As of SVN 6783 for release version 1.0.5, new functions have been added to Combat.lua in order to make scaling much easier to manage. These include scaling functions and limit functions. To use these functions in addons prior to ToME 1.0.5's release you can superload them:
(change the extension to .lua)
Scaling functions:
These functions are designed to manage diminishing returns effects. One is for talents and the other is for raw stats, but they both employ a variation of the same formula:
f(x) = m*(x + shift)^power + b + add)
where shift, power, and add are inputs, and m and b are internally computed to match desired values.
Code: Select all
-- Compute a diminishing returns value based on talent level that scales with a power
-- t = talent def table or a numeric value
-- low = value to match at talent level 1
-- high = value to match at talent level 5
-- power = scaling factor (default 0.5) or "log" for log10
-- add = amount to add the result (default 0)
-- shift = amount to add to the talent level before computation (default 0)
-- raw if true specifies use of raw talent level
function _M:combatTalentScale(t, low, high, power, add, shift, raw)
-- Compute a diminishing returns value based on a stat value that scales with a power
-- stat = "str", "con",.... or a numeric value
-- low = value to match when stat = 10
-- high = value to match when stat = 100
-- power = scaling factor (default 0.5) or "log" for log10
-- add = amount to add the result (default 0)
-- shift = amount to add to the stat value before computation (default 0)
function _M:combatStatScale(stat, low, high, power, add, shift)
For combatTalentScale the first argument is either the talent definition table (from which the talent level is determined) or a number. The high and low arguments correspond to the values you want to match for effective talent levels 1.0 and 5.0. The add argument is added after the computation. The shift argument adjusts talent level up or down before computing the curve. This allows for better matching of desired values in between the points specified, and generally has the effect of straightening out the curve, like fitting the part of the curve to the right by the specified amount.
This function never returns negative values, but otherwise always returns low + add for talent level 1.0 and high + add for talent level 5.0.
The combatStatScale function is very similar, except that the first value is a stat abbreviation ("str", "dex", "mag", "wil", "cun", "con") or the stat value to use, and the high and low arguments correspond to stat values of 10 and 100 respectively.
Examples:
- self:combatTalentScale(t, 1, 7)
This function computes a power curve (with default power 0.5) passing through the points (1, 1) and (5, 7). Using self as the actor with the talent, this takes the talent definition table, t, and returns 1 if the talent level is 1 and 7 if the talent level is 5. For (effective) talent levels {1, 2, 3, 4, 5, 10} it returns:
{1.00, 3.01, 4.55, 5.85, 7.00, 11.50}
self:combatTalentScale(t, 1, 7, 0.75)
As before, but uses power = 0.75 and returns:
{1.00, 2.75, 4.28, 5.68, 7.00, 12.84}
self:combatTalentScale(t, 1, 7, "log")
Uses a logarithmic scale, returning:
{1.00, 3.58, 5.10, 6.17, 7.00, 9.58}
self:combatTalentScale(t, 1, 7, 0.5, 2)
Adds 2 to the first result, returning:
{3.00, 5.01, 6.55, 7.85, 9.00, 13.50}
self:combatTalentScale(t, 1, 7, 0.5, 0, 10)
Uses the straighter part of the first curve, shifting the references "to the right" by 10, returning:
{1.00, 2.59, 4.12, 5.58, 7.00, 13.46}
Limit functions:
These functions are designed to allow for a gradual (smooth) progression from values you specify towards a limit that cannot be passed. There is one for talents and another for stats that use the same default base values as the scaling functions. The formula used is a variation of:
f(x) = (limit-b)*x/(x + h) + b
where b and h ("add" and "halfpoint" in the lua code) are computed to match the high and low values specified. Note that the function reaches the midpoint of its range when x = h.
Code: Select all
-- Compute a diminishing returns value based on talent level that cannot go beyond a limit
-- t = talent def table or a numeric value
-- limit = value approached as talent levels increase
-- high = value at talent level 5
-- low = value at talent level 1 (optional)
-- raw if true specifies use of raw talent level
-- returns (limit - add)*TL/(TL + halfpoint) + add == add when TL = 0 and limit when TL = infinity
-- TL = talent level, halfpoint and add are internally computed to match the desired high/low values
-- note that the progression low->high->limit must be monotone, consistently increasing or decreasing
function _M:combatTalentLimit(t, limit, low, high, raw)
-- Compute a diminishing returns value based on a stat value that cannot go beyond a limit
-- stat == "str", "con",.... or a numeric value
-- limit = value approached as talent levels increase
-- high = value to match when stat = 100
-- low = value to match when stat = 10 (optional)
-- returns (limit - add)*stat/(stat + halfpoint) + add == add when STAT = 0 and limit when stat = infinity
-- halfpoint and add are internally computed to match the desired high/low values
-- note that the progression low->high->limit must be monotone, consistently increasing or decreasing
function _M:combatStatLimit(stat, limit, low, high)
combatStatLimit is similar, except that the first argument is a stat abbreviation or a number, and the high and low values correspond to stat values of 100 and 10 respectively.
Examples:
- Calling
self:combatStatLimit("dex", 100, 30, 85)
causes the function to compute an increasing curve passing through the points (10, 30) and (100, 85) that is always less than 100, look up the value of the Dexterity stat and then find the corresponding point on the curve (the return value). For dex values of {10, 25, 50, 75, 100, 250}, the following values are returned:
{30.00, 56.55, 73.38, 80.81, 85.00, 93.51}
Conversely, calling
self:combatStatLimit("wil", 0, 50, 15)
on the same values for Willpower computes a curve that is always more than 0, and returns the following values:
{50.00, 36.00, 24.55, 18.62, 15.00, 6.92}
Code: Select all
-- Scale a value up or down by a power
-- x = a numeric value
-- y_low = value to match at x_low
-- y_high = value to match at x_high
-- power = scaling factor (default 0.5)
-- add = amount to add the result (default 0)
-- shift = amount to add to the input value before computation (default 0)
function _M:combatScale(x, y_low, x_low, y_high, x_high, power, add, shift)
-- Scale a value up or down subject to a limit
-- x = a numeric value
-- limit = value approached as x increases
-- y_high = value to match at when x = x_high
-- y_low (optional) = value to match when x = x_low
-- returns (limit - add)*x/(x + halfpoint) + add (= add when x = 0 and limit when x = infinity), halfpoint, add
-- halfpoint and add are internally computed to match the desired high/low values
-- note that the progression low->high->limit must be monotone, consistently increasing or decreasing
function _M:combatLimit(x, limit, y_low, x_low, y_high, x_high)
Negative values are valid results for combatScale and the power, if specified, must be a numeric argument ("log" cannot be specified).
Integrated Example: Converting a talent to use infinite scaling
To illustrate the use of the new scaling methods, here is a step-by-step example of how the Scoundrel Strategies talent was modified. This talent has a number of effects when being attacked by or when attacking bleeding enemies. The starting talent definition is (for ToME 1.0.4):
Code: Select all
newTalent{
name = "Scoundrel's Strategies", short_name = "SCOUNDREL",
type = {"cunning/scoundrel", 2},
require = cuns_req2,
mode = "passive",
points = 5,
getDuration = function(self, t) return 3 + math.ceil(self:getTalentLevel(t)/2) end,
getMovePenalty = function(self, t) return (5 + self:combatTalentStatDamage(t, "cun", 10, 30)) / 100 end,
getAttackPenalty = function(self, t) return 5 + self:combatTalentStatDamage(t, "cun", 5, 20) end,
getWillPenalty = function(self, t) return 5 + self:combatTalentStatDamage(t, "cun", 5, 20) end,
getCunPenalty = function(self, t) return 5 + self:combatTalentStatDamage(t, "cun", 5, 20) end,
do_scoundrel = function(self, t, target)
if not rng.percent(5+(self:getTalentLevel(t)*3)) then return end
if rng.percent(50) then
if target:hasEffect(target.EFF_DISABLE) then return end
target:setEffect(target.EFF_DISABLE, t.getDuration(self, t), {speed=t.getMovePenalty(self, t), atk=t.getAttackPenalty(self, t), apply_power=self:combatAttack()})
else
if target:hasEffect(target.EFF_ANGUISH) then return end
target:setEffect(target.EFF_ANGUISH, t.getDuration(self, t), {will=t.getWillPenalty(self, t), cun=t.getCunPenalty(self, t), apply_power=self:combatAttack()})
end
end,
info = function(self, t)
local duration = t.getDuration(self, t)
local move = t.getMovePenalty(self, t)
local attack = t.getAttackPenalty(self, t)
local will = t.getWillPenalty(self, t)
local cun = t.getCunPenalty(self, t)
return ([[Learn to take advantage of your enemy's pain.
If your enemy is bleeding and attempts to attack you, their critical hit rate is reduced by %d%%, as their wounds make them more predictable.
If you attack a bleeding enemy, there is a %d%% chance that, for %d turns, they are disabled as you take advantage of openings (reducing their movement speed by %d%% and Accuracy by %d) or anguished as you strike their painful wounds (reducing their Willpower by %d and their Cunning by %d).
The statistical reductions will increase with your Cunning.
]]):format(5+(self:getTalentLevel(t)*5),5+(self:getTalentLevel(t)*3),duration,move * 100,attack,will,cun)
end,
}
Code: Select all
...
-- Scoundrel's Strategies
if self:attr("cut") and target:knowTalent(self.T_SCOUNDREL) then
chance = chance - (5 + (target:getTalentLevel(self.T_SCOUNDREL)*5))
end
...
Looking at this code, consider what happens when a character of arbitrarily high level and corresponding stats invests fully in this talent. What will be the effects for a level 100 character? 200? 500+? (These levels can occur for NPCs in the High Peak or deep in the Infinite Dungeon.)
The duration (of the disabling effect) increases linearly with talent level (and thus character level) and could get excessively long - 10's of turns, quite possibly much longer than the fight itself. The duration can be brought in line with more typical game time scales by replacing the getDuration function with:
Code: Select all
getDuration = function(self, t) return math.ceil(self:combatTalentScale(t, 3.3, 5.3)) end,
The movement penalty, though it's based on the combatTalentStatDamage function, which has diminishing returns built in, can increase beyond 100%, which would correspond to a negative movement speed. The call to self:combatTalentStatDamage(t, "cun", 10, 30) has a minimum value of 0 and returns 22.4 for the high level reference character. To limit this to less than a 1.0 move factor (100% move speed reduction), replace the getMovePenalty function with:
Code: Select all
getMovePenalty = function(self, t) return self:combatLimit(self:combatTalentStatDamage(t, "cun", 10, 30), 1, 0.05, 0, 0.274, 22.4) end, -- Limit <100%
The chance to apply the Disable or Anguish effects, computed with rng.percent(5+(self:getTalentLevel(t)*3)), can also increase to more than 100%. To limit these to 100%, introduce a new function to the talent definition:
Code: Select all
disableChance = function(self,t) return self:combatTalentLimit(t, 100, 8, 20) end, -- Limit <100%
The crit rate reduction for a bleeding enemy attacking a character with this talent, computed as 5 + (target:getTalentLevel(self.T_SCOUNDREL)*5) in the _M:physicalCrit and the talent info functions, scales linearly with talent level (1.0 power). This is faster than the normal crit rate (0.5-0.75 power, see the scaling factors above). This means that a high level character with this talent can become completely immune to critical strikes, even against opponents with a bonus crit chance, since normal crit rates cannot keep up. As this is not a widely available talent, the lower tier scaling (0.5 power) is appropriate. The scaling can be made more consistent by adding a another new function:
Code: Select all
getCritPenalty = function(self,t) return self:combatTalentScale(t, 10, 30) end,
Here is the revised talent definition (for ToME 1.0.5):
Code: Select all
newTalent{
name = "Scoundrel's Strategies", short_name = "SCOUNDREL",
type = {"cunning/scoundrel", 2},
require = cuns_req2,
mode = "passive",
points = 5,
getDuration = function(self, t) return math.ceil(self:combatTalentScale(t, 3.3, 5.3)) end,
-- _M:physicalCrit function in mod\class\interface\Combat.lua handles crit penalty
getCritPenalty = function(self,t) return self:combatTalentScale(t, 10, 30) end,
disableChance = function(self,t) return self:combatTalentLimit(t, 100, 8, 20) end, -- Limit <100%
getMovePenalty = function(self, t) return self:combatLimit(self:combatTalentStatDamage(t, "cun", 10, 30), 1, 0.05, 0, 0.274, 22.4) end, -- Limit <100%
getAttackPenalty = function(self, t) return 5 + self:combatTalentStatDamage(t, "cun", 5, 20) end,
getWillPenalty = function(self, t) return 5 + self:combatTalentStatDamage(t, "cun", 5, 20) end,
getCunPenalty = function(self, t) return 5 + self:combatTalentStatDamage(t, "cun", 5, 20) end,
do_scoundrel = function(self, t, target)
if not rng.percent(t.disableChance(self, t)) then return end
if rng.percent(50) then
if target:hasEffect(target.EFF_DISABLE) then return end
target:setEffect(target.EFF_DISABLE, t.getDuration(self, t), {speed=t.getMovePenalty(self, t), atk=t.getAttackPenalty(self, t), apply_power=self:combatAttack()})
else
if target:hasEffect(target.EFF_ANGUISH) then return end
target:setEffect(target.EFF_ANGUISH, t.getDuration(self, t), {will=t.getWillPenalty(self, t), cun=t.getCunPenalty(self, t), apply_power=self:combatAttack()})
end
end,
info = function(self, t)
local duration = t.getDuration(self, t)
local move = t.getMovePenalty(self, t)
local attack = t.getAttackPenalty(self, t)
local will = t.getWillPenalty(self, t)
local cun = t.getCunPenalty(self, t)
return ([[Learn to take advantage of your enemy's pain.
If your enemy is bleeding and attempts to attack you, their critical hit rate is reduced by %d%%, as their wounds make them more predictable.
If you attack a bleeding enemy, there is a %d%% chance that, for %d turns, they are disabled as you take advantage of openings (reducing their movement speed by %d%% and Accuracy by %d) or anguished as you strike their painful wounds (reducing their Willpower by %d and their Cunning by %d).
The statistical reductions will increase with your Cunning.
]]):format(t.getCritPenalty(self,t), t.disableChance(self, t), duration, move * 100, attack, will, cun)
end,
}
Code: Select all
...
-- Scoundrel's Strategies
if self:attr("cut") and target:knowTalent(self.T_SCOUNDREL) then
chance = chance - target:callTalent(target.T_SCOUNDREL,"getCritPenalty")
end
...