Before You Start
This guide assumes you have Tunnelcast installed and working on your UT2004 server. If you haven't set that up yet, start with the installation guide.
You'll also need a basic understanding of UnrealScript and a working ucc make setup.
By the end of this guide, you'll have a working handler that detects game events, sends messages to Discord, and responds to the /status command.
Create Your Package
A GameHandler is your mod's bridge to Discord. It's the class that Tunnelcast loads to watch your game and translate what's happening into messages. You'll write this in its own package, separate from your game mod, because it keeps your handler code isolated, makes it easy to distribute, and means people can use your mod without Tunnelcast and vice versa.
Create a new folder for your package under your UT2004 directory. The folder name becomes the package name. Inside it, create a classes subfolder for your source files:
Tunnelcast_MyMod/classes/
Now open your Make.ini (or UT2004.ini, depending on your build setup) and tell the compiler about every package it needs, in order:
[Editor.EditorEngine]
EditPackages=JsonLib
EditPackages=EventGrid
EditPackages=MyGameMod
EditPackages=Tunnelcast
EditPackages=Tunnelcast_MyModWhy does the order matter? The UT2004 compiler processes packages top-to-bottom, and each package can only reference things defined in packages listed above it. Your handler needs:
- JsonLib: for building the JSON payloads you'll send to Discord
- EventGrid: the pub/sub event system that routes messages between Tunnelcast components
- MyGameMod: your game mod, so you can access its classes and game state
- Tunnelcast: the base
GameHandlerclass you'll extend - Tunnelcast_MyMod: your new handler package (listed last, since it depends on everything above)
Write Your GameHandler
Every GameHandler starts with a simple question: is this the right game type? Tunnelcast tries to load your handler for every game that runs on the server, but your handler probably only makes sense for one specific game type. So the first thing you do is check, and if it's the wrong game, you quietly remove yourself.
Create a file called GameHandler_MyMod.uc in your classes folder:
class GameHandler_MyMod extends GameHandler;
var MyGameMod.MyGameInfo MyGame;
function PreBeginPlay()
{
Super.PreBeginPlay();
MyGame = MyGameInfo(Level.Game);
if(MyGame == None)
{
log("GameHandler_MyMod: wrong game type, destroying.", 'Tunnelcast');
Destroy();
}
} Two things worth calling out: store a typed reference to your game (var MyGameMod.MyGameInfo MyGame) so you can reach its custom properties later, and always call Super.PreBeginPlay() first, since it wires up EventGrid and internal state. If the cast to your game type returns None, this isn't your game, so Destroy() and let another handler take over.
Super.PreBeginPlay() before your own logic. Skipping it will break the handler initialization chain. Send a Text Message
Now let's actually send something to Discord. Here's the data flow: your handler creates a JSON message → publishes it to EventGrid → Tunnelcast picks it up → sends it to the Tunnelcast server → the server forwards it to Discord. You don't need to worry about any of that plumbing. You just build the JSON and publish.
Every message follows a three-step pattern you'll use everywhere:
- Create a
JsonObject - Fill it with data and send it
- Clean up the
JsonObject
function SendRelayText(string Message)
{
local JsonObject Json;
Json = new class'JsonObject';
Json.AddString("Text", Message);
EventGrid.SendEvent("v1/tunnelcast/relay/text", Json);
Json.Clear();
} The topic v1/tunnelcast/relay/text tells Tunnelcast this is a plain text message. The Text property is the actual content that shows up in Discord. You can call this helper from anywhere in your handler:
SendRelayText("**Match started!** Map: " $ GetMapTitle());Clear() on your JsonObjects after sending. UnrealScript's garbage collector only handles Actor-based objects, but JsonObject is created with new (non-Actor), so it needs manual cleanup to avoid memory leaks. Since Discord parses the text on its end, you can use Discord markdown in your messages:
**bold**→ bold_italic_→ italic~~strikethrough~~→strikethrough`inline code`→inline code
TunnelcastUtils.SanitizeMarkupText() rather than SanitizeRelayText(): one backtick in a name opens a code span that runs to the next one and swallows every flag, score and prefix in between. FormatPlayerName() already does this for you. You can also pin an icon to the line by adding an optional Icon property. Its value is a rich-icon catalog name from the Rich Icons Explorer. The generic modder icons take a -color suffix, for example flame-amber, swords-red, trophy-amber, or sparkles-cyan. Pick the color in the explorer and copy the exact token it shows:
Json = new class'JsonObject';
Json.AddString("Text", "**The gate opened!**");
Json.AddString("Icon", "flame-amber");
EventGrid.SendEvent("v1/tunnelcast/relay/text", Json);
Json.Clear();
A line tagged with an Icon in Discord
That produces a payload like { "Text": "**The gate opened!**", "Icon": "flame-amber" }. The name renders wherever the line lands: in Discord it resolves to the colored custom emoji; on the Portal live feed it resolves to the base @tui.* line icon, since the color only applies to the Discord emoji. If Icon is omitted or unrecognised, the feed falls back to the default relay icon.
Icon (and country flags), though Discord markdown like **bold** still applies. Monitor Game State
Your handler has two main ways to detect things happening in the game:
Event-based: Override methods like HandleMatchStarted(), HandleMatchEnded(), or HandleActorSpawned() for events that Tunnelcast already tracks. These fire exactly once when the event happens, so they're perfect for announcements:
| Override Method | When It Fires |
|---|---|
HandleMatchStarted() | A new match begins |
HandleMatchEnded() | The match ends |
HandlePlayerJoin(PlayerController PC) | A player joins the server |
HandlePlayerLeft(PlayerController PC) | A player leaves the server |
HandleCommand(string Topic, optional JsonObject EventData) | React to custom Discord commands sent to the game handler |
MonitorGame() | Called every tick; use for polling |
function HandleMatchStarted()
{
SendRelayText("**Match has started!**");
}
function HandleMatchEnded()
{
SendRelayText("**Match over!** GG everyone.");
}Polling: Override MonitorGame() for anything else, like score changes or custom game state. This method is called every tick, so the idea is simple: store the previous value, compare it to the current value, and fire an event when something changes.
var int LastScore;
public function MonitorGame()
{
local int CurrentScore;
Super.MonitorGame();
CurrentScore = MyGame.Teams[0].Score;
if(CurrentScore != LastScore)
{
SendRelayText("**Score changed!** New score: " $ CurrentScore);
LastScore = CurrentScore;
}
} The pattern is always the same: save, compare, react. Store LastScore as a class variable so it persists between ticks. When the current score doesn't match, you know something changed, so you send your alert and update the stored value.
Super.MonitorGame() so the base class can run its own internal housekeeping. Match snapshot & /status
As your game runs, Tunnelcast keeps a single match snapshot per server: the current status, scores, objectives, and players. It renders /status, the portal live view, and the live-status embed from that one snapshot. You never build those outputs yourself; you just keep the snapshot current by emitting small events as things change.
GameHandler already reports match info, start/end, and team scores, and the built-in CTF, Assault, and Invasion handlers report flags, objectives, and waves. You only emit a snapshot event yourself when your mod tracks state the base class can't see, like a custom objective, a boss, a special phase. When your mod does track custom state, the dedicated guide covers the snapshot model, every match event, and a full walkthrough of the real CTF handler as a worked example:
For custom command responses and ad-hoc messages, you build your own Discord embeds. The full feature set (colors, fields, country flags, images, custom emojis) is covered in:
Register Your Handler
Your handler is written, but Tunnelcast doesn't know about it yet. You need to tell it: "when this game type runs, use this handler." Open your Tunnelcast.ini and add a GameHandlers entry:
GameHandlers=(GameTypeName="MyGameMod.MyGameInfo",GameHandler=class'Tunnelcast_MyMod.GameHandler_MyMod') The GameTypeName is the full class path of your game type, that is, Package.ClassName. If you're not sure what yours is, look for Level.Game.Class in your server log, or check your game mod's GameInfo subclass.
You'll also want to add defaultproperties to your handler class. The bIsCoopGame flag tells Tunnelcast how to display players in status embeds. Set it to false for competitive games (players split into teams) or true for cooperative games (everyone shown together):
defaultproperties
{
bIsCoopGame=false
}Compile & Deploy
Compile with ucc make, copy the .u to your server, and restart. In the Tunnelcast Portal, make sure Text Relay and/or Embed Relay are enabled in your server's Relay Settings.
After restart, check your server console for 'Tunnelcast' log lines. You should see your handler loading for the matching game type.
