Before You Start
This guide covers TunnelcastPlugin: standalone server-side components that hook into player connections, chat, and EventGrid. Plugins are independent from GameHandlers and don't require a game mod.
If you're new to UnrealScript and haven't set up a package before, start with the Game Handler guide. It covers the fundamentals of package setup and compilation. You don't need a GameHandler to build a plugin, but the workflow is the same.
Understand Plugins vs GameHandlers
Think of GameHandlers as specialists: each one knows about a specific game type. A handler for Onslaught only runs when Onslaught is active. Plugins are generalists: they run on every server regardless of what game type is active. Use a plugin when your feature isn't tied to a specific game mode.
| GameHandler | TunnelcastPlugin | |
|---|---|---|
| Scope | Tied to one game type | Runs on every game type |
| Loaded via | GameHandlers array | Plugins array |
| Best for | Game events, scores, match flow | Chat, player connections, cross-mod features |
| Needs game mod? | Yes | No |
Plugins are great for things like:
- Welcome messages for new players
- Chat filtering: preventing certain messages from reaching Discord
- Chat formatting: modifying messages before they go to Discord
- Periodic announcements
- Custom EventGrid subscribers for cross-mod integration
The TunnelcastPlugin Base Class
Every plugin extends TunnelcastPlugin, which itself extends Actor. The base class gives you access to EventGrid and the Tunnelcast mutator, plus a set of lifecycle and chat hooks you can override:
class TunnelcastPlugin extends Actor
abstract;
var EventGrid EventGrid;
var MutTunnelcast TunnelcastMutator;
public function OnInitialize()
{ }
public function OnPlayerConnected(string Ip, int PlayerIndex)
{ }
public function OnPlayerDisconnected(int PlayerIndex)
{ }
public function bool PreventReportChat(PlayerReplicationInfo PRI, out string Message, name Type)
{
return false;
}
public function string FormatChatMessage(PlayerReplicationInfo PRI, coerce string Message, name Type)
{
return Message;
}You don't need to override every method. Just pick the ones you need. The defaults are safe no-ops: lifecycle hooks do nothing, chat hooks pass everything through unchanged.
Create Your Plugin Package
The package structure is the same as a GameHandler: create a folder with a classes subfolder inside your UT2004 directory:
Tunnelcast_MyPlugin/classes/
The key difference from a GameHandler package: you don't need your game mod in Make.ini. Plugins are game-type agnostic, so the only dependencies are JsonLib, EventGrid, and Tunnelcast:
[Editor.EditorEngine]
EditPackages=JsonLib
EditPackages=EventGrid
EditPackages=Tunnelcast
EditPackages=Tunnelcast_MyPluginWrite a Welcome Message Plugin
Let's build a practical plugin that sends a welcome message to players when they join. Tunnelcast doesn't ship one, which makes it a good first plugin to build and adapt for your own server. (Players are always told their chat is relayed; Tunnelcast does that for you, so your welcome can say whatever you like.)
Create a file called Plugin_Welcome.uc in your classes folder:
class Plugin_Welcome extends TunnelcastPlugin
config(Tunnelcast);
var config string WelcomeMessage;
var config int WelcomeMessageDelay;
struct PendingWelcome
{
var PlayerController PC;
var float SendTime;
};
var array<PendingWelcome> PendingMessages;
function OnInitialize()
{
if(WelcomeMessage == "")
{
log("Plugin_Welcome: No welcome message configured, disabling.", 'Tunnelcast');
Destroy();
return;
}
SetTimer(1, true);
}
function OnPlayerConnected(string Ip, int PlayerIndex)
{
local PendingWelcome Welcome;
Welcome.PC = TunnelcastMutator.Players[PlayerIndex].PC;
Welcome.SendTime = Level.TimeSeconds + WelcomeMessageDelay;
PendingMessages[PendingMessages.Length] = Welcome;
}
function Timer()
{
local int i;
for(i = PendingMessages.Length - 1; i >= 0; i--)
{
if(PendingMessages[i].SendTime <= Level.TimeSeconds)
{
if(PendingMessages[i].PC != None)
SendWelcome(PendingMessages[i].PC);
PendingMessages.Remove(i, 1);
}
}
}
function SendWelcome(PlayerController PC)
{
local PlayerReplicationInfo SenderPRI;
if(TunnelcastMutator.ChatSpectator == None)
return;
SenderPRI = TunnelcastMutator.ChatSpectator.PlayerReplicationInfo;
if(Level.Game.BroadcastHandler != None && SenderPRI != None)
Level.Game.BroadcastHandler.BroadcastText(SenderPRI, PC, WelcomeMessage, 'Say');
}
defaultproperties
{
WelcomeMessage="Welcome to the server! Type !help for info."
WelcomeMessageDelay=15
}That's a lot of code, so let's break down the key concepts:
config(Tunnelcast)makes yourvar configvariables editable in Tunnelcast.ini. Server admins can customize the welcome message and delay without touching your code.defaultpropertiessets sensible defaults that admins can override in the config file.SetTimer(1, true)creates a repeating 1-second timer that calls theTimer()function.- Why the delay? Players need time to fully load in. A message sent immediately can be missed because the player's HUD hasn't finished initializing yet.
- Iterating backwards: the
Timer()loop runs fromLength - 1down to0when removing items. This avoids skipping elements, which happens when you remove from a forward loop. TunnelcastMutator.ChatSpectatoris a special spectator actor used to send in-game messages. TheBroadcastHandlerdelivers the message to a specific player.
None before using ChatSpectator and BroadcastHandler. They may not be available during server startup or shutdown. Filter Chat Messages
PreventReportChat() is called for every chat message before it's sent to Discord. Return true to block the message, false to let it through.
Here's a practical example: a plugin that blocks messages starting with ! (a common command prefix for other mods) from reaching Discord:
class Plugin_CommandFilter extends TunnelcastPlugin;
function bool PreventReportChat(PlayerReplicationInfo PRI, out string Message, name Type)
{
// Block bot commands from reaching Discord
if(Left(Message, 1) == "!")
return true;
return false;
} You can also modify messages before they reach Discord using FormatChatMessage(). This hook is called after PreventReportChat() passes, so you only format messages that are actually going through:
function string FormatChatMessage(PlayerReplicationInfo PRI, coerce string Message, name Type)
{
// Could add rank, clan tag, or modify message format
return "[Player] " $ Message;
}PreventReportChat() is checked first. If it returns false (allow), FormatChatMessage() is then called to optionally modify the message before it goes to Discord. Send Events to Discord
Plugins have full EventGrid access, so they can send text messages and embeds to Discord just like GameHandlers. The same three-step pattern applies: create a JsonObject, fill and send it, then clean up.
Here's a plugin that posts join/leave notifications to Discord:
function SendDiscordAlert(string Message)
{
local JsonObject Json;
Json = new class'JsonObject';
Json.AddString("Text", Message);
EventGrid.SendEvent("v1/tunnelcast/relay/text", Json);
Json.Clear();
}
function OnPlayerConnected(string Ip, int PlayerIndex)
{
local string PlayerName;
PlayerName = class'JsonLib.JsonUtils'.static.StripIllegalCharacters(
TunnelcastMutator.Players[PlayerIndex].PC.PlayerReplicationInfo.PlayerName);
SendDiscordAlert(":wave: **" $ PlayerName $ "** joined the server!");
}
function OnPlayerDisconnected(int PlayerIndex)
{
SendDiscordAlert(":door: A player left the server.");
} Note the use of StripIllegalCharacters() on the player name. This sanitizes any characters that could break the JSON payload. Always sanitize player-provided text before sending it through EventGrid.
The v1/tunnelcast/relay/text event also takes an optional Icon property (a rich-icon catalog name; the generic modder icons take a color suffix like flame-amber or trophy-amber) that pins an icon to the line (a Portal-feed @tui.* icon and the matching Discord emoji, or the default relay icon when omitted). The Game Handler guide covers it in full.
Clear() your JsonObjects after sending to avoid memory leaks. (See the Game Handler guide for why this is manual.) Register Your Plugin
Your plugin is written, but Tunnelcast doesn't know about it yet. Unlike GameHandlers, which are tied to a specific game type, plugins load for every server session. Open your Tunnelcast.ini and add your plugin to the Plugins array:
[Tunnelcast.TunnelcastSettings]
Plugins=Tunnelcast_MyPlugin.Plugin_WelcomeThe loading process works like this:
- Tunnelcast reads the
Pluginsarray fromTunnelcastSettings - Each plugin class is Spawned as an Actor
EventGridandTunnelcastMutatorreferences are setOnInitialize()is called- The plugin runs until the server shuts down (or it
Destroy()s itself)
Destroy(). This is useful when your plugin detects an invalid configuration, like the welcome plugin above does when no welcome message is set. 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 plugin loading and initializing.
Plugin API Reference
| Method | When Called | Use For |
|---|---|---|
OnInitialize() | Plugin loads | Setup, validation, timers |
OnPlayerConnected(string Ip, int PlayerIndex) | Player joins | Welcome messages, logging |
OnPlayerDisconnected(int PlayerIndex) | Player leaves | Goodbye messages, cleanup |
PreventReportChat(PRI, Message, Type) | Every chat message | Filter/block messages |
FormatChatMessage(PRI, Message, Type) | Allowed chat messages | Modify message content |
Available References
| Property | Type | What It Gives You |
|---|---|---|
EventGrid | EventGrid | Publish events to Discord |
TunnelcastMutator | MutTunnelcast | Server state, player list |
TunnelcastMutator.Players | array | Connected player data |
TunnelcastMutator.ChatSpectator | MessagingSpectator | Send in-game messages |
Level.Game | GameInfo | Current game state |
What's Next
Now that you can build plugins, take your Discord messages to the next level with rich embeds: colors, fields, images, and more.
