PvP Matchmaking Algorithm

From Guild Wars 2 Wiki
Jump to: navigation, search

Ratings[edit]

At the heart of PvP matchmaking algorithm is the Glicko2 matchmaking rating (MMR). This rating, which is an approximation of your skill level, helps match you with other players with similar skill level. In addition to two core ratings (one for unranked and ranked arena), a rating is also kept for each profession, but the profession ratings are not currently used for matchmaking.

Glicko was chosen over its main alternative, Elo. Like Elo, Glicko tracks MMR for each player and updates that rating over time as you play the game. Glicko's main improvement over its predecessor is the inclusion of a ratings deviation (RD), which measures the reliability of the rating. By using RD, the matchmaking algorithm can compensate for players it has little or incomplete information about.

A volatility measurement is also included to indicate the degree of fluctuation in a player's rating. The higher the volatility, the more the rating fluctuates. Volatility changes over time in response to how you play the game. During periods of stability, your volatility should remain low, and reciprocally. The point of this is to allow the system to hone in on your appropriate rating as quickly as possible.

The system is also set up to increase your RD after periods of inactivity, just in case you're a little rusty. (See Ratings/@period and Ratings/@max-periods below).

Configuration[edit]

<Ratings>
  <Rating default="1200" min="100" max="5000" max-change="300" profession-ratio="0.0"/>
  <Deviation default="350" min="30" max="350" period="3d" max-periods="20"/>
  <Volatility default="0.06" min="0.04" max="0.08" system-constant="0.5"/>
</Ratings>

<Ratings type="Ranked" use-season="true" reset="2013-11-26T08:00:00-08:00" partial-reset="2015-01-27T16:30:00-08:00">
  <Decay points="700" grace-period="3d" decay-period="1w" recover-per-game="1d"/>
  <Placement type="LinearScale" param1="1200" param2="0.5"/>
</Ratings>

<Ratings type="Unranked" reset="2013-11-26T08:00:00-08:00" partial-reset="2015-01-27T16:30:00-08:00"/>
Element (XPath) Description
Ratings/@type If specified, indicates this element contains per-type overrides.
Ratings/@use-season Season games use their own instance of this rating instead of the account-wide version.
Ratings/@reset Any ratings data with a timestamp before this date is reset to the default.
Ratings/@partial-reset Any ratings data with a timestamp before this date is partially (deviation only) reset to the default.
Rating/@default Default rating that is used when no data is available.
Rating/@min Minimum rating allowed, anything below will be clamped to this value.
Rating/@max Maximum rating allowed, anything above will be clamped to this value.
Rating/@max-change The maximum, absolute amount a rating can change from a single game.
Rating/@profession-ratio Controls the balance between profession rating and core rating. '0' means only the core rating is used, while '1' means only the profession rating is used.
Deviation/@default Default ratings deviation that is used when no data is available.
Deviation/@min Minimum deviation allowed, anything below will be clamped to this value.
Deviation/@max Maximum deviation allowed, anything above will be clamped to this value.
Deviation/@period Length of inactivity for a single period.
Deviation/@max-periods Number of periods of inactivity before a player's deviation to go from the minimum to the maximum amount.
Volatility/@default Default ratings volatility that is used when no data is available.
Volatility/@min Minimum volatility allowed, anything below will be clamped to this value.
Volatility/@max Maximum volatility allowed, anything above will be clamped to this value.
Volatility/@system-constant Glicko2 tuning parameter that constrains the change in volatility over time.
Decay/@points The maximum amount of decay subtracted from effective rating.
Decay/@decay-period The amount of time before the maximum decay value is reached.
Decay/@grace-period The amount of time before decay starts affected the rating.
Decay/@recover-per-game The amount of time removed from active decay per PvP game with this rating.
Placement/@type Formula to use when calculated new season ratings from previous
Placement/@param1 Configuration for placement formula. In the case of "LinearScale", param1 is the center point to scale ratings towards.
Placement/@param2 Configuration for placement formula. In the case of "LinearScale", param2 is the ratio to scale ratings with.

Matchmaking[edit]

Matchmaking is the process of organizing players in such a way as to encourage competitive and fun gameplay. The system uses a two-phase, score-based search method that takes into consideration several metrics. A score-based search method was used over other methods because it's a good compromise between the often competing goals of match quality and short wait times.

At the start of matchmaking, the system attempts to find a match customized for the first Filter/Iteration/@rosters rosters (party) in the queue. If no match can be created, these players will be put at the end of the queue to ensure other players have a chance at a match customized for them. While this may seem unfair at first, this has actually been shown to decrease wait times for all players.

The first phase, called filtering, gathers players based on their current MMR. The primary purpose of this phase is to both reduce the number of players being considered for a match, and to ensure that the match is appropriate given each player's skill level. Over time, padding is added to your player rating. While this may decrease match quality, it helps ensure that outliers still receive matches.

The second phase of the algorithm is the scoring phase. During this phase each player is scored against every other player being considered for matchmaking. The metrics used during this phase include: rating, rank, games played, party size, profession, and dishonor. With each metric the system is looking for players that are as close as possible to the average of those already selected. The system also attempts to keep the number of duplicate professions to a minimum.

Configuration[edit]

<Arena name="Unranked Arena">
  <Queue>
    <RosterSize min="1" max="5"/>
    <Iteration interval="30s" rosters="50" limit="50ms"/>
    <Potentials min="20" max="500" falloff="0.16" start="1m" end="3m"/>
    <Rating start="3m" end="10m" max="1200" min="25"/>
    <Power curve="1" percent="1"/>
    <Rank min="0"/>
  </Queue>
  <Matcher type="Team">
    <Age seconds="2"/>
    <RosterSize max-diff="3" distance="-100" perfect-fit="0"/>
    <Rank distance="0"/>
    <Rating distance="-10"/>
    <Profession max="2" common="-100" unique="0" matching="100"/>
    <Dishonor distance="-100" stack="-50"/>
    <GuildTeam affinity="50"/>
    <Games max="500" distance="-0.25"/>
  </Matcher >
</Arena>

<Arena name="Ranked Arena">
  <Queue>
    <RosterSize min="1" max="2"/>
    <Iteration interval="30s" rosters="100" limit="250ms"/>
    <Potentials min="20" max="500" falloff="0.375" start="1m" end="3m"/>
    <Rating start="5m" end="10m" max="1200" min="25"/>
    <Power curve="1" percent="1"/>
    <Rank min="20"/>
  </Queue>
  <Matcher type="Team">
    <Age seconds="2"/>
    <RosterSize max-diff="3" distance="-100" perfect-fit="0"/>
    <Rank distance="0"/>
    <Rating distance="-10"/>
    <Profession max="2" common="-100" unique="0" matching="100"/>
    <Dishonor distance="-100" stack="-50"/>
    <GuildTeam affinity="50"/>
    <Games max="500" distance="-0.25"/>
  </Matcher>
</Arena>

<Arena name="Ranked Arena Off-Season">
  <Queue>
    <RosterSize min="1" max="5"/>
    <Iteration interval="30s" rosters="100" limit="250ms"/>
    <Potentials min="20" max="500" falloff="0.375" start="1m" end="3m"/>
    <Ladder start="0ms" end="0ms" max="0" min="0"/>
    <Rating start="5m" end="10m" max="1200" min="25"/>
    <Power curve="1" percent="1"/>
    <Rank min="20"/>
  </Queue>
  <Matcher type="Team">
    <Age seconds="2"/>
    <RosterSize max-diff="3" distance="-100" perfect-fit="0"/>
    <Rank distance="0"/>
    <Rating distance="-10"/>
    <Profession max="2" common="-100" unique="0" matching="100"/>
    <Dishonor distance="-100" stack="-50"/>
    <GuildTeam affinity="50"/>
    <Games max="500" distance="-0.25"/>
  </Matcher>
