Files
tws-zombies-scripts/t5

tws-t5-aio

Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5 (Black Ops 1) zombies dedicated servers. Split across nine .gsc files, each installable / removable in isolation (with tws_common as the shared hub).

Platform quirks and deploy commands: NOTES.md.

Files

File Role
tws_common.gsc Shared config, HUD helpers, .help, connect/spawn registries, single connected listener + dispatcher
tws_db.gsc MariaDB pool config + reachability probe
tws_hud.gsc Bottom-left HUD stack: timers, zombie count, health bar
tws_bank.gsc Persistent per-player bank + console commands
tws_afk.gsc .afk / .back chat toggle + [AFK] indicator
tws_join.gsc .join lets newcomers spawn before the current round ends
tws_lock.gsc Lobby g_password lock at a milestone round
tws_telemetry.gsc Direct HTTP game start, round, heartbeat, and game end reports
tws_leaderboard.gsc Private map high-round record and all tied holders on connection

Map record announcements

Copy tws_leaderboard.gsc and the updated tws_common.gsc to storage/t5/scripts/sp/, then restart the map. Leaderboard requires a t5-gsc-utils build exposing http::request, jsonParse, typeof and formatString. A plugin upgrade requires a full server-process restart. To omit leaderboard, remove its script; the enable dvar does not bypass native linking dependencies.

Records are read from the public Electric Cherry API with no credentials and work independently of telemetry, HUD and the bank. Spectator newcomers receive the same private announcement as playable joins. T5 matches its engine map to the API's map list. Shared configuration, caching and validation apply to both games.

Player commands

Type .help in chat to see available player commands and a short description. The response is visible only to the requesting player and works while spectating, before the first spawn. Commands are case-insensitive. Help appears below the health bar for 12 seconds, clear of the timers, zombie counter and health display. Typing .help again restarts that display time.

Command Effect
.help List available commands
.afk Pause with protection
.back Return from AFK
.join Join mid-round (newcomers only)

Each module registers its own help entries; removing AFK or join also removes those entries from .help after a map restart. The bank's bank, deposit, and withdraw commands are server-console commands and are not listed in chat help.

Dependency graph

Arrows read "depends on" — solid means the source names the target with scripts\sp\tws_common::..., dashed means the source waits on a level notify fired by the target. Transitive edges are elided (so tws_bank and tws_lock reach tws_common through tws_db / tws_hud on the graph, but they also call tws_common directly).

graph BT
    common[tws_common<br/><i>config + helpers + dispatcher</i>]

    db[tws_db]
    hud[tws_hud]
    afk[tws_afk]
    join[tws_join]
    bank[tws_bank]
    lock[tws_lock]
    telemetry[tws_telemetry]
    leaderboard[tws_leaderboard]

    db --> common
    hud --> common
    afk --> common
    join --> common
    telemetry --> common
    leaderboard --> common

    bank -. db_probe_done .-> db
    lock -. hud_round_changed<br/>hud_game_over .-> hud
  • tws_common is the only module named by other files at compile time.
  • All other cross-module coupling flows through level notifies (db_probe_done, hud_round_changed, hud_match_started, hud_game_over) or through spawn handlers registered with tws_common::register_spawn_handler / register_connect_handler.
  • Any file except tws_common can be removed; the rest still load. tws_afk has no downstream consumers — pull it independently. tws_bank needs tws_db; tws_lock needs tws_hud.

Runtime notifies

Notify Fired by Read by
db_probe_done tws_db tws_bank (via bank_deferred_start)
hud_round_changed tws_hud tws_hud (round-timer reset), tws_lock
hud_match_started tws_hud tws_hud (match-timer reveal)
hud_game_over tws_hud tws_hud (freeze timers), tws_lock (clear password)

tws_common.gsc

Hub file. Shared config, HUD helpers, spawn-handler registry, and the single level waittill("connected") in the fleet. Adding a second connect listener anywhere else crashes the T5 SP handshake.

Features

  • Shared HUD constants: level.hud_x, hud_y, hud_row_*, hud_col_*, hud_font_*, hud_bar_*, hud_c_*, hud_rate, hud_health_threshold
  • Spawn-handler registry
  • Connection-handler registry for optional modules that serve spectator newcomers
  • .help listener and help registry populated by installed command modules
  • Connect listener + per-player dispatch: iterates level.tws_spawn_handlers after spawned_player and threads each on the player

