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:

  1. A server-side mutator that exposes a TCP API server written in UnrealScript
  2. 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:

  1. Located my UT2004 installation (usually C:\Program Files (x86)\Unreal Tournament 2004\)

  2. Created the mutator structure:

    UT2004\
    └── UTAdmin\
        └── Classes\
            ├── UTAdmin.uc
            ├── UTAdminAPIServer.uc
            ├── UTAdminConnection.uc
            └── UTAdminMessage.uc
  3. Modified UT2004.ini to add the compile path:

    [Editor. EditorEngine]
    EditPackages=UTAdmin
  4. 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.ini file
  • 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 Windows
  • MODE_Line tells UnrealScript to buffer incoming data line-by-line
  • Each new connection spawns a UTAdminConnection actor
  • The GainedChild event 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.Game and Level.ControllerList to 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:

  1. Windows Defender Firewall → Advanced settings
  2. Inbound Rules → New Rule
  3. Port → TCP → 7778
  4. Allow the connection

Part 2: The Flutter App

Setting Up Flutter on Windows

On my Windows development machine, I installed Flutter:

  1. Downloaded Flutter SDK from flutter.dev
  2. Extracted to C:\src\flutter
  3. Added C:\src\flutter\bin to PATH
  4. Ran flutter doctor to verify installation
  5. 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 StreamController to 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. kts with 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