</Arena>
Element (XPath) Description
Filter/RosterSize/@min The minimum number of players that must be in a roster in order to queue.
Filter/RosterSize/@max The maximum number of players that must be in a roster in order to queue.
Filter/Iteration/@rosters The number of rosters the filtering phase will try to form custom matches for.
Filter/Iteration/@limit The maximum amount of time the server should spend trying to create matches per iteration. This is a performance fail-safe to keep the server responsive.
Filter/Iteration/@interval The time interval between matchmaking attempts.
Filter/Potentials/@min The minimum number of rosters that must pass the filter phase before attempting to create a match.
Filter/Potentials/@max The maximum number of rosters the filter phase should gather. This is a performance fail-safe to keep the server responsive.
Filter/Potentials/@falloff The number of potentials @max should be reduced by per-second.
Filter/Potentials/@start The amount of time that must pass before the @max potential @falloff begins.
Filter/Potentials/@end The amount of time that must pass before the @max potential @falloff ends.
Filter/Rating/@padding Padding is added every second you wait in the queue after Filter/Rating/@start has passed. This is an outlier fail-safe to ensure everyone gets a match.
Filter/Rating/@start No padding is added to a roster's ratings range until this length of time has passed.
Filter/Rating/@end No additional padding will be added after this length of time has passed. This is a fail-safe to prevent match quality from degrading further than preferred.
Filter/Rating/@Min The maximum rating difference between rosters the filter starts at.
Filter/Rating/@Max The maximum rating difference between rosters that can exist after padding is applied.
Filter/Power/@percent The percent a roster's rating is inflated due to the number of players in the roster.
Filter/Power/@curve The exponent used to curve the effect the number of players has on the roster's power inflation.
Scoring/@type The type of scoring algorithm to use. Team will score rosters on a per-team basis, i.e. will only check for duplicate profession on the team, not the entire match.
Scoring/Age/@seconds Score added or removed for every second a roster has been waiting. Outlier fail-safe to ensure no one waits too long.
Scoring/GuildTeam/@affinity Score added when comparing two guild teams for a potential match.
Scoring/RosterSize/@max-diff The maximum allowed roster size difference between teams.
Scoring/RosterSize/@distance Score added or removed based on the distance between the potential roster's size and the max roster size of all selected rosters, including both teams.
Scoring/RosterSize/@perfect-fit Score added or removed if the potential roster's size matches the exact number of players needed to fill empty spots on the team.
Scoring/Rank/@distance Score added or removed based on the distance between the potential roster's average rank and the average rank of all selected rosters, including both teams.
Scoring/Rating/@distance Score added or removed based on the distance between the potential roster's average effective rating (i.e. rating - deviation) and the average effective rating of all selected rosters, including both teams.
Scoring/Profession/@max Target maximum number of professions that should exist on a team.
Scoring/Profession/@unique Score added or removed for each unique profession, under Scoring/Profession/@max, the potential roster would add to the team.
Scoring/Profession/@common Score added or removed for each duplicate profession, at or above Scoring/Profession/@max, the potential roster would add to the team.
Scoring/Profession/@matching Score added or removed each professions that the other team has more of. This promotes profession balance.
Scoring/Dishonor/@distance Score added or removed based on the distance between the potential roster's total dishonor and the total dishonor of all selected rosters, including both teams.
Scoring/Dishonor/@stack Score added or removed per stack of dishonor the potential roster has on any of its members.
Scoring/Games/@distance Score added or removed per difference in games played.
Scoring/Games/@max The maximum difference in games before to consider for adjust score.

Pseudo-Code (New February 7th 2017)[edit]

A new matchmaker has been written to solve some of the failings of the previous while maintaining a similar flow. This new matcher will score rosters against both teams and the entire match instead of only considering alternating target teams. This is most notable when scoring ratings as a roster's fit is based on how it will balance team ratings instead of just how close it is to the target team's rating. One additional scoring parameter includes a bonus for balancing profession counts.

def createMatches(queue, config):
  rosters = queue
  failed = []
  
  # try to make a match for each roster in the queue
  while len(rosters) > 0:
    roster = rosters.pop()
    queue.remove(roster)
    if not tryMakeMatch(roster, queue, config):
      failed.append(roster)

  # move rosters we couldn't find match for to the end of the queue
  queue.append(failed)
  
  
def tryMakeMatch(target, queue, config):
  # gather rosters that are good potential matches
  potentials = gatherPotentials(target, queue, config)
  
  # enforcing a minimum allows some flexibility in choices
  if len(potentials) < config.potentials.min:
    return False
  
  team1 = []
  team2 = []
  match = []
  
  team1.append(target)
  match.append(target)

  while len(match) < config.teamSize * 2:

    bestRoster     = None
    bestTeam1Score = -infinity
    bestTeam2Score = -infinity
  
    for roster in potentials:
      # score the roster against team1 and the match
      team1Score = -infinity
      if canJoinTeam(roster, team1, team2, config):
        team1Score = scoreRoster(roster, team1, team2, match, config)
          
      # score the roster against team2 and the match
      team2Score = -infinity
      if canJoinTeam(roster, team2, team1, config):
        team2Score = scoreRoster(roster, team2, team1, match, config)
      
      # found a better roster!
      if bestRoster is None or max(team1Score, team2Score) > max(bestTeam1Score, bestTeam2Score):
        bestRoster = roster
        bestTeam1Score = team1Score
        bestTeam2Score = team2Score
          
    # could not find any roster for the match, abort
    if bestRoster is None:
      return False
      
    # add this player to whichever team is a better fit
    match.append(bestRoster)
    if bestTeam1Score > bestTeam2Score:
      team1.append(bestRoster)
    else
      team2.append(bestRoster)
      
  queue.remove(team1)
  queue.remove(team2)
  
  createMatch(team1, team2)
  
  return True

  
def gatherPotentials(queue, target, config):
  potentials = []
  
  for roster in queue:
    # check conditions where rosters are never allowed to match
    if roster.gameModes != target.gameModes:
      continue
    if roster.rating > target.ratingHigh:
      continue
    if roster.rating < target.ratingLow:
      continue
    
    potentials.append(roster)
    
    # limit choices for performance
    if len(potentials) >= config.filter.potentials.max:
      break

  return potentials
  

def canJoinTeam(roster, team, otherTeam, match, config):
  # roster is too big for this team
  if len(team) + len(roster) > config.teamSize:
    return False 
      
  # don't pick rosters that are much different size than what exists
  if abs(len(roster) - otherTeam.maxRosterSize) > config.rosterSize.maxDiff:
    return False
    
  return True
  
  
def scoreRoster(roster, team, otherTeam, match, config):
  score = 0
  
  # adjust score by time queued
  score += roster.age * config.age.seconds
  
  # adjust score by games played difference
  distance = abs(match.averageGames - roster.games)
  score += distance * config.rating.distance
  
  # adjust score by rank difference
  distance = abs(match.averageRank - roster.rank)
  score += distance * config.rank.distance
    
  # adjust score by roster size difference
  distance = abs(len(roster) - otherTeam.maxRosterSize)
  score += distance * config.rosterSize.distance
  
  # adjust score by POTENTIAL rating difference
  distance = abs(team.ratingWithRoster(roster) - otherTeam.rating)
  score += distance * config.rating.distance
  
  # adjust score by profession counts
  for profession in allProfessions:    
    # roster has none of these professions
    if roster.count(profession) == 0:
      continue
    
    # too many of the same profession
    totalCount = roster.count(profession) + team.count(profession)
    if totalCount > config.professions.max:
      score += (totalCount - config.professions.max) * config.professions.common
      
    # otherwise favor the variety
    elif team.count(profession) == 0:
      score += config.professions.unique
      
    # favor matching professions between teams
    if team.count(profession) < otherTeam.count(profession):
      score += config.professions.matching
      
  return score 

Pseudo-Code (Old)[edit]

def createMatches(queue, config):
  rosters = queue
  failed = []
  
  # try to make a match for each roster in the queue
  while len(rosters) > 0:
    roster = rosters.pop()
    queue.remove(roster)
    if not tryMakeMatch(roster, queue, config):
      failed.append(roster)

  # move rosters we couldn't find match for to the end of the queue
  queue.append(failed)
  
  
def tryMakeMatch(target, queue, config):
  # gather rosters that are good potential matches
  potentials = gatherPotentials(target, queue, config)
  
  # enforcing a minimum allows some flexibility in choices
  if len(potentials) < config.potentials.min:
    return False
    
  team1 = []
  team2 = []

  team1.append(target)

  # track the biggest roster for scoring purposes
  maxRosterSize = len(target)
  
  while len(team1) < config.teamSize or len(team2) < config.teamSize:
    # populate smaller team first
    if len(team1) > len(team2):
        swap(team1, team2)

    # otherwise pick a random team if they're equal
    elif len(team1) == len(team2) and randomChoice():
        swap(team1, team2)
  
    # pick the best roster for the team we've built so far
    bestRoster = pickBestRoster(team1, maxRosterSize, potentials, config)
    
    # could not find any roster for this team
    if bestRoster is None:
      break
      
    maxRosterSize = max(maxRosterSize, len(bestRoster))
    potentials.remove(bestRoster)
    team1.append(bestRoster)
  
  # could not fill up both teams
  if len(team1) < config.teamSize or len(team2) < config.teamSize:
    return False
      
  queue.remove(team1)
  queue.remove(team2)
  
  createMatch(team1, team2)
  
  return True

  
