Before You Start
This guide assumes you already have a working GameHandler that compiles and loads. If you don't, start there. This guide picks up where it leaves off.
The Match Snapshot
The key idea: Tunnelcast keeps a single match snapshot per server: the current status, scores, objectives, and players. You never build /status yourself. Instead, Tunnelcast renders all three of these from that one snapshot:
- the
/statusDiscord command - the live view on the portal
- the auto-updating live-status embed in your channel
Your handler's only job is to keep that snapshot current by emitting small match events as things change. Send a flag's new state, a team's new score, a phase change, and the backend merges each one into the snapshot and re-renders everything above. Here's what the rendered /status looks like:

Backend-rendered /status, built entirely from snapshot events
What You Get for Free
Before you write any snapshot code, know what already happens without you. The base GameHandler reports the essentials for every game, and the built-in handlers cover the standard UT2004 modes:
- Base
GameHandler: match info on load, start/end transitions, and team scores (polled automatically for team games). - CTF / Assault / Invasion handlers: flags, objectives, and waves for those modes, out of the box.
A simulated Team Deathmatch using only what the base handler reports: map, match state, elapsed time and who's on which team. The message is edited in place as the match plays out, with no snapshot code of your own.
The Match Events
These are the events that build the snapshot. Each updates one slice of it, so send only what changed and the backend merges it in. Reach for one only when the "Emit it yourself when…" column matches your mod:
| Event topic | Updates | Emit it yourself when… | Key fields |
|---|---|---|---|
v1/match/state/delta | Top-level match status & phase | You have a custom status or phase (base sends load, start, map switch, end) | Status, Phase |
v1/match/team/score | One team's score | You score teams in a non-standard way (base polls scores for team games) | Team, Score, Label, ColorKey |
v1/match/objective/state | State of one objective (flag, control point) | Your mod has custom objectives (CTF & Assault cover flags and standard objectives) | Id, Label, State, OwnerKey |
v1/ut2004/match/invasion/wave | Current Invasion wave / countdown | You build a custom wave-based mode (the Invasion handler covers stock Invasion) | N, CountdownSeconds, ClearCountdown |
/status and the live view show right now. A separate topic, v1/match/flag/event, instead posts a one-shot activity line ("Red flag taken!") to the feed. You'll see both in action below. Worked Example: How the CTF Handler Hooks In
The best way to understand snapshot events is to read a handler that uses them. GameHandler_CTF ships with Tunnelcast and reports both flags' states. Let's walk through it.
It hooks two methods from the base class. SendMatchInfo() runs once at the start and reports the full picture; MonitorGame() runs every tick and reports changes. Both delegate to one helper, ReportFlagStates():
class GameHandler_CTF extends GameHandler;
function PreBeginPlay()
{
Super.PreBeginPlay();
PreviousFlags.Length = 2;
// Wrong game type? Remove ourselves; another handler can take over.
if (CTFGame(Level.Game) == None)
{
Log("Not a CTF game. Destroying self.", 'Tunnelcast');
Destroy();
}
}
// Emit the full picture once, when match info is first sent.
public function SendMatchInfo()
{
ResetTrackedState();
Super.SendMatchInfo(); // base reports map, gametype, scores
ReportFlagStates(true); // force = emit every flag right now
}
// Re-check every tick; emit only what changed.
public function MonitorGame()
{
Super.MonitorGame();
ReportFlagStates(false);
} Notice the bForce argument: true means "emit everything now" (so the snapshot starts complete), false means "emit only what changed" (so each tick is cheap). This is the same save-compare-react pattern from MonitorGame() in the Game Handler guide, just applied to flags.
Capture → Compare → Emit
ReportFlagStates() is the heart of the handler. For each flag it captures the current state, compares it to the state from last tick, and emits an event only when something changed:
function ReportFlagStates(bool bForce)
{
local FlagSnapshot Current;
local int TeamIndex;
for (TeamIndex = 0; TeamIndex < PreviousFlags.Length; TeamIndex++)
{
// 1. Capture the flag's state right now.
Current = CaptureFlagState(GetFlagForTeamIndex(TeamIndex), TeamIndex);
if (Current.ObjectiveId == "")
continue;
// 2. If it just transitioned, post a one-shot feed line.
if (!bForce)
SendFlagEventForTransition(TeamIndex, PreviousFlags[TeamIndex], Current);
// 3. If anything changed, update the persistent snapshot.
if (bForce || !AreSnapshotsEqual(PreviousFlags[TeamIndex], Current))
SendFlagState(Current);
// 4. Remember it, so next tick can compare against it.
PreviousFlags[TeamIndex] = Current;
}
} "Capturing state" just means reading the engine and packing it into a small struct. The interesting part is GetFlagState(), which turns UT2004's internal flag states into the stable strings the backend expects (home, taken, dropped):
function FlagSnapshot CaptureFlagState(CTFFlag Flag, int TeamIndex)
{
local FlagSnapshot Snapshot;
Snapshot.ObjectiveId = GetFlagObjectiveId(TeamIndex); // "red-flag"
Snapshot.Label = GetFlagLabel(TeamIndex); // "Red Flag"
Snapshot.State = GetFlagState(Flag); // home/taken/dropped
Snapshot.OwnerKey = GetTeamKey(TeamIndex); // "red" / "blue"
return Snapshot;
}
// Translate raw engine state into a stable string the backend understands.
function string GetFlagState(CTFFlag Flag)
{
if (Flag == None)
return "home";
if (Flag.IsInState('Held'))
{
if (Flag.Holder != None)
return "taken";
return "dropped";
}
if (Flag.IsInState('Dropped'))
return "dropped";
return "home";
}Two Kinds of Output: Snapshot vs. Feed
The loop above calls two different send functions, and the difference is the most important thing in this guide.
Persistent state goes to v1/match/objective/state. This is the flag's current condition, and it stays on /status and the live view until it changes again:
// Persistent state -> drives /status, the live view, and the live embed.
function SendFlagState(FlagSnapshot Snapshot)
{
local JsonObject Json;
Json = new class'JsonObject';
Json.AddString("Id", Snapshot.ObjectiveId);
Json.AddString("Label", Snapshot.Label);
Json.AddString("State", Snapshot.State);
Json.AddString("OwnerKey", Snapshot.OwnerKey);
EventGrid.SendEvent("v1/match/objective/state", Json);
}One-shot events go to v1/match/flag/event. This is a single announcement ("the flag was just taken") that posts one line to the activity feed and is done:
// One-shot event -> a single activity-feed line ("Red flag taken!").
function SendFlagEvent(string Action, int TeamIndex, string PlayerName)
{
local JsonObject Json;
Json = new class'JsonObject';
Json.AddString("Action", Action); // taken / returned / captured
Json.AddString("FlagTeam", GetTeamKey(TeamIndex));
Json.AddString("FlagLabel", GetFlagLabel(TeamIndex));
if (PlayerName != "")
Json.AddString("PlayerName", PlayerName);
EventGrid.SendEvent("v1/match/flag/event", Json);
}
The flag event as a one-shot line in the activity feed
/status. If you only send the snapshot, players never get the "flag taken!" announcement. CTF sends both, on purpose. Apply It to Your Own Mod
Now the payoff. Say your mod has control points instead of flags. You report them exactly the way CTF reports a flag: same topic, same shape. A "Point A" that a team can capture is just an objective with an Id, a State, and an OwnerKey:
// Your mod's custom objective, reported exactly like CTF reports a flag.
function ReportControlPoint(string Id, string Label, string State, string OwnerKey)
{
local JsonObject Json;
Json = new class'JsonObject';
Json.AddString("Id", Id); // stable id, e.g. "point-a"
Json.AddString("Label", Label); // shown in /status, e.g. "Point A"
Json.AddString("State", State); // "neutral", "captured", ...
Json.AddString("OwnerKey", OwnerKey); // "red" / "blue" / ""
EventGrid.SendEvent("v1/match/objective/state", Json);
Json.Clear();
} Call it from your MonitorGame() when a point changes hands, using the capture-compare-emit pattern from Step 5. Tunnelcast renders your control points into /status and the live view alongside everything the base class already reports, with no extra wiring.
v1/match/flag/event. That's the feed side of the snapshot-vs-feed split from Step 6. To format custom command responses and ad-hoc messages with colors, fields, and country flags, continue with:
