feat(modes): matches now end, and something is at stake when they do

There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.

globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:

  Deathmatch        25 frags or 10 minutes.
  Team Deathmatch   two squads to 50, teams balanced by COUNT on join (a 4v4
                    that loses three from one side must refill the short side;
                    round-robin on join order leaves it 4v1 forever), friendly
                    fire off and enforced on the server before damage is even
                    broadcast — a mode where the damage lands but the kill does
                    not count is worse than either.
  Gun Game          every kill promotes you a rung and swaps your weapon. The
                    score IS the rung and the limit IS the ladder's length, so
                    finishing the ladder and reaching the score limit are the
                    same event and only one win condition exists.

`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.

Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.

ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.

Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.

spawn smoke 0, game modes 30/30, movement 11/11.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 12:28:32 -04:00
co-authored by Claude Opus 5
parent f1a4f7df52
commit 986179854d
12 changed files with 1030 additions and 76 deletions
+94 -46
View File
@@ -14,6 +14,35 @@ var _killfeed_vbox: VBoxContainer
var _scoreboard_panel: PanelContainer
var _scoreboard_grid: GridContainer
var _summary: MatchSummary
func _on_match_ended(result: Dictionary) -> void:
_scoreboard_panel.hide()
_summary.show_result(result)
func _on_play_again() -> void:
if not _nm:
return
# The host decides for everyone; a client's button is disabled.
if _nm.multiplayer.has_multiplayer_peer() \
and not _nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
if _nm.multiplayer.is_server():
_nm.restart_match.rpc()
else:
_nm.restart_match()
_summary.visible = false
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _on_to_menu() -> void:
if _nm:
_nm.match_active = false
_nm.disconnect_game()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _ready() -> void:
_nm = get_node_or_null("/root/NetworkManager")
# Ensure the comic theme is active even when a level is loaded directly
@@ -98,12 +127,27 @@ func _ready() -> void:
_populate_scoreboard_headers()
# The end-of-match screen. Built once and hidden, rather than instantiated on
# the signal, so the moment the match ends nothing has to be loaded.
_summary = MatchSummary.new()
_summary.name = "MatchSummary"
_summary.visible = false
_summary.play_again.connect(_on_play_again)
_summary.to_menu.connect(_on_to_menu)
add_child(_summary)
# Signals
if _nm:
_nm.stats_updated.connect(_update_hud)
_nm.killfeed_event.connect(_on_killfeed_event)
_nm.match_state_updated.connect(_update_timer)
_nm.match_ended.connect(_on_match_ended)
_update_hud()
# A HUD that loads AFTER the match ended — a late joiner, or a scene
# reload — still has to show the result, so it asks rather than only
# listening. `last_result` is empty during a live match.
if not _nm.last_result.is_empty():
_on_match_ended(_nm.last_result)
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("scoreboard"):
@@ -125,8 +169,12 @@ func _update_timer() -> void:
func _populate_scoreboard_headers() -> void:
for child in _scoreboard_grid.get_children():
child.queue_free()
var headers = ["Player", "Kills", "Deaths", "Assists", "Ping"]
# SCORE is first and is the mode's own noun, because it is what decides the
# match. Kills and score are the same number in Deathmatch and different in
# Gun Game, which is exactly why both are shown.
var noun := GameMode.score_noun(_nm.current_gamemode) if _nm else "SCORE"
var headers = ["Player", noun, "Kills", "Deaths", "Assists", "Streak", "Ping"]
for h in headers:
var l = Label.new()
l.text = h
@@ -138,59 +186,59 @@ func _update_hud() -> void:
if not _nm: return
var my_id = multiplayer.get_unique_id()
var leader_name = "None"
var max_kills = -1
var mode: String = _nm.current_gamemode
var noun := GameMode.score_noun(mode)
var stats = _nm.player_stats
# Sort players by kills for scoreboard
var players = stats.keys()
players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0))
# Ranked by SCORE, which is what the mode counts — not by kills, which in
# Gun Game is the same number by coincidence and in a mode with objectives
# would not be.
var players := GameMode.standings(stats)
_populate_scoreboard_headers()
for pid in players:
var p_data = stats[pid]
var p_kills = p_data.get("kills", 0)
# Find leader
if p_kills > max_kills:
max_kills = p_kills
leader_name = p_data.get("username", "Player " + str(pid))
# Update local kills
var p_score = p_data.get("score", p_data.get("kills", 0))
if pid == my_id:
_local_kills_label.text = str(p_kills) + " Kills"
# Build Scoreboard Row
_local_kills_label.text = "%s %s" % [p_score, noun]
var row_color := Color(p_data.get("color", "cccccc"))
if int(p_data.get("team", 0)) > 0:
row_color = GameMode.team_color(int(p_data.get("team", 0)))
var c_name = Label.new()
c_name.text = p_data.get("username", "Player")
c_name.add_theme_font_size_override("font_size", 24)
c_name.add_theme_color_override("font_color", Color(p_data.get("color", "cccccc")))
c_name.add_theme_color_override("font_color", row_color)
_scoreboard_grid.add_child(c_name)
var c_k = Label.new()
c_k.text = str(p_kills)
c_k.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_k)
var c_d = Label.new()
c_d.text = str(p_data.get("deaths", 0))
c_d.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_d)
var c_a = Label.new()
c_a.text = str(p_data.get("assists", 0))
c_a.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_a)
var c_p = Label.new()
c_p.text = str(p_data.get("ping", 0)) + "ms"
c_p.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_p)
_leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills]
for value in [str(p_score), str(p_data.get("kills", 0)),
str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)),
"x%d" % int(p_data.get("best_streak", 0)),
"%dms" % int(p_data.get("ping", 0))]:
var cell = Label.new()
cell.text = value
cell.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(cell)
# The top line: who is leading, and how far there is to go. A score limit
# nobody can see is a win condition players cannot play toward.
var limit := GameMode.score_limit(mode)
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var parts: PackedStringArray = []
for t in totals:
parts.append("%s %d" % [GameMode.team_name(t), totals[t]])
_leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "")
elif players.is_empty():
_leader_label.text = "Leader: None"
else:
var top = players[0]
_leader_label.text = "Leader: %s (%s%s)" % [
stats[top].get("username", "Player " + str(top)),
stats[top].get("score", 0),
"/%d" % limit if limit > 0 else ""]
func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
var l = RichTextLabel.new()
+169
View File
@@ -0,0 +1,169 @@
extends Control
class_name MatchSummary
## The end of a match: who won, why, and what everyone did.
##
## There was nothing here before. The clock reached zero, `match_active` went
## false, the timer froze at 00:00, and the players kept shooting each other in a
## match that had stopped counting. A mode without an ending is a mode without
## stakes — everything a player does in the last minute only matters if there is
## a last minute.
##
## Built from the theme's own parts, so it reads as the same game as the menu it
## came from and the HUD it covers.
signal play_again
signal to_menu
var _result: Dictionary = {}
func show_result(result: Dictionary) -> void:
_result = result
for c in get_children():
c.queue_free()
_build()
visible = true
# The summary takes the mouse, which the match had captured.
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _build() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.86)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wash)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(centre)
var card := UITheme.card()
card.custom_minimum_size = Vector2(880, 0)
centre.add_child(card)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 10)
card.add_child(col)
# ── Who won, and why ────────────────────────────────────────────────────
var winner := String(_result.get("name", "DRAW"))
var title := UITheme.title(winner if winner == "DRAW" else winner + " WINS",
68, -2.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# A winning TEAM's name is written in that team's colour. The wordmark
# treatment is papaya by default, which on "MAGENTA TEAM WINS" says the
# opposite of what the words do.
var win_team := int(_result.get("team", 0))
if win_team > 0:
title.add_theme_color_override("font_color", GameMode.team_color(win_team))
col.add_child(title)
# The REASON, not just the result. "Time" and "frag limit" are different
# stories about the same scoreline and the player was in one of them.
var reason := "TIME"
if String(_result.get("reason", "")) == "score":
reason = "%s LIMIT REACHED" % GameMode.score_noun(
String(_result.get("mode", GameMode.DEATHMATCH)))
var sub := UITheme.caption("%s%s" % [
GameMode.display_name(String(_result.get("mode", GameMode.DEATHMATCH))),
reason], 22)
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(sub)
var rule := UITheme.divider(0.42)
rule.custom_minimum_size = Vector2(0, 20)
col.add_child(rule)
# ── Team scores, if this was a team mode ────────────────────────────────
var mode := String(_result.get("mode", GameMode.DEATHMATCH))
var stats: Dictionary = _result.get("stats", {})
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 46)
col.add_child(row)
for t in totals:
var box := VBoxContainer.new()
var nm := UITheme.heading(GameMode.team_name(t), 24)
nm.add_theme_color_override("font_color", GameMode.team_color(t))
nm.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(nm)
var sc := UITheme.heading(str(totals[t]), 54)
sc.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(sc)
row.add_child(box)
# ── The standings ───────────────────────────────────────────────────────
var grid := GridContainer.new()
grid.columns = 6
grid.add_theme_constant_override("h_separation", 34)
grid.add_theme_constant_override("v_separation", 6)
col.add_child(grid)
for h in ["#", "PLAYER", GameMode.score_noun(mode), "K", "D", "BEST RUN"]:
var lbl := UITheme.caption(h, 20)
grid.add_child(lbl)
var order: Array = _result.get("standings", [])
for i in order.size():
var pid = order[i]
var s: Dictionary = stats.get(pid, {})
var place := i + 1
# First place gets the volt. It is the only place in this UI outside the
# moment of input that volt appears, and winning is worth the exception.
var tint: Color = UITheme.VOLT if place == 1 else UITheme.PAPER
grid.add_child(_cell(str(place), tint, place == 1))
var who := _cell(String(s.get("username", "Player")), tint, place == 1)
if int(s.get("team", 0)) > 0:
who.add_theme_color_override("font_color",
GameMode.team_color(int(s.get("team", 0))))
grid.add_child(who)
grid.add_child(_cell(str(s.get("score", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("kills", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("deaths", 0)), tint, place == 1))
grid.add_child(_cell("x%d" % int(s.get("best_streak", 0)), tint, place == 1))
col.add_child(UITheme.divider(0.6))
# ── Out ─────────────────────────────────────────────────────────────────
var buttons := HBoxContainer.new()
buttons.alignment = BoxContainer.ALIGNMENT_CENTER
buttons.add_theme_constant_override("separation", 18)
col.add_child(buttons)
var again := UITheme.primary_button("PLAY AGAIN", 34)
again.pressed.connect(func(): play_again.emit())
buttons.add_child(again)
var menu := Button.new()
menu.text = "Main Menu"
menu.add_theme_font_size_override("font_size", 28)
menu.pressed.connect(func(): to_menu.emit())
buttons.add_child(menu)
# Only the host may restart a running server; everyone else waits for them.
var nm = get_tree().root.get_node_or_null("NetworkManager")
if nm and nm.multiplayer.has_multiplayer_peer() \
and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer \
and not nm.multiplayer.is_server():
again.disabled = true
again.text = "WAITING FOR HOST"
UITheme.wire_sounds(self)
again.grab_focus()
func _cell(text: String, tint: Color, bold: bool) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 30 if bold else 26)
l.add_theme_color_override("font_color", tint)
l.add_theme_color_override("font_outline_color", UITheme.INK)
l.add_theme_constant_override("outline_size", 6)
return l
+1
View File
@@ -0,0 +1 @@
uid://ygajwj3bek8k