def gatherPotentials(queue, target, config):
  potentials = []
  
  for roster in queue:
    # check conditions where rosters are never allowed to match
    if roster.gameModes != target.gameModes:
      continue
    if roster.ratingLow > target.ratingHigh:
      continue
    if roster.ratingHigh < target.ratingLow:
      continue
    
    potentials.append(roster)
    
    # limit choices for performance
    if len(potentials) >= config.filter.potentials.max:
      break

  return potentials
  
  
def pickBestRoster(team, maxRosterSize, potentials, config):
  bestRoster = None
  bestScore  = -infinity
  
  playersNeeded = config.teamSize - len(team)
  
  for roster in potentials:
    # roster is too big for this team
    if len(roster) > playersNeeded:
      continue
      
    # don't pick rosters that are much bigger than what's chosen so far
    if abs(len(roster) - maxRosterSize) > config.rosterSize.maxDiff:
        continue
    
    # how well does this roster match the team
    score = scoreRoster(roster, team, maxRosterSize, config)
    
    # found a better roster!
    if bestRoster is None or bestScore < score:
      bestRoster = roster
      bestScore  = score
      
  return best
  
  
def scoreRoster(roster, team, maxRosterSize, config):
  score = 0
  
  # adjust score by time queued
  score += roster.age * config.age.seconds
  
  # adjust score by rating difference
  distance = abs(team.averageRating - roster.rating)
  score += distance * config.rating.distance
  
  # adjust score by games played difference
  distance = abs(team.averageGames - roster.games)
  score += distance * config.rating.distance
  
  # adjust score by rank difference
  distance = abs(team.averageRank - roster.rank)
  score += distance * config.rank.distance
    
  # adjust score by roster size difference
  distance = abs(maxRosterSize - len(roster))
  score += distance * config.rosterSize.distance
  
  # adjust score by profession counts
  for profession in allProfessions:    
    # roster has none of these professions
    if roster.count(profession) == 0:
      continue
    
    # too many of the same profession
    totalCount = roster.count(profession) + team.count(profession)
    if totalCount > config.professions.max:
      score += (totalCount - config.professions.max) * config.professions.common
      
    # otherwise favor the variety
    elif team.count(profession) == 0:
      score += config.professions.unique
      
  return score 

Dishonor[edit]

Dishonor is one of the methods used to encourage good sportsmanship. Behavior is tracked in the medium to long range through stacks. Each stack represents a duration that decays over time. Every time you receive dishonor you also receive a timeout. The length of a timeout increases exponentially based on how many stacks you currently have. In other words, your first offense may yield a short timeout, while your 20th may keep you from playing for a much longer amount of time.

While in timeout, you may not participate in ranked or unranked arena, but you can still play in custom arenas.

Dishonor also impacts matchmaking by preferring to place you with other players that also have dishonor. This isn't a separate queue, but merely a suggestion to the matchmaking system.

It's possible to have stacks of dishonor without having an active timeout because dishonor decays at a much slower rate.

Configuration[edit]

<Dishonor stack-duration="4h" timeout-duration="30s" timeout-exponent="1.5" timeout-rounding="1m">
  <Penalty reason="Abandon"    stacks="10"/>
  <Penalty reason="QueueDodge" stacks="3"/>
  <Penalty reason="Banned"     stacks="1000000"/>
</Dishonor>
Element (XPath) Description
Dishonor/@stack-duration Length of time a single stack of dishonor will last before expiring.
Dishonor/@timeout-duration Length of time, per stack of dishonor to the power of @timeout-exponent, to add to the player's timeout.
Dishonor/@timeout-exponent Exponent used when multiplying dishonor stacks by Dishonor/@timeout.
Dishonor/@timeout-rounding Additional timeout is rounded to the nearest @timeout-rounding interval before being added to the player's timeout. Also acts as the minimum length of timeout that can be earned.
Penalty/@reason Reason the dishonor is being awarded. Abandon: leaving a match before the end. QueueDodge: leaving or failing to confirm you're ready for a match. Banned: When a GM determines you may no longer participate in ranked or unranked arena.
Penalty/@stacks Number of stacks of dishonor to add to the player.

Pseudo-Code[edit]

def roundInterval(value, interval):
  return max(interval, round(value / interval) * interval)

