From ebf1254194330032ff7ef93a00d6c32ae6d2e201 Mon Sep 17 00:00:00 2001 From: Dino <8dino2@gmail.com> Date: Tue, 24 Mar 2026 15:32:25 -0400 Subject: [PATCH] Stagger scoreboard sends to prevent buffer overflows on full servers Scoreboard layout messages are now spread across multiple server frames instead of being sent to all clients simultaneously. This prevents packet buffer overflows that could crash the server or disconnect legacy clients with small reliable message limits (~1400 bytes). - Periodic scoreboard updates (every 3 seconds) are staggered per-client across the full cycle, so a 32-player server sends ~1 per frame instead of 32 at once. teams_changed still forces immediate update. - Intermission scoreboard sends are spread across 4 frames (~0.4s) instead of bursting all clients in a single frame. - On-demand score key (TAB) switched from reliable to unreliable send, since the periodic refresh catches any dropped packets within 3 seconds. - MAX_SCOREBOARD_SIZE raised from 1024 to 1300 bytes and MAX_PLAYERS_PER_TEAM raised from 8 to 10, using the headroom from reduced burst pressure while staying within legacy packet limits. - Document scoreboard delivery changes and the configurable scoreboard field codes (previously undocumented) in action.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/action.md | 42 ++++++++++++++++++++++++++++++++++++++++++ src/action/a_team.c | 8 ++++---- src/action/g_local.h | 1 + src/action/g_main.c | 27 +++++++++++++++++++++++---- src/action/p_hud.c | 7 +++---- 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/doc/action.md b/doc/action.md index 97609d603..113a2ccc8 100644 --- a/doc/action.md +++ b/doc/action.md @@ -59,6 +59,8 @@ Additions and enhancements by darksaint, Reki, Rektek and the AQ2World team - [Grenade Strength](#grenade-strength) - [Commands](#commands-21) - [Total Kills](#total-kills) + - [Scoreboard Delivery](#scoreboard-delivery) + - [Configurable Scoreboard](#configurable-scoreboard) - [Random Rotation](#random-rotation) - [Commands](#commands-22) - [Vote Rotation](#vote-rotation) @@ -604,6 +606,46 @@ Grenades are a little bit more powerful, so they're not as useless as they were ### Total Kills The scoreboard of TNG will now show the total kills for each player. Kills is the total number of kills without the negatives (suicides, cratering, teamkills) subtracted. +### Scoreboard Delivery + +Scoreboard layout messages are staggered across multiple server frames to prevent packet buffer overflows on servers with many players. Previously, all connected clients received their scoreboard update in a single frame, which could overwhelm the server's outbound message buffers — particularly for legacy clients with small reliable message limits (~1400 bytes). With 32 players this could cause client disconnects or server crashes. + +**Periodic updates**: Each player's scoreboard refreshes every 3 seconds, but individual clients are spread across different frames within that window. A 32-player server sends ~1 scoreboard per frame instead of 32 at once. Team roster changes (`teams_changed`) still trigger an immediate update to all clients. + +**Intermission (end-of-map) scoreboards**: When intermission begins, scoreboard sends are spread across 4 frames (~0.4 seconds) instead of sending all at once. This is imperceptible to players since intermission lasts several seconds. + +**On-demand (TAB key)**: Scoreboard requests from pressing the score key are sent as unreliable messages. If the packet is lost, the periodic 3-second refresh fills it in. This eliminates the risk of dropping a client whose reliable buffer was already full. + +The maximum scoreboard buffer has been increased from 1024 to 1400 bytes, and the maximum players shown per team raised from 8 to 10, taking advantage of the reduced burst pressure from staggering. + +### Configurable Scoreboard + +The `scoreboard` cvar accepts a string of field codes that define which columns appear on the in-game scoreboard (accessed via TAB). Each character maps to a column: + +| Code | Column | Width | +|------|--------|-------| +| `F` | Frags | 5 chars | +| `N` | Player name | 15 chars | +| `M` | Time (minutes) | 4 chars | +| `P` | Ping | 4 chars | +| `S` | Score | 5 chars | +| `K` | Kills | 5 chars | +| `D` | Deaths | 6 chars | +| `I` | Damage (raw) | 6 chars | +| `A` | Accuracy (%) | 3 chars | +| `T` | Team | 4 chars | +| `C` | CTF Caps | 4 chars | + +Default layouts (when `scoreboard` is empty): +- Standard teamplay: `FNMPIT` +- Team deathmatch: `FNMPDT` +- CTF: `SNMPCT` +- No-score mode: `NMP` + +Example: `set scoreboard "FNMPKIT"` replaces frags with kills and adds both raw damage and team columns. + +The layout string sent to clients is constrained to 1400 bytes. Each additional column adds approximately 7-8 bytes per player row. Server operators should be mindful of the total column count when many players are connected — if the string exceeds the limit it will be truncated, which may cut off players at the bottom of the list. + ### Random Rotation Random Map Rotation will make the server pick a random map from the maplist when the current map ends. This will make the rotations less static. diff --git a/src/action/a_team.c b/src/action/a_team.c index c00d9eb7a..4605ee4e2 100644 --- a/src/action/a_team.c +++ b/src/action/a_team.c @@ -3316,7 +3316,7 @@ int G_NotSortedClients( gclient_t **sortedList ) return total; } -#define MAX_SCOREBOARD_SIZE 1024 +#define MAX_SCOREBOARD_SIZE 1300 #define TEAM_HEADER_WIDTH 160 //skin icon and team tag #define TEAM_ROW_CHARS 32 //"yv 42 string2 \"name\" " #define TEAM_ROW_WIDTH 160 //20 chars, name and possible captain tag @@ -3327,12 +3327,12 @@ int G_NotSortedClients( gclient_t **sortedList ) // Maximum number of lines of scores to put under each team's header. #define MAX_SCORES_PER_TEAM 9 -#define MAX_PLAYERS_PER_TEAM 8 +#define MAX_PLAYERS_PER_TEAM 10 void A_NewScoreboardMessage(edict_t * ent) { - char buf[1024]; - char string[1024] = { '\0' }; + char buf[MAX_SCOREBOARD_SIZE]; + char string[MAX_SCOREBOARD_SIZE] = { '\0' }; gclient_t *sortedClients[MAX_CLIENTS]; int total[TEAM_TOP] = { 0, 0, 0, 0 }; int i, j, line = 0, lineh = 8; diff --git a/src/action/g_local.h b/src/action/g_local.h index 409384ad5..de1de31ae 100644 --- a/src/action/g_local.h +++ b/src/action/g_local.h @@ -2179,6 +2179,7 @@ struct gclient_s int damage_dealt; // total damage dealt to other players (used for hit markers) float killer_yaw; // when dead, look at killer + qboolean needs_intermission_scoreboard; // deferred intermission layout send weaponstate_t weaponstate; vec3_t kick_angles; // weapon kicks diff --git a/src/action/g_main.c b/src/action/g_main.c index 3077f82cc..7430bfc3d 100644 --- a/src/action/g_main.c +++ b/src/action/g_main.c @@ -828,24 +828,37 @@ void ClientEndServerFrames (void) int i, updateLayout = 0, spectators = 0; edict_t *ent; + // Stagger intermission scoreboard sends across frames if (level.intermission_framenum) { for (i = 0, ent = g_edicts + 1; i < game.maxclients; i++, ent++) { if (!ent->inuse || !ent->client) continue; ClientEndServerFrame(ent); + + if (ent->client->needs_intermission_scoreboard) { + int frames_since = level.realFramenum - level.intermission_framenum; + int my_slot = i % 4; + if (frames_since >= my_slot) { + DeathmatchScoreboardMessage(ent, NULL); +#ifndef NO_BOTS + if (!ent->is_bot) +#endif + gi.unicast(ent, true); + ent->client->needs_intermission_scoreboard = false; + } + } } return; } + // teams_changed forces immediate update for all clients if( teams_changed && FRAMESYNC ) { updateLayout = 1; teams_changed = false; UpdateJoinMenu(); } - else if( !(level.realFramenum % (3 * HZ)) ) - updateLayout = 1; // calc the player views now that all pushing // and damage has been added @@ -856,7 +869,12 @@ void ClientEndServerFrames (void) ClientEndServerFrame(ent); - if (updateLayout && ent->client->layout) { + // Stagger periodic layout updates: each client on a different frame + // within the 3-second cycle, unless forced by teams_changed + int clientUpdate = updateLayout || + ((level.realFramenum % (3 * HZ)) == (i % (3 * HZ))); + + if (clientUpdate && ent->client->layout) { if (ent->client->layout == LAYOUT_MENU) PMenu_Update(ent); else @@ -871,7 +889,8 @@ void ClientEndServerFrames (void) spectators++; } - if (updateLayout && spectators && spectator_hud->value >= 0) { + int updateSpectators = updateLayout || !(level.realFramenum % (3 * HZ)); + if (updateSpectators && spectators && spectator_hud->value >= 0) { G_UpdateSpectatorStatusbar(); if (level.spec_statusbar_lastupdate >= level.realFramenum - 3 * HZ) { diff --git a/src/action/p_hud.c b/src/action/p_hud.c index ae752ceb4..8631e7906 100644 --- a/src/action/p_hud.c +++ b/src/action/p_hud.c @@ -132,9 +132,8 @@ void MoveClientToIntermission(edict_t *ent) if( ent->is_bot ) return; #endif - // add the layout - DeathmatchScoreboardMessage(ent, NULL); - gi.unicast(ent, true); + // Defer the layout send to ClientEndServerFrames for staggered delivery + ent->client->needs_intermission_scoreboard = true; } void BeginIntermission(edict_t *targ) @@ -415,7 +414,7 @@ void DeathmatchScoreboard(edict_t *ent) return; #endif DeathmatchScoreboardMessage(ent, ent->enemy); - gi.unicast(ent, true); + gi.unicast(ent, false); // unreliable; periodic refresh catches drops }