Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions doc/action.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions src/action/a_team.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/action/g_local.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions src/action/g_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
{
Expand Down
7 changes: 3 additions & 4 deletions src/action/p_hud.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}


Expand Down
Loading