def applyDishonor(player, penalty, config):
  player.stacks += penalty.stacks
  newTimeout   = pow(player.stacks, config.timeoutExponent) * config.timeoutDuration
  player.timeout += roundInterval(newTimeout, config.timeoutRounding)

Ladder (Deprecated)[edit]

As of 12/13/2016, this feature is no longer used.

The ladder is a list of all players currently participating in competitive play. Your ladder ranking is determined by how many points you are awarded throughout a season.

You are awarded points for playing well, and often, and sometimes even if you lose a game. Even if a comeback may not seem possible, you can still be rewarded for continuing to try your very best. If you find yourself in an uneven match, fear not, you will risk fewer points for losing, and have more points to gain for doing well. Likewise, if you participate in an easy match, don't think you're home free. Performing well might award you points, but performing poorly will take even more away.

(See Match Prediction for more information on how the system determines your odds of victory.)

Configuration[edit]

<PointRules Mode="Conquest">
  <PointRule Odds="0">
    <Checkpoint LadderPoints="-1" ScoreRatio="0"/>
    <Checkpoint LadderPoints="0" ScoreRatio="0.4"/>
    <Checkpoint LadderPoints="1" ScoreRatio="0.6"/>
    <Checkpoint LadderPoints="2" ScoreRatio="0.8"/>
    <Checkpoint LadderPoints="3" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.2">
    <Checkpoint LadderPoints="-1" ScoreRatio="0"/>
    <Checkpoint LadderPoints="0" ScoreRatio="0.6"/>
    <Checkpoint LadderPoints="1" ScoreRatio="0.8"/>
    <Checkpoint LadderPoints="2" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.4">
    <Checkpoint LadderPoints="-1" ScoreRatio="0"/>
    <Checkpoint LadderPoints="1" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.6">
    <Checkpoint LadderPoints="-2" ScoreRatio="0"/>
    <Checkpoint LadderPoints="-1" ScoreRatio="0.6"/>
    <Checkpoint LadderPoints="1" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.8">
    <Checkpoint LadderPoints="-3" ScoreRatio="0"/>
    <Checkpoint LadderPoints="-2" ScoreRatio="0.4"/>
    <Checkpoint LadderPoints="-1" ScoreRatio="0.6"/>
    <Checkpoint LadderPoints="1" ScoreRatio="1"/>
  </PointRule>
</PointRules>
<PointRules Mode="Stronghold">
  <PointRule Odds="0">
    <Checkpoint LadderPoints="-1" ScoreRatio="0"/>
    <Checkpoint LadderPoints="0" ScoreRatio="0.2"/>
    <Checkpoint LadderPoints="1" ScoreRatio="0.4"/>
    <Checkpoint LadderPoints="2" ScoreRatio="0.6"/>
    <Checkpoint LadderPoints="3" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.2">
    <Checkpoint LadderPoints="-1" ScoreRatio="0"/>
    <Checkpoint LadderPoints="0" ScoreRatio="0.4"/>
    <Checkpoint LadderPoints="1" ScoreRatio="0.6"/>
    <Checkpoint LadderPoints="2" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.4">
    <Checkpoint LadderPoints="-1" ScoreRatio="0"/>
    <Checkpoint LadderPoints="1" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.6">
    <Checkpoint LadderPoints="-2" ScoreRatio="0"/>
    <Checkpoint LadderPoints="-1" ScoreRatio="0.4"/>
    <Checkpoint LadderPoints="1" ScoreRatio="1"/>
  </PointRule>
  <PointRule Odds="0.8">
    <Checkpoint LadderPoints="-3" ScoreRatio="0"/>
    <Checkpoint LadderPoints="-2" ScoreRatio="0.2"/>
    <Checkpoint LadderPoints="-1" ScoreRatio="0.4"/>
    <Checkpoint LadderPoints="1" ScoreRatio="1"/>
  </PointRule>
