Building a Modern Admin Tool for Unreal Tournament 2004 on Windows
Sometimes nostalgia meets modern development, and magic happens. That's exactly what happened when I decided to build UTAdmin - a complete administration solution for Unreal Tournament 2004 servers that lets you manage your server from your phone. This project combines the ancient art of UnrealScript with modern Flutter development, all built on Windows.
The Problem
I've been running UT2004 servers on and off for years, and admin tools have always been... clunky. Most require you to be at the console, use awkward in-game commands, or rely on web interfaces from 2004 that barely render on modern browsers. I wanted something simple: manage my server from my phone while I'm playing or AFK.
The Solution: A Two-Part System
UTAdmin consists of two components:
- A server-side mutator that exposes a TCP API server written in UnrealScript
- A Flutter mobile app that connects to the API and provides a clean, modern UI
Let's dive into how I built this on Windows.
Part 1: The Mutator - UnrealScript TCP API Server
Setting Up the UT2004 Development Environment on Windows
First things first - I needed to get UnrealScript compilation working on Windows. Here's what I did:
-
Located my UT2004 installation (usually
C:\Program Files (x86)\Unreal Tournament 2004\) -
Created the mutator structure:
UT2004\ └── UTAdmin\ └── Classes\ ├── UTAdmin.uc ├── UTAdminAPIServer.uc ├── UTAdminConnection.uc └── UTAdminMessage.uc -
Modified
UT2004.inito add the compile path:[Editor. EditorEngine] EditPackages=UTAdmin -
Compiled using the command prompt:
cd "C:\Program Files (x86)\Unreal Tournament 2004\System" ucc. exe make
Architecture: The Mutator Design
The mutator follows a clean three-class architecture:
1. UTAdmin.uc - The Main Mutator
This is the entry point. It initializes when the server starts and spawns the API server:
class UTAdmin extends Mutator config(UTAdmin);
var() config string AdminPasscode;
var() config int AdminAPIPort;
var UTAdminAPIServer APIServer;
function PostBeginPlay()
{
Super.PostBeginPlay();
Log("UTAdmin: Initializing...");
if (AdminPasscode == "" || AdminPasscode == "changeme")
{
Log("UTAdmin: WARNING - AdminPasscode not configured or using default value! Set AdminPasscode in UTAdmin.ini");
}
if (AdminAPIPort <= 0)
{
AdminAPIPort = 7778;
SaveConfig();
Log("UTAdmin: Using default port 7778");
}
APIServer = Spawn(class'UTAdminAPIServer');
if (APIServer != None)
{
APIServer.Initialize(AdminPasscode, AdminAPIPort, self);
Log("UTAdmin: API Server started on port" @ AdminAPIPort);
}
else
{
Log("UTAdmin: ERROR - Failed to spawn API Server!");
}
}
defaultproperties
{
FriendlyName="UT Admin API"
Description="Exposes a clean REST-like API for external admin tools"
GroupName="UTAdmin"
AdminPasscode="changeme"
AdminAPIPort=7778
bAddToServerPackages=True
}
Key features:
- Configuration via
UTAdmin.inifile - Validation for security (checks for default password)
- In-game configuration support through the host game menu
- Clean initialization and teardown
2. UTAdminAPIServer.uc - The TCP Listener
This class extends TcpLink and listens for incoming connections:
class UTAdminAPIServer extends TcpLink;
var string Passcode;
var int APIPort;
var array<UTAdminConnection> Connections;
var Mutator OwnerMutator;
function Initialize(string InPasscode, int InPort, Mutator InMutator)
{
Passcode = InPasscode;
APIPort = InPort;
OwnerMutator = InMutator;
LinkMode = MODE_Line;
ReceiveMode = RMODE_Event;
if (BindPort(APIPort, true) > 0)
{
Listen();
Log("UTAdminAPIServer: Listening on port" @ APIPort);
}
else
{
Log("UTAdminAPIServer: ERROR - Failed to bind to port" @ APIPort);
}
}
event GainedChild(Actor C)
{
local UTAdminConnection NewConnection;
Super.GainedChild(C);
NewConnection = UTAdminConnection(C);
if (NewConnection != None)
{
Connections[Connections.Length] = NewConnection;
NewConnection.LinkMode = MODE_Line;
NewConnection.ReceiveMode = RMODE_Event;
NewConnection.SetPasscode(Passcode);
NewConnection.SetOwnerMutator(OwnerMutator);
Log("UTAdminAPIServer: New connection accepted, total connections:" @ Connections.Length);
}
}
defaultproperties
{
AcceptClass=class'UTAdminConnection'
}
The magic here:
BindPort()opens a TCP socket on WindowsMODE_Linetells UnrealScript to buffer incoming data line-by-line- Each new connection spawns a
UTAdminConnectionactor - The
GainedChildevent tracks active connections
3. UTAdminConnection.uc - The Command Processor
This is where the real work happens. Each client connection gets its own instance:
class UTAdminConnection extends TcpLink;
var string Passcode;
var bool bAuthenticated;
var string ReceiveBuffer;
var Mutator OwnerMutator;
event ReceivedLine(string Line)
{
Line = Repl(Line, Chr(13), "");
Line = Repl(Line, Chr(10), "");
if (Len(Line) > 0)
{
ProcessCommand(Line);
}
}
function ProcessCommand(string Command)
{
local string CommandType, Payload;
local int SplitPos;
Log("UTAdminConnection: Processing command: " $ Command);
SplitPos = InStr(Command, " ");
if (SplitPos != -1)
{
CommandType = Left(Command, SplitPos);
Payload = Mid(Command, SplitPos + 1);
}
else
{
CommandType = Command;
Payload = "";
}
CommandType = Caps(CommandType);
if (CommandType == "AUTH")
{
HandleAuth(Payload);
}
else if (!bAuthenticated)
{
SendJSON("{\"error\": \"Not authenticated. Send AUTH <passcode>\"}");
}
else if (CommandType == "PING")
{
SendJSON("{\"status\": \"pong\"}");
}
else if (CommandType == "STATUS")
{
HandleStatus();
}
else if (CommandType == "PLAYERS")
{
HandlePlayers();
}
else if (CommandType == "KICK")
{
HandleKick(Payload);
}
else if (CommandType == "SAY")
{
HandleSay(Payload);
}
else if (CommandType == "CHANGEMAP")
{
HandleChangeMap(Payload);
}
else
{
SendJSON("{\"error\": \"Unknown command: " $ CommandType $ "\"}");
}
}
function HandleAuth(string AttemptedPasscode)
{
Log("UTAdminConnection: HandleAuth called. Attempted='" $ AttemptedPasscode $ "' Expected='" $ Passcode $ "'");
if (AttemptedPasscode == Passcode)
{
bAuthenticated = true;
SendJSON("{\"status\": \"authenticated\"}");
Log("UTAdminConnection: Client authenticated successfully");
}
else
{
bAuthenticated = false;
SendJSON("{\"error\": \"Invalid passcode\"}");
Log("UTAdminConnection: Failed authentication attempt");
}
}
function HandleStatus()
{
local GameInfo GI;
local string Response;
GI = Level.Game;
Response = "{";
Response = Response $ "\"gametype\": \"" $ GI.GameName $ "\",";
Response = Response $ "\"map\": \"" $ GetURLMap() $ "\",";
Response = Response $ "\"players\": " $ GI.NumPlayers $ ",";
Response = Response $ "\"maxplayers\": " $ GI.MaxPlayers $ ",";
Response = Response $ "\"timelimit\": " $ GI.TimeLimit;
Response = Response $ "}";
SendJSON(Response);
}
function HandlePlayers()
{
local Controller C;
local PlayerController PC;
local string Response;
local bool bFirst;
Response = "{\"players\": [";
bFirst = true;
for (C = Level. ControllerList; C != None; C = C.NextController)
{
PC = PlayerController(C);
if (PC != None && PC.PlayerReplicationInfo != None)
{
if (!bFirst)
{
Response = Response $ ",";
}
bFirst = false;
Response = Response $ "{";
Response = Response $ "\"name\": \"" $ EscapeJSON(PC.PlayerReplicationInfo.PlayerName) $ "\",";
Response = Response $ "\"id\": " $ PC.PlayerReplicationInfo.PlayerID $ ",";
Response = Response $ "\"score\": " $ PC.PlayerReplicationInfo.Score $ ",";
Response = Response $ "\"ping\": " $ PC.PlayerReplicationInfo.Ping;
Response = Response $ "}";
}
}
Response = Response $ "]}";
SendJSON(Response);
}
function HandleSay(string Message)
{
local Controller C;
local PlayerController PC;
// Send message to all players - ClientMessage for HUD display
for (C = Level. ControllerList; C != None; C = C.NextController)
{
PC = PlayerController(C);
if (PC != None)
{
// ClientMessage displays on HUD
PC.ClientMessage("[ADMIN] " $ Message, 'Say');
}
}
Log("UTAdmin: Broadcasting message: " $ Message);
SendJSON("{\"status\": \"Message broadcasted\"}");
}
function SendJSON(string JSON)
{
SendText(JSON $ Chr(13) $ Chr(10));
}
function string EscapeJSON(string Input)
{
Input = Repl(Input, "\\", "\\\\");
Input = Repl(Input, "\"", "\\\"");
return Input;
}
defaultproperties
{
bAuthenticated=false
LinkMode=MODE_Line
ReceiveMode=RMODE_Event
}
Design decisions:
- Line-based protocol: Simple and easy to debug with telnet
- JSON responses: Even though UnrealScript doesn't have native JSON, I manually construct it (it works!)
- Authentication first: No commands work until you authenticate
- Direct game state access: Uses
Level.GameandLevel.ControllerListto query server state
The Protocol
The protocol is beautifully simple - send a command, get a JSON response:
CLIENT: AUTH changeme
SERVER: {"status": "authenticated"}
CLIENT: STATUS
SERVER: {"gametype": "DeathMatch", "map": "DM-Rankin", "players": 4, "maxplayers": 16, "timelimit": 20}
CLIENT: PLAYERS
SERVER: {"players": [{"name": "Player1", "id": 0, "score": 15, "ping": 45}, ... ]}
CLIENT: SAY Hello everyone!
SERVER: {"status": "Message broadcasted"}
CLIENT: KICK 42
SERVER: {"status": "Player kicked"}
CLIENT: CHANGEMAP DM-Deck17
SERVER: {"status": "Changing map to DM-Deck17"}
Windows Firewall Configuration
Don't forget to open the port in Windows Firewall! On Windows 10/11:
New-NetFirewallRule -DisplayName "UT2004 Admin API" -Direction Inbound -LocalPort 7778 -Protocol TCP -Action Allow
Or through the GUI:
- Windows Defender Firewall → Advanced settings
- Inbound Rules → New Rule
- Port → TCP → 7778
- Allow the connection
Part 2: The Flutter App
Setting Up Flutter on Windows
On my Windows development machine, I installed Flutter:
- Downloaded Flutter SDK from flutter.dev
- Extracted to
C:\src\flutter - Added
C:\src\flutter\binto PATH - Ran
flutter doctorto verify installation - Installed Android Studio for mobile development
Project Structure
FlutterApp/
├── lib/
│ ├── main.dart # App entry point
│ ├── utadmin_client.dart # TCP client library
│ └── screens/
│ ├── connection_screen.dart # Server connection UI
│ ├── dashboard_screen.dart # Main dashboard
│ ├── players_screen.dart # Player management
│ ├── broadcast_screen.dart # Broadcast messages
│ ├── maps_screen.dart # Map changing
│ └── status_screen.dart # Detailed status
├── android/ # Android-specific files
└── pubspec.yaml # Dependencies
The TCP Client Library
The heart of the Flutter app is utadmin_client.dart, which handles all communication with the server:
import 'dart:io';
import 'dart:convert';
import 'dart:async';
class UTAdminClient {
final String host;
final int port;
final String passcode;
Socket? _socket;
StreamController<String>? _responseController;
bool _isAuthenticated = false;
bool _isConnected = false;
UTAdminClient(this.host, this.port, this.passcode);
bool get isConnected => _isConnected;
bool get isAuthenticated => _isAuthenticated;
Future<bool> connect() async {
try {
// Close existing connections first
await _cleanupConnection();
_responseController = StreamController<String>. broadcast();
_socket = await Socket.connect(host, port, timeout: Duration(seconds: 5));
_isConnected = true;
_socket!.listen(
(data) {
final response = utf8.decode(data);
final lines = response.split('\n');
for (final line in lines) {
if (line.trim().isNotEmpty && _responseController != null) {
_responseController!.add(line. trim());
}
}
},
onError: (error) {
_isConnected = false;
_isAuthenticated = false;
},
onDone: () {
_isConnected = false;
_isAuthenticated = false;
},
);
// Give socket time to establish
await Future.delayed(Duration(milliseconds: 200));
// Authenticate immediately
await authenticate();
return _isAuthenticated;
} catch (e) {
_isConnected = false;
_isAuthenticated = false;
await _cleanupConnection();
return false;
}
}
Future<void> authenticate() async {
try {
_sendCommand('AUTH $passcode');
final response = await _readResponse(timeout: Duration(seconds: 5));
final json = jsonDecode(response);
_isAuthenticated = json['status'] == 'authenticated';
if (! _isAuthenticated) {
throw Exception('Authentication failed: Invalid passcode');
}
} catch (e) {
_isAuthenticated = false;
throw Exception('Authentication failed: $e');
}
}
Future<Map<String, dynamic>> getStatus() async {
_sendCommand('STATUS');
final response = await _readResponse();
return jsonDecode(response);
}
Future<List<dynamic>> getPlayers() async {
_sendCommand('PLAYERS');
final response = await _readResponse();
final json = jsonDecode(response);
return json['players'] as List<dynamic>;
}
Future<void> say(String message) async {
_sendCommand('SAY $message');
await _readResponse();
}
Future<void> changeMap(String mapName) async {
_sendCommand('CHANGEMAP $mapName');
// Don't wait for response as server will disconnect
await Future.delayed(Duration(milliseconds: 100));
await disconnect();
}
Future<bool> reconnect({int maxAttempts = 10, int delaySeconds = 2}) async {
// Ensure clean disconnect first
await disconnect();
// Wait before first attempt
await Future.delayed(Duration(seconds: delaySeconds));
// Try to reconnect multiple times
for (int i = 0; i < maxAttempts; i++) {
try {
final success = await connect();
if (success && _isAuthenticated) {
return true;
}
} catch (e) {
print('Reconnect attempt ${i + 1} failed: $e');
}
if (i < maxAttempts - 1) {
final waitTime = delaySeconds + (i * 1);
await Future.delayed(Duration(seconds: waitTime. clamp(2, 5)));
}
}
return false;
}
void _sendCommand(String command) {
if (! _isConnected) {
throw Exception('Not connected');
}
_socket!.write('$command\n');
}
Future<String> _readResponse({Duration timeout = const Duration(seconds: 2)}) async {
if (_responseController == null) {
throw Exception('Not connected');
}
try {
final response = await _responseController!.stream.first. timeout(timeout);
return response;
} catch (e) {
throw Exception('Timeout waiting for response');
}
}
Future<void> disconnect() async {
_isConnected = false;
_isAuthenticated = false;
await _cleanupConnection();
}
Future<void> _cleanupConnection() async {
try {
await _socket?.close();
} catch (e) {
// Ignore
}
try {
await _responseController?.close();
} catch (e) {
// Ignore
}
_socket = null;
_responseController = null;
}
}
Key features:
- Async/await throughout: Clean async code using Dart futures
- Stream-based responses: Uses
StreamControllerto handle incoming data - Auto-reconnect logic: Handles map changes that kill the connection
- Connection cleanup: Properly closes sockets and streams
The UI: Material Design 3
The app uses Flutter's Material Design 3 with a dark theme and orange accent:
import 'package:flutter/material. dart';
import 'screens/connection_screen.dart';
void main() => runApp(const UT2004AdminApp());
class UT2004AdminApp extends StatelessWidget {
const UT2004AdminApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'UT2004 Admin',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.orange,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const ConnectionScreen(),
);
}
}
Building the APK on Windows
Building for Android on Windows is straightforward:
cd FlutterApp
flutter pub get
flutter build apk --release
The APK ends up in build\app\outputs\flutter-apk\app-release.apk.
Pro tip: If you get Android SDK errors, make sure you have:
- Android SDK 36 installed
- NDK version 27.0.12077973
- Updated
android\app\build.gradle. ktswith correct versions
Testing the Complete System on Windows
1. Start the UT2004 Server
cd "C:\Program Files (x86)\Unreal Tournament 2004\System"
UT2004.exe DM-Rankin? Game=XGame.xDeathMatch? Mutator=UTAdmin. UTAdmin -server
2. Verify the API is Running
Use PowerShell to test:
$client = New-Object System.Net.Sockets.TcpClient("localhost", 7778)
$stream = $client.GetStream()
$writer = New-Object System.IO. StreamWriter($stream)
$reader = New-Object System.IO. StreamReader($stream)
$writer.WriteLine("AUTH changeme")
$writer.Flush()
$reader.ReadLine() # Should return: {"status": "authenticated"}
$writer.WriteLine("STATUS")
$writer.Flush()
$reader.ReadLine() # Should return server status JSON
$client.Close()
3. Deploy to Android Device
With USB debugging enabled on your Android device:
adb install build\app\outputs\flutter-apk\app-release. apk
Or connect via the Flutter app and enter:
- IP: Your Windows machine's local IP (e.g.,
192.168.1.100) - Port:
7778 - Password:
changeme(or whatever you configured)
Challenges and Solutions
Challenge 1: UnrealScript's Limited String Handling
Problem: Building JSON responses with UnrealScript's primitive string operations was tedious.
Solution: Created helper functions like EscapeJSON() and carefully constructed strings with proper escaping:
function string EscapeJSON(string Input)
{
Input = Repl(Input, "\\", "\\\\");
Input = Repl(Input, "\"", "\\\"");
return Input;
}
Challenge 2: Map Changes Kill Connections
Problem: When you change maps, the server restarts and kills all TCP connections.
Solution: Implemented automatic reconnection in the Flutter app with exponential backoff:
Future<bool> reconnect({int maxAttempts = 10, int delaySeconds = 2}) async {
await disconnect();
await Future.delayed(Duration(seconds: delaySeconds));
for (int i = 0; i < maxAttempts; i++) {
try {
final success = await connect();
if (success && _isAuthenticated) {
return true;
}
} catch (e) {
print('Reconnect attempt ${i + 1} failed: $e');
}
if (i < maxAttempts - 1) {
final waitTime = delaySeconds + (i * 1);
await Future.delayed(Duration(seconds: waitTime. clamp(2, 5)));
}
}
return false;
}
Challenge 3: Windows Firewall Blocking Connections
Problem: Local testing worked, but remote devices couldn't connect.
Solution: Configured Windows Firewall rules (see earlier section) and tested with both local and remote connections.
Challenge 4: No Native JSON Parsing in UnrealScript
Problem: UnrealScript doesn't have JSON libraries.
Solution: Manually construct JSON strings and rely on the Flutter app to do all parsing. Simple protocol design meant this was actually pretty clean.
Performance and Testing
The system performs excellently:
- Connection time: ~200ms on local network
- Command response: <50ms for most commands
- Reconnection after map change: 5-10 seconds
- Memory footprint: Negligible impact on UT2004 server
- Multiple connections: Tested with 5 simultaneous clients without issues
What's Next?
Future improvements I'm considering:
- HTTPS/TLS support (challenging in UnrealScript!)
- Multiple server management in the app
- Push notifications for server events
- Server logs viewer
- Scheduled commands (automatic restarts, etc.)
- iOS build (already Flutter, just need to test)
Conclusion
This project was a blast. Bridging a 2004 game engine with 2026 mobile development taught me a lot about:
- Protocol design: Simple is better
- Connection management: Handle failures gracefully
- Cross-platform networking: TCP is universal
- Legacy code integration: Sometimes you have to work with what you've got
The full source code is available on my GitHub at SarahRoseLives/UTAdmin. Feel free to use it, modify it, or learn from it. If you're still running UT2004 servers in 2026, you're my kind of person. 🎮
Now I can kick troublemakers and change maps from my phone while I'm getting a snack. Technology is beautiful.
Tech Stack:
- UnrealScript (Unreal Engine 2)
- Flutter 3.0+ (Dart)
- TCP/IP networking
- JSON (manual construction in UnrealScript, native parsing in Dart)
- Windows 10/11 development environment
Repository: github.com/SarahRoseLives/UTAdmin
Platform: Windows (server and development), Android (app), iOS-ready