Saltar al contenido
ROXEX Labs
IA & Automatización

Cómo construimos el simulador del Mundial 2026

Build log del simulador del Mundial 2026: modelo Elo y Poisson, cuadro oficial FIFA, 100.000 iteraciones reproducibles, sus límites y qué cambiaríamos.

Por Levi Olivare · ROXEX Labs13 min de lectura
Monte CarloSimulaciónTypeScriptModelos predictivosMundial 2026Ingeniería de software
IA & Automatización

El 12 de junio de 2026, al día siguiente del partido inaugural y sin incorporar ningún resultado del torneo, publicamos en roxex.es/mundial-2026 una simulación del torneo: 100.000 mundiales completos jugados por ordenador con el sorteo real y el cuadro de eliminatorias oficial. España salía favorita. Este artículo no va de si acertamos, que eso ya está en la página; va de cómo está hecho: qué entra en el modelo, qué hace cada función, qué decisiones tomamos, qué salió regular y qué cambiaríamos si lo empezáramos hoy.

Todas las cifras de este texto salen del código y de los datos del proyecto, y lo que no hemos podido comprobar lo hemos dejado fuera. El repositorio no es público, así que los fragmentos de código que verás están copiados del código tal cual, sin sus comentarios.

Qué construimos

El proyecto tiene tres piezas:

  1. 1.El simulador por lotes: scripts/simulate-world-cup.ts, unas 450 líneas de TypeScript. Lee los datos de las selecciones, juega 100.000 torneos y escribe los resultados agregados en un JSON.
  2. 2.La página /mundial-2026: Next.js con export estático. Importa ese JSON al compilar, así que en producción no se calcula nada: se sirve HTML ya generado.
  3. 3.El simulador interactivo: un componente de cliente que vuelve a implementar el modelo en el navegador para que cualquiera pulse un botón y juegue su propio Mundial.

La separación es deliberada. Las 100.000 iteraciones se ejecutan una vez, en local, y el resultado queda congelado con su fecha (generatedAt: 2026-06-12T11:13:41Z). Se publicó al día siguiente del partido inaugural, sin usar ningún resultado del torneo, y quien tenga el código puede reproducirla con la misma semilla.

El stack

  • •TypeScript para el script y para la web. El script se lanza con npx tsx scripts/simulate-world-cup.ts, como indica su propia cabecera.
  • •Cero dependencias en el simulador: solo node:fs y node:path, de la librería estándar de Node.
  • •Datos como código: las 48 selecciones, el sorteo y las métricas viven en ficheros .ts tipados dentro de src/data/, no en una base de datos.
  • •Next.js y Tailwind CSS para la página, desplegada como sitio estático.
  • •Salida en JSON (src/data/mundial-2026-simulation.json) que la página importa directamente.

Para un problema de este tamaño no hace falta más: ni notebooks ni infraestructura. Basta un proceso de Node que tarda segundos y un fichero que se versiona con el resto de la web.

Los datos de entrada

Cada selección es un registro con su código de tres letras, nombre, bandera, confederación, un Elo base y una marca host para los tres anfitriones (México, Estados Unidos y Canadá). Los grupos son los del sorteo oficial del 5 de diciembre de 2025: 12 grupos de 4.

El Elo base parte de las World Football Elo Ratings (eloratings.net), pero no son los valores publicados tal cual. Los ajustamos a mano hacia la fuerza actual de cada selección, usando como referencia las probabilidades del supercomputador de Opta. Más abajo contamos por qué y qué problema tiene haberlo hecho así.

Encima del Elo base va un bonus de forma que combina cuatro índices por selección: valor de mercado de la plantilla (Transfermarkt), calidad media de las ligas donde juegan sus convocables, proporción de jugadores con títulos nacionales esa temporada y rendimiento continental de sus clubes. Son índices de 0 a 100 estimados a nivel de selección, no datos jugador a jugador: cuando construimos el modelo no existían las listas definitivas de convocados. La función que los convierte en puntos Elo es esta:

ts
export function computeFormBonus(m: TeamMetrics): number { const mvScore = Math.min(50, Math.max(0, (Math.log10(m.marketValueMEur) - 1.3) * 30)); const leagueScore = ((m.leagueQualityIndex - 50) / 50) * 30; const titlesScore = m.domesticTitlesIndex >= 50 ? ((m.domesticTitlesIndex - 50) / 50) * 25 : -((50 - m.domesticTitlesIndex) / 50) * 10; const contiScore = m.continentalIndex >= 50 ? ((m.continentalIndex - 50) / 50) * 40 : -((50 - m.continentalIndex) / 50) * 10; return mvScore + leagueScore + titlesScore + contiScore; }

El valor de mercado entra en escala logarítmica y con tope de 50 puntos; la liga suma o resta hasta 30, los títulos hasta 25 y el rendimiento continental hasta 40. En teoría el bonus va de −50 a 145 puntos. Con los datos reales va de −32 (Jordania) a 125,4 (Inglaterra). Los pesos no se ajustaron con ningún procedimiento: el propio código los describe como "empíricos, ajustables". El Elo efectivo resultante va de unos 1.448 puntos (Haití) a 2.204 (España).

El modelo de partido

El corazón del simulador son estas seis líneas de la función de partido (sin los comentarios ni las líneas intermedias):

ts
const eloDiff = homeElo + hostBonus - awayElo; const pHome = 1 / (1 + Math.pow(10, -eloDiff / 1500)); const homeMean = 1.4 * Math.pow(10, eloDiff / 1500); const awayMean = 1.4 / Math.pow(10, eloDiff / 1500); const homeGoals = poisson(homeMean); const awayGoals = poisson(awayMean);

Cada equipo marca un número de goles que sigue una distribución de Poisson. La media de partida es 1,4 goles y la diferencia de Elo la desplaza de forma simétrica: lo que gana uno lo pierde el otro en proporción. Dos ejemplos con los datos reales:

  • •España contra Francia (20 puntos de diferencia): 1,44 goles esperados contra 1,36. Prácticamente una moneda al aire.
  • •España contra Cabo Verde (663 puntos, la mayor diferencia de toda la fase de grupos): 3,87 contra 0,51.

Un matiz importante: la probabilidad de ganar no sale de una fórmula logística. Sale de comparar dos sorteos de Poisson. Dos equipos idénticos, con 1,4 goles esperados cada uno, empatan alrededor del 25,3% de las veces. La variable pHome, con escala 1.500 en lugar de los 400 habituales del Elo, solo se usa para desempatar en eliminatorias: si un cruce acaba igualado, se va directamente a penaltis, que gana el local con probabilidad pHome · 0,7 + 0,15. Eso acota los penaltis entre el 15% y el 85%. Entre España y Francia sale un 50,5%; con 400 puntos de diferencia, un 60,4%.

Los goles se generan con el algoritmo de Knuth, que el código da por bueno para medias de hasta 30. La mayor media de la fase de grupos es 3,87, así que sobra margen:

ts
function poisson(lambda: number): number { const L = Math.exp(-lambda); let k = 0; let p = 1; do { k++; p *= rng(); } while (p > L); return k - 1; }

Los anfitriones reciben 80 puntos Elo extra en la fase de grupos, y solo ahí. El bonus se aplica cuando el anfitrión es el primer equipo del emparejamiento; como México, Canadá y Estados Unidos aparecen primeros en sus grupos (A, B y D), lo reciben en sus tres partidos. Funciona, pero depende del orden de los datos, y eso es frágil.

La fase de grupos

Cada grupo se juega como liga a una vuelta: seis partidos, 3 puntos por victoria y 1 por empate. La clasificación se ordena por puntos, diferencia de goles, goles a favor y, si todo sigue igual, por Elo efectivo. El reglamento real tiene más criterios (enfrentamiento directo, entre otros); nosotros sustituimos los últimos por el Elo. Pasan los dos primeros de cada grupo y los ocho mejores terceros de los doce, ordenados con el mismo criterio.

El cuadro oficial y el problema de los terceros

Aquí estuvo la parte más interesante. En lugar de ordenar a los 32 clasificados por ranking, el script reproduce el cuadro de dieciseisavos del formato FIFA 2026 (partidos 73 a 88). Cada hueco del cuadro apunta a una posición de grupo: "1E" es el primero del grupo E y "3:ABCDF" es un tercero que solo puede venir de los grupos A, B, C, D o F.

