Cómo Crear un Ranking en Tiempo Real para tu Servidor MU
Aprende a construir un ranking web en tiempo real para tu servidor MU Online consultando directamente la base de datos GameServer con PHP y MySQL.
Un ranking visible y actualizado es una de las funcionalidades que más engagement genera en cualquier servidor privado de MU Online. Los jugadores consultan su posición varias veces al día, y un ranking que refleja el estado real del juego incentiva la competencia y el tiempo de conexión. En este tu
Un ranking visible y actualizado es una de las funcionalidades que más engagement genera en cualquier servidor privado de MU Online. Los jugadores consultan su posición varias veces al día, y un ranking que refleja el estado real del juego incentiva la competencia y el tiempo de conexión. En este tutorial construirás un sistema de ranking web que lee directamente desde la base de datos del servidor y muestra los datos actualizados con un intervalo configurable.
Arquitectura del sistema
Antes de escribir código, conviene entender el flujo de datos para no cometer errores de diseño que luego sean difíciles de deshacer.
GameServer (proceso del juego)
→ escribe → Base de datos MuOnline (MySQL o MSSQL)
|
| consulta de solo lectura
|
Script PHP (backend)
→ genera → JSON de ranking
|
| fetch cada N segundos
|
Navegador del jugador (frontend)
El GameServer escribe el estado de los personajes continuamente. Tu sitio web se conecta a esa misma base de datos usando un usuario con permisos de solo lectura, genera un JSON con los datos procesados, y el frontend lo consume mediante una petición periódica. El servidor de juego nunca sabe que el sitio existe; la separación es total.
> [!ATENCION] > Nunca uses las credenciales de administrador del GameServer para conectar tu sitio web. Crea un usuario MySQL exclusivo con permisos SELECT únicamente sobre las tablas necesarias. Si tu sitio web es comprometido, un atacante con credenciales de solo lectura no puede modificar cuentas ni personajes.
Preparar la base de datos
Lo primero es crear el usuario de base de datos restringido. Conecta a MySQL como administrador y ejecuta:
-- Crear usuario de solo lectura para el sitio web
CREATE USER 'mu_web_reader'@'localhost' IDENTIFIED BY 'contraseña-segura-aqui';
-- Otorgar permisos de lectura únicamente sobre las tablas del ranking
GRANT SELECT ON MuOnline.Character TO 'mu_web_reader'@'localhost';
GRANT SELECT ON MuOnline.AccountCharacter TO 'mu_web_reader'@'localhost';
-- Aplicar los cambios
FLUSH PRIVILEGES;
Con este usuario el sitio puede leer personajes pero no puede insertar, actualizar ni eliminar nada. Si más adelante necesitas mostrar estadísticas de gremios, otorga también SELECT sobre la tabla Guild.
db_datareader restringido a la base de datos MuOnline, en lugar del comando SQL anterior.Verifica que la tabla Character tenga índices sobre las columnas que usarás para ordenar. La columna cLevel y Resets (si tu emulador la tiene) deben tener índices individuales o compuestos. Una consulta de ranking sin índice sobre una tabla de 50.000 personajes puede tardar varios segundos y bloquear conexiones simultáneas.
-- Verificar índices existentes en la tabla de personajes
SHOW INDEX FROM MuOnline.Character;
-- Crear índice compuesto si no existe
-- → ordena primero por resets, luego por nivel, luego por experiencia
CREATE INDEX idx_ranking
ON MuOnline.Character (Resets DESC, cLevel DESC, Experience DESC);
Construir el endpoint PHP
Crea el archivo api/ranking.php en tu servidor web. Este script recibe las peticiones del frontend y devuelve el ranking como JSON.
<?php
// api/ranking.php
// → Endpoint de ranking de solo lectura
// → Solo SELECT, nunca INSERT / UPDATE / DELETE
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: public, max-age=120');
$db_host = 'localhost';
$db_name = 'MuOnline';
$db_user = 'mu_web_reader';
$db_pass = 'contraseña-segura-aqui';
$categoria = $_GET['cat'] ?? 'nivel';
$pagina = max(1, (int)($_GET['p'] ?? 1));
$por_pagina = 20;
$offset = ($pagina - 1) * $por_pagina;
// Whitelist de categorías → columnas de ordenamiento
// → el valor insertado en SQL proviene solo de esta lista, no del usuario
$columnas_validas = [
'nivel' => 'cLevel DESC, Experience DESC',
'resets' => 'Resets DESC, cLevel DESC',
'maestro' => 'MasterLevel DESC, cLevel DESC',
'fuerza' => 'Strength DESC',
];
if (!array_key_exists($categoria, $columnas_validas)) {
http_response_code(400);
echo json_encode(['error' => 'Categoría no válida']);
exit;
}
$orden = $columnas_validas[$categoria];
try {
$pdo = new PDO(
"mysql:host={$db_host};dbname={$db_name};charset=utf8",
$db_user,
$db_pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare("
SELECT
Name AS nombre,
cLevel AS nivel,
Resets AS resets,
MasterLevel AS nivel_maestro,
Class AS clase
FROM Character
WHERE CtlCode = 0
AND AccountID != ''
ORDER BY {$orden}
LIMIT :limit OFFSET :offset
");
$stmt->bindValue(':limit', $por_pagina, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$personajes = $stmt->fetchAll(PDO::FETCH_ASSOC);
$posicion_base = $offset + 1;
foreach ($personajes as $i => &$p) {
$p['posicion'] = $posicion_base + $i;
$p['clase_nombre'] = nombre_clase((int)$p['clase']);
}
unset($p);
$total = $pdo->query(
"SELECT COUNT(*) FROM Character WHERE CtlCode = 0 AND AccountID != ''"
)->fetchColumn();
echo json_encode([
'actualizado' => date('Y-m-d H:i:s'),
'categoria' => $categoria,
'pagina' => $pagina,
'total' => (int)$total,
'datos' => $personajes,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Error de base de datos']);
error_log($e->getMessage()); // → loguear en el servidor, nunca exponer al cliente
}
function nombre_clase(int $codigo): string {
$clases = [
0 => 'Dark Wizard', 16 => 'Soul Master',
32 => 'Grand Master', 48 => 'Soul Wizard',
1 => 'Dark Knight', 17 => 'Blade Knight',
33 => 'Blade Master', 49 => 'Dragon Knight',
64 => 'Fairy Elf', 80 => 'Muse Elf',
96 => 'High Elf',
// → Agrega las clases de tu versión de MU aquí
];
return $clases[$codigo] ?? 'Desconocida';
}
> [!CONSEJO] > Agrega una capa de caché de archivo antes de consultar la base de datos. Guarda el resultado en un archivo con file_put_contents y verifica su antigüedad con filemtime. Si el archivo tiene menos de 120 segundos de antigüedad, devuélvelo directamente sin tocar MySQL. Esto reduce la carga a una consulta cada dos minutos, independientemente de cuántos jugadores visiten el ranking en simultáneo.
Construir el frontend
El frontend consume el endpoint periódicamente y actualiza la tabla sin recargar la página completa. JavaScript vanilla es suficiente; no se necesita ningún framework.
<!-- ranking.html (fragmento funcional) -->
<div id="ranking-container">
<nav class="ranking-tabs">
<button class="tab activo" data-cat="nivel">Por Nivel</button>
<button class="tab" data-cat="resets">Por Resets</button>
<button class="tab" data-cat="maestro">Nivel Maestro</button>
</nav>
<p class="ranking-estado">
Actualizado: <span id="ultima-actualizacion">cargando...</span>
· Próxima actualización en <span id="cuenta-regresiva">120</span>s
</p>
<div class="tabla-wrapper" style="overflow-x: auto;">
<table id="tabla-ranking">
<thead>
<tr>
<th>#</th>
<th>Personaje</th>
<th>Clase</th>
<th>Nivel</th>
<th>Resets</th>
</tr>
</thead>
<tbody id="ranking-body">
<!-- → filas generadas por JavaScript -->
</tbody>
</table>
</div>
</div>
<script>
var ENDPOINT = '/api/ranking.php';
var INTERVALO_SEG = 120;
var categoriaActual = 'nivel';
var paginaActual = 1;
var cuentaRegresiva;
function cargarRanking() {
var url = ENDPOINT + '?cat=' + categoriaActual + '&p=' + paginaActual;
fetch(url)
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(function(datos) {
renderizarTabla(datos);
document.getElementById('ultima-actualizacion').textContent = datos.actualizado;
iniciarCuentaRegresiva();
})
.catch(function(err) {
console.error('Error al cargar ranking:', err);
});
}
function renderizarTabla(datos) {
var tbody = document.getElementById('ranking-body');
tbody.innerHTML = '';
if (!datos.datos || datos.datos.length === 0) {
tbody.innerHTML = '<tr><td colspan="5">Sin datos disponibles</td></tr>';
return;
}
datos.datos.forEach(function(p) {
var tr = document.createElement('tr');
// → escapeHtml protege contra nombres con caracteres especiales HTML
tr.innerHTML =
'<td>' + p.posicion + '</td>' +
'<td>' + escapeHtml(p.nombre) + '</td>' +
'<td>' + escapeHtml(p.clase_nombre) + '</td>' +
'<td>' + p.nivel + '</td>' +
'<td>' + (p.resets !== null ? p.resets : '—') + '</td>';
tbody.appendChild(tr);
});
}
function iniciarCuentaRegresiva() {
clearInterval(cuentaRegresiva);
var restante = INTERVALO_SEG;
document.getElementById('cuenta-regresiva').textContent = restante;
cuentaRegresiva = setInterval(function() {
restante--;
document.getElementById('cuenta-regresiva').textContent = restante;
if (restante <= 0) {
clearInterval(cuentaRegresiva);
cargarRanking(); // → dispara la siguiente actualización automática
}
}, 1000);
}
function escapeHtml(texto) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(String(texto)));
return div.innerHTML;
}
document.querySelectorAll('.tab').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab').forEach(function(t) {
t.classList.remove('activo');
});
btn.classList.add('activo');
categoriaActual = btn.getAttribute('data-cat');
paginaActual = 1;
clearInterval(cuentaRegresiva);
cargarRanking();
});
});
cargarRanking(); // → carga inicial al abrir la página
</script>
La función escapeHtml es obligatoria: los nombres de personaje provienen de la base de datos y podrían contener caracteres que el navegador interprete como HTML si se insertan directamente con innerHTML.
Consideraciones de seguridad y rendimiento
Con el sistema funcionando, algunos ajustes adicionales marcan la diferencia entre una implementación que funciona en desarrollo y una que resiste el uso real en producción.
Limitar el rate de peticiones. Si usas nginx, agrega una zona de limitación para la ruta del endpoint:
# nginx.conf → dentro del bloque http
limit_req_zone $binary_remote_addr zone=ranking:10m rate=6r/m;
# nginx.conf → dentro del bloque location para el endpoint
limit_req zone=ranking burst=3 nodelay;
Esto limita a seis peticiones por minuto por IP, suficiente para un refresco manual pero no para un script de scraping agresivo.
Monitorear el tiempo de consulta. Agrega logging del tiempo de ejecución de la consulta SQL durante las primeras semanas. Si supera 200ms de forma consistente, analiza el plan de ejecución con EXPLAIN SELECT ... e identifica si falta algún índice o si la tabla ha crecido más allá de la estrategia de indexado actual.
No exponer información sensible. El endpoint no debe devolver AccountID, hashes de contraseñas, direcciones IP ni ningún campo que no sea estrictamente necesario para el ranking. Selecciona solo las columnas que el frontend utiliza para renderizar la tabla.
web_ranking. El sitio lee esa tabla en lugar de recalcular el orden en cada petición, desacoplando la carga del sitio del rendimiento de la base de datos principal y permitiendo escalar ambos de forma independiente.Con esta arquitectura tienes un ranking funcional, seguro y con impacto mínimo sobre el servidor de juego. Los jugadores ven datos actualizados con el intervalo que decidas, tú controlas la carga según la capacidad real del servidor, y la separación de credenciales protege la integridad de los datos del juego ante cualquier vulnerabilidad en el lado web.
Preguntas frecuentes
¿Necesito acceso root al servidor para implementar el ranking?
No necesariamente. Solo necesitas acceso de lectura a la base de datos MuOnline. Un usuario MySQL con permisos SELECT sobre las tablas relevantes es suficiente.
¿Con qué frecuencia debo actualizar el ranking para que sea 'tiempo real'?
Para la mayoría de servidores, un intervalo de 60 a 300 segundos es suficiente. Actualizar cada segundo sobrecarga la base de datos sin beneficio visible para los jugadores.
¿Qué tablas de la base de datos contienen los datos del ranking?
Depende del emulador. En Season 6 estándar, los datos de personajes están en la tabla Character de la base de datos MuOnline. En emuladores como OpenMU, la estructura puede diferir.
¿El ranking en tiempo real afecta el rendimiento del servidor de juego?
Si usas un usuario de solo lectura y cacheas los resultados, el impacto es mínimo. Evita consultas sin índices sobre tablas grandes y nunca compartas las credenciales del GameServer con el sitio web.