</PointRules>
<!-- Season begins Friday March 20, 2015 @ 9 am PDT -->
<!-- Season ends   Thursday May 14, 2015 @ 9 am PDT -->
<Ladder start="2015-03-20T09:00:00-07:00" end="2015-05-14T09:00:00-07:00" grace="60"/>
Element (XPath) Description
Ladder/@start Any game results that occur before this date are not included on the ladder.
Ladder/@end Any game results that occur on or after this date are not included on the ladder.
Ladder/@grace Grace period (in minutes) before the end of the season where new season games cannot begin.
Ladder/@leaderboard-points Minimum number of points required before the player's data will be submitted to the Leaderboard.
PointRules/@Mode The game mode these point rules are used for.
PointRule/@Odds Minimum odds threshold. Paired with Checkpoint/@ScoreRatio to determine the number of ladder points to award.
Checkpoint/@ScoreRatio Minimum score threshold required to earn Checkpoint/@LadderPoints ladder points. Score ratio is relative to the winning score (usually 500).
Checkpoint/@LadderPoints Number of points to award if both PointRule/@odds and Checkpoint/@ScoreRatio thresholds are met. Only the highest thresholds count.

Pseudo-Code[edit]

def getPoints(oddsOfVictory, finalScore, config):
  currentDate = Time.now()
  if currentDate < config.startDate or currentDate >= config.endDate:
    return 0
  bestMatrix = null
  for matrix in config.ladderMatrix:
    if matrix.odds > oddsOfVictory:
      continue
    if bestMatrix is null or matrix.odds > bestMatrix.odds:
      bestMatrix = matrix
  bestScore = null
  for score in bestMatrix.scores:
    if score.min > finalScore:
      continue
    if bestScore is null or score.min > bestScore.min
      bestScore = score
  return bestScore.points

def processGame(player, game, config):
  oddsOfVictory = predictionToOddsOfVictory(game.prediction, player.team)
  finalScore = game.score[player.team]
  if game.result == 'desertion':
    finalScore = 0
  prevPoints = player.ladderPoints
  pointsAwarded = getPoints(oddsOfVictory, finalScore, config)
  if game.result == 'victory':
    pointsAwarded = max(1, pointsAwarded)
  player.ladderPoints += pointsAwarded
  if player.ladderPoints >= config.leaderboardPoints:
    sendLeaderboardUpdate(config.leaderboard, player.id, player.ladderPoints)
  else if prevPoints >= config.leaderboardPoints:
    sendLeaderboardRemove(config.leaderboard, player.id)

Match Prediction (Deprecated)[edit]

As of 12/13/2016, this feature is no longer used.

The system attempts to predict the outcome of a match with the same metrics used in matchmaking, though can be configured separately.

Configuration[edit]

<Prediction>
  <Ladder method="Spread" spread="30"  weight="5"/>
  <Rank   method="Spread" spread="40"  weight="1"/>
  <Rating method="Spread" spread="200" weight="0"/>
  <Roster method="Spread" spread="4"   weight="2"/>
</Prediction>
Element (XPath) Description
Prediction/*/@method Specifies which calculation method to use.
Prediction/*/@spread Maximum spread to calculate.
Prediction/*/@weight The impact, with higher numbers indicating more impact, this calculation has on the final prediction.
Prediction/Ladder If present, each team's average ladder is used for the calculation.
Prediction/Rank If present, each team's average rank is used for the calculation.
Prediction/Rating If present, each team's average effective rating (i.e. rating - deviation) is used for the calculation.
Prediction/Roster If present, each team's maximum roster size is used for the calculation.

Pseudo-Code[edit]

# Return the spread between two values normalized to -1..1.
def calculateSpread (red, blue, maxSpread):
  spread = (blue - red) / maxSpread
  return clamp(spread, -1, +1);

# Returns a prediction value between -1 and 1, where -1 means red dominate,
# and +1 means blue dominate.
def predict(red, blue, config):
  ladder = calculateSpread(red.averageLadder, blue.averageLadder, config.ladderSpread) * config.ladderWeight
  rank = calculateSpread(red.averageRank, blue.averageRank, config.rankSpread) * config.rankWeight
  rating = calculateSpread(red.averageRatingLow, blue.averageRatingLow, config.ratingSpread) * config.ratingWeight
  roster = calculateSpread(red.maxRosterSize, blue.maxRosterSize, config.rosterSpread) * config.rosterWeight
  totalScore = ladder + rank + rating + roster
  totalWeight = config.ladderWeight + config.rankWeight + config.ratingWeight + config.rosterWeight
  return clamp(totalScore / totalWeight, -1, +1)

# Returns the team's odds of victory as a ratio of 0..1, where 0 means
# minimal chance of victory and 1 means minimal chance of defeat.
def predictionToOddsOfVictory (prediction, team):
  normalized = prediction / 2 + 0.5;
  if team == 'red':
    return 1 - normalized
  else:
    return normalized

References[edit]