Provides

Function Purpose
common_config() Populate every level.hud_* (idempotent)
ensure_config() No-op if config already set; called by every module's init
register_spawn_handler(fn) Appends ::fn to level.tws_spawn_handlers
register_connect_handler(fn) Threads ::fn after connection dispatch yields, without waiting for playable spawn
register_help_command(command, description) Adds a player command to .help; call from the owning module's init()
make_text_elem(p, x, y, ax, s, c) NewClientHudElem text element with black-glow outline
make_shader_elem(p, x, y) NewClientHudElem shader element
destroy_on_disconnect(elems) Waits on disconnect, destroys the array
int_dvar_or(name, fallback) Parse an integer dvar with default
format_mmss(elapsed_ms) Format milliseconds as M:SS

Depends on: nothing (leaf module).

Live validation (requires a T5 server)

  1. Type .help and .HELP while playing and while spectating before the first spawn. Verify all four descriptions appear below the health bar, only for the requesting player. Take damage so health numbers are visible and check that they do not cover the help text.
  2. Remove AFK or join, restart the map, and verify its entries disappear while .help and the remaining commands still work. Restore the scripts afterward.
  3. Repeat .help before 12 seconds elapse; verify one list stays visible for 12 seconds after the latest request, then hides. Reopen it after expiry, die/respawn and reconnect; verify no duplicate text or leftover help elements.

tws_db.gsc

Features

  • One MariaDB connection pool for the whole script (used by bank now, leaderboard later)
  • Reachability probe: round-trips SELECT 1 and flips level.db_online
  • Fires db_probe_done so waiters can proceed regardless of outcome

Provides

Level var / notify Meaning
level.db_online true iff the probe query returned without error
level.db_probe_done true once the probe has completed (either way)
notify db_probe_done Fired once after probe completion

Depends on

  • scripts\sp\tws_common::ensure_config()
  • t5-gsc-utils (mysql::set_config, mysql::query)

Config (currently hardcoded — TODO: dvarize before repo goes public)

  • Host mariadb, port 3306, DB tws_zm, user josh, password joshster

tws_hud.gsc

Bottom-left cluster. Per-player elements spawn via ::hud_start.

Features

  • Match timer + round timer (round resets each round, both freeze on game over)
  • Player-name row (only visible in co-op)
  • Zombies-remaining counter
  • Health bar with green / amber / red ramp; numbers fade in below full HP
  • Round-transition and game-over notify machinery

Provides

Notify / level var Meaning
notify hud_round_changed Fires each time level.round_number transitions
notify hud_match_started Fires once when the match clock seeds
notify hud_game_over Fires on the first end_game / game_ended / intermission notify
level.round_start_time Anchored at first observed round transition
level.match_start_time Anchored at first observed round-1 (or on first player spawn)

Spawn handler registered: ::hud_start — threads hud_timer_row, hud_zombie_counter, hud_health_display.

Depends on

  • scripts\sp\tws_common:: — ensure_config, make_text_elem, make_shader_elem, destroy_on_disconnect, format_mmss, register_spawn_handler
  • Every level.hud_* config value from tws_common
  • Stock BO1 state: level.round_number, level.zombie_total, level.players, self.health, self.maxhealth, self.name / self.playername, AI entities' .animname and .ignore_enemy_count

tws_bank.gsc

Persistent per-player bank. Balances survive server hops within the fleet because rows are keyed on XUID.

Features

  • Server-console commands: bank, deposit <amount>, withdraw <amount>
  • DB-first / memory-second: self.bank and self.score only move after the DB write confirms
  • Deposits: INSERT ... ON DUPLICATE KEY UPDATE — lazy row create, atomic
  • Withdrawals: WHERE balance >= ? — DB blocks overdrafts under concurrency
  • Score-drift refund guard on deposit
  • bank_load currently a diagnostic stub (bare-minimum to isolate the connect-handshake crash — do not restore the stripped body until it passes join)

Provides

Command Args Effect
bank — Prints current Bank and Score to the caller
deposit <amount> Score → bank, DB-first with refund on race
withdraw <amount> Bank → score, DB-first with overdraft guard

