Before You Start
This guide assumes you already have a working GameHandler that compiles and loads on your server. If you haven't built one yet, start with the game handler guide. It covers creating the package, extending GameHandler, and getting everything compiled.
By the end of this guide, you'll have custom slash commands that respond with rich embeds and appear in the /commands list.
Understand the Command Flow
Commands are just EventGrid topics with the prefix v1/tunnelcast/command/. Tunnelcast has a few built-in Discord slash commands like /status and /commands. For anything custom, players use the /customcommand slash command followed by a name, for example /customcommand top. Here's what happens behind the scenes:
- Someone types
/customcommand topin Discord. - The Tunnelcast server sends a message to EventGrid with topic
v1/tunnelcast/command/top. MutTunnelcastEventGridSubscriberreceives it, since it subscribes to thev1/tunnelcast/command/prefix.- It calls
GameHandler.HandleCommand("v1/tunnelcast/command/top"). - Your handler checks the topic string and routes to the right function.
- Your function responds by sending a
v1/tunnelcast/relay/embedorv1/tunnelcast/relay/textevent.
The base GameHandler already handles one command out of the box:
v1/tunnelcast/command/commands: callsHandleCommandListCommand()- Anything else: calls
SendCommandNotAvailable()(red error embed)
Here's what that routing looks like in the base class:
// This is what the base GameHandler does
function HandleCommand(string Topic)
{
if(Topic == "v1/tunnelcast/command/commands")
{
HandleCommandListCommand();
}
else
{
SendCommandNotAvailable();
}
}GameHandler doesn't need to know whether a command came from /status (built-in) or /customcommand top (custom). It just sees the EventGrid topic. The /commands list is built by HandleCommandListCommand(), and it's just an embed that lists what's available. You control what shows up there by overriding that method. Add Your First Custom Command
Let's add a top command that shows the top 3 players by score. Players will invoke it with /customcommand top in Discord. To add a command, you override HandleCommand() in your handler. Check for your topic first, then call Super.HandleCommand() for anything you don't recognize. This keeps /status and /commands working.
function HandleCommand(string Topic)
{
if(Topic == "v1/tunnelcast/command/top")
{
HandleTopCommand();
}
else
{
Super.HandleCommand(Topic);
}
}Super.HandleCommand(Topic) in the else branch. That's how /status and /commands keep working. Without it, those built-in commands will silently stop responding. Now write the actual command handler. This function builds a Discord embed with a gold sidebar, lists the top 3 players sorted by score, and includes a timestamp:
function HandleTopCommand()
{
local JsonObject Json, Color;
local array<JsonObject> Fields;
local int i;
local string PlayerList;
// Sort players by score and build list
PlayerList = "";
for(i = 0; i < Min(3, GetNumPlayers()); i++)
{
PlayerList $= "**" $ (i + 1) $ ".** "
$ class'JsonLib.JsonUtils'.static.StripIllegalCharacters(
GetSortedPlayerName(i))
$ ": " $ GetSortedPlayerScore(i) $ " pts\\n";
}
Json = new class'JsonObject';
Json.AddString("Title", ":trophy: Top Players");
Json.AddString("Description", PlayerList);
Color = new class'JsonObject';
Color.AddInt("R", 255);
Color.AddInt("G", 215);
Color.AddInt("B", 0);
Json.AddJson("Color", Color);
Json.AddBool("ShowTimestamp", true);
EventGrid.SendEvent("v1/tunnelcast/relay/embed", Json);
Json.Clear();
Color.Clear();
}Register It in the Commands List
Adding the command handler alone isn't enough, because players need to know it exists. When someone types /commands, they should see your new command listed. Override HandleCommandListCommand() to include your new command in the response:
function HandleCommandListCommand()
{
local JsonObject Json, Color;
local string Description;
Json = new class'JsonObject';
Json.AddString("Title", "Available Commands");
Color = new class'JsonObject';
Color.AddInt("R", 73);
Color.AddInt("G", 35);
Color.AddInt("B", 255);
Json.AddJson("Color", Color);
Description = "`/status` - Server status with player list\\n";
Description $= "`/customcommand top` - Top 3 players by score\\n";
Description $= "`/commands` - Show this list\\n";
Json.AddString("Description", Description);
EventGrid.SendEvent("v1/tunnelcast/relay/embed", Json);
Json.Clear();
Color.Clear();
}Add More Commands
The pattern for adding commands is always the same: add a topic check in HandleCommand(), write the handler function, and add the entry to HandleCommandListCommand(). Let's add a /customcommand mapinfo command that shows current map details:
function HandleCommand(string Topic)
{
if(Topic == "v1/tunnelcast/command/top")
{
HandleTopCommand();
}
else if(Topic == "v1/tunnelcast/command/mapinfo")
{
HandleMapInfoCommand();
}
else
{
Super.HandleCommand(Topic);
}
}And the handler that builds the map info embed:
function HandleMapInfoCommand()
{
local JsonObject Json, Color;
Json = new class'JsonObject';
Json.AddString("Title", ":map: "
$ class'JsonLib.JsonUtils'.static.StripIllegalCharacters(
GetMapTitle()));
Json.AddString("Description",
"**Game Type:** " $ Level.Game.GameName $ "\\n"
$ "**Players:** " $ GetNumPlayers()
$ " / " $ Level.Game.MaxPlayers $ "\\n"
$ "**Time:** "
$ Level.Game.GameReplicationInfo.ElapsedTime $ "s");
Color = new class'JsonObject';
Color.AddInt("R", 46);
Color.AddInt("G", 204);
Color.AddInt("B", 113);
Json.AddJson("Color", Color);
EventGrid.SendEvent("v1/tunnelcast/relay/embed", Json);
Json.Clear();
Color.Clear();
}/customcommand mapinfo to your HandleCommandListCommand() as well. Otherwise players won't know it exists even though it works. Handle Unknown Commands Gracefully
The base SendCommandNotAvailable() sends a red embed with "Command not available or not recognized." It's functional, but not very helpful. You can override it for a friendlier message that points users to /commands:
function SendCommandNotAvailable()
{
local JsonObject Json, Color;
Json = new class'JsonObject';
Json.AddString("Title", "Unknown Command");
Json.AddString("Description",
"That command doesn't exist. Try `/commands` to see what's available.");
Color = new class'JsonObject';
Color.AddInt("R", 237);
Color.AddInt("G", 66);
Color.AddInt("B", 69);
Json.AddJson("Color", Color);
EventGrid.SendEvent("v1/tunnelcast/relay/embed", Json);
Json.Clear();
Color.Clear();
}== which is case-sensitive. The topics always arrive lowercase from the server, so use lowercase strings in your comparisons. Here's a summary of the complete command routing flow:
| What Happens | Where |
|---|---|
User types /customcommand top in Discord | Discord → Tunnelcast Server |
| Server publishes to EventGrid | Topic: v1/tunnelcast/command/top |
| EventGridSubscriber receives it | MutTunnelcastEventGridSubscriber.ProcessEvent() |
| Routed to your handler | GameHandler.HandleCommand("v1/tunnelcast/command/top") |
| Your code responds | Send embed via v1/tunnelcast/relay/embed |
Every custom command follows the same three-step pattern: route the topic, build the embed, register it in the commands list. For more on building embed responses, see the Discord Embeds guide:
