← Back to rankings
for the truly devoted

Rankings — Full Details

every multiplier · every penalty · every edge case

Two Numbers, Two Purposes

Every player carries two scores in the database, and they are computed independently from the same approved-game event:

  • Rating (players.rating) — an Elo-flavored number that drives the Power Rankings leaderboard. Can go up and down. Both winners and losers move.
  • Total Points (players.total_points) — a lifetime NMJL-style payout total shown on each player's own page. Monotonically non-decreasing. Only the winner of a hand gains points; losers never lose total points.

Total Points answers "how much would I have collected at a real table over my whole mahjong career?" Rating answers "who's actually playing well lately?"

Current Default Values

All of these live in the app_settings row and are editable from the Admin → Settings tab without a redeploy:

starting_rating
1000
k_factor
16
jokerless_bonus
5
concealed_bonus_multiplier
0.2
discard_penalty_multiplier
2.0
base_win_points
25 (legacy, not currently in formula)

Line Points (NMJL Card)

Every win is tied to a specific line on the current National Mah Jongg League card. That line carries:

  • point_value — typically 25, 30, 35, 40, 50, 60, or 75. Hard hands pay more.
  • concealed_only — line can only be won concealed. Triggers the concealed multiplier automatically.
  • is_singles_and_pairs — line cannot legally use jokers, so the jokerless bonus is suppressed (otherwise it would be free money).

linePoints below always refers to the point_value of the exact line the winner declared.

Rating — Per-Game Formula

Step 1 — Expected score (Elo)

avgOpponentRating = mean(rating of each loser at the table)
winnerExpected = 1 / (1 + 10^((avgOpponentRating − winnerRating) / 400))

Standard Elo against the average of opponent ratings. A 400-point gap = ~10× expected win odds.

Step 2 — Winner gain

concealedPart = concealed
  ? linePoints × (1 + concealed_bonus_multiplier)
  : linePoints
jokerlessForRating = (used_jokers || isSinglesPairs)
  ? 0
  : jokerless_bonus
winnerRatingGain = concealedPart
  + jokerlessForRating
  + K × (1 − winnerExpected)

Step 3 — Loser losses

baseLoss = (K × winnerExpected) / numLosers
for each loser:
  if loser.id === discarder_id:
    loss = baseLoss × discard_penalty_multiplier
  else:
    loss = baseLoss

Note: the discarder pays a 2× share, but the other losers are NOT discounted. The total rating change of the table is not zero-sum — line points and bonuses are added on top of the Elo exchange.

Special cases

  • Self-pick: no discarder. Every loser pays baseLoss equally.
  • Wall game: is_wall_game = true. No winner, no discarder. Zero rating change for everyone. Still counted as a game played.
  • Discard win with no discarder recorded: falls back to a flat split (every loser pays baseLoss).

Worked Example

4-player table. Winner rated 1050. Three losers rated 1000, 980, 1020 (avg 1000). Winner declared a 35-point line, not concealed, used a joker. Lost the hand off a discard from the 980 player.

winnerExpected = 1 / (1 + 10^((1000 − 1050)/400)) ≈ 0.571
concealedPart = 35
jokerlessForRating = 0 (used a joker)
winnerRatingGain = 35 + 0 + 16 × (1 − 0.571) ≈ 35 + 6.86 = +41.86
baseLoss = (16 × 0.571) / 3 ≈ 3.05
discarder (980) loses 3.05 × 2.0 = −6.10
other two losers each lose −3.05

Same hand self-picked instead: all three losers would have lost ≈ −3.05.

Total Points — NMJL Payout Logic

Added to players.total_points for the winner only. Models how chips would actually change hands at a real table:

if self_pick:
  winnerPoints = numLosers × 2 × linePoints
else if discarder_id is set:
  winnerPoints = 2 × linePoints (from discarder)
    + (numLosers − 1) × linePoints (from each other loser)
else (fallback):
  winnerPoints = numLosers × linePoints
if !used_jokers && !is_singles_and_pairs:
  winnerPoints += jokerless_bonus

Concealed multiplier is not applied to total points — only to rating. This keeps the total points number numerically aligned with NMJL table-payout math.

"Games Played" & Win Rate

  • Every approved game increments the games-played count for every participant listed in game_participants, including wall games.
  • Win rate = wins ÷ games_played. Wall games count in the denominator but never in the numerator, so wall-heavy nights gently drag your win rate down — which matches table reality.
  • Pending and rejected games do not count for anything.

What We're Not Tracking (Yet)

To keep submission fast and friendly, we deliberately skip several optional NMJL penalties:

  • Feeding a 2-exposure hand (−10) or 3-exposure / 2-exposure-on-quint hand (−25).
  • Misnamed discard penalties (−10 to −25 depending on whether it gave mahjong).
  • Dead-hand penalties on wall games (−10).
  • Per-section difficulty multipliers (the column exists on nmjl_sections but isn't currently fed into the formula).

If we ever want them, they belong in the admin approval flow — not on the submitter — so the table consensus is what gets recorded.

Tunable Knobs (Admin)

All editable on Admin → Settings, applied immediately to all future approvals. Past games keep the deltas they were approved with.

  • starting_rating — what new players begin at.
  • k_factor — how aggressive the Elo swings are. Higher = bigger upsets, more volatility.
  • jokerless_bonus — flat reward added to both rating and total points for a no-joker win.
  • concealed_bonus_multiplier — multiplies linePoints in the rating formula for concealed-only hands. Default 0.2 = +20%.
  • discard_penalty_multiplier — how many baseLoss shares the discarder eats. Default 2.0.