Colocar a los ocho terceros en sus ocho huecos respetando esas restricciones es un problema de emparejamiento bipartito. Lo resolvimos con backtracking, empezando por el hueco con menos candidatos:

ts
function backtrack(k: number): boolean { if (k === slotsByConstraint.length) return true; const slot = slotsByConstraint[k]; for (const cand of slot.candidates) { if (usedThird.has(cand.ti)) continue; usedThird.add(cand.ti); assignment.set(slot.i, bestThirds[cand.ti].code); if (backtrack(k + 1)) return true; usedThird.delete(cand.ti); assignment.delete(slot.i); } return false; }

Para escribir este artículo comprobamos las 495 combinaciones posibles de 8 grupos entre 12: todas tienen una asignación válida con nuestros huecos. Hay un matiz: el backtracking se queda con la primera asignación válida que encuentra y no hemos comprobado que coincida, en cada combinación, con la que fija FIFA. Además, si alguna vez no encontrara asignación, el código no fallaría de forma visible: el hueco quedaría vacío sin avisar.

A partir de ahí, octavos, cuartos, semifinales y final se emparejan por índice según el cuadro oficial. No se juega el partido por el tercer puesto. Cada torneo simulado son 72 partidos de grupos y 31 de eliminatorias: 103 partidos, unos 10,3 millones en total.

Un Monte Carlo reproducible

Math.random() no admite semilla, así que el script trae su propio generador, Mulberry32, inicializado con un número fijo:

ts
function makeRng(seed: number) { return function () { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = makeRng(20260615);

En cada iteración se cuentan, por selección, los títulos, las finales, las semifinales, los cuartos y la entrada en la fase eliminatoria, además de las parejas de finalistas más repetidas. La ejecución publicada tardó 3,7 segundos, según el propio JSON.

La semilla es lo que permite auditar el resultado. El 9 de noviembre de 2026 ejecutamos el script sin cambios sobre una copia y comparamos la salida con el JSON publicado en junio: los recuentos de las 48 selecciones y de las diez finales más probables coinciden uno a uno. Quien tenga el código puede comprobar que lo publicado es exactamente lo que el código produce.

¿Por qué 100.000 iteraciones? El código no lo justifica, pero la cuenta sale bien: con una probabilidad del 18,7%, el error estándar de Monte Carlo es de unos 0,12 puntos porcentuales. El ruido de la simulación es mucho menor que la incertidumbre del propio modelo.

La calibración, contada sin adornos

La página del Mundial dice que el modelo se calibró contra Opta. En la práctica eso no está en el código: no hay ninguna función que ajuste parámetros. Lo que hicimos fue retocar a mano el Elo base de algunas selecciones hasta que el orden de favoritos se pareció al del supercomputador. El caso más claro era Argentina, cuyo Elo reflejaba su gran racha de 2022 a 2024.

En el repositorio no quedó guardado ni el Elo original de cada selección ni la ejecución anterior a la calibración, así que no podemos decir cuánto se movió cada cifra. Es el punto más débil de la metodología: el modelo está anclado en parte a la salida de otro modelo, y por eso la comparación con Opta no es independiente.

SelecciónNuestro modeloOpta
España18,7%16,1%
Francia15,7%13,0%
Inglaterra15,2%11,2%
Argentina10,9%10,4%
Portugal9,0%7,0%
Brasil7,5%6,6%
Alemania6,6%5,1%

El orden coincide en lo esencial, pero nuestro modelo concentra más probabilidad en los favoritos: los tres primeros suman un 49,6%, frente al 40,3% de Opta. No hemos aislado la causa. Las hipótesis razonables están en el propio modelo (un único rating por equipo, bonus de forma de hasta 125 puntos, ausencia de prórroga), pero no las hemos medido una a una.

Qué pasó en el torneo real, y por qué no valida el modelo

España ganó la final a Argentina 1-0 en la prórroga el 19 de julio de 2026. Era la favorita del modelo, y España-Argentina era la segunda final más repetida en la simulación (5.360 de cada 100.000, un 5,4%). Pero Alemania y Países Bajos, ambas entre las ocho primeras del modelo, cayeron en dieciseisavos en los penaltis. El modelo les daba un 40,2% y un 39,3% de llegar a cuartos.

Un solo torneo es una sola muestra. Si el modelo da a España un 18,7%, también está diciendo que en el 81,3% de los casos no gana. Un resultado no valida ni refuta un modelo probabilístico. La evaluación honrada sería partido a partido, con una métrica de puntuación como Brier o log-loss sobre todos los partidos reales. No la hemos hecho, y es lo primero que añadiríamos. Es el mismo principio que aplicamos al medir los resultados de un proyecto de IA: sin una métrica fijada antes, "acertamos" es una anécdota.

Lo que haríamos diferente

  1. 1.Un único módulo de modelo. El simulador del navegador es una reimplementación, y se desvió del script: empareja a los 32 clasificados por Elo (primero contra trigésimo segundo) en lugar de usar el cuadro oficial, usa Math.random() sin semilla y calcula el sesgo de penaltis con escala 400 en vez de 1.500. Tienen el mismo nombre y son modelos distintos. Lo correcto es un módulo puro, importado por los dos, con el generador aleatorio inyectado.
  2. 2.Nombres que digan lo que miden. En el JSON, el campo round16 cuenta en realidad a los 32 equipos que entran en dieciseisavos, no a los que llegan a octavos. Y no existe ningún contador de "llegar a octavos". Es un error de nombre que invita a leer mal los datos.
  3. 3.Calibración en código y con rastro. Guardar el Elo original, cada ajuste y la ejecución previa. Mejor todavía: ajustar los parámetros (la media de 1,4 goles, la escala de 1.500, los pesos del bonus) contra resultados históricos en lugar de a ojo.
  4. 4.Comentarios verificados. El comentario del valor de mercado dice que 200 millones dan unos 23 puntos y 1.000 millones unos 43; la fórmula da 30 y el tope de 50, que se alcanza desde unos 926 millones. Por eso seis selecciones (España, Francia, Inglaterra, Portugal, Alemania y Brasil) empatan en ese componente. El rango del bonus que indica el comentario tampoco coincide con el de la fórmula. Son detalles, pero los comentarios son lo primero que lee quien llega después.
  5. 5.Fallar en voz alta. El bonus de anfitrión depende del orden de los datos y la asignación de terceros puede devolver un hueco vacío sin avisar. Harían falta comprobaciones explícitas y tests: hoy el simulador no tiene ninguno.
  6. 6.Un modelo de partido más rico. Ataque y defensa por separado en lugar de un único número, y prórroga modelada antes de los penaltis.

Limitaciones: lo que el modelo no sabe

  • •Lesiones, sanciones y convocatorias reales.
  • •Cómo cambia la fuerza de un equipo durante el torneo: el Elo es fijo.
  • •Sedes, viajes, descanso entre partidos y clima.
  • •La prórroga: un empate en eliminatorias va directo a penaltis.
  • •Los criterios de desempate completos de FIFA.
  • •Los índices de forma, que son estimaciones a nivel de selección.

Qué nos llevamos al trabajo con clientes

Casi nada de esto es exclusivo del fútbol. Una semilla fija para poder reproducir cualquier resultado, salidas fechadas y congeladas, datos tipados como código, una sola fuente de verdad para la lógica y una métrica de evaluación decidida antes de presumir de acierto. Son las mismas reglas que seguimos al construir modelos predictivos y aplicaciones con IA, y lo primero que revisamos en una consultoría de IA. Si tienes un problema de previsión o simulación en tu empresa, cuéntanoslo.

Preguntas frecuentes

¿El simulador usa inteligencia artificial?

El simulador es estadística clásica: Monte Carlo con Elo y Poisson. Dentro del código no hay ningún modelo entrenado.

¿Puedo ver el código completo?

No, el repositorio no es público. Los fragmentos de este artículo están copiados tal cual del código que generó la simulación publicada, sin sus comentarios.

¿Por qué no se cambian los resultados después del torneo?

Porque la gracia de una predicción es que no se ajuste a lo que pasó después. El JSON lleva la fecha de generación (12 de junio de 2026, al día siguiente del partido inaugural), no incorpora ningún resultado del torneo y se puede reproducir con la misma semilla.

Fuentes

¿Hablamos de tu proyecto?

Una llamada de 30 minutos, gratis y sin compromiso, con quien escribiría el código.