Skip to content

[Feature / Security] 1v1 ranked: add 30-second disconnect grace period to prevent ragequit denial and protect match integrity #4093

@berkelmali

Description

@berkelmali

Summary

In upstream main, a single disconnection event in a 1v1 ranked match immediately and permanently terminates the game, awarding the win to whichever player happened to still be connected at that exact tick. This behaviour exposes a trivially exploitable ragequit-denial attack and simultaneously punishes legitimate players for transient ISP-level network hiccups that resolve within seconds.

This master issue covers two related PRs that together deliver a complete, hardened solution:


The Problem: Upstream Instant-Win on Disconnect

In src/core/execution/WinCheckExecution.ts, the upstream 1v1 ranked check is:

// ⚠️ Upstream (unpatched) — instant match termination on any disconnect
const humans = sorted.filter(
  (p) => p.type() === PlayerType.Human && !p.isDisconnected(),
);
if (humans.length === 1) {
  this.mg.setWinner(humans[0], this.mg.stats().stats()); // fires immediately
}

Failure Mode 1: Ragequit Denial (Exploitable)

A player who is losing a ranked 1v1 can intentionally kill their network connection the moment they recognise the match is unwinnable:

  1. Force a rematch by invalidating a clean win at the last moment.
  2. Manipulate ranked point calculations — the losing player may suffer a smaller penalty than a clean loss.
  3. Grief specific opponents repeatedly, exhausting their session time without recording a proper match result.

The exploit requires zero technical sophistication — any player with physical access to their network hardware can execute it reliably.


Failure Mode 2: Innocent Players Penalised for ISP Drops

A legitimate player experiencing a transient 5-second network interruption triggers the exact same code path as a deliberate ragequit. The match ends instantly with no reconnect window.


The Solution: A 300-Tick Deterministic Grace Period State Machine

PRs #3945 and #3972 replace the single-line instant-win with a four-state timer-based state machine:

State 0: Both connected       → normal win-check proceeds
State 1: One disconnected     → grace timer starts (300 ticks = 30 seconds)
State 2: Reconnected          → grace timer resets; back to State 0
State 3: Grace expired        → winner declared (or tiebreaker applied)

The "Both Disconnected" Tiebreaker (added in #3972):

If both players disconnect, holding the game indefinitely would create zombie game rooms that leak server resources. The timer continues from the first disconnect, and the player with more tiles at that moment is awarded the win — a deterministic, un-gameable metric.


Security Properties

Threat Upstream With Grace Period
Ragequit to deny win Works — match ends unnaturally Opponent still wins after 30s
ISP drop (< 30s) Match ends, player loses Match resumes on reconnect
Mutual disconnect Race condition Deterministic tile-count tiebreaker
Grace period stalling N/A Not possible — 30s is fixed

Test Coverage

  • ✅ No winner declared immediately on first disconnect
  • ✅ Winner declared exactly after 300-tick grace expiry
  • ✅ Grace timer fully resets on reconnect
  • ✅ Both-disconnected tiebreaker resolves by tile count
  • ✅ Bots and nations correctly excluded from 1v1 human-only logic
  • ✅ Normal FFA win-check unaffected when rankedType !== OneVOne

Affected File

src/core/execution/WinCheckExecution.tscheckWinnerFFA() method

Linked PRs

#3945 and #3972

Metadata

Metadata

Assignees

No one assigned

    Labels

    not-approvedThis issue has NOT been approved by the maintainers.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions