Reference the Rift Logic language

Rift Logic Reference

Overview

Rift Logic is a small C++-style language for making claims about League of Legends that can be checked against the game's own data. It has three kinds of answer:

  • Calculations: damage, healing, stats, cooldowns, projectile travel times, from the formulas the game ships with.
  • Simulations: timed fights between champions or team comps, with runes, item passives, heals and shields.
  • Arguments: rules that argue for or against a claim like beats(Fizz, Syndra), where the strongest argument wins.

Every result shows its working and lists every assumption the model made. A result is only as good as those assumptions, which is why they're always visible.

Running programs

In the editor, results update as you type (Ctrl/⌘ + Enter re-runs). Click a row in the results to see its working, assumptions and tables. The Inspect tab shows any champion's or item's data, including every named formula you can reach.

  • At a glance: above the results, one chip per assert, prove and error (✓ proved, ✗ refuted, ≈ contested, ! error), with its line number. Click a chip to jump to its result and its line.
  • Errors are underlined in the editor, with the message at the end of the line and a mark in the line numbers.
  • Autocomplete (as you type, or Ctrl + Space): champions, items, runes, built-ins, members after a dot (a., f., s.), and inside a call its named arguments (fight(a, b, 10, spr → spread:).
  • Copy link puts the whole program in the link (#code=…, compressed when it is long). Opening the link loads the program and runs it; nothing is saved until you save it. #ex=<name> opens one of the examples.
  • Charts: print() of lists of numbers draws a line chart (see lists).

From the command line, the same engine runs a file:

node tools/rl.js program.rl           # full working for every result
node tools/rl.js program.rl --brief   # one line per result
echo 'print(Syndra(11).Q.damage);' | node tools/rl.js -

The exit code is 0 when every assert and prove held, 1 when anything was refuted or errored, and 3 if the engine itself crashed, so programs can be used as checks. --max-ms=60000 adds a time limit (the editor uses 3 seconds while you type, 10 seconds for Run or Ctrl/⌘ + Enter); --usage prints how much of each limit below the program used.

Imports (command line only): import "../lib.rl"; at the top of a file pulls in another program's functions, rules, variables and data corrections. Paths are relative to the importing file, each file is imported once, and the imported file's own assert/print/prove output is not shown. The reasoning workspace (reasoning/) uses this to share scenario comps and rules between questions.

A first program

ItemSet burst = {Ludens, Sorc};
Champion a = Syndra(11).with(burst, Electrocute);
Champion tank = Garen(11).with(Plated, Kaenic);

assert(a.Q.damage(vs: tank) > 150);          // Proved or Refuted, with the formula
print(a.ap, a.Q.cd, a.combo(Q, E, R, vs: tank));
assert(canKill(a, tank, 5), "Syndra kills Garen in 5 seconds");

Examples

The editor's Examples menu has these ready-made questions. Each link opens one in the editor and runs it. Every example is also a regression test (tests/examples/), so they all come back Proved on the current engine.

Syntax

C++ you'd sketch on a whiteboard. Statements end with ;. Comments are // … and /* … */. Strings use double quotes. There are no pointers, headers, classes or templates.

FormMeaning
Champion a = Syndra(11);Declare with a type. Several at once: Champion a = …, b = …;
auto x = a.ap * 2;auto works anywhere a type does.
Champion a(Syndra, 11);Constructor-style declaration.
ItemSet s = {Ludens, Sorc};Brace lists become ItemSets, TeamComps or lists, depending on the type (or contents, with auto).
+ - * / %Arithmetic. All numbers are decimal: 2 / 5 is 0.4. int truncates only when stored.
30%A percent literal: 0.3.
== != < <= > >=Comparisons. Champions compare equal when same champion, level, items, runes and ranks.
&& || !Logic, short-circuiting.
= += -= *= /= ++ --Assignment. Objects are copied on assignment and when passed to functions.
"text" + xString concatenation with any value.
f(x, vs: t)Named arguments follow the positional ones.

Types and values

TypeWhat it holds
int double floatNumbers. Dividing by zero, or any result that isn't a number (sqrt(-1)), is an error. Infinity is a value (timeToKill when the target survives) and prints as infinity.
bool stringtrue/false; text.
ChampionA champion at a level, with items, runes, ability ranks and fight settings.
AbilityOne of a champion's abilities: a.Q. Keeps a reference to its champion.
Item ItemSetOne shop item; a set of items.
RuneA keystone, minor rune or stat shard.
SummonerA summoner spell: Flash, Ghost, Heal, Barrier, Ignite, Exhaust, Cleanse (Summoner spells).
TeamCompA list of champions.
ListA list of any values: List xs = {};, {3, 6, 9}, range(0, 250, 25), items(). See TeamComp and lists.
FightThe result of fight(…).
FightsThe result of fights(…), a sweep of fights (Fight sweeps).
StatA number from real games with its sample: winrate(Syndra, vs: Orianna) (Real-game stats). It compares and calculates as its value.
DummyThe practice-tool target dummy, Dummy(hp: 3000, resists: 50). It is also a Champion, so it goes anywhere a target does (Practice-tool dummy).
autoWhatever the value is.

Assignment and parameter passing copy champions, sets and comps. To change the original inside a loop or function, use a reference: for (Champion& c : team) c.target = x;.

Names: champions, items, runes

A capitalised name that isn't a variable is looked up in this order: classes (Mage), summoner spells (Flash, Ignite, …), champions, exact rune names, items, then rune name prefixes. Lower-case names are variables or ability tags (stun, dash).

  • Champions are written without spaces or punctuation: KSante, MissFortune, Wukong. Syndra alone is Syndra at defaultLevel with no items; Syndra(11) sets the level.
  • Items accept any unique start of the name: Ludens, Rabadon, Sorc, Plated. An ambiguous prefix is an error that lists the matches.
  • Runes by name, without spaces: Electrocute, SuddenImpact, AdaptiveForce, HealthScaling.
  • Ability slots P Q W E R are used in combo(Q, E, R).
  • Globals: defaultLevel (11) and gameMinute (20) can be assigned.

Functions and control flow

double burstOn(Champion c, Champion t) {
    return c.combo(Q, E, vs: t) + c.proc(Ludens, vs: t);
}
bool oneShots(Champion c, Champion t) { return burstOn(c, t) >= t.hp; }

for (Champion c : blue) if (c.is(Mage)) mages++;
for (int i = 0; i < 5; i++) { … }
while (x < 10) { … }             // break and continue work as usual

Functions can be declared anywhere at the top level and called before their declaration. Parameters need types; a function with a return type other than void must return. break and continue outside a loop are errors.

Limits. So that a runaway program ends with an error instead of freezing the page or running out of memory, every run has these limits (the same in the editor and on the command line):

  • 3,000,000 steps (“program stopped after 3,000,000 steps — infinite loop?”); the program stops.
  • 200 nested function calls (endless recursion), and brackets or blocks nested too deeply to read.
  • A list, TeamComp or Combo holds at most 100,000 values (“a list can hold at most 100,000 values …”); that statement fails.
  • 2,000,000 values created in one run: list and TeamComp elements (copies included; assigning a list copies it), champion copies and combo steps. A list that copies itself into itself doubles every round and hits this after about twenty rounds; the program stops.
  • 10,000 results (print, assert, prove); the program stops.
  • perform plays combos of up to 10,000 steps; a fight lasts at most 120 seconds; fights(…) runs at most 5,000 fights.
  • Steps count your program’s own work, not the simulation: a fight() call costs a few steps however long the fight is, and the fights inside a fights(…) sweep cost none (only its role functions do, once per level and team). The same sweep written as a loop also pays for the loop and for re-running the role functions before every fight (about 20 times the steps), so use fights(…) for sweeps.
  • Strings up to 1,000,000 characters.
  • The working under one result is cut after 5,000 lines, and after 200,000 lines in the whole program (a note marks the cut); the values are unaffected.
  • Time (for example a loop of fights): in the editor 3 seconds while you type and 10 seconds for Run or Ctrl/⌘ + Enter; none on the command line unless --max-ms= is given.

assert, print, prove

StatementResult
assert(a.Q.damage > b.Q.damage);Proved or Refuted. When the condition is a comparison, both sides are shown with their working and the percentage difference.
assert(cond, "title");The same, with your own title.
print(a, b, "text");Each value with how it was calculated.
prove beats(Fizz, Syndra);Runs the rules for a claim: Proved, Refuted, Contested or No rule applies, with the argument tree.
prove forall (Champion c : list) beats(c, x);The same claim for every champion in a list, grouped by verdict.
prove expr;With a non-claim expression, behaves like assert.

Every result lists its assumptions: ranks chosen for you, facts you asserted, item passives not modelled, simulator simplifications.

Champion

Making and changing

Syndra(11)A champion at level 11 (1–18; any other level is an error). defaultLevel = 11; sets the level of a bare Syndra.
a.with(Ludens, burst, Electrocute)A copy with more items, item sets or runes.
a.at(16)A copy at another level.
a.items.add(x) / a.items.remove(x)Change items in place.
a.runes.add(Conqueror) / a.runes.remove(x)Change runes in place.
a.level = 13; a.Q.rank = 3;Settable. Unset ranks follow a skill order: one point in each basic ability first, then max them in a.skillOrder order (default "QEW"), never above ⌈level/2⌉; the ultimate at 6, 11 and 16. At level 11 that is Q5 W1 E3 R2.

Stats

ap ad bonusad baseadAbility power, total, bonus and base attack damage.
hp bonushp basehp manaHealth and mana.
armor bonusarmor basearmor mr bonusmr basemrResistances.
ehpphysical ehpmagicEffective health: hp × (1 + resist ÷ 100).
haste ms bonusms as bonusas crit critdmg rangeAbility haste, move speed, attack speed, crit chance and damage, attack range.
lethality armorpen armorpenpct magicpen magicpenpctPenetration.
lifesteal omnivamp tenacity healpower healIn shieldInSustain; outgoing heal & shield power; extra healing and shielding received.
aa dps gold level nameOne auto attack; auto DPS with expected crit; item cost; level; name.
attack defense magic difficulty melee hp6 hp11 hp18 …Riot's 1–10 ratings; melee (bool); stats at fixed levels regardless of the champion's level.

Methods

a.combo(Q, E, R, vs: t)Each listed ability's damage once; after t's resistances with vs. Abilities without a damage formula count 0 (noted).
a.proc(Ludens, vs: t)An item's main damage formula with a's stats, in its own damage type (Luden's at all six echoes, Terminus on-hit, Kraken's third hit before the missing-health bonus…).
a.stacks = 225; a.stacksThe champion's own permanent stacks: Syndra's Splinters of Wrath (default: 5 per skill point after level 1, up to 85, as leveling in the practice tool gives; 40 = two Dark Sphere charges, 60 = Force of Will true damage, 100 = Unleashed Power execute, 120 = +15% AP), Smolder's Dragon Practice (default 0; tier 1/2/3 at 25/125/225, bonus magic damage on Q, W and E), Veigar's Phenomenal Evil Power (default 0; +1 AP each, amplified by Rabadon's), Aurelion Sol's Stardust (default 0; Breath of Light's burst +0.031% of maximum health each, Singularity's execute), Nasus's Siphoning Strike stacks and Senna's Mist (default 0). In-fight stacks to start a fight or perform with (default 0, as in a fresh trade; they last 6 s): Yasuo's and Yone's Gathering Storm (2 = the first Q is Q3 and knocks up), Samira's Style (6 = Inferno Trigger ready). Reading it gives the count in use.
a.evolved = {Q, E}; a.evolvedEvolved or augmented abilities: Kai'Sa's Second Skin (default from her stats: Q at 100 AD from items and growth, W at 100 AP, E at 100% attack speed from items and growth), Viktor's Hex Core augments (default none: they cost Hex Fragments from kills). Reading it gives the abilities in use, e.g. "QE".
a.stacks(Heartsteel, 400)A copy of a with stacks earned before the fight: Heartsteel health, Mejai's / Dark Seal Glory, Hubris, Yun Tal, Rod of Ages, Gluttonous Greaves / Immortal Path, Manaflow mana. Runes too: a.stacks(LegendAlacrity, 10), Legend: Haste / Bloodline, Ultimate Hunter, Overgrowth, DarkHarvest (souls), ManaflowBand (bonus mana). Without it an item counts as freshly bought and a rune as a fresh game (0 stacks), as in the practice tool; the assumptions say which.
a.is(Mage) a.has(stun)Class and ability-tag checks.
a.cdOf(tag) a.cdmaxOf(tag) a.rangeOf(tag)Rank-1 cooldown, max-rank cooldown or range of whichever ability has the tag (shortest cooldown, longest range). An error if no ability has it (check a.has(tag) first).
a.threatRangeLongest reach of any damaging ability or attack.
a.gapClose a.closeGap(d)Total dash and blink distance; seconds to cover d units using dashes then walking.
a.hitboxThe champion's gameplay radius, used in dodge checks: the game files' character record (overrideGameplayCollisionRadius), else 65. Most champions 65; large ones 80 (Cho'Gath, Malphite, Sion, …), small ones 55 (Teemo, Fizz, Lulu, …), Ivern 70, Warwick 74.75 (65 + his innate 15% size). Size effects (Cho'Gath's Feast, Nasus R, Zac's health, …) aren't simulated; the assumptions mention them.
a.summoners a.Flash a.summonerHasteThe champion's summoner spells (a list), one of them bound to the champion (so its numbers use the champion's level and summoner haste), and summoner spell haste from items and Cosmic Insight. See Summoner spells.

Fight settings

a.target = b;Focus b in fights. Otherwise attacks go to the lowest-health enemy.
a.stasis = "pop";When a holds Zhonya's Hourglass / Seeker's Armguard, when it presses it (in fights and as a combo target): "low" (below 30% health, the default), "dash" (as an enemy Zed's Death Mark dash ends, so the mark isn't applied), "pop" (just before a Death Mark on it pops), "cover" (as early as the 2.5 s stasis still covers the pop), "never", or a time in seconds after the start.
a.exhaustAt = "arrival";When a (with Exhaust, or no summoners set) presses Exhaust, in fights and as a combo target (even without fightBack): "arrival" (the first step an enemy is targetable within 650; against Zed's Death Mark, as he lands), "arrival+0.25" (that much later: a reaction time), a time in seconds after the start (or as soon as an enemy is in range after it), "never", or "default" (on its target once it is fighting). A combo's caster counts as in range.
a.skill = 90;a's player skill in fights, 0–100 (default 70): its reaction time for walking out of skillshots and areas (100 → 0.10 s, 70 → 0.25 s, 0 → never). See Fight.
a.healPolicy = "save";Who a heals and shields: "lowest" (default), "save" (whoever dies soonest at the current damage rate), "self", or a Champion.
a.passive = true;Only heals and shields; never attacks.
a.rotation = "QEWR";Ability priority (default R, Q, E, W).
a.souls = 10;Dark Harvest souls.
a.hpNow = 0.6;a's current health when a fight() starts, as a fraction of its maximum health: above 0, at most 1 (default 1, full health). Real fights rarely start at full health; a champion who bought an item since recalling was in the fountain and is full. Only fight() and fights() use it; a.hp stays the maximum.
a.role = "peel";How a fights (see Crowd control and positions): "kite" (default for ranged champions), "dive" (default for melee), "peel", "engage" or "fight".
a.tenacity a.slowresistTenacity and slow resist from items, elixirs and the rune shard, stacking multiplicatively: Mercury's Treads + Sterak's Gage = 1 − 0.7 × 0.8 = 44%.

Ability

a.Q.damage a.Q.damage(vs: t)The tooltip's damage. Values the tooltip joins with “plus” are summed, each reduced by its own damage type. Percent-of-health formulas use the target's max health with vs. When the tooltip deals one formula twice as different damage types, both passes count (Ahri Q: magic out, true back; Yone W and R: physical and magic).
a.Q.damage(passes: 1)Only the first pass of those abilities (Ahri Q's orb misses on the way back).
a.R.damage(spheres: 5)For abilities whose damage is per unit with a guaranteed minimum count, .damage is that minimum (Syndra R: 3 spheres); name the count to use more (Syndra R: 3–7 spheres; count: also works).
a.Q.damage(snips: 3) a.R.damage(needles: 5)Champion kits (champion audit): hits, recasts and empowered parts the tooltip gives in words. .damage is what perfect play lands, and the working says so: Gwen Q 5 snips in the centre (snips: 1–5, center:), R 9 needles (needles:); Viktor Q with Discharge (discharge:), R 6 storm ticks (ticks:); Dr. Mundo W 12 ticks + recast (ticks:, recast:), Q never below its minimum; Kai'Sa Q all missiles on one target (missiles:); Smolder E all bolts (bolts:), R in the centre (sweetspot:); Samira W 2 slashes (slashes:), R 10 shots (shots:), Q as the blade slash (melee:); Zed Q shurikens: 1–3; Akali E and R with the recast (recast:); Yasuo E stacks: 0–4; Kassadin R stacks: 0–4 (Riftwalk stacks from casts in the last 15 s: +35–55 (+7% AP) (+1% maximum mana) each; perform() and fight() count the casts; the doubling mana cost isn't modelled); Aurelion Sol Q is one second of the beam on a champion: its damage per second and the burst. Kayle's Divine Ascent follows her level: ranged with 525 range at 6, 625 range and full Zeal (+30% attack speed, +10% move speed) at 16; in perform() and fight() each attack gives a Zeal stack (+6% attack speed, 5 s, up to 5; Exalted at 5: +10% move speed) and from 11 an Exalted attack launches a fire wave (Kayle.P.damage) through every enemy in its path; every attack deals E's on-hit damage, E empowers the next attack (attack reset; from 11 it explodes on enemies within 350) and Q lowers armor and magic resistance 15% for 4 s. Yasuo and Yone Q (and Yone W) cooldowns come from bonus attack speed; Yasuo and Yone crit chance is doubled. perform() and fight() use what actually happens instead: Snippy stacks from attacks, live shadows and Dark Spheres, Plasma, Headshot counts, Style, Gathering Storm, marks (Zed R, Yone E, Ezreal W) and missing health. Zed R: untargetable and unable to act for 0.95 s from the cast (0.6 s delay + 0.35 s dash); the mark lands when the dash ends (not on an untargetable target) and pops 3 s later. fight() throws Zed's W at the target, mimics Q/E from the shadows and swaps to it. Syndra E stuns only through a pushed Dark Sphere (otherwise a 400-unit knockback); Zed W/E and Syndra Q/W have no cast time.
v.E.hold = 0.5; v.E.damage(hold: 0.5)A held charge's hold time in seconds (Vladimir E, 0–1.5). The default is the whole 1.5 s: full damage from 1 s, the slow only when held 1 s or more, released at 1.5 s. A shorter hold deals less damage (linear from the minimum to the maximum over the first second) and releases earlier. v.E.hold = s; applies to that champion everywhere: .damage, .arrival, canDodge (the charge is seen from the press), perform and fight. damage(hold: s) changes only that one value. Example: Champion v = Vladimir(11); v.E.hold = 0.5; print(v.E.damage, v.E.arrival(distance: 400), v.perform({E, Q}, vs: Dummy()).time);
a.Q.value("name", vs: t) a.Q.name_of_formulaAny named formula or value of the ability (the Inspect tab lists them).
a.W.heal a.W.heal(target: x)Heal amount with heal & shield power, and what x receives. Targets the tooltip can't reach are an error.
a.E.shield(target: x)The same for shields.
a.W.healTarget a.E.shieldTarget“caster only”, “another ally”, “an ally or self”, “self and an ally”, “every ally” or “none”.
a.Q.cd a.Q.cdbase a.Q.cost a.Q.range a.Q.rank a.Q.nameCooldown after haste; before haste; mana; cast range; rank; ability name.
a.Q.speed a.Q.width a.Q.radius a.Q.castTime a.Q.delay a.Q.kind a.Q.reach a.Q.delivery a.Q.length a.Q.originPhysics: projectile speed, full width, area radius, cast time, appear delay, kind (line, area, cone, targeted, self), how far it can hit, where it comes from (skillshot, lobbed, placed, vector, remote, unit, self, cone; see Physics), a vector's length, and a remote ability's distance from its object to the target.
a.Q.arrival(distance: d)Seconds until the ability lands at d units.
a.Q.reachTo(b)The greatest centre-to-centre distance at which a.Q hits b (the reach rule): centred range for point-and-click spells, range + both radii for edge-range ones, reach + b's radius otherwise.
a.E.ccDuration("root") a.E.ccDuration("root", vs: t)The crowd control's duration at the ability's rank; with vs, after t's tenacity (floor 0.3 s; airborne, suppression and drowsy are unaffected). 0 if the ability doesn't apply that type. The working says where the number comes from (game data value or the wiki).
a.R.ccTypes a.R.knockback a.W.slowAmountThe crowd control types the ability applies in fights; its knockback or pull distance (Janna R knocks back to 875 units from her); its slow (0.8 = 80%).
a.E.has(untargetable)Tag check on this ability.
Syndra.R.addTag(targeted); removeTag(pull);Correct the tags (see Correcting the data).
Fizz.E.setCooldown(10); Syndra.Q.setPhysics(delay: 0.6);Override numbers. setPhysics takes speed, width, radius, castTime, delay, dashSpeed, dashRange, kind, delivery, origin, originRange, length, reveal, reach.

Item and ItemSet

Ludens.gold Ludens.nameTotal cost; display name.
Ludens.ap Ludens.haste …Any stat (0 if the item doesn't have it).
Ludens.basedamageNamed passive values from the game data.
ItemSet s = {Ludens, Sorc};A set. s.add(x), s.remove(x), s.contains(x), s.size(), s + x. A set that breaks an item limit (two boots, Void Staff with Cryptbloom) is an error.
s.gold s.apTotals across the set.
items()Every finished item, for loops.

Rune

Runes go into with() or a.runes.add(). Shards can be repeated: .with(AdaptiveForce, AdaptiveForce, HealthScaling). Conqueror.description returns the rune's text, which is where its numbers come from. runes() lists every rune. See Modelled runes for which ones change stats and which act in fights.

Summoner spells

Flash, Ghost, Heal, Barrier, Ignite, Exhaust and Cleanse are values of type Summoner. Give a champion two with x.with(Flash, Ignite) or x.summoners = {Flash, Ignite};. A champion without any set is assumed to have whichever one you use (the result says so).

Champion o = Orianna(11).with(Flash, Ignite);
print(o.Flash.cooldown, o.Ignite.damage);           // 300, 300 at level 11
prove canDodge(o, Syndra(11).Q, distance: 800, using: o.Flash);
Combo c = {Flash, Q, W, Ignite};
print(Annie(11).perform(c, vs: Dummy(hp: 3000)).damage);
spellnumbers (game files; checked against the wiki)cooldown
Flash.distance 400-unit blink, no cast time300 s
Ghost.speed 24% → 48% bonus move speed over levels 1–18, .duration 10 s, no ramp-up240 s
Heal.heal 80 → 318 over levels 1–18; .speed +30% move speed for 1 s240 s
Barrier.shield 100 → 460 over levels 1–18, .duration 2.5 s180 s
Ignite.damage 70 true damage, +20 per level to 5, +25 per level from 6 (475 at 18), over 5 s; 40% grievous wounds180 s
Exhaust.damageReduction 35% less damage dealt, .slow 40%, .duration 3 s240 s
Cleanse.tenacity 75% for 3 s; removes disables240 s

.cooldown uses summoner spell haste: base × 100 ÷ (100 + haste), from Ionian Boots of Lucidity (10), Crimson Lucidity (20) and Cosmic Insight (18). Numbers that scale with level use the champion's level (x.Ignite.damage) or defaultLevel (Ignite.damage).

  • Dodging: using: Flash, using: Ghost, using: Heal (its speed), or combined with abilities: using: {o.W, Flash}. See Physics and dodging.
  • Combos: Combo c = {Flash, R, Ignite};. Ignite adds its true damage (5 ticks, one every 1.056 s) and grievous wounds; Exhaust cuts the target's damage by 35% for 3 s; Heal and Barrier heal and shield the user. A summoner the champion didn't take is skipped; one on cooldown is waited for, like abilities.
  • Fights: a champion with .summoners set uses Ignite and Exhaust as soon as the target is within cast range (Exhaust 650, Ignite 600, centre to centre; Exhaust on the enemy with the most AD + AP), and Heal and Barrier below 30% health. Flash, Ghost and Cleanse don't change fight numbers.

TeamComp and lists

TeamComp blue = {Ornn(11), Vi(11), a};A team.
blue[0] blue.size() blue.add(x) blue.contains(x)Index, size, add a copy, membership by champion.
for (Champion c : blue) …Loop over copies; Champion& c to change members.
champions() champions(Assassin)Every champion (of a class) at defaultLevel.
List xs = {}; xs.add(v) xs[0] xs.size()A list of numbers, strings or objects, grown with add; for (double x : xs) loops over it.
print(levels, a, b)In the editor, printing lists of numbers draws a line chart: when the first list increases and the others have its length, they are plotted against it (up to 4 lines, each labelled at its end; hover a column for the values); a single list is plotted against 1…n.
List levels = {3, 6, 9, 11, 13, 16, 18}, margin = {};
for (int L : levels) {
    Champion riven = Riven(L).with(Eclipse, Sterak, Conqueror), renek = Renekton(L).with(Eclipse, Sterak, Conqueror);
    Fight f = fight(riven, renek, 30);
    margin.add(f.hpPercent(riven) - f.hpPercent(renek));   // above 0: Riven ends healthier
}
print(levels, margin);   // a chart: Riven's margin by level

Fight

Fight f = fight(blue, red, 10);Both sides fight for up to 10 seconds (0–120). A side is a Champion or TeamComp.
fight(blue, red, 20, start: 1000)The two front lines start 1000 units apart (default 0: in contact). Also formation: false (everyone on the front line instead of ranged units at their attack range behind it), kite: false (nobody kites: role "kite", whether default or set with x.role, becomes "fight" for this fight), room: 600 (a champion can back off at most 600 units from where it started; default: unlimited). See Crowd control and positions.
fight(blue, red, 20, spread: 0) rHold: 0Team-fight shape (3 or more a side). spread: is how far apart each side's champions stand sideways (default 350 units between neighbours; spread: 0 puts everyone on one line, so every area, line or cone can hit all five). rHold: is how many seconds in combat a champion waits before its ultimate (default 5; rHold: 0 casts it as soon as it's ready; heal/shield ultimates and R used to peel or escape aren't held). Both work in fights(…) too. Example: Wombo: spread and ult timing. Details in Positions.
fight(blue, red, 20, skill: 90) skill: {70, 90} x.skill = 100Player skill, 0–100 (default 70): how well a champion dodges. A skillshot or area that isn't unit-targeted misses a defender who, reacting 0.10 + 0.35 × (100 − skill) / skill seconds after it shows, could walk out of it: 100 → 0.10 s, 90 → 0.14 s, 70 → 0.25 s, 50 → 0.45 s, 30 → 0.92 s, 0 → never dodges. The check is canDodge's own physics (reveal from the press, reach, width/radius + hitbox, landing time) at the distance when it was cast, with the defender's current move speed, after any stun or root it is under. Walking only: Flash, dashes, spell shields and walls aren't spent on dodges (the kits still raise their shields and walls with perfect reaction). Point-and-click abilities, basic attacks and dashes are unaffected, and so is the attacker's aim (always centred on the target). skill: n sets everyone, skill: {n1, n2} each side; a champion's own x.skill wins. perfectAim: true lands every ability in reach, the engine before skill existed (skill 0 gives the same). perform(), canKill and timeToKill always land everything. f.dodges(x): how many hits x walked out of.
fight(blue, red, 30, levelGap: {0, -2}) levelGap: -2Level gap: an offset from the levels the teams were built at, per champion, clamped to 1–18 (skill points follow the new level unless the program set ranks). {a, b} gives each team its own offset; one number is team 1's lead: levelGap: 2 lowers team 2 by two levels, levelGap: -2 lowers team 1 (so a lead never needs a level above 18). Default 0: every champion at its own level.
fight(blue, red, 30, goldGap: {0, -1500}) goldGap: 1500Gold gap, per champion: a team g gold behind loses g gold of its build, last item first (the order you listed them is the buy order). An item only partly paid for keeps that share of its stats and none of its passive or active (roughly what its components give): Ahri with Luden's, Shadowflame, Sorcerer's Shoes and Rabadon's (3500) at goldGap: {-1750, 0} keeps half of Rabadon's AP and no Magical Opus. A build can't lose more than it has. {a, b} is each team's deficit (0 or less); one number is team 1's lead, taken off the other team. Rough sizes (pro games 2026, Oracle's Elixir, whole team): the median gold difference is 1,900 at 15 minutes, 3,200 at 20 and 4,700 at 25, and 1 game in 10 is past 5,200 / 8,300 / 11,500. That is about 400 / 650 / 950 per champion at the median and 1,000 / 1,650 / 2,300 at the 90th percentile. A player's level gap to their opponent is usually under one level; about 2 levels at 25 minutes in 1 game in 10.
f.ccTime(x) f.blocks(x) f.position(x) f.distance(x, y)Seconds x spent unable to act (stunned, airborne, suppressed, asleep, charmed, feared, taunted); abilities x's spell shields blocked; x's position on the line at the end (side 1 faces +, side 2 faces −); the distance between two champions at the end.
f.casts(x, Q) f.bySource(x, "E Aftershock")How many times x started that ability (first casts; recasts a kit plays itself aren't counted); x's damage after resistances from the hits with that label (as the log shows them: "R storm", "Ignite", "Swinging Kama"). The match is exact, ignoring case, spaces and punctuation, except that an instance count or a bracketed qualifier after the name still matches: "R storm" counts R storm 1/6…6/6, but "R" is only the R hit itself. f.bySource(x, "R", prefix: true) counts every label starting with the name.
f.winner f.duration f.log1 or 2 if only that side has anyone alive, else 0; end time; every event as text.
f.alive(x) f.dead(x) f.hp(x) f.hpPercent(x) f.deathTime(x)How one champion ended up (deathTime is infinite if alive).
f.dealt(x) f.taken(x) f.healed(x) f.shielded(x) f.received(x)Damage dealt and taken, healing and shielding done, healing received.
f.survivors(1) f.deaths(blue) f.totalHealing(2)Per side, by number or by the TeamComp you passed in.
canKill(a, t, 3)Can a kill t within 3 seconds? t doesn't hit back unless fightBack: true.
canKill(a, t, 5, healers: soraka)Healers join t's side and only heal and shield.
timeToKill(a, t, healers: …, fightBack: …, within: 60)Seconds until t dies, or infinity if it survives within seconds.
Champion k = Karthus(11); k.rotation = "Q"; k.role = "fight";
Champion g = Garen(11); g.passive = true;
print(canDodge(g, k.Q, distance: 700, reaction: 0.25));   // true: Lay Waste can be walked out of
print(fight(k, g, 3, start: 600).dodges(g));                // 3: at skill 70 (0.25 s) Garen walks out of all three Qs
print(fight(k, g, 3, start: 600, skill: 100).bySource(k, "Q"));      // 0: every Q dodged at 0.10 s
print(fight(k, g, 3, start: 600, perfectAim: true).bySource(k, "Q")); // 478: every Q lands (as skill: 0)

Fight sweeps

One fight() is one cell, and its winner can flip when the start distance moves by 100 units or a role changes. fights(…) runs fight() over every start × level × role set and gives the share with an interval, so a claim can rest on the sweep instead of one cell. Example: Fight sweeps in the editor.

TeamComp wombo = {Malphite(13).with(Sunfire, Thornmail), Amumu(13).with(Sunfire, Liandry), Orianna(13).with(Ludens, Shadowflame),
                  MissFortune(13).with(Eclipse, Collector), Leona(13).with(Locket, Thornmail)};
TeamComp split = {Fiora(13).with(Ravenous, Trinity, Conqueror), Graves(13).with(Eclipse, Collector), Viktor(13).with(Ludens, Shadowflame),
                  Jinx(13).with(Infinity, Kraken), Lulu(13).with(Moonstone, Redemption)};
Fights s = fights(wombo, split, 30, starts: range(0, 1000, 200), levels: {9, 13, 16}, bothSides: true);
print(s);   // … 16-4-16 of 36, share 50.0% (95% CI 34.5%–65.5%), 0 side-swap mismatches
print(s.byLevel(9).share, s.byLevel(16).share);   // 0.67  0.42: the engage comp is stronger early
TeamComp engage(TeamComp t, int level) {   // a role set (roles: engage): gets each team at each level, returns it ready to fight
    for (Champion& c : t) if (c.name == "Malphite" || c.name == "Amumu" || c.name == "Leona") c.role = "engage";
    return t;
}
fights(a, b, 40)fight(a, b, 40, start: s) for s = 0, 50, …, 1200 (25 fights) at the champions' own levels. Teams and seconds as in fight.
starts: range(0, 1200, 50)Start distances (a number or a list). range(from, to, step) is a list from from to to, both included (step default 1).
levels: {9, 13, 16}Every champion is set to each level in turn (default: as built).
roles: f roles: {setA, setB}Role sets: functions you write, TeamComp f(TeamComp t), f(TeamComp t, int level) or f(TeamComp t, int level, int team) (team 1 or 2, whichever side it stands on). Each gets a copy of each team already at the level and returns the team to fight with (roles, rotations, ranks, items…). It runs once per level and team, not once per fight.
bothSides: trueAlso runs every fight with the teams on swapped sides, counted from team 1's view: twice the fights, and s.sideMismatches counts the cells whose result changed. The simulation is side-symmetric, so it should be 0.
levelGaps: range(-3, 3, 1) goldGaps: {-2000, -1000, 0}Game-state gaps as sweep axes: each value is team 1's lead (-2 = team 1 two levels behind, +1000 = team 2 1000 gold behind per champion), with the rules of levelGap: and goldGap: in fight. The level gap is applied before a role function (it sees the level the team fights at), the gold gap after it (off the final build). A gap belongs to its team, so it follows the team when bothSides: swaps the sides. levelGap: {0, -2} / goldGap: (one fixed gap) work here too.
kite: room: formation: spread: rHold: skill: perfectAim:Passed to every fight, as in fight (a per-side skill: {n1, n2} follows its team when bothSides: swaps them).
s.wins s.draws s.losses s.totalFrom team 1's side. A draw: neither team wiped within the seconds, or both in the same step.
s.share s.low s.highshare = (wins + draws / 2) / total: a mirror is 0.5, and the shares of fights(a, b) and fights(b, a) add up to 1. low–high is the Wilson 95% score interval on the share. Fights are deterministic: the interval says how much the answer depends on where in the sweep you look, not luck.
s.meanTime s.marginAverage end time of the decided fights (infinity if none); average of (team 1 alive − team 2 alive) at the end.
s.byStart(600) s.byLevel(13) s.byRoles(2) s.bySide(2)The same numbers for part of the sweep (another Fights value): one start, one level, one role set (counting from 1), the fights as given (1) or swapped (2).
s.byLevelGap(-2) s.byGoldGap(1000)The fights at one level or gold gap (team 1's lead, as swept).
crossover(a, b, 30, axis: "level") axis: "gold"The gap where team 1's share of the sweep crosses 50%, as team 1's lead. From an even game it steps toward team 1 behind while team 1 still wins at least half, and returns the last gap that held: -2 = team 1 can be 2 levels behind. If team 1 is under 50% even, it steps toward team 1 ahead and returns the first gap that reaches 50%: +1 = team 1 needs a level lead. step: is 1 level or 500 gold by default; max: is 17 levels or 10,000 gold. Only the first crossing from an even game counts. If none is found it returns -max (still winning at every deficit tried) or infinity (no lead up to max is enough). Every other option is passed to fights (starts:, bothSides:, levels:, roles:, skill:…); the trace lists the share at each gap tried.

Early game against scaling: the draft-circle study's scaling comp L1 (Sion, Dr. Mundo, Ryze, Corki, Nami) against its skirmish comp S1 (Aatrox, Vi, Ryze, Lucian, Nami), both with their level-16 builds (a 30-minute fight), at player skill 70:

Fights s = fights(scaling, early, 30, starts: range(0, 1200, 200), levelGaps: range(-2, 2, 1), bothSides: true);
print(s.byLevelGap(-2).share, s.byLevelGap(0).share, s.byLevelGap(2).share);   // 0.14  0.43  0.86: 1, 3 and 6 of 7 starts
print(crossover(scaling, early, 30, starts: range(0, 1200, 200), bothSides: true));                // 1: scaling needs a 1-level lead
print(crossover(scaling, early, 30, starts: range(0, 1200, 200), bothSides: true, axis: "gold"));  // 500: or 500 gold per champion
print(crossover(early, scaling, 30, starts: range(0, 1200, 200), bothSides: true, axis: "gold"));  // 0: the skirmish comp wins even, not 500 behind

In this engine, at equal level and gold, a "scaling" comp is simply its champions at those levels and items. Gaps are how you test the game state a draft is aiming for. The fights are deterministic but not guaranteed to be monotone: over a few starts, one more level can still flip a single cell the other way. Sweep enough starts, or read crossover's trace, before trusting one gap.

Printed, a sweep is one line; its detail (and the working of any s.… value) is a grid with one square per start for each role set, level and side order, so you can see where the result flips. At most 5,000 fights per sweep. The fights inside a sweep cost no steps (only the role functions do, once per level and team); they count against the time limit, about 15–25 ms per 5-v-5 fight of 40 s.

Combo

A combo is an exact sequence of abilities and auto attacks, run through the fight simulator against one target, so every proc applies: spellblade after abilities, Electrocute on the third separate hit, Luden's, on-hit items, crits, burns, and the target's shields, Zhonya's and healers.

Combo burst = {R, E, Q, AA};               // AA = one auto attack
Combo full = burst + W + Q;                // or Combo(Q, AA) + E, or c.add(W)
ComboResult r = zed.perform(full, vs: lux);
print(r.damage, r.time, r.killed, r.steps);
a.perform(c, vs: t)Runs the combo on t and returns a ComboResult. An inline list works too: a.perform({Q, AA, E}, vs: t).
wait: falseSkip an ability that's on cooldown instead of waiting for it (default: wait).
healers: s fightBack: true within: 30Healers on t's side; t attacks back; time limit in seconds.
distance: 750Units between the two champions' centres, for effects that depend on it (Arcane Comet +0–100% over 0–750 units, Aftershock's 350 radius). The steps still ignore range, but a dash toward t travels it: its hits land on arrival (edge gap ÷ dash speed, as in fight(); Akali R1 from 675 lands 545/1500 = 0.36 s later) and the next step waits for it. Without distance: positions are ignored.
a.combo(c, vs: t)Shorthand for a.perform(c, vs: t).damage. (a.combo(Q, E, R, vs: t) with plain slots is the instant formula sum, without procs or timing.)
r.damage r.time r.dpsTotal damage (including burns after the last step); time of the last step; damage per second.
r.bySource("Kraken Slayer")Total damage after resistances from one source (an item, rune or ability), like an item's "damage dealt" counter. The name is matched exactly, as for f.bySource. prefix: true counts every label starting with it. r.floatingText lists every source's total.
r.killed r.killTime r.hpLeft r.hpPercentWhether and when the target died; what it has left.
r.steps r.lingering r.logDamage per step (skipped steps say why); damage after the last step; every event.
r.healed r.shieldedHealing and shielding done by the performer (lifesteal, Sundered Sky, Eclipse, item actives…).
{ProfaneHydra, Q, AA}An item with an active can be a step: it is pressed at that point (Tiamat and the Hydras, Stridebreaker, Gunblade, Rocketbelt, Zhonya's, Redemption, Locket, Mikael's, Actualizer, potions…). Damaging actives are only used when listed; in fight() fighters press them whenever they're ready.
c.size() c.add(W) c.stepsCombo length, extending it, the sequence as text.
{Q, E, P, W, E, P}P applies the passive's damage formula once (e.g. Katarina picking up a dagger). Its trigger is assumed to happen; the assumptions say so. Katarina's P needs a Dagger on the ground: it waits for the next one to land (Q: 1 s after the blade strikes; W: 1.25 s after the toss; wiki) and is skipped when none was thrown. Passives without a damage formula are skipped with a note.
SyndraA W step grabs a Dark Sphere on the ground, so after Q it waits for the sphere to appear (0.6 s); with no sphere at all a minion is assumed. The thrown sphere stays on the ground. R counts spheres at its press, including one still forming ("summoned very shortly before", wiki) but not one held by W or being pushed by E, so it waits (up to 1 s) for such spheres to land, as a player maximising damage would. With 40+ Splinters of Wrath (level 9 by default) Dark Sphere holds 2 charges: her level-11 max-damage combo is x.combo("Max damage (2 Q charges)") = {Q, W, Q, E, R, Q}.

Each step happens as soon as it can: an ability when it's off cooldown (casts lock the champion for 0.25 s), an auto attack when the attack timer allows. Every step hits; the assumptions list says so.

print(r.floatingText);Every damage instance in order: time, step, source (ability, attack, Luden's, Electrocute…), type, exact value, and the value floored and rounded, then the per-step totals. For comparing with the practice tool line by line.
r.stepDamage(i) r.hit(i) r.hitCountDamage of step i and of damage instance i (both count from 1); the number of instances. Burn ticks after their step are instances with step “tick”.

Combo library

Commonly cited combos from champion guides (Mobalytics, the League wiki and other guide sites), stored as step sequences with their source links in data/combos.json. Only the sequence is kept; when a combo is used, the working names its sources.

print(Syndra(11).combos);                  // the library combo names
Combo c = Akali(11).combo("Full all-in"); // a library combo as a Combo
print(Akali(11).perform(c + AA, vs: Dummy(hp: 3000)).damage);
x.combosThe names of x's library combos (the working lists each one's steps and when to use it).
x.combo("name")That combo as a Combo, for perform, + and add. Case and spaces don't matter.
E recastA step marked as a recast (Akali E and R, Zed W, Lee Sin Q, Yone E…). Where the engine already counts the recast in the first cast (Akali E/R, Dr. Mundo W, Gwen R) or has a step for it (Yone E returns early), that's used. Riven Q (Q2, Q3: the third knocks back, radius 250), Riven R (the recast is Wind Slash; Blade of the Exile itself deals nothing), Ahri R (R2, R3, 1 s apart) and Camille Q (Q2, then the attack it empowers) play the recast as a real cast of the ability, with its own damage. Otherwise the recast adds no damage and the working says so.
Q3 openersLibrary combos the guides open with a stacked Q3 (Yasuo Beyblade, Airblade, Keyblade, Delayed ult; Yone Q3 all-in, Flash Q3 ult) start with 2 Gathering Storm stacks (start.stacks in data/combos.json), unless you set x.stacks yourself. For your own combos set x.stacks = 2 first.
What's approximatedAnimation cancels, ward hops, Flash positioning, walls and terrain, form swaps: the ability stays in the sequence, the positioning is dropped. Each combo's notes say what was approximated.

Rounding. The game shows whole numbers in floating combat text and on the dummy. Whether it rounds or truncates damage numbers is not documented: the wiki only says the health bar rounds up and the C panel rounds armor, MR, AD and AP to the nearest whole number (wiki “Health”). floatingText shows both until measurements settle it; the practice tests allow ±1 per displayed number.

Practice-tool dummy

Dummy(hp:, armor:, mr:) is the Practice Tool target dummy, usable wherever a target Champion is: x.Q.damage(vs: d), x.proc(Item, vs: d), x.perform(combo, vs: d), canKill, timeToKill, fight. All settings are optional.

Dummy d = Dummy();                           // 1000 health, 0 armor, 0 MR
Dummy big = Dummy(hp: 3000, resists: 50);    // 20 × Add 100 Max HP, 5 × Add 10 Resistances
print(big);                                  // Target Dummy (practice tool: 3000 health, 50 armor, 50 magic resist)
auto r = Syndra(11).perform({Q, W, E}, vs: big);
print(r.floatingText);
Defaults1000 health, 0 armor, 0 magic resist, level 1, no stat growth, move speed 370, attack range 175 (melee), gameplay radius 65. Game file Characters/PracticeTool_TargetDummy (patch 16.19: baseHP 1000, baseArmor 0, no base MR, unit tag “Champion”) and the wiki's Practice Tool page agree.
hp:“Add 100 Max HP” gives 100 bonus health; “Remove” only takes away what was added. So the tool offers 1000, 1100, 1200… and everything above 1000 is bonus health (it matters for Lord Dominik's Regards and other effects that read bonus health).
resists: armor: mr:“Add 10 Resistances” gives 10 bonus armor and 10 bonus MR, so the tool offers 0, 10, 20… with armor = MR. resists: 50 sets both. armor: and mr: can be set apart for theory; the output then says the practice tool can't do it.
Counts as a champion“It stands in as a champion for all intents and purposes” (wiki); the game file tags it Champion. Champion-only effects (Electrocute, Kraken Slayer, Conqueror, Lord Dominik's, …) apply.
Can't dieIts health stops at 1; it takes at most (max health − 1). r.killed/r.killTime, canKill and timeToKill report when damage taken reached its max health, the moment a champion with these stats would die. After that, current-health effects see a 1-health target, as in game. Only Urgot's R can actually kill it (wiki).
Resets after 3 sAfter 3 seconds without damage it restores to full health and resets its counters (wiki). A combo with a gap of 3 s or more starts over on a full dummy, in the engine too.
Doesn't actIt never attacks, moves or casts; fightBack: does nothing. canDodge(d, …) is an error. It is immune to Grievous Wounds (it doesn't heal anyway).
d.hp d.armor d.mr d.bonushp d.hitboxIts settings. d.hitbox is 65 and grows with bonus health up to 130 at 10,000 health (wiki); straight-line growth in between is an assumption and is labelled as one.
CountersSince patch 25.14 the dummy's “last hit” counter adds up every damage instance from one hit (an attack plus its on-hit procs) (wiki Practice Tool, patch history). r.floatingText prints per-step totals for that.

Measurements from practice-tool sessions go in tests/practice/ (format in its README); tools/practice_to_tests.py turns them into assertions that the regression gate runs.

Built-in functions

min max abs floor ceil round sqrt powMaths.
champions(Class?) items() runes()Lists for loops.
fight canKill timeToKillSee Fight.
fights range(0, 1200, 50)See Fight sweeps; range(from, to, step) is a list of numbers, both ends included.
canDodge(b, a.Q, distance: d, using: b.E, reaction: 0.25)See Physics and dodging.
Champion(Syndra, 11) ItemSet(…) TeamComp(…)Constructors.
winrate pickrate banrate presence games stat matchups topPicks wpa winprob draftwp pickwpa laneDiff similar itemWpa eventWpa wpLostNumbers from real games and models fitted on them. See Real-game stats and Draft win probability.

Real-game stats

Everything else in Rift Logic is computed from the game files. These functions instead read what happened in real games: ranked solo queue by default, pro play with pro: true. Each returns a Stat: a number together with its sample size and a 95% interval, printed like 51.2% (n 7,713; 95% CI 50.1–52.3%). If there are too few games, it says so instead of giving a noisy number. Example: Real-game stats in the editor.

print(winrate(Syndra));                              // 51.2% (n 7,713; 95% CI 50.1–52.3%)
print(winrate(Syndra, role: "mid"));                 // 50.6% (n 5,270; 95% CI 49.3–52.0%)
print(winrate(Syndra, vs: Orianna));                 // 54.4% (n 241; 95% CI 48.0–60.5%)   any roles
print(winrate(Syndra, vs: Orianna, lane: true));     // 53.3% (n 167; 95% CI 45.7–60.7%)   same lane
print(winrate(Syndra, with: Nautilus));              // 49.9% (n 675; 95% CI 46.2–53.7%)   same team
print(winrate(Syndra, vs: Orianna, pro: true));      // 43.7% (n 142; 95% CI 35.8–51.9%)
print(presence(Syndra, pro: true));                  // 29.2% (2,638 picks or bans in 9,026 games; 95% CI 28.3–30.2%)
print(stat(Syndra, "gd15", pro: true));              // +114 gold (n 1,223 games; 95% CI +61 to +167 gold)
print(winrate(Syndra, vs: Rammus, pro: true));       // too little data: fewer than 20 games (pro play 2026 doesn't store pairs under 20 games)
assert(winrate(Syndra, vs: Orianna) > 0.5);        // a Stat compares and calculates as its value

These outputs were produced on 2026-09-25 with the data described below; they change when the data is rebuilt.

winrate(c)Share of c's games that c's team won. c is a champion: Syndra, Syndra(11) (the level is ignored) or "Syndra".
winrate(c, vs: d)Games with c and d on opposite teams, in any roles. lane: true: only games where both played the same role (summed over the roles that have enough games; role: "mid" picks one role and implies the same lane). winrate(c, vs: d) and winrate(d, vs: c) count the same games, so they add up to 100%.
winrate(c, with: d)Games with c and d on the same team.
games(…)The number of games behind winrate(…) with the same arguments (0 for a pair under the minimum, see below).
pickrate(c) banrate(c) presence(c)Share of all games in which c was picked, banned, or either (a champion is never both in one game). pickrate also takes role:, patch: and league: (then the share of that patch's or league's games); bans are stored for the whole dataset only. Solo-queue bans are the ten bans of each game.
stat(c, "gd15")A per-game average with a 95% interval (mean ± 1.96 SD / √n). Pro only: gd10 gd15 xpd10 xpd15 csd10 csd15 (gold, XP and CS difference against the opponent in the same position at 10 and 15 minutes). Both: kills deaths assists dmgshare dpm cspm (damage share of the team's damage to champions, damage to champions per minute, CS per minute), and kda = (kills + assists) / deaths from the averages (no interval). Takes role:.
matchups(c, lane:, role:, min:)A list of Stats: c's win rate against every champion with enough games, best first. Printed, its working shows the best and worst six. min: 100 raises the minimum number of games.
topPicks(role:, by:, n:, min:)A list of the n (default 10) champions with the highest by: "pickrate" (default), "winrate", "banrate", "presence" or "wpa" (with phase:; pro or solo; see Win probability); by: "draftwpa" ranks picks for a draft (below). Ranking by win rate puts small samples at the top (a 46-game 65% beats a 2,800-game 54%): add min: 500.
wpa(c, phase:, role:, vs:, pro:, year:)Solo queue (default; Riot timelines, lane phase to 14 min) or pro (pro: true; lane phase to 15 min): two separate models. Win probability added: the average change in c's team's chance to win that is credited to c, in percentage points per game, with a 95% interval. phase: "draft" (the picks: side prior → the draft model), "lane" (draft → 15 min pro / 14 min solo), "team" (the rest of the game) or "total" (default, all three). vs: d: games where d was c's lane opponent (roles pooled; role: picks one). See Win probability.
winprob(goldDiff:, at:, xpDiff:, killDiff:, csDiff:, roleGold:, roleXp:, side:, blue:, red:, byRole:, blueRating:, redRating:, rating:)Pro (the default here, for compatibility; pro: false is the solo model, below). The model's chance (0–1) that a team wins, given its gold lead at minute at: (10–25; 0 = the side prior), optionally its XP, kill and CS leads (or gold and XP by position with roleGold:/roleXp:, five numbers each), side: "blue" / "red" (default neutral) and the pre-game terms: the comps (blue:/red:: draft and comp scaling) and team ratings. See Win probability and WPA. A number, not a Stat.
matchups(c, by: "wpa", phase:)c's WPA against every same-lane opponent with enough games (solo, or pro with pro: true), best first.
winprob(pro: false, at:, goldDiff:, xpDiff:, killDiff:, towers:, plates:, inhibs:, inhibsDown:, dragons:, soulPoint:, soul:, elders:, elderActive:, grubs:, heralds:, barons:, baronActive:, items:, deadDiff:, side:, blue:, red:)The solo-queue in-game model (ranked solo 16.x timelines): the chance (0–1) that a team wins at minute at: (any minute; coefficients per minute 1–40) from its leads: gold, XP, kills, turrets, plates, inhibitors (destroyed; down now), drakes, soul point (1 = the team is on it, −1 = the enemy), soul, elders and the elder buff, void grubs, heralds, barons and the baron buff, completed legendary items, deadDiff: (enemies dead minus own dead right now), and the comps (blue:/red:). See Win probability and WPA (solo).
itemWpa(item, champ:, role:)Solo. Item WPA in percentage points per completion: the team's win probability 5 minutes after the item was completed vs the minute before, minus the average change of game states at the same minute and win probability. Overall, or for a champion (and role).
eventWpa(type, at:, by:, role:)Solo. What an event is worth at minute at:, in percentage points of win probability for the team it happens to: "baron", "elder", "herald", "grubs", "dragon" (or a type: "infernal", "ocean", …), "soul", "tower"/"inner"/"base"/"nexus", "inhib", "plate", "kill", "death" (role: for whose). by: "swing" (default: the observed change over the minute it happened, minus the average change of similar states: the whole play, fight included) or "direct" (the model's coefficient value of the event itself with gold and the rest of the state held fixed; small or noisy for objectives whose value arrives as gold, e.g. Baron). A number.
wpLost(c, role:, phase:)Solo. Win probability lost per game to c's own deaths (lower is better), with the role's average, untraded deaths and conversion; phase: "lane" = deaths before 14:00. See WP lost.
draftwp(blue: {…}, red: {…}, pro:, byRole:, blueRating:, redRating:)The draft model's chance (a model Stat, printed as a percentage) that blue wins with these picks. Separate models for pro (pro: true) and solo queue (default). Sides are lists or TeamComps of champions (or names); fewer than five picks is a partial draft. Roles are inferred from who plays where, or byRole: true reads the lists in role order top, jungle, mid, bot, support with "?" for an open slot. Pro only: blueRating:/redRating: (Elo numbers, or 2026 team names). See Draft win probability.
pickwpa(c, role:, side:, blue:, red:, pro:)The pick's win probability added for its side (side: "blue" default or "red"), in percentage points: P(side wins | draft + c) − P(side wins | draft). role: defaults to c's most-played open role.
laneDiff(A, vs: B, role:, pro: true, stat:)A's expected gold difference at 15 min against B in the same lane (a Stat in gold, with .n = the pair's games with the diff recorded and .role), pro only for now: Oracle's Elixir 2022–2026, each champion's lane average plus the pair's own residual, shrunk by empirical Bayes (Draft win probability). stat: "gd15" (default), "xpd15", "gd10", "xpd10". Without role:, the lane the pair met in most. laneDiff(Orianna, vs: Syndra, pro: true) is exactly minus laneDiff(Syndra, vs: Orianna, pro: true).
similar(A, n:, pro:)A list of the n (default 5) champions nearest to A in the draft model's champion vectors (cosine similarity, 1 = the same direction; solo queue by default, pro: true for the pro space). The Champions page's overview shows the same list. What the space means: Draft win probability, Champion vectors.
topPicks(by: "draftwpa", blue:, red:, side:, role:, n:, pro:)The n best picks for side by pickwpa, each in its most-played open role (or role:); champions only in roles where they have 10%+ of their picks.

Options

pro: truePro play instead of ranked solo queue.
year: 2025 year: "2025+"Pro only: which pro dataset, 2026 (default), 2025, or "2025+" (2025 and 2026 together).
role: "mid""top", "jungle", "mid", "bot" or "support" (also jng, middle, adc, sup…). Solo queue uses Riot's assigned position (teamPosition).
patch: "16.18"One patch (write 16.10 as "16.10": the number 16.10 is 16.1). winrate and pickrate of one champion only.
league: "LCK"Pro only: one league (any case). winrate and pickrate of one champion only; an unknown league lists the ones there are.
length: "short" length: "long"Win rate in games under 25 minutes, or of 32 minutes or more (the draft page's split).

One of role:, patch:, league:, length: at a time: the data stores each split on its own, not their combinations. Pairs (vs:, with:) are stored for the whole dataset only.

A Stat

s > 0.5 s - t abs(s - 0.5) double x = s;A Stat is its value in comparisons, arithmetic and maths functions (win rates are shares: 0.512, not 51.2). With too little data this is an error that names the reason, so check s.enough first when looping.
s.value s.n s.wins s.low s.highThe value; the games (for pick and ban rates, all games); the wins (or picks, bans: also s.count); the 95% interval (Wilson score interval for rates, normal for means; s.sd for means).
s.enough s.why s.what s.source s.champ s.otherWhether there is enough data, and if not why; what the number is; the dataset; the champion (and the vs:/with: one) as Champions.

The data

  • Solo queue: ranked solo games (queue 420) of patch 16.x from this project's Riot API crawl (high-ranked players; about a quarter each from EUW, KR, NA and OCE), 15 minutes or longer (no remakes): 49,858 games from 2026-02-26 to 2026-09-23, mostly patches 16.16–16.18. The same games as the Draft page.
  • Pro: Oracle's Elixir match data (downloaded 2026-09-29), every league in the file, games with ten player rows: 2026: 9,294 games (2026-01-01 to 2026-09-29); 2025: 10,041 games; 2025+: 19,335. Bans are the teams' ban1–ban5. The @10/@15 differences are missing for some games (some leagues' data is partial), so stat()'s n can be below the champion's games.
  • Minimum sample: 30 games in solo queue, 20 in pro. A win rate or average from fewer games is reported as too little data, and pairs (vs, same lane, with) under the minimum are not stored at all, so for them it can only say "fewer than 30". Pick, ban and presence rates are shares of every game, so they always have a number.
  • Built by src/stats_export.py into stats.json (about 1.7 MB, of which about 170 KB is the in-game win probability data and 200 KB the two draft models). The editor loads it the first time a program names one of these functions and then runs the program again; the command line (tools/rl.js) reads web/stats.json.

Win probability and WPA (pro)

Win rate only says whether a champion's team won. Win probability added (WPA) asks how much the team's chance of winning moved while the champion played, and credits the champion with a share of that move. It needs a model of the chance of winning at each point of a game: winprob(). The idea follows Coachless, who describe a calibrated in-game win probability model; theirs isn't published, so this one is built here from Oracle's Elixir by src/winprob.py (run it for the full validation report, --gbm for the gradient-boosting comparison).

print(winprob(goldDiff: 1000, at: 10));                 // 0.69
print(winprob(goldDiff: 3000, at: 15));                 // 0.84
print(winprob(goldDiff: 3000, at: 20));                 // 0.81   gold only: its weight includes the XP and kills that come with gold
print(winprob(goldDiff: 3000, xpDiff: 0, at: 20));      // 0.73   3k gold with no XP or kill lead is worth less
print(winprob(goldDiff: 5000, at: 25));                 // 0.91
print(winprob(at: 0, side: "blue"));                    // 0.53   the side prior
print(winprob(roleGold: {0, 0, 1500, 1500, 0}, roleXp: {0, 0, 500, 500, 0}, at: 15));   // 0.80  a 3k lead on mid and bot
print(winprob(roleGold: {1500, 1500, 0, 0, 0}, roleXp: {500, 500, 0, 0, 0}, at: 15));   // 0.73  the same 3k on top and jungle
print(winprob(goldDiff: 3000, at: 20, side: "blue", blueRating: "Gen.G", redRating: "T1"));   // 0.87
print(winprob(goldDiff: -2000, at: 10, side: "blue"));  // 0.18
print(winprob(goldDiff: -2000, at: 10, side: "blue", blue: {Ornn, Belveth, Kassadin, Jinx, Yuumi}, red: {Renekton, Hecarim, LeBlanc, Draven, Nidalee}));   // 0.25
print(winprob(goldDiff: -2000, at: 25, side: "blue"));  // 0.28
print(winprob(goldDiff: -2000, at: 25, side: "blue", blue: {Ornn, Belveth, Kassadin, Jinx, Yuumi}, red: {Renekton, Hecarim, LeBlanc, Draven, Nidalee}));   // 0.59  the scaling comp, 2k down at 25
print(wpa(Syndra, pro: true));                          // +0.35 pp (n 1,223 games; 95% CI -0.43 to +1.14 pp)
print(wpa(Syndra, pro: true, phase: "draft"));          // -0.39 pp (n 1,223 games; 95% CI -0.44 to -0.34 pp)
print(wpa(Syndra, pro: true, phase: "lane"));           // +1.07 pp (n 1,223 games; 95% CI +0.43 to +1.71 pp)
print(wpa(Syndra, pro: true, phase: "team", role: "mid")); // -0.25 pp (n 1,096 games; 95% CI -0.72 to +0.22 pp)
print(wpa(Syndra, pro: true, vs: Orianna));             // -2.81 pp (n 122 games; 95% CI -5.19 to -0.42 pp); Orianna vs Syndra: +2.81
print(topPicks(pro: true, by: "wpa", role: "mid", n: 5, min: 100));
    // Mel +3.56 pp (n 302), LeBlanc +3.29 (359), Cassiopeia +2.99 (904), Orianna +2.46 (1,603), Locke +2.43 (280)
print(topPicks(pro: true, by: "wpa", phase: "draft", n: 5, min: 100));
    // Taliyah +1.88 pp (n 934), Rumble +1.87 (1,941), Xayah +1.33 (567), Kai'Sa +1.19 (933), Rakan +1.18 (1,037)

Options of winprob(): at: the minute (10–25, interpolated between the snapshots; 0 = the side prior); the team's leads goldDiff:, xpDiff:, killDiff:, csDiff:, or per position roleGold: and roleXp: (five numbers each, top to support, instead of goldDiff:/xpDiff:); side:; and the pre-game terms blue:/red: (the two comps, as in draftwp(), with byRole:), blueRating:/redRating: (Elo or a 2026 team name) or rating: (the team's Elo lead). With comps or blue/red ratings, side: says which team the leads belong to. Pre-game terms that aren't given count as even (a typical draft, equal scaling, equal ratings). Calls with only goldDiff: (and xpDiff:/killDiff:) use the same gold-only and gold/XP/kills models as before, so their numbers didn't change.

LeBlanc is the example of what WPA adds: her 2026 pro mid win rate is an ordinary 51.1% (n 362), but her lane-phase WPA is +4.28 pp (n 359; 95% CI +3.04 to +5.51; second among mids with 100+ games, after Mel's +5.54), and her teams then give part of it back after 15 minutes: team-phase WPA −0.96 pp (95% CI −1.78 to −0.14). Her draft-phase WPA is −0.02 pp: the draft model thinks picking her is neutral, so what she adds shows up in the lane.

The model

  • What it predicts: P(team wins | what is known at minute t) for t = 10, 15, 20, 25: one logistic regression per minute on Oracle's Elixir games, fitted from blue's point of view (red's probability is exactly 1 − blue's; a neutral side drops the side term). Minutes between the snapshots interpolate the coefficients.
  • Features (the full model): gold and XP differences per position (per 1,000; each player against the same-position opponent), the team's kill difference, the team's CS difference (per 100), and three things known before the game:
    • Team strength: the pre-game Elo difference / 400 (the ratings of the draft model, updated only after each game).
    • The draft: the pro draft model's logit for the two drafts at equal ratings and without its side term. Each year's games are scored by a draft model fitted only on the four years before (2026 by 2022–2025, 2025 by 2021–2024, …), so the in-game model learns how much an out-of-sample draft prediction is worth, and the draft term never carries team strength (ratings are a separate term).
    • Comp scaling: each champion's scaling score is the slope of its win rate (log-odds per 10 minutes) against game length in ranked solo queue (games of 15+ minutes, shrunk to the mean by empirical Bayes: a champion with few games stays near 0); a team's score is the sum over its five champions, and the feature is blue's minus red's. Highest: Bel'Veth +0.27, Kassadin +0.22, Azir +0.21, Ornn +0.19, Yuumi +0.19, Kha'Zix +0.17; lowest: Locke −0.23, Irelia −0.18, Hecarim −0.17, Nidalee −0.17, LeBlanc −0.15. It is a champion attribute measured on solo games (like the hand-labelled comp profiles), not a solo-queue model: the pro model is fitted on pro games only. The same slope measured on pro games (earlier years only) made the model worse: pro game length is mostly team strength. The hand label (the "scaling" archetype in labels/draft_profiles.csv) helped about half as much.
    The scaling term has its own weight at each minute, so the model learns how scaling matters as the game goes on: 0.30 at 10 minutes, 0.47 at 15, 0.63 at 20, 0.74 at 25 (logit per unit of scaling difference). A gold × scaling interaction (a lead worth less against a scaling comp) was tested and changed nothing out of sample, so it isn't used: the scaling comp's edge is a shift that grows with time, not a discount on the gold.
  • What it can't use, and why: towers, dragons, heralds, barons, Atakhan and "firsts" are in Oracle's Elixir only as whole-game totals. A game's dragon count includes the dragons taken after minute t, which is part of what the model predicts, so using them would leak the result; none is used. Void grubs are the exception whose total is complete by 20 minutes (the pit turns into Rift Herald's); they were tested at 20 and 25 minutes and added nothing beyond the gold they bring. Assists added nothing either. Items, wards and objective timers aren't in the data at all. The solo-queue model will be separate and built from Riot timelines, which do have objectives and items at every minute (stats.json keeps winprob.solo for it).
  • Coefficients (full model, all games 2020–2026; logit per 1,000 gold top / jungle / mid / bot / support; kills; CS per 100; Elo/400; draft logit; scaling): 10 min 0.40 / 0.41 / 0.54 / 0.52 / 0.42, 0.07, 0.70, 1.30, 1.82, 0.30; 15 min 0.29 / 0.24 / 0.39 / 0.40 / 0.32, 0.07, 0.40, 1.13, 1.67, 0.47; 20 min 0.25 / 0.20 / 0.30 / 0.37 / 0.30, 0.06, 0.22, 0.94, 1.31, 0.63; 25 min 0.19 / 0.14 / 0.25 / 0.31 / 0.20, 0.07, 0.30, 0.78, 1.01, 0.74. A mid or bot laner's gold is worth more than the same gold on top or jungle; team strength and the draft matter most early and fade as the state takes over. The old gold-only weights (used when only goldDiff: is given): 0.796, 0.563, 0.484, 0.452 per 1,000.
  • Validation: fitted on 2020–2025 (the old model on 2019–2025) and tested on the 2026 games, which neither saw; the same test games for every row (8,892 / 8,893 / 8,874 / 8,288 at 10 / 15 / 20 / 25 minutes):
    minutelog loss: full (old / side-only)Brier: full (old)ECE: full (old)AUC: full (old)
    100.535 (0.563 / 0.691)0.181 (0.192)0.018 (0.009)0.803 (0.776)
    150.480 (0.502 / 0.691)0.159 (0.167)0.018 (0.012)0.848 (0.832)
    200.417 (0.433 / 0.691)0.136 (0.141)0.022 (0.020)0.889 (0.880)
    250.346 (0.357 / 0.691)0.110 (0.114)0.016 (0.018)0.926 (0.921)
    Log loss, Brier and AUC improve at every minute, most at 10 minutes (−0.028 log loss), where the pre-game terms matter most. The calibration error (ECE: the game-weighted gap between predicted and observed win rates in ten bins) is a little higher at 10 and 15 minutes: the full model is slightly overconfident in 2026 (predicted 0.15 → observed 0.19, 0.65 → 0.61 at 15 minutes), mostly from the rating term. The old model's numbers are unchanged from before.
  • Ablations (log loss on the same 2026 games; the full model without one group, and rejected candidates added to it):
    minutefull− Elo− draft− scaling− per-position (team totals)− CS+ gold × scaling+ assists+ void grubs
    100.53460.55550.53870.53530.53540.53450.53470.5345–
    150.48000.49270.48360.48170.48180.48030.48000.4799–
    200.41720.42400.41930.41990.41950.41760.41730.41710.4173
    250.34620.34910.34700.34880.34890.34700.34610.34610.3462
    Every kept group helps at every minute except CS at 10 minutes (±0.0001; kept for one form at every minute). The rejected ones change log loss by at most 0.0001. Grubs aren't tried before 20 minutes (their total isn't known yet).
  • Gradient boosting (sklearn HistGradientBoosting) was worse than the logistic model on the same features: log loss 0.540 / 0.485 / 0.423 / 0.355 at 10 / 15 / 20 / 25 minutes with the full features (logistic: 0.535 / 0.480 / 0.417 / 0.346), with higher calibration error (0.023 / 0.025 / 0.030 / 0.020). So the language and the page use the logistic model, whose coefficients fit in stats.json.

How WPA is credited

  • The team's path: side prior (53.0% blue) → the draft model's pre-game probability at equal ratings → the in-game model at 15 minutes without its rating term → the result (1 or 0). Over a game a team's WPA is result − prior, so the two teams' WPA adds up to zero, phase by phase.
  • Draft phase: the change from the side prior to the pro draft model's probability for the two drafts (below), at equal team ratings (team strength is not something a pick adds). Each game is scored by a draft model fitted only on the four years before it (2026 games by the 2022–2025 fit, 2025 games by 2021–2024), so a champion's own results don't leak into its draft value. The draft logit is written as a sum of five per-role terms from blue's point of view (red's are the negatives): the champion's strength minus the opponent's, the lane-matchup term, half of each synergy pair it is in, and its Shapley share of the team's comp-profile score (averaged over every order of adding the five picks, with open slots at the role's typical profile). These five terms are split by Shapley values of sigmoid(side term + the terms in a coalition) − the side prior. Lane opponents get exactly opposite draft WPA. The part of a change that no player causes is split equally.
  • Lane phase (draft → 15 min): the change from the draft probability to the rating-neutral 15-minute probability, split among the five players by Shapley values. Each player's contribution to the team's 15-minute logit is the full model's weights for their position times their own gold and XP differences against the same-position opponent, plus the kill weight times their kills minus the opponent's and the CS weight times their CS difference (Oracle's Elixir golddiffat15, xpdiffat15, csdiffat15, kills at 15; the five add up to the team's). The draft and scaling terms of the 15-minute model are the fixed part; the difference between them and the draft probability is split equally, like any change no single player causes.
  • Team phase (15 min – end): result − the rating-neutral 15-minute probability, split equally (a fifth each). It holds what happens after 15 minutes and team strength: the rating term is left out of the 15-minute point on purpose, so a strong team's expected edge lands here and is never credited to a champion's draft or lane. Oracle's Elixir's 20- and 25-minute differences are per position too, but after laning they reflect whoever took the gold in fights, so no per-player split is attempted.
  • Total = draft + lane + team. A champion's number is the mean over its games with 15-minute data (games without it, mostly some LPL and LDL games, are left out, so the n is a little below the win rate's). Means below 20 games are too little data; vs: pairs are stored from 20 games.
  • What the new model changed (2026, before → after): Syndra total +0.55 → +0.43 pp, lane +1.26 → +1.03, team −0.40 → −0.28 (draft −0.31, unchanged); Syndra mid +0.55 → +0.41 (after the 2026-09-26 draft-model update, with lane gold and role fit: total +0.35, lane +1.07, team −0.32, draft −0.39); Orianna mid +2.52 → +2.46 (lane +1.13 → +1.46); LeBlanc mid lane +3.89 → +4.28, team −1.17 → −0.96; Mel mid +2.41 → +3.56 (lane +4.36 → +5.54); Azir mid −1.51 → −1.72; Taliyah mid −0.89 → −1.32. Mid and bot leads now weigh more than top and jungle leads, and scaling comps' teams are expected to do better after 15 minutes, so less of their late wins counts as team WPA.

Limits

  • Team strength still reaches the total. It is kept out of the draft and lane phases, but a champion that strong teams play still gets their wins in team WPA. WPA is better than win rate at showing where a champion helps (a lane-winning pick that falls off, like LeBlanc, looks different from one that scales), but it is still observational.
  • Coarse snapshots. Oracle's Elixir gives the state only at 10, 15, 20 and 25 minutes, and no objective, item or vision state at the snapshot (see above). Two teams with the same gold lead can be in very different games.
  • Pro only. Solo queue gets its own model from Riot timelines (objectives, items and gold at every minute); until then wpa() and winprob() say "needs timelines; not available yet". stats.json keeps the slots ready: winprob.solo next to winprob.pro (each model lists its own features and variants), draftwp.solo next to draftwp.pro, and the solo dataset will get the same wpa/wpaDraft/wpaLane/wpaTeam averages.
  • The draft phase is small: a champion's draft WPA is nearly the same every game (its strength term dominates), so its interval is narrow, but it is a model estimate, not a measured effect.
  • The model behind WPA is fitted on all years, including the 2026 games it scores. Every league is pooled; a lead is probably worth more in a top league, where it is converted more reliably. The scaling scores come from 2026 solo-queue games and are applied to every year's champions (a reworked champion keeps its current score).

Win probability and WPA (solo)

Solo queue has its own in-game model and its own WPA, built by src/winprob_solo.py from Riot match timelines (data/timelines.db: 49,856 ranked solo 16.x games, every game of the stats sample; a timeline's participant k is the k-th player of the match, checked on every game by recounting each player's kills and deaths from the kill events: 2 games that didn't match are left out). It is never mixed with the pro model: pro only has Oracle's Elixir snapshots at 10–25 minutes and no objective timing, solo has the whole game minute by minute.

What the model sees at minute m (only what is known at m:00): gold and XP by position against the lane opponent, kills, CS, champions dead on the clock (death timers from level and game time), turrets by tier, plates, inhibitors destroyed and currently down, drakes by type, soul point, soul, elders and the elder buff, void grubs, Rift Herald, Baron and the Baron buff, completed legendary items (purchases net of undo and sell), the solo draft model's logit and the comps' scaling. The draft logit is cross-fitted: each game is scored by a draft model fitted on the other four fifths of games, so a game's own result never enters its draft term. Scaling is each champion's win-rate slope against game length in these games (cross-fitted the same way). One logistic regression per game minute (fitted every second minute on minutes m−2…m+2, odd minutes interpolated; 40+ uses 40).

Validation: trained on the first 85% of games by date (42,377), scored on the last 15% (7,479; every minute from 3 on, pooled; log loss, lower is better):

ModelLog lossBrierAUCLog loss at 10 · 20 · 30 min
gold difference only0.52460.17720.8110.569 · 0.447 · 0.499
pro-style (gold and XP by position, kills, CS, draft, scaling)0.51120.17190.8220.555 · 0.431 · 0.487
team totals + objectives (winprob(pro: false))0.50090.16790.8300.551 · 0.421 · 0.445
full (WPA uses it)0.49940.16730.8320.549 · 0.419 · 0.445
gradient boosting on the full features + minute0.51060.17130.824–

Calibration: predicted and observed agree within 1 pp in every decile (e.g. 15.0% → 15.9%, 50% → 50%, 85.0% → 85.1%; calibration error 0.35 pp pooled, 1–2 pp at single minutes). Gradient boosting did worse out of sample than the logistic model, so the logistic model is the one used everywhere (offline WPA and the browser). Ablations (log loss increase when a group is removed, pooled / at 35 min): drakes and soul +0.0075 / +0.016, scaling +0.0034, draft +0.0028, deaths on the clock +0.0023 / +0.034, turrets and inhibitors +0.0016 / +0.0074, Baron +0.0003 / +0.0035, items +0.0002 / +0.0043, grubs and herald +0.0002. Baron and items matter late; most of what they are worth early is already in the gold.

Timeline scaling: also measured is each champion's team out- or under-performing the state-based win probability as minutes pass (a champion × game-time residual slope, ridge-shrunk). It ranks sensibly (top: Jinx, Sivir, Aphelios, Yunara, Caitlyn; bottom: Locke, Akali, Lee Sin, Kai'Sa, Bard) but predicted held-out games less well than the length slope (0.5022 vs 0.4994 as the model's scaling term; both together 0.4991), so the model keeps the length slope and the timeline slope is only reported (stats.json winprob.solo.scaling.timeline).

print(winprob(pro: false, goldDiff: 3000, at: 20));
print(winprob(pro: false, goldDiff: 3000, at: 25, dragons: 2, towers: 3, barons: 1, baronActive: 1));
print(wpa(Jinx, role: "bot"));                           // solo WPA (default mode is solo)
print(wpa(Ahri, phase: "lane", vs: Syndra));
print(itemWpa(InfinityEdge, champ: Jinx, role: "bot"));
print(eventWpa("baron", at: 25));                        // the team's win probability swing in the minute of a Baron at 25
print(eventWpa("death", at: 15, role: "support"));
print(wpLost(Katarina, role: "mid"));

Solo WPA follows pro's philosophy: side prior → draft (the cross-fitted solo draft model; each champion's share by Shapley values of its draft terms) → the full model at 14:00 (the lane phase: Shapley values of each player's gold, XP, kill, CS and completed-item differences against the lane opponent; objectives, deaths on the clock and the pre-game terms count for the five equally) → the result (team phase, split equally). The ten players of a game sum to zero and lane opponents' lane WPA is exactly opposite. tools/wpa_solo_truth.py recomputes it from the raw timelines with its own state parser and Shapley values over all 120 orders.

Item WPA (Builds tab, itemWpa()): for every completed legendary, the team's win probability 5 minutes after completion vs the minute before, minus the average change of game states at the same minute and win-probability decile (games that end inside the window are left out). Observational: who buys an item, and when, isn't random, and a 95% interval that includes 0 means "no measurable difference". Rune WPA (Runes tab): a keystone's players' WPA minus the champion's average in the role, for the same reason a comparison of groups, not the rune's effect. Event WPA (eventWpa()): by default the swing, the team's win probability change over the minute the event happened minus the average for similar states (the whole play: a Baron at 20 / 25 / 30 min is worth +6.5 / +7.1 / +7.6 pp, a drake at 15 +5.2, the soul at 25 +5.7, an elder at 30 +16, a death at 15 −4.4); by: "direct" is the model's own coefficient value with gold held fixed, which is small or noisy for objectives whose value arrives as gold.

WP lost: an everyday-player stat

WPA credits the lane phase by gold and splits the rest of the game equally, so it says little about one player's mistakes. WP lost does: for each of a player's deaths, the drop in the team's win probability over the minute it happened in (0 if the team gained anyway, e.g. a won trade), shared among the team's deaths in that minute, summed per game. Lower is better. Alongside: untraded deaths (no enemy died within 15 s) and conversion (games won when the team's win probability reached 80%; teams that got there won 84.7%, at 90% 93.0%).

Does it tell more than deaths? Correlation with the team's result per player-game, controlling for the team's gold lead at 15 and the role: WP lost −0.270, untraded deaths −0.270, plain deaths −0.227; before 14 minutes WP lost −0.038 vs deaths −0.006 (at 15 minutes the gold lead has absorbed most early deaths). It is part of the win-probability path, so some of this is by construction; the fair comparison is with deaths, which have the same problem, and WP lost carries more. wpLost(c, role:) gives a champion's average with the role's; per-player numbers would need the player's own match history from the Riot API (the player-report pipeline could compute it the same way from each game's timeline).

Draft win probability (pro and solo queue)

Who wins, given only the picks? draftwp() answers with a model fitted on real games, and pickwpa() says how much one pick moves it. Pro play and solo queue are different games (coordinated teams and a known team strength vs five strangers), so each has its own model, fitted separately: pro: true picks the pro one. They are built by src/draftwp.py (run it for the validation report) into stats.json, and the Draft page uses the same coefficients. Example: Real-game stats in the editor.

print(draftwp(blue: {Ornn, LeeSin, Ahri, Jinx, Leona}, red: {Sion, Vi, Orianna, Kaisa, Nautilus}, pro: true));   // 55.0% (pro draft model)
print(draftwp(blue: {Ornn, LeeSin, Ahri, Jinx, Leona}, red: {Sion, Vi, Orianna, Kaisa, Nautilus}));              // 55.1% (solo-queue draft model)
print(draftwp(blue: {Ornn, LeeSin, Ahri, Jinx, Kassadin}, red: {Sion, Vi, Orianna, Kaisa, Nautilus}, byRole: true));   // 47.7%: Kassadin support is off-role
print(laneDiff(Gwen, vs: KSante, role: "top", pro: true));                           // +119 gold @15 (622 games)
print(draftwp(blue: {Ornn, LeeSin, Ahri, Jinx, Leona}, red: {Sion, Vi, Orianna, Kaisa, Nautilus}, pro: true,
              blueRating: "T1", redRating: "Gen.G"));                                // 33.2%: T1 1900 vs Gen.G 2115 Elo
print(draftwp(blue: {}, red: {}, pro: true));                                        // 53.4%: the empty draft is the side prior
print(draftwp(blue: {Ornn, LeeSin, "?", Jinx, "?"}, red: {"?", Vi, Orianna, "?", "?"}, byRole: true, pro: true));  // 51.9%
print(pickwpa(Rakan, role: "support", side: "blue", blue: {Ornn, LeeSin, Ahri, Jinx}, red: {Sion, Vi, Orianna}, pro: true));
    // +0.76 pp: blue 54.7% → 55.4% (solo queue: +0.15 pp)
print(topPicks(by: "draftwpa", side: "blue", role: "support", blue: {Ornn, LeeSin, Ahri, Jinx}, red: {Sion, Vi, Orianna}, n: 5, pro: true));
    // Bard +0.98 pp, Rakan +0.76, Neeko +0.7, Seraphine +0.28, Renata Glasc +0.22

A result is a model Stat: it prints with the model's name, compares and calculates as its value (a probability for draftwp, percentage points for pickwpa), and has .value, .what, .source (and .champ for a pick). It has no interval: it is a model output, not a count.

The model

  • Form (both): logit P(blue wins) = side term + Σ over roles [strength(blue champion, role) − strength(red champion, role)] + Σ lane matchup(blue, red) + synergy(blue) − synergy(red) + w · (profile(blue) − profile(red)) + wfit × Σ over roles [fit(blue champion, role) − fit(red champion, role)], plus, in pro, wR × (Elo blue − Elo red) / 400 and Σ over roles wr × expected gold difference at 15 min (blue laner vs red laner) / 1000. Every term is antisymmetric, so swapping the sides gives exactly 1 − P apart from the side term.
  • Lane gold matchups (pro): Oracle's Elixir records each player's gold and XP difference at 10 and 15 min against the same-position opponent (not LPL). Per role, empirical Bayes in two levels: a champion's lane strength u = its side-signed sum of (diff − the blue-side mean) / (games + k1), and the pair's own residual m = its sum of (diff − u(blue) + u(red)) / (pair games + k2), with k = noise variance / between-group variance by the method of moments (top: k1 ≈ 12, k2 ≈ 23 games). Expected diff of A vs B = u(A) − u(B) + m(A, B), so a rare pair leans on the two champions' averages. In training each game's own diff is left out of the estimates it is scored with. The weights per lane (logit per 1,000 expected gold, the 2022–2025 fit, with ratings): top +0.10, jungle −0.13, mid +0.02, bot +0.05, support 0.00; a small effect (a 300-gold top matchup ≈ 0.8 pp). Out of sample the lane averages predict next year's diffs well (2026 slope ≈ 1 in top, jungle and mid), the pair residuals only weakly. The export folds the term into the strength table and the lane pairs (it is linear), so partial drafts and pick WPA handle it like the rest. laneDiff() reads the same table. Solo queue: the same code runs on Riot timelines (per-minute gold and XP) once data/timelines.db covers the sample; until then solo keeps only its win-rate matchup terms.
  • Role fit (both): fit(c, role) = log of the share of the champion's games in that role (floor 0.2%, one pseudo-game spread over the roles), leave-one-out in training. It is what makes an off-role or troll pick cost something: rare champion-by-role strengths are shrunk to 0, so without it Kassadin support read as neutral (pro −0.25 pp against Leona before, −4.8 pp now; solo −2.9 pp before, −7.3 pp now). Weights: pro 0.026, solo 0.028 logit per log unit (fits on all data). Folded into the strength table too.
  • Champion strength = a champion main effect + a champion-by-role deviation. Lane matchups: one antisymmetric term per same-role pair with 15+ (pro) or 30+ (solo) games. Synergy (pro only): bot + support, jungle + support and jungle + mid pairs with 15+ games; in solo queue they made held-out predictions worse, so the solo model has none. Comp profile (labels/draft_profiles.csv, the Draft page's hand profiles): shortfalls in frontline (below 2), engage + ½ pick (below 3.5), carry (below 4.5) and ranged threats (below 2), too much frontline (above 3), a damage mix outside 28–62% magic, class stacking, five melee, the poke × protect mix, and the archetype shares. These carry signal for pairs too rare to have their own term.
  • Fitting: L2-penalised logistic regression (sklearn) with a separate penalty per group, chosen on a validation split inside the training data (pro: fit 2022–2024, validate 2025; solo: first 70%, validate the next 15%). Pro games are weighted by recency (half-life one year) and the pro model starts in 2022. Heavier models weren't tried: the gains below are already at the size where the logistic form is not the limit.
  • Pro team ratings: Elo over every Oracle's Elixir game 2019–2026 in date order, K = 64 (tuned on 2025: log loss 0.634 against 0.642 at K = 16), updated only after each game, so a game sees only earlier results. A new team starts at the mean rating of its league's teams; each new year ratings move 25% back toward the league mean; international events don't change a team's home league. The rating weight is fitted together with the draft terms (1.68 logit per 400 Elo), so the champion terms mean "at equal team strength". blueRating: takes an Elo number or a 2026 team name (the table as of 2026-09-29; e.g. Gen.G 2115, T1 1900). Ratings compare well within a league and across leagues only through international games; small circuits can drift.
  • Partial drafts: an open slot counts as the role's pick distribution (pro: 2026 pro picks by position; solo: ranked solo 16.x by teamPosition): its expected strength, the lane term against a known opponent averaged over the open side's distribution (0 when both are open: antisymmetric), synergy likewise, and the role's average profile in the comp features. P = sigmoid of that expected logit. So the empty draft is exactly the side prior, and a pick's WPA is P(after) − P(before): the WPAs of a whole draft, in order, add up to the full draft's P − the side prior (tests/draft_wp.rl). Bans are not in the model, and an open slot's distribution doesn't exclude champions already picked. Roles are inferred by the most likely assignment (the product of each champion's share in each role) unless byRole: true.

Validation (held out by time)

Pro: fitted on 2022–2025 (43,871 games), tested on the 9,294 games of 2026. Solo: fitted on the first 85% of games by date (42,379), tested on the last 15% (7,479). Log loss / Brier / calibration error (ECE, ten bins) / accuracy / AUC:

prolog lossBrierECEaccuracyAUC
side only (blue 53.0%)0.69080.24880.00453.4%0.500
side + champion win rates (sum of shrunk per-role win-rate logits)0.68870.24780.01954.2%0.549
draft model without ratings0.68710.24700.00654.3%0.551
side + Elo ratings0.62970.21980.01564.4%0.697
full model (ratings + draft)0.62730.21880.02264.5%0.701
solo queue
side only (blue 48.2%)0.69310.25000.00851.0%0.500
side + champion win rates0.69190.24930.03053.0%0.543
full model0.68820.24760.00853.5%0.555
  • Pro: team strength is almost everything. Ratings alone take the log loss from 0.691 to 0.629; the draft terms add 0.002 more (AUC 0.698 → 0.701). Without ratings the draft model beats the side prior by 0.003 and the win-rate baseline by 0.0006, which is the honest size of "who drafted better" in pro once you can't see the teams. Reliability (full model, predicted → observed): 0.16 → 0.19, 0.26 → 0.23, 0.36 → 0.32, 0.45 → 0.42, 0.55 → 0.53, 0.65 → 0.64, 0.74 → 0.73, 0.84 → 0.85; slightly over-confident below 40% (as the ratings-only model is).
  • Solo queue: the draft matters more, and the model is well calibrated. 53.6% accuracy (side only 51.0%), log loss 0.0047 below the side prior and 0.0035 below the champion-win-rate baseline, calibration error 0.007 (reliability 0.38 → 0.39, 0.46 → 0.47, 0.53 → 0.54, 0.61 → 0.62). The win-rate baseline is over-confident (ECE 0.030): per-champion win rates add up noise that shrinkage removes.
  • Lane matchups, role fit and champion vectors (added 2026-09-26; log loss on the held-out test, lower is better). Pro, full 0.6262: without the lane gold term 0.6268, without role fit 0.6265, both out (the old model) 0.6269; the lane term's forms: summed over the lanes with one weight 0.6265, the pair residual only 0.6273, instead of the win-rate matchup terms 0.6264, GD15 + XPD15 0.6263, GD10 0.6265; with champion embeddings 0.6291, embeddings instead of the lane term 0.6302. So: none 0.6268 · lane gold only 0.6262 · embeddings only 0.6302 · both 0.6291. Draft terms without ratings: 0.6875 → 0.6868. Solo, full 0.6882: without role fit 0.6884; with embeddings 0.6896. Honest size: these are gains of 0.0002–0.0007, near the noise between years. On the 2025 check (fitted 2022–2024) the lane gold term was slightly worse (0.6318 with, 0.6314 without; the residual-only form 0.6310 was best there but worst on 2026), so the per-lane form was kept because it wins on the pre-set 2026 test, not because it wins everywhere. Role fit helped in both years.
  • Champion vectors (evaluation only): a factorisation model gives each champion a 4-d vector z = F P + e, where F is side information (the 8-d kit embedding in data/processed/embedding.npy and the draft-profile attributes), P is learned and e is a heavily penalised free offset, so a rare champion sits near champions with similar kits. Lane matchups are za′ Sr zb (Sr skew-symmetric, so antisymmetric) and synergies za′ Vj zb, fitted by penalised logistic regression (L-BFGS) on the linear model's logit, backfitted twice. Every penalty that left the vectors non-zero made the held-out log loss worse in pro; in solo queue the best validation penalty helped on validation and hurt on the test. So they are not in the win probability. They do give a similarity space: similar() and the Champions page's "Similar champions" strip (cosine). It is mostly a learned weighting of kit features (Syndra: Zyra, Taliyah, Xerath, Anivia, Lux; Thresh: Blitzcrank, Milio, Pyke, Soraka, Nautilus), not proof that two champions play the same.
  • Ablations (log loss when one group is dropped and the rest refitted; lower is better). Pro, full 0.6262: without champion main effects 0.6266, without champion × role 0.6265, win-rate lane matchups 0.6264, synergy 0.6265, profile 0.6263, lane gold 0.6268, role fit 0.6265, ratings 0.6868; ratings + profile only 0.6288; profile only (no ratings) 0.6899. Solo, full 0.6882: without champion 0.6887, champion × role 0.6887, lane 0.6884, profile 0.6887, role fit 0.6884; synergy made it worse (before role fit: 0.6884 without, 0.6886 with) and was dropped; profile only 0.6922. Every draft group helps a little and none much: champion strength is the largest, the comp profile next (and alone it beats the side prior in both), lane matchups least.
  • What the coefficients say: pro champion terms are tiny (the strongest role picks: Rumble top +0.038 logit ≈ +0.9 pp, Cassiopeia mid +0.029, Ashe bot +0.027; K'Santé top −0.042); solo ones are about three times larger (Rell support +0.139 ≈ +3.5 pp, Karthus bot +0.121, Alistar support +0.115; Yuumi −0.108, Aphelios −0.107, Ezreal −0.105). In the pro profile, the engage shortfall has the largest weight (−0.035 per missing point, matching the draft-circle verdict: comps short on engage win less), then poke share (−0.022) and too much frontline (−0.020). In solo queue a lopsided damage mix is the strongest profile signal (−1.9 per unit outside 28–62% magic), and pick-archetype comps beat the other types (every other archetype share is −0.05 to −0.49 against pick).

Limits

  • Small effects. A pick typically moves the pro model by under ±1 pp and the solo model by under ±3 pp. Treat differences of a few tenths of a point as noise.
  • Confounding. The model learns which drafts won, not why. In solo queue a champion's strength term includes who plays it (one-tricks, rank); in pro, the ratings remove team strength only as far as Elo captures it (rosters change mid-year, and minor-league ratings are only linked to major ones through international games).
  • No game plan. The model doesn't know what a comp intends. The Draft page keeps its needs and game-plan logic and blends the model in (next paragraph).
  • Draft page blend: each suggestion shows its pick WPA from the mode's model (pro or solo) as "WP ±x", the header shows the side's current win chance and each comp panel a "Win prob." row. The ranking adds 1 × the pick WPA (in pp) to the heuristic score when the model's held-out calibration error is under 1.5% (both models today: pro without ratings 0.8%, solo 0.8%), otherwise ½ ×. Ban ideas show what the enemy would gain by picking the champion. The pro page uses equal team ratings. Under the board a Lanes strip shows each lane where both sides have a champion (in the lanes the board assigns, set or inferred): the expected gold difference at 15 min (pro, as laneDiff(), with the pair's games) and the pair's same-lane win rate in the mode's data. An Off-role row in a comp panel flags picks under 5% of the champion's games in their role. The Scaling row is the in-game model's champion scaling: each champion's solo-queue win-rate slope (log-odds) per 10 minutes of game length, shrunk by empirical Bayes and centred, summed over the picks, shown for both teams. It replaced a raw "win rate in 32+ min games minus under 25 min" difference, which let small samples dominate (Milio +10.6 pp on 352 long games). Game length is an outcome, so it stays a hint; a timeline-based measure is planned.

Reading them carefully

  • Solo-queue win rates are observational. A champion's win rate mixes in who picks it (mains and one-tricks win more on hard champions; weaker players drift to others), what else is in the draft, and blind or counter picks. A matchup win rate is not a counter by itself: the champion picked second often picked because it counters.
  • Pro win rates carry team strength. Strong teams get the meta champions and win anyway; every league from LCK to minor regions is pooled, and samples are small. Presence (picks + bans) is the more reliable pro signal of how highly a champion is rated.
  • Small samples. Read the interval, not the point: 53.3% (n 167; 95% CI 45.7–60.7%) is consistent with an even matchup. matchups() and topPicks(by: "winrate") list the extremes, which are where small samples land; use min:.
  • Every result's working says which games it counted, and the footnotes name the dataset and these caveats.

Rules and claims

rule dive(Champion x, Champion y) -> beats(x, y) [1] {
    return x.is(Assassin) && x.has(mobile) && !y.has(mobile);
}
rule stunTheDive(Champion x, Champion y) -> !beats(x, y) [2] {
    return x.has(mobile) && y.has(stun);
}
prove beats(Fizz, Syndra);
  • A rule is an argument that the claim is true (-> beats(x, y)) or false (-> !beats(x, y)) whenever its body returns true. [n] is its strength, default 1.
  • For a claim, every rule with that name and number of arguments is tried. The strongest argument that fires wins. If the strongest for and against are equally strong, the claim is contested. If none fire, no rule applies.
  • A body written as return c1 && c2 && c3; is shown condition by condition, with the evidence for each (the tooltip phrase behind a tag, the formula behind a number). Other bodies are shown as a whole.
  • Claims can be used inside functions and other rules as booleans: beats(a, b). A claim is true only when it's proved. Sub-proofs are shown nested.
  • Rule arguments are the parameter names in the claim, in order: -> counters(y, x) is allowed.

Correcting the data

Tags are read from tooltip text, and a few physics values are missing from the game files. Correct them in your program. Every result that uses a correction lists it under Holds only if you accept.

Syndra.R.addTag(targeted);Add a tag to an ability.
Syndra.W.removeTag(pull);Remove a wrong one.
Fizz.E.setCooldown(10);Override a cooldown.
Syndra.Q.setPhysics(speed: 0, delay: 0.6);Override physics.
Fizz.addClass(Mage); Fizz.removeClass(Fighter);Change classes.
Katarina.P.setDamageType("magic");Set an ability's damage type when the tooltip doesn't say and the engine's guess (physical if it scales with AD, else magic) is wrong.

Corrections apply to that champion everywhere in the program, from the line where they're made.

Physics and dodging

canDodge(defender, ability, distance: d, using: defender.X, reaction: r, preBuff: bool, recast: bool, hitbox: units) checks whether the defender can avoid an ability aimed perfectly at them from d units away.

Where it comes from. Range doesn't always mean the ability travels from the champion to the target. Every ability has a delivery (a.Q.delivery), read from the game files' targeting type and corrected by hand where they mislead (each correction cites its source):

deliverywhat happenstime to land at d unitsreach
skillshotFired from the caster in a direction (Ezreal Q, Lux Q).cast + d ÷ speed + delayrange
lobbedThrown from the caster to a chosen point; hits an area where it lands (Lux E, Ziggs Q, Gragas Q).cast + d ÷ speed + delayrange + radius
placedAppears at a chosen point within range (Syndra Q, Xerath W, Karthus Q, Veigar W). Distance doesn't matter.cast + delayrange + radius
vectorThe caster picks a start point anywhere within range, then it runs its length in a chosen direction (Viktor E, Rumble R). Aimed perfectly the start sits on the target.cast + delay + (d − range, if beyond range) ÷ speedrange + length
remoteComes from another object: Orianna's ball, Azir's soldiers, Ornn's elemental, Xayah's feathers. The object starts on the target (perfect play) unless you say otherwise with setPhysics(origin: units).cast + origin ÷ speed + delaythe object's range + radius
unitPoint-and-click, including homing bolts and empowered attacks.cast + d ÷ speedrange, centre to centre (see the reach rule)
selfCentred on the caster (Amumu R, Alistar Q).cast (+ d ÷ speed if it expands)radius
coneA cone from the caster.cast + d ÷ speedcone length
  • Arrival time (a.Q.arrival(distance: d)) is measured from the start of the cast, by the delivery's formula above.
  • Time to respond = arrival time − the moment it becomes visible − reaction time (default 0, perfect play). It becomes visible when the cast ends (the missile or ground marker appears then; during the cast time the target point can't be seen), unless its data says it shows later (Rumble R: nothing is on the ground until the first rockets land). So Syndra Q gives 0.6 s to respond at any distance.
  • Hitbox: the defender's gameplay radius (b.hitbox: 65 for most champions, 80 for large ones, 55 for small ones). Test a grown champion with hitbox: 100.
  • Out of reach — the reach rule (one rule for canDodge, fight() and combos; a.Q.reachTo(b) prints it): the greatest centre-to-centre distance at which something hits b. From the wiki's Range page (Calculations, Targeted abilities), each ability page's range icon (centred cr or edge er), and the game files' castRangeUseBoundingBoxes flag on the spell record — the three agree on every point-and-click damage ability but Twitch R (whose bolts are his attacks) and Bel'Veth R (a Void Coral, not a champion):
    • Basic attacks: edge range, always: attack range + the attacker's radius + the target's. Annie (radius 55) attacks Syndra (65) from 625 + 55 + 65 = 745.
    • Point-and-click spells: centred range: the cast range, whatever either champion's size. Annie Q (625) reaches Syndra at 625 but not at 626.
    • …except edge-range spells (castRangeUseBoundingBoxes: Tristana E/R, Vayne E, Viktor Q, Ryze W/E, Anivia E, Lucian Q, Jhin Q, Quinn E, Poppy E, Senna Q, Zilean E, Janna W, Evelynn E, Singed E, Smolder Q, Cho'Gath R, Nunu Q, Rek'Sai E, Elise Q, Jayce E, Miss Fortune Q, Bel'Veth R): range + both radii. The flag also sits on two self-centred searches (Kennen W, Kai'Sa Q); what it means there isn't verified (the wiki gives Kennen W 775 centred, "pending for test"), so they stay centred. Vayne E reaches Syndra at 550 + 65 + 65 = 680.
    • Everything else (skillshots, areas, cones): centre to edge: its reach (.reach) + the target's radius. Exception: a skillshot whose wiki page marks the game files' range with the centred-range icon ({{tip|cr}}, e.g. Zed Q 925, Ezreal Q 1200) reaches only that far (the target's centre must be within it); one marked edge range ({{tip|er}}, e.g. Yone R 1000, Akali E 825) adds both radii.
    • Hits that are basic attacks (Twitch R): the attack range + the bonus (+300) + both radii.
    • A point-and-click ability without a cast range of its own (an empowered attack) uses the attack reach.
    Beyond that distance the defender is safe standing still.
  • Walking: the defender must move half the width (lines), the radius (areas) or the cone's half-width at that distance, plus their hitbox, before it lands. Move speed includes boots and other items, runes and League's soft caps (b.ms). For an area centred on the caster, they walk out of it instead. The line prints the move speed that would be needed.
  • Point-and-click abilities can't be walked out of.
  • using: an ability that makes the defender untargetable or invulnerable dodges anything if its cast time is shorter than the time to respond. A dash or blink adds its distance (after its cast and travel time), then the defender keeps walking. A move-speed boost (Orianna W, Hecarim E, Singed R, Ghost, Heal, …) starts after its cast: the defender stands still while casting, then walks at the boosted speed (with its decay, ramp or field, and League's soft caps above 415). Perfect play: the defender uses whatever moves it farthest, so a boost whose cast costs more than it gains is skipped and plain walking counts.
  • preBuff: true: the move-speed boosts in using: were cast before the ability appeared, so they're already running (fresh) when the time to respond starts, and cost no cast time. For an invisibility ability (Akali W) it means she's already unseen in the shroud: a point-and-click spell can't be cast on her (wiki Invisibility). Without it, a point-and-click spell cast before she's invisible still lands.
  • recast: true: the using: ability is on its recast. Akali R's recast is an 800-unit dash at 3000/s with no cast time (in any direction); her first R is a 750-unit dash at 1500/s after a 0.25 s cast. Akali E's first cast flips her back 400 units, starting 0.15 s into the cast, at 1500/s.
  • Stacking fields (Viktor W): what must be dodged is the stun on the 5th stack, 2.0 s after the cast starts (placed at the end of the 0.25 s cast, active 0.75 s later, a stack every 0.25 s inside). The defender must be outside (radius 340 + hitbox) by then, and is slowed while inside once the field is active.
  • Viktor R: after the answer, the working adds how many of the storm's 6 strikes land if the defender walks straight away (the storm follows at 300/s within 300 units of Viktor, down to 200/s at 900).
  • Several at once: using: {o.W, Flash} tries every combination of the listed abilities and summoner spells (boost casts first, then dashes and blinks, then walking) and uses the best. using: Flash adds a 400-unit blink with no cast time.

setPhysics also takes delivery: "placed" (or any of the above), origin: (units from the remote object to the target), length: (a vector's length), reveal: (seconds into the cast when it becomes visible) and reach:.

Speeds, widths, radii and cast times come from the game files; where only the wiki has a value (some radii and dash speeds), the result lists it as an assumption. Dash speeds and appear delays that neither source has default to 1200 units/s and 0, also listed as assumptions and settable with setPhysics.

How fights are simulated

  • Time moves in 0.05-second steps. Within a step everyone acts at once, on the state at the start of the step: first items, summoner spells, heals and shields, then everything else. Damage between the sides (and the healing it causes), deaths, walking, blinks, new crowd control (and the interruptions it causes), stasis and knockbacks take effect at the end of the step. So the order of the champions, and which side is side 1, doesn't change the result: a mirror match ends even, and fight(a, b) is fight(b, a) with the sides swapped (tests/fight_symmetry.rl). Two champions can kill each other in the same step. The line is centred on 0 (side 1's front at −start/2) so mirrored positions are exact.
  • Each champion, when free, first uses a heal or shield if an ally needs one (healing ultimates are saved for allies below 40% health), then peels (below), then the first ability in its rotation that is off cooldown and in reach, otherwise an auto attack if the target is in attack range, otherwise it moves. Casting locks the champion for 0.25 seconds. See Crowd control and positions.
  • Damage goes through amplifiers, resistances after penetration, Bone Plating and shields, then health. Lifesteal (attacks), omnivamp and Conqueror heal the attacker; grievous wounds cut healing received by 40%.
  • Heals go to whoever the healer's policy picks; team heals reach everyone; Moonstone chains 30% to a second ally.
  • Zhonya's (below 30% health, or when x.stasis says) puts a champion in 2.5 seconds of stasis; Guardian Angel revives once after 4 seconds.
  • Untargetable from the cast: Zed R (0.95 s), Fizz E (1.25 s), Vladimir W (2 s), Master Yi Q (0.94 s), Kayn R (1.35 s) and Pantheon E (1.5 s, his block) have no cast time, so their casters act first in a step and are untargetable for that whole step: nothing cast at them that step lands (and it neither starts nor cancels First Strike), and an Ignite or Exhaust pressed on them that step is taken back. Hits already in flight that arrive that step still land. Elise's spider-form E isn't modelled.
  • Energy (Zed, Akali, Lee Sin, Kennen, Shen; game data): 200 maximum (Shen 400), +10 per second; abilities wait until it covers their cost, in fights and combos (wait: false skips them instead). Restores: Zed's double hits with a shadow (30–50), Lee Sin's Flurry attacks (20/30/40 then 10/15/20; Q2 costs 25), Kennen E (40) and his passive stun (25), Shen Q/E (30/40/50), Akali W (100, +100 maximum while the shroud lasts). Mana is still ignored.
  • Summoner spells (only for champions with .summoners set, or as combo steps): Ignite and Exhaust once the target is within cast range (650 / 600), Heal and Barrier below 30% health.
  • Viktor and Akali (wiki, checked 2026-09-24; fight() only, perform() keeps the simpler combo rules): Viktor's Gravity Field is placed on the target, activates 0.75 s after the cast and stuns only a unit still inside on its 5th stack (every 0.25 s); fighters walk out at the last moment that still makes it and wait at its edge while the next stack would stun. Aftershock can be sidestepped by a free enemy (it spends the time walking). The storm follows its target at 200–300/s. Turbocharge and Magnetize work. Akali spends energy (200, +10/s; Q 110–70, E 30), casts W (move speed, energy, invisibility: no attacks or point-and-click spells on her while unseen) when she needs energy, to leave a Gravity Field, or first if her rotation starts with W; her E flips her back 400 units and the recast dashes to the marked target; R1 dashes 750 through the target; R2 is used when it kills, at the 70%-missing cap, as its window closes or to escape a field; she steps out of her ring for Swinging Kama.
  • The fight ends when one side has nobody left, or at the time limit.
Positions are on one line; attacks, point-and-click abilities and dashes in reach hit, and skillshots and areas hit unless the defender's player skill lets it walk out (skill: / x.skill, default 70; perfectAim: true = everything in reach hits), an ability lands after its cast time, flight and appear-delay (the timing .arrival and canDodge use: Lux Q at 500 units 0.67 s, Lux R 1 s; cooldown and cost start at the press; crowd control never cancels a cast time (wiki Cast time: only death does) but does cancel channels and charges; dashes start when the cast time ends and hit when they arrive in reach; a displacement or knockdown stops a dash under way, a stun doesn't; unstoppable abilities (Malphite R, Vi R, Hecarim R, Jarvan IV R, …) ignore displacements and can't be stopped, though spell shields still block them; perform() without distance: counts no flight), mana is ignored (energy is not), every champion fights at its own level and items unless levelGap: / goldGap: offset them (a gold gap removes items from the last one bought; a partly-bought item gives only its share of stats), and special mechanics beyond an ability's tooltip formula aren't simulated. Use canDodge and your own rules to reason about the rest.

Crowd control and positions

Positions

  • One line, centred on 0. Side 1's front is at −start/2 and faces +; side 2's front is at +start/2 and faces − (start: default 0). Each champion stands max(0, attack range − 175) behind its front, so ranged champions start at their attack range from where the enemy front will be (formation: false: everyone on the front line).
  • Teamfights (3 or more a side): each side's champions stand 350 units apart sideways (spread:, default 350; spread: 0 puts everyone on one line), and an area, line or cone hits a second enemy only if it is inside it in 2-D. Ultimates wait until 5 s into the fight (rHold:, default 5; rHold: 0 casts at once). Both came from testing against real 5v5 fights: reasoning/fight-accuracy/loop5v5.md.
  • Reach (wiki "Range"): basic attacks use edge range (attack range + both gameplay radii); point-and-click abilities centred range (edge range, + both radii, for the castRangeUseBoundingBoxes spells); other abilities their reach (.reach) + the target's radius. Placed and lobbed areas hit every enemy within their radius of the target; self-centred areas every enemy within the radius of the caster; lines and cones every enemy within reach (on one line everything is in the path).
  • Walking uses the current move speed: (base + flat) × (1 + % bonuses) × (1 − the strongest slow × (1 − slow resist)), then the soft caps (wiki "Movement speed": only the strongest slow counts).
  • Dashes and blinks (abilities tagged dash or blink) use the dash range and speed from the game data (dashInfo; 1200 units/s when the data has none). A dash ends touching its target or at its range; its hits land when it arrives. A blink moves at once.
  • Knockbacks move the target away from the caster by the data distance over the airborne time (Janna R: to 875 units from her; Gragas R 900; Alistar W 700; Vayne E 475); pulls move it toward the caster. A placed knockback (Gragas R) is aimed away from the caster's team, or toward it for role "engage". Knockbacks whose distance neither source gives (Gragas E, Draven E, Ornn Q) only make the target airborne.

Roles (x.role)

"kite"Default for ranged champions. Targets the lowest-health enemy it can reach (else the nearest). Backs off from any shorter-ranged enemy inside its attack range between attacks, uses dashes and blinks to escape such an enemy, and uses hard crowd control first on enemies diving an ally (peel). Knockbacks are held for peel.
"dive"Default for melee champions. Walks or dashes to the lowest-health enemy and uses everything on it, except knockbacks, which it holds to peel an enemy diving an ally.
"engage"Like dive, but also uses knockbacks offensively at once (placed ones push the target into its team).
"peel"Holds every hard crowd control ability for enemies that reach an ally they are outranged by (or itself), guards the rearmost ranged ally when nobody is diving.
"fight"Targets the lowest-health enemy it can reach, walks to attack range, no kiting. Every "kite" champion, default or set with x.role, uses it under fight(…, kite: false).

An enemy "dives" ally A when it is within its own attack range (+ 50) of A and A's attack range is longer. Spell shields, Morgana E and cleanses react perfectly: Sivir E and Nocturne W are raised as a hostile ability lands; Morgana E is cast on the ally a hard crowd control is about to land on (if in range); Quicksilver Sash and Mercurial Scimitar cleanse as soon as a cleansable crowd control lands; Mikael's Blessing is used on a crowd-controlled ally.

Crowd control types

stun, suppression, sleep, airborne (knock-up, knockback, pull)No moving, attacking or casting. Interrupts channels.
charm, fear (flee), taunt, berserkNo control: charm and taunt walk to the caster, fear walks away, taunt and berserk attack the caster when in range.
rootNo moving or dashing; attacks and casts allowed.
silenceNo casting. polymorph: no attacking or casting (walking allowed). disarm: no attacking. ground: no dashes. blind: attacks miss.
slowLower move speed (strongest slow only; decaying slows fade linearly).

Source: wiki "Types of Crowd Control" (summary table). Tenacity (wiki "Tenacity") shortens every type except airborne, suppression and drowsy, to no less than 0.3 s; items, elixirs and the rune shard stack multiplicatively (1 − (1 − a)(1 − b)…). Quicksilver removes everything except airborne (suppression included); Mikael's Blessing removes stuns, roots, silences, polymorphs, forced actions, sleep and slows, not suppression or airborne. Spell shields (Banshee's Veil, Edge of Night, Verdant Barrier, Sivir E, Nocturne W) block one whole ability, crowd control included.

Where the numbers come from

src/cc_export.py reads which crowd control each ability applies from the wiki ability text ({{tip|stun|…}} for {{fd|1}} seconds, knock back 900 units, slowed by 80%) and takes the value from the game data (StunDuration, RootDuration, KnockupDuration, KnockbackDistance, SlowAmount, …) when one exists and agrees with the wiki at every rank; otherwise the wiki's value, and the working says so. Where they disagree the wiki value is used and the game data value is shown (python src/cc_export.py lists them: KSante Q, Rammus Q, Urgot E, Hwei R, Lissandra R, Maokai Q…). Durations that grow with distance travelled (Xerath E 0.75–2.25 s, Braum R) are assumed linear up to the ability's reach; charge-dependent ones (Janna Q, K'Sante W, Sion Q) use the uncharged value, since fights cast at once. Kennen's Mark of the Storm is modelled: every ability hit marks, the third mark stuns 1.25 s (0.5 s if stunned by it within 6 s; game data), Slicing Maelstrom marks each target up to 3 times. Effects the wiki makes conditional on terrain (Vayne E stun, Poppy E stun, Ornn E) are left out, as are effects with no known duration (the assumptions list each one). Braum R: the first target's knock-up grows from 0.6 s with distance, assumed linear up to the ability's reach.

Damage, healing, stats

  • Stats at a level: base + growth × (level − 1) × (0.7025 + 0.0175 × (level − 1)), the game's formula.
  • Ability formulas are evaluated from the game's calculation trees: per-rank values, stat ratios (total, bonus or base), level curves, sums, products and clamps. Stack-based parts count as 0 stacks (noted).
  • Resistances: percent penetration first, then flat (lethality is flat armor penetration); damage × 100 ÷ (100 + resist), or 2 − 100 ÷ (100 − resist) below zero.
  • Adaptive runes give AD when bonus AD exceeds AP and AP when AP exceeds bonus AD (1 adaptive = 0.6 AD or 1 AP). The AP compared includes Rabadon's +30%, which then also amplifies adaptive AP. A tie (e.g. no items) goes to the champion's adaptive type from the game files (Zed: AD, Syndra: AP). Adaptive damage is physical or magic by the same test.
  • Abilities that apply on-hit effects (the wiki's "Attack effects" list: Katarina's Shunpo, Sinister Steel and Death Lotus, Ezreal Q, Fiora Q, Gangplank Q, Irelia Q, Yasuo Q, Yone Q…) trigger on-hit items at the listed effectiveness (Death Lotus 25/30/35%, Master Yi Q 18.75%, Urgot W 50%…). Channels like Death Lotus are separate hits (15 daggers per target). Ones that also trigger on-attack effects count as basic attacks for Kraken Slayer, Terminus and Hullbreaker stacks. On-hit-only ones stack and consume Kraken's Bring It Down, per the wiki's Kraken Slayer page, but build no Terminus stacks (assumed).
  • Critical strikes deal 200% (base; Infinity Edge +30% = 230%). Ashe's crits deal no bonus damage.
  • Attack speed is capped at 3.003 (one attack per 0.333 s); Bel'Veth has no cap. Cap exceptions that only last during an ability (Jinx, Kennen, Varus, Sion, Zeri) aren't modelled.
  • Move speed = (base + flat) × (1 + % bonuses), then soft caps: above 415 → × 0.8 + 83, above 490 → × 0.5 + 230.
  • Item limits from the game's item data: one pair of boots, one Blight item (Void Staff, Cryptbloom, Terminus…), one Hydra, one Spellblade, no two copies of a legendary, and so on. Breaking one is an error.
  • Cooldowns: base × 100 ÷ (100 + ability haste). Haste that one ability gives another (Syndra R: Dark Sphere +10 per R rank) is included.
  • Heals: formula × (1 + heal & shield power) × (1 + healing received), capped at missing health.

Modelled runes

Values the rune text gives as a range “based on level” are spread evenly from level 1 to 18 unless the wiki gives another curve (Fleet Footwork follows the champion-growth curve). Stacks earned before the fight (Legend runes, Ultimate Hunter, Overgrowth, Dark Harvest souls, Manaflow Band mana) start at 0, as in a fresh practice-tool game; set them with a.stacks(LegendAlacrity, 10). Gathering Storm, Conditioning and Biscuit Delivery follow gameMinute. Every rune's status, source and approximation is in the runes audit (outputs/runes_audit/RUNES.md), and every choice appears in the assumptions.

Modelled item passives

Every item's stats are always included. These items' passives and actives are also modelled in fights and combos, using the values in the game data (the few values the data lacks come from the item text):

These items have nothing a fight can see (movement, vision, mana, cleanses, gold); a build that contains one says so in the assumptions:

Stacks earned before a fight (Heartsteel, Mejai's, Hubris, Yun Tal, Rod of Ages…) start at 0, as when you buy the item in the practice tool; set them with a.stacks(Item, n). Every approximation (for example Energized items recharging only from attacks, because nobody moves) is listed in the assumptions of any result that uses the item.

Ability tags

Tags are read from each ability's tooltip text. mobile means dash or blink; hardcc means any hard crowd control; targeted means point-and-click. The Inspect tab shows the phrase behind every tag.

Data sources

  • Riot Data Dragon (patch —): champion stats, ability tooltips, cooldowns, ranges, item names and prices.
  • CommunityDragon game files: ability calculation trees, per-rank values, projectile speeds, widths, cast times; item stats and passive values.
  • Client rune data: rune descriptions, whose numbers the rune model uses.

The data is rebuilt for a new patch with python src/fetch_static.py, python src/calc_export.py and python src/kb_export.py.

Limits

  • A proof holds for the model, not the live game. The assumptions list says exactly where the two can differ.
  • Tooltip-derived tags and damage types can be wrong; check the evidence and correct with addTag / removeTag.
  • Champion-specific mechanics (Zed's shadows, stored damage, stacking passives, empowered recasts) are only as good as the tooltip formula.
  • Fights are one-dimensional: no terrain, walls, flanks or body blocking, and area abilities catch everyone within their radius on the line (2-D spacing would catch fewer). Mana is ignored. Physics checks assume perfect aim and a straight-line dodge.