Per-player state: self.bank (int), self.bank_unavailable (bool), self.bank_guid (set in the full impl — currently unset by the stub).

Depends on

  • scripts\sp\tws_common:: — ensure_config, register_spawn_handler
  • tws_db — waits on db_probe_done, gates on level.db_online
  • t5-gsc-utils — mysql::query, mysql::prepared_statement, command::add_sv
  • Stock BO1: self.score
  • Dvar: tws_server_id (optional; labels last_server on bank rows)
  • External: MariaDB schema — bank(guid, balance, last_name, last_server)

Spawn handler registered: ::bank_load.


tws_afk.gsc

Self-invoked pause mode. Owns the [AFK] indicator element entirely — HUD module has zero knowledge of AFK.

Features

  • Chat commands .afk and .back (via level waittill("say") — not callbackPlayerSay, which never fires on T5 SP dedicated)
  • On enter: enableInvulnerability, freezeControls(true), self.ignoreme = true (drops out of zombie targeting)
  • On exit: reverse, then a 2s grace tail keeps invuln + ignoreme through the target-reacquisition window
  • [AFK] HUD element created per-player via spawn handler; alpha-toggled by enter / exit

Provides

  • Per-player: self.hud_afk_on (bool), self.hud_afk_elem (HUD element), writes self.ignoreme (stock BO1 targeting flag)
  • Level var: level.afk_grace_seconds (default 2)

Depends on

  • scripts\sp\tws_common:: — ensure_config, make_text_elem, destroy_on_disconnect, register_spawn_handler, register_help_command
  • Shared config: level.hud_x, level.hud_row_match, level.hud_font_small, level.hud_c_afk
  • Stock BO1: enableInvulnerability, disableInvulnerability, freezeControls, .ignoreme, level waittill("say", message, player)

Spawn handler registered: ::afk_spawn.


tws_join.gsc

Type .join in chat while spectating after joining a match in progress to spawn immediately. Install with tws_common.gsc; no HUD or DB dependency.

Eligibility

  • Only before the player's first playable spawn on the current connection.
  • A normal spawn or successful .join permanently closes eligibility for that connection. Getting downed, bleeding out, or changing rounds never restores it. Dead players must wait for the normal round-end respawn.
  • Requests during connection setup, without a living teammate, or after game over cannot spawn a player. Repeated .join requests cannot create overlapping spawns.
  • This is connection-local tracking; reconnecting creates a new player and resets eligibility. It is not an account-level reconnect restriction.

Uses the stock level.spawnPlayer entry point and preserves map-specific level.custom_spawnPlayer callbacks. If absent, installs the stock zombies spectator callback, as the round-end routine does. Missing spectator spawn locations are seeded from the map's initial_spawn_points.

Spawn handler registered: ::join_spawn. Stock self.initialized provides an additional first-spawn guard; hasSpawned cannot distinguish spectators. See the stock zombies lifecycle and spawn entry point.

Live validation (requires a T5 server)

  1. Join during an active round, type .join, and verify one playable spawn with normal weapons, controls, zombie targeting, and HUD initialization. Check both round 1 and a later round, including each deployed map.
  2. Repeat .join rapidly; verify no duplicate spawns. Try it alive, while downed, and after bleeding out; verify no revive or respawn.
  3. Join and wait for the normal round-end spawn instead; after dying, verify .join remains blocked, including on subsequent rounds.
  4. Try during round transition and game over; verify no duplicate spawn or restart of an ended match. Check that .afk / .back still work.

tws_lock.gsc

Features

  • Sets g_password to a random per-lobby value when the round crosses a milestone (default: round 20)
  • Existing players are unaffected; g_password is only checked at connect
  • Clears g_password on hud_game_over so the next lobby opens unlocked
  • Trigger round is dvar-driven — retune without redeploying

Provides

Level var Meaning
level.lock_round Round at which the lobby locks (read at init)
level.lock_active true after the password has been set

Depends on

  • scripts\sp\tws_common:: — ensure_config, int_dvar_or
  • tws_hud — waits on hud_round_changed and hud_game_over
  • Stock BO1: level.round_number
  • Dvar: tws_lock_round (int, default 20)

Dvars

Dvar Consumer Default Purpose
zombiemode every module — Gate: every init() returns early unless "1"
tws_lock_round tws_lock 20 Round at which the lobby locks
tws_server_id tws_bank, tws_telemetry Required for telemetry Exact registered Electric Cherry server name; also used as the bank label
tws_telemetry_enabled tws_telemetry 1 Set to 0 to disable telemetry at init
tws_telemetry_url tws_telemetry http://192.168.1.199:3000/api/telemetry Electric Cherry telemetry endpoint for JSON POSTs
tws_telemetry_log_level tws_telemetry 1 Read live: 0 warnings/errors/recovery only, 1 lifecycle/request/health logs, 2 also capture/queue logs and response previews

tws_telemetry.gsc

Uses t5-gsc-utils HTTP/JSON components to POST directly to Electric Cherry's /api/telemetry. Install alongside tws_common.gsc in storage/t5/scripts/sp/. No HUD or database dependency. Reports run whether or not a beta is active.

Example server config (except log level, read at script init; restart the map after changes):

set tws_server_id "t5_zombie_prototype_1"
set tws_telemetry_enabled "1"
set tws_telemetry_url "http://192.168.1.199:3000/api/telemetry"
set tws_telemetry_log_level "1"

tws_server_id must match the registered server name exactly. If it is unset, the script logs a configuration error and does not start reporting. An unknown or destroyed server is rejected by Electric Cherry with HTTP 404.

The reporter emits exactly four event types:

Event When Payload
game_start First playable spawn, with the stock begin_spawning flag as a fallback server, map, players with name and guid
round_change First observed round after start, then each change (checked every 0.5 seconds) server, map, round, full player stats
heartbeat Every 30 seconds after game_start, until game end Same fields as round_change, with current round and player stats
game_end First stock end_game, game_ended, or intermission notification server, map, final round, full player stats

Each player in round/heartbeat/end reports has name, guid, kills, headshots, downs, revives, and score. Names and GUIDs remain strings; GUIDs never pass through numeric conversion. Round and stats are nonnegative integers, with missing or negative stat values reported as zero. The four counters come from T5's player.stats; score is the player's current spendable player.score, rather than cumulative points earned. map is mapname.

Every report includes everyone returned by getPlayers(), including spectators, downed players, and AFK players. Empty rosters are sent as []. Undefined players and players with missing/empty names or GUIDs are skipped with a warning containing their roster index; a later snapshot can include them once their identity is available. The script builds the JSON array delimiters explicitly because the plugin serializes an empty GSC array as null; player objects and strings still use jsonSerialize for escaping.

{"event":"game_start","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","players":[{"name":"Josh","guid":"12345678901234567"}]}
{"event":"round_change","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":420,"headshots":160,"downs":1,"revives":3,"score":32000}]}
{"event":"heartbeat","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":430,"headshots":165,"downs":1,"revives":3,"score":32500}]}
{"event":"game_end","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":18,"players":[{"name":"Josh","guid":"12345678901234567","kills":445,"headshots":170,"downs":2,"revives":3,"score":33500}]}

Events are captured immediately into an in-memory queue. One HTTP worker POSTs each snapshot once, in gameplay order, with Content-Type: application/json, waiting for completion before sending the next. Gameplay and round detection continue while requests are pending. Repeated joins or end notifications do not duplicate start/end reports. At game over, the reporter captures any round change since the last poll, stops detection, and lets the sender drain through game_end.

The first heartbeat is captured 30 seconds after match start, then every 30 seconds even when the round is unchanged or the roster is empty. Round changes and later spawns do not reset the interval or add heartbeat workers. Heartbeats use the same snapshot builder and ordered sender as round reports; pending HTTP requests can delay transmission. Game end stops the heartbeat worker, and already queued heartbeats drain before game_end.

There is no discovery, beta token, session ID, sequence field, custom event, external helper, or separate beta acknowledgement. Delivery is best effort: no retries or persistence across map reloads, and no cross-game exactly-once guarantee. A pending request delays later reports; the current plugin has no request timeout option. A map reload can discard queued reports.

Transport failures log to the server console. The plugin does not expose HTTP status codes, so its success flag does not establish API acceptance. Verify acceptance at Electric Cherry: success is HTTP 200 with ok: true and telemetry containing receivedAt; invalid payloads return 400 and non-JSON content types return 415.

Diagnosing a lobby that stops reporting

Default logging includes initialization, match start/end, each request's event/round and local ID, queue wait and request duration in milliseconds, transport failures/recovery, and the final queue drain. IDs reset on map load and only appear in logs; payloads are unchanged. Every log includes the server, map, and game time in milliseconds.

An independent watchdog prints health every 30 seconds, even before match start and while HTTP is pending or the game-end queue is draining. It shows the sender stage, current snapshot capture, pending queue size (excluding the in-flight request), total queued, completed transports, failures, and time since the last completion (-1 means none). Warnings remain enabled at log level 0:

  • HTTP stalled: a request has been pending for at least 30 seconds. The warning repeats on watchdog ticks with its ID, event, age, and queue size.
  • sender is not consuming: the oldest queued report has waited at least 30 seconds with no in-flight request.
  • round watcher has stopped advancing: no polling progress for over 5 seconds, detected on the next watchdog tick.
  • no snapshot captured: an active match has gone at least 65 seconds without a new snapshot, despite its 30-second heartbeat.

Set tws_telemetry_log_level "2" in the server console while investigating; no map restart is needed. This adds capture/queue stages and API response previews, escaped onto one line and limited to the first 256 characters. Look for ok: false and the rejection reason in those previews; HTML may indicate a proxy error. Previews can include player identities echoed by the API. Restore 1 afterward, or use 0 to retain only warnings, errors, and transport recovery messages.

Missing request handles and reported transport failures are counted and skipped so the sender can continue with the next event. Empty responses produce a warning. Arbitrary response bodies are not parsed as JSON, because malformed/proxy responses must not break the sender. The watchdog does not cancel or retry stuck requests: the current plugin offers neither timeout nor cancellation through GSC, and starting another request could reorder events or accumulate native workers. A stall remains blocked until the plugin completes it; these logs identify that condition, not fix it.

Requires a plugin build containing http::request and jsonSerialize. Disabling the dvar does not remove that compile-time dependency; remove the script when using a plugin build without those functions.

Stat and lifecycle references: stock T5 zombies and score tracking.

Live validation (requires a T5 server)

  1. Start a match and verify one game_start, then an initial round_change and one per new round. Check the exact server name, map, string GUIDs, all player identities, and all five stats against the game.
  2. Join again in the same round, go AFK, become downed, and spectate. Verify the next report includes the full roster without another start event.
  3. End a match and verify one game_end with final stats after all round reports, including if more than one stock end notification fires. Check a round-0 intro and an empty roster, including last-player disconnect.
  4. Delay HTTP completion while rounds advance and the match ends. Verify requests remain ordered and retain the stats captured at each event. With the API unreachable, verify transport-error logging and continued gameplay. Repeat without an active beta and with HUD/DB modules removed.
  5. Stay in one round for over a minute. Verify heartbeats at 30 and 60 seconds after start, with current stats and the same fields as round_change. Check that round changes and joins do not reset or duplicate the interval, and no heartbeat follows game_end, including with requests pending.
  6. Change log level between 0, 1, and 2 without a map restart. Check request IDs pair sends with completions and level 2 shows bounded, escaped response previews. Use an unregistered server name on a test server and confirm the API rejection is visible in the response even though transport completes. Also check an empty or HTML response.
  7. Point a test server at an endpoint that accepts a connection but does not reply. Verify recurring HTTP stalled warnings and increasing pending counts while gameplay continues, including after game over. Release the delayed reply and verify the queue resumes in order and the watchdog stops after the final drain. Test a refused connection separately to check failure counts and the recovery log once restored.

External dependencies

  • t5-gsc-utils — required. tws_db and tws_bank need the mysql:: namespace; tws_bank also needs command::add_sv.
  • MariaDB / MySQL server reachable from the Plutonium container as mariadb:3306 (currently hardcoded — see tws_db.gsc).
  • Schema — bank(guid VARCHAR PRIMARY KEY, balance INT, last_name VARCHAR, last_server VARCHAR). tws_bank lazily inserts rows on first deposit.