Skip to content

feat: let a custom game mode define its own team size - #406

Open
LCrew wants to merge 3 commits into
5stackgg:mainfrom
LCrew:feat/custom-mode-player-counts
Open

feat: let a custom game mode define its own team size#406
LCrew wants to merge 3 commits into
5stackgg:mainfrom
LCrew:feat/custom-mode-player-counts

Conversation

@LCrew

@LCrew LCrew commented Sep 5, 2026

Copy link
Copy Markdown

Why

A match's player count comes from match_options.type alone, and that fact is hard-coded in four places that have to be kept in sync:

Where Values
get_match_type_min_players() per lineup 5 / 2 / 1
tbi_draft_games capacity 10 / 4 / 2
ExpectedPlayers (api) total 10 / 4 / 2
GetExpectedPlayerCount() (plugin) total 10 / 4 / 2

A custom game mode can already change the plugins and cvars a match runs under, but not its shape — so a 3v3 retakes mode still demands a full 5v5 lobby, and the draft room's Start button stays locked until 10 people are seated.

What

game_modes gains players_per_team and allow_short_handed_start.

One new resolver, get_match_options_min_players(mo), coalesces the size a match actually launched at → the mode's → the type's. Every gate that keyed on the type now reads it instead: match_min/max_players_per_lineup, check_match_has_min_players, is_match_lineup_ready, the match_lineup_players removal trigger, and draft_games capacity.

A mode may also permit starting a draft lobby before both sides are full. The new startDraftGame action narrows the lobby to the players who turned up before flipping it to Filled — the other order leaves a draft stuck in CreatingMatch that beginDraft then refuses to act on, with every player in it locked out of joining anything else. Authorization is unchanged: isOrganizerOrHost matches is_draft_game_organizer exactly.

Two snapshots, because one number can't describe an uneven start

column records why
min_players_per_lineup the smaller side the gates apply one number to both lineups, so a 1v2 has to record 1 or the short side never clears
expected_players the total what the game server waits for; a 1v2 needs 3, and doubling the above gives 2

Both count starters only, read off draft_game_players rather than the match lineups — buildTeams also seats waitlisted backups in substitute slots, and a backup isn't someone warmup should wait for. Captains still requires an even pool, because get_draft_game_pattern builds its pick order off capacity / 2.

Zero change by default

Every column is nullable. With no custom mode configured, each path resolves to exactly what the type gave before:

case resolves to
Competitive, no mode 5
Wingman, no mode 2
mode with players_per_team = NULL 5
mode with players_per_team = 3 3

Inert until an admin sets players_per_team; clearing it back to NULL is a kill switch needing no deploy.

Tested

  • Deployed to a live install with attached game servers. A mode with players_per_team = 1 produces a 1v1 lobby, sends 2 players, and the game server goes to [0/2] instead of [0/4].
  • 844 unit tests, and integration tests across the draft, game-mode, match-lifecycle, match-options and migration suites.
  • SQL gates exercised against a real Postgres: resolver precedence, capacity derivation, and every start gate — a 3v2 starts, 2v0 is refused, captains-odd is refused, and uneven-on-a-normal-mode is still refused.

Notes for review

  • generated/ covers the first three columns but not expected_players — worth a yarn codegen against a Hasura carrying the migrations. Related: the current-match selection in matches.controller.ts turns out not to be type-checked at all (a deliberately bogus field compiles), so column names there are only validated at runtime. Might be worth a look independently of this PR.
  • Rollback doesn't need the down-migrations: hasura/functions and triggers re-apply whenever a file's SHA changes and are all CREATE OR REPLACE, so reverting the code restores the old definitions on the next boot. The migrations are additive and safe to leave.
  • Part of a three-repo change. Merge order: this → web → game-server.

LCrew and others added 3 commits September 4, 2026 21:08
A match's player count came from match_options.type alone, hard-coded in
four places that had to be kept in sync. A custom game mode could change
the plugins and cvars a match runs under but not its shape, so a 3v3
retakes mode still demanded a full 5v5 lobby.

game_modes gains players_per_team and allow_short_handed_start. A new
resolver, get_match_options_min_players(mo), coalesces the size a match
actually launched at, then the mode's, then the type's -- and every gate
that gated on the type now reads it instead:

  match_min/max_players_per_lineup, check_match_has_min_players,
  is_match_lineup_ready, the match_lineup_players removal trigger,
  and draft_games capacity.

A mode may also permit starting a draft lobby before both sides are full.
The new startDraftGame action narrows the lobby to the players who turned
up *before* flipping it to Filled -- doing it the other way round would
leave a draft stuck in CreatingMatch that beginDraft then refuses to act
on, with every player in it locked out of joining anything else. Uneven
sides are allowed; Captains still requires an even pool, because
get_draft_game_pattern builds its pick order off capacity / 2.

match_options.min_players_per_lineup records the size a short-handed
match launched at, so editing the mode later cannot invalidate a match
already running, and the game server is told the same number.

All three columns are nullable, so with no custom mode configured every
one of those resolves to exactly what the match type gave before. The
feature is inert until an admin sets players_per_team, and clearing it
back to NULL is a kill switch that needs no deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz
min_players_per_lineup records the SMALLER side, because the gates apply
one number to both lineups -- a 1v2 has to record 1 or the short side
never clears check_match_has_min_players. The game server was doubling
it, so a 1v2 asked for 2 players and could go live while the third was
still connecting.

One number cannot express an uneven start, so record the total too.
expected_players counts starters off draft_game_players rather than the
match lineups: buildTeams also seats waitlisted backups in the substitute
slots, and a backup is not someone warmup should wait for.

Only written when a lobby actually starts short-handed. An even start
leaves it null and the server keeps doubling the per-lineup figure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz
applyMigrations tracks versions, not content: `if (!applied.has(version))`.
Appending the column to 1886000000000 was therefore a silent no-op on any
database that had already run it -- while the metadata in the same build
still granted permission on it. Hasura then failed to track match_options,
dropped the objects that depended on it, and `matches_bool_exp` lost its
`options` relationship: the draft room stopped loading.

Its own version applies everywhere -- in order on a fresh install, and on
its own for a database already carrying 1886000000000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz
@LCrew LCrew changed the title feat(draft): let a custom game mode define its own team size feat: let a custom game mode define its own team size Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant