How to build an API between the website and the GameServer in MU Online
Learn how to build a PHP REST API between your website and the MU Online GameServer to exchange data securely, trigger commands and sync information without exposing the SQL database directly to the web.
Connecting your PHP website directly to the GameServer's SQL Server database works, but it is fragile: you scatter database credentials across many scripts, expose port 1433 when the site runs on another machine, and mix business logic with data access. A REST API solves this by creating a clear bou
Connecting your PHP website directly to the GameServer's SQL Server database works, but it is fragile: you scatter database credentials across many scripts, expose port 1433 when the site runs on another machine, and mix business logic with data access. A REST API solves this by creating a clear boundary — the website speaks HTTP with the API, and only the API speaks SQL with the database. This tutorial shows, step by step, how to build that layer in plain PHP, with token authentication, input validation, rate limiting and a channel to trigger commands to the GameServer. The table names, ports and password formats appear as examples and vary by version and by distribution of your MuServer, so treat every value as something to confirm in your own environment. If you have not yet set up the server's foundation, start with the how to create a MU Online server guide and come back here for the web layer.
Prerequisites
Before writing a line of code, have your environment ready. The API is the centerpiece of the integration, so every dependency needs to be solid.
- A working GameServer and SQL Server — the database (
MuOnline, for example) already created, with theMEMB_INFO,Characterand related tables populated. - PHP 8.1 or higher with the
sqlsrvandpdo_sqlsrvdrivers installed and enabled (php -m | grep sqlsrv). - A web server (Apache or Nginx) with URL rewrite support, to route every request to a single entry point.
- A valid TLS certificate for the API domain (Let's Encrypt handles this for free).
- A defined internal network — ideally the API, the GameServer and the SQL Server on the same VLAN, with the database closed off from the internet.
- A dedicated SQL user for the API, with minimal permissions (never use
sa).
| Component | Role in the architecture | Example (varies by version) |
|---|---|---|
| Site / Launcher / Bot | Client that consumes the API | PHP, JS, Python |
| REST API | Validation and access layer | PHP 8.x + sqlsrv |
| SQL Server | Persistence of game data | MuOnline, port 1433 |
| GameServer | Consumes commands and serves the game | MuServer S6 / S12+ |
Solution architecture
The core idea is that no client talks to the database directly. All traffic passes over HTTP to the API, which authenticates, validates and translates it into SQL.
CLIENTS API (PHP) BACKEND
┌───────────┐ HTTPS + token ┌─────────────┐ sqlsrv ┌────────────┐
│ Website │ ────────────────▶ │ Router │ ───────▶ │ SQL Server │
│ Launcher │ │ Auth │ │ (MuOnline) │
│ Disc. Bot │ ◀──────────────── │ Validation │ ◀─────── └────────────┘
└───────────┘ JSON │ Rate limit │ │
└──────┬──────┘ queue / socket
│ ▼
└───── command ────▶ GameServer
The GameServer comes in at two points: when the API reads data it wrote (rankings, online status) and when the API needs to send an action back — give an item, kick a player, grant VIP. The way that "send back" works varies by version: some builds offer an administrative socket, others rely on a queue table that the GameServer scans.
Folder structure and routing
Organize the API so there is a single entry point (index.php), which makes it easy to apply authentication and CORS centrally.
/api
├── public/
│ └── index.php ← front controller (single entry point)
├── src/
│ ├── Database.php ← sqlsrv connection (singleton)
│ ├── Auth.php ← token validation
│ ├── RateLimiter.php ← rate limiting
│ └── Controllers/
│ ├── AccountController.php
│ ├── RankingController.php
│ └── CommandController.php
├── config.php ← credentials (outside public!)
└── .htaccess
The .htaccess (Apache) redirects everything to the front controller:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
# Never serve sensitive files
<FilesMatch "\.(env|ini|log)$">
Require all denied
</FilesMatch>
Secure database connection setup
Isolate the connection in a single file, outside the public folder, using a SQL user with minimal permissions.
<?php
// src/Database.php
final class Database
{
private static ?PDO $pdo = null;
public static function conn(): PDO
{
if (self::$pdo === null) {
$cfg = require __DIR__ . '/../config.php';
$dsn = "sqlsrv:Server={$cfg['host']};Database={$cfg['db']}";
self::$pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // real prepares
]);
}
return self::$pdo;
}
}
In SQL Server, create the API user with the bare minimum. Grant SELECT, INSERT and UPDATE only on the tables the API actually touches — never db_owner.
-- Example (varies by version): dedicated API user
CREATE LOGIN api_mu WITH PASSWORD = 'S3nh4_Forte_Da_API!2026';
CREATE USER api_mu FOR LOGIN api_mu;
GRANT SELECT, INSERT, UPDATE ON dbo.MEMB_INFO TO api_mu;
GRANT SELECT ON dbo.Character TO api_mu;
GRANT SELECT, INSERT, UPDATE ON dbo.MEMB_VIP TO api_mu;
-- Command queue created by you (see below)
GRANT SELECT, INSERT, UPDATE ON dbo.WEB_COMMAND_QUEUE TO api_mu;
Token authentication
The API needs to know who is calling. For server-to-server communication (site → API), the simplest and most solid approach is an application token sent in the Authorization header. For actions on behalf of a player, generate a session token after login.
<?php
// src/Auth.php
final class Auth
{
public static function exigirToken(): array
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.+)/', $header, $m)) {
self::negar('Token missing');
}
$token = trim($m[1]);
$stmt = Database::conn()->prepare(
"SELECT app_id, scope, expira_em
FROM WEB_API_TOKEN
WHERE token_hash = ? AND ativo = 1"
);
// Always store the token HASH, never the raw token
$stmt->execute([hash('sha256', $token)]);
$row = $stmt->fetch();
if (!$row) {
self::negar('Invalid token');
}
if ($row['expira_em'] !== null && strtotime($row['expira_em']) < time()) {
self::negar('Expired token');
}
return $row; // contains scope/permissions
}
public static function negar(string $msg): never
{
http_response_code(401);
echo json_encode(['erro' => $msg]);
exit;
}
}
Storing only the sha256 of the token in the database is essential: if the database leaks, the attacker cannot reconstruct the tokens in use. The raw token exists only at the moment of generation and in the client that received it.
Rate limiting
Without a request limit, a single client can flood the API — whether from a bug or an attack. A simple per-IP and per-token limiter already filters out gross abuse.
<?php
// src/RateLimiter.php
final class RateLimiter
{
// Maximum requests per time window
public static function verificar(string $chave, int $limite = 60, int $janela = 60): void
{
$dir = sys_get_temp_dir() . '/api_rl';
@mkdir($dir, 0700, true);
$arq = $dir . '/' . hash('sha256', $chave);
$agora = time();
$dados = @json_decode(@file_get_contents($arq), true) ?: ['inicio' => $agora, 'hits' => 0];
if ($agora - $dados['inicio'] >= $janela) {
$dados = ['inicio' => $agora, 'hits' => 0]; // new window
}
$dados['hits']++;
if ($dados['hits'] > $limite) {
http_response_code(429);
header('Retry-After: ' . ($janela - ($agora - $dados['inicio'])));
echo json_encode(['erro' => 'Request limit exceeded']);
exit;
}
file_put_contents($arq, json_encode($dados), LOCK_EX);
}
}
In production with multiple servers, swap the file for Redis, so the counter is shared across instances.
Read endpoint: ranking
Read endpoints are the safest place to start, since they change nothing. The controller reads the Character table and returns clean JSON.
<?php
// src/Controllers/RankingController.php
final class RankingController
{
public function top(int $limite = 50): void
{
$limite = max(1, min($limite, 200)); // clamp the range
$stmt = Database::conn()->prepare(
"SELECT TOP (?) Name, Class, cLevel, Resets, ConnectStat
FROM Character
WHERE CtlCode = 0
ORDER BY Resets DESC, cLevel DESC"
);
$stmt->execute([$limite]);
$dados = array_map(function ($r) {
return [
'nome' => $r['Name'],
'classe' => (int) $r['Class'],
'level' => (int) $r['cLevel'],
'resets' => (int) $r['Resets'],
'online' => (bool) $r['ConnectStat'],
];
}, $stmt->fetchAll());
Response::json(['ranking' => $dados]);
}
}
Passing the limit as a prepared parameter and clamping it between 1 and 200 prevents anyone from asking for TOP 9999999 and bringing down the database.
Write endpoint: send a command to the GameServer
Here is the most delicate part. When the site needs to give an item, activate VIP or kick a player, the API should not run that "by hand" against the database in a risky way. The most reliable and portable pattern is the command queue: the API inserts a row into a table and the GameServer (or an auxiliary service) consumes it. This decouples the two systems and leaves an auditable trail.
Create the queue table:
-- Example (varies by version): web -> game command queue
CREATE TABLE WEB_COMMAND_QUEUE (
id INT IDENTITY(1,1) PRIMARY KEY,
conta VARCHAR(10) NOT NULL,
comando VARCHAR(50) NOT NULL, -- e.g.: 'ADD_VIP', 'GIVE_ITEM'
parametros NVARCHAR(MAX) NULL, -- JSON with details
status TINYINT NOT NULL DEFAULT 0, -- 0=pending 1=ok 2=error
criado_em DATETIME NOT NULL DEFAULT GETDATE(),
processado_em DATETIME NULL
);
The controller only validates and enqueues:
<?php
// src/Controllers/CommandController.php
final class CommandController
{
private const COMANDOS_PERMITIDOS = ['ADD_VIP', 'GIVE_ITEM', 'KICK'];
public function enfileirar(array $auth): void
{
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$conta = trim($body['conta'] ?? '');
$comando = strtoupper(trim($body['comando'] ?? ''));
$params = $body['parametros'] ?? [];
// Strict validation
if (!preg_match('/^[a-zA-Z0-9]{4,10}$/', $conta)) {
Response::erro(422, 'Invalid account');
}
if (!in_array($comando, self::COMANDOS_PERMITIDOS, true)) {
Response::erro(422, 'Command not allowed');
}
// The token scope must authorize commands
if (($auth['scope'] ?? '') !== 'admin') {
Response::erro(403, 'No permission for commands');
}
$stmt = Database::conn()->prepare(
"INSERT INTO WEB_COMMAND_QUEUE (conta, comando, parametros)
VALUES (?, ?, ?)"
);
$stmt->execute([$conta, $comando, json_encode($params)]);
Response::json(['status' => 'queued', 'id' => Database::conn()->lastInsertId()]);
}
}
A lightweight service (a scheduled script or a daemon) reads the pending records and applies them — either by writing to the game's final table or by sending them over the administrative socket when your GameServer version offers that channel. Keeping the list of allowed commands as a constant (allowlist) prevents any string from becoming an executable action.
Front controller and standardized response
The index.php ties it all together: it sets headers, applies rate limiting, authenticates and routes.
<?php
// public/index.php
require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Auth.php';
require __DIR__ . '/../src/RateLimiter.php';
// ... require the controllers
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
RateLimiter::verificar($_SERVER['REMOTE_ADDR'] ?? 'anon');
$rota = $_GET['rota'] ?? '';
$metodo = $_SERVER['REQUEST_METHOD'];
try {
switch ("$metodo $rota") {
case 'GET ranking':
(new RankingController())->top((int)($_GET['limite'] ?? 50));
break;
case 'POST comando':
$auth = Auth::exigirToken();
(new CommandController())->enfileirar($auth);
break;
default:
Response::erro(404, 'Route not found');
}
} catch (Throwable $e) {
error_log('[API] ' . $e->getMessage());
Response::erro(500, 'Internal error');
}
Notice that the error message returned to the client is generic (Internal error), while the real detail goes only to the log. Leaking SQL Server exception messages hands the attacker your table names and database structure.
Testing the API
Before plugging in the site, test each endpoint in isolation with curl. This separates API problems from front-end problems.
- Start the API and confirm the front controller responds:
curl https://api.yourserver.com/?rota=ranking. - Check that the ranking returns valid JSON and not a connection error.
- Generate a token with
adminscope and store only the hash in the database. - Test the command:
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" -d '{"conta":"test","comando":"ADD_VIP","parametros":{"dias":30}}' https://api.yourserver.com/?rota=comando. - Confirm in the
WEB_COMMAND_QUEUEtable that the row was created withstatus = 0. - Run the consumer and check that the
statuschanges to1.
Common errors and fixes
| Error | Likely cause | Fix |
|---|---|---|
Could not find driver | pdo_sqlsrv not enabled in PHP | Confirm with php -m, enable it in php.ini and restart the web server |
401 Invalid token every time | Comparing the raw token against the hash in the database | Apply hash('sha256', $token) before querying |
Login failed for user 'api_mu' | SQL user without permission or wrong password | Recreate the login and grant only the minimal GRANTs |
| Command queued but never applied | Queue consumer stopped | Check the service/scheduler that processes WEB_COMMAND_QUEUE |
429 under normal use | Rate limit too low | Adjust limite/janela in the RateLimiter to match real traffic |
| Sensitive data in the HTTP error | SQL exception leaking to the client | Catch Throwable, log the detail and return a generic message |
| CORS blocking the site | Missing headers | Set Access-Control-Allow-Origin to the exact site domain |
Security best practices
The API becomes your server's gateway, so treat it as critical infrastructure. Never leave SQL Server open to the internet — the API should be the only thing that reaches it, over the internal network. Always use prepared statements, no exceptions, even for queries that look harmless. Keep tokens scoped: a token that only reads the ranking should never be able to send commands. Rotate tokens periodically and have a way to revoke (ativo = 0) any compromised token in seconds. Finally, log every write action with date, IP and originating token — when something goes wrong, that trail is the difference between resolving it in minutes and being left in the dark.
Launch checklist
- PHP with
sqlsrv/pdo_sqlsrvenabled and tested - Dedicated SQL user with minimal permissions (no
sa, nodb_owner) - SQL Server closed to the internet, reachable only over the internal network
- Valid HTTPS/TLS on the API domain
- Single front controller with rate limiting applied
- Tokens stored only as a
sha256hash in the database - Token scopes defined (read vs. admin)
- Command allowlist implemented in
CommandController WEB_COMMAND_QUEUEqueue created and consumer running- Generic error messages to the client, detail only in the log
- Endpoints tested individually with
curl - Audit log for every write operation
Frequently asked questions
Why use an API instead of connecting the site directly to SQL?
An API creates an intermediate layer that validates, authenticates and limits every operation. This avoids exposing SQL Server's port 1433 to the internet and reduces the attack surface to a single controlled endpoint.
Does the API have to run on the same server as the GameServer?
It is not mandatory, but it is recommended that it stay on the same internal network as the GameServer and the SQL Server. That way the database never has to accept external connections and the API becomes the only entry point.
How does the GameServer receive commands coming from the website?
It depends on the version. Many GameServers expose an administrative socket (ConnectServer/GS Live) or read a queue table in the database. The API writes the command and the GameServer consumes it, or sends it over the socket when the version supports it.
Do I need HTTPS on the API?
Yes, always. Tokens and account data travel in the requests. Without TLS, any intermediary on the network reads the tokens in plain text and impersonates the website.
Can I reuse this API for a mobile app or a Discord bot?
Yes. That is the biggest benefit of an API: any authorized client (website, launcher, app, bot) consumes the same endpoints with the same authentication model, without duplicating database access logic.