< Volver 

Sword Masters: Rondas

2024-09-15

El ciclo que corre cada servidor de Sword Masters: intermedio, votación de mapa, carga, teletransporte, cuenta regresiva, la ronda y sus recompensas. Es una máquina de cinco estados movida por una tabla de handlers, un tick por segundo, dentro de una promesa que se reinicia una vez antes de cerrar el servidor.

LuauRobloxGameplayBackend
Ir a vista rápida

Una ronda son cinco estados y un reloj.

Nota

Decidí no arreglar este código.

Este texto es mi yo del 2026 analizando mi código del 2024, por lo que incluso anoto lo malo.

Resumen. Cada servidor de Sword Masters repite el mismo ciclo: 30 segundos de intermedio con una votación de mapa a la mitad, unos segundos para cargar el mapa y teletransportar a todos, una cuenta regresiva, la ronda, las recompensas y de vuelta al lobby. RoundService corre ese ciclo como una máquina de estados finitos con cinco estados, Intermission, Paused, Teleporting, Starting y Running. Cada estado es una función en una tabla, y un bucle dentro de una promesa llama una y otra vez a la función del estado actual. Cada llamada es un tick de más o menos un segundo, y un estado cambia asignando round.State. Paused es el raro: la máquina pasa por él dos veces por ciclo, una para empezar una ronda y otra para cerrarla, y decide cuál mirando dos variables. Este artículo recorre los estados, la duración de un ciclo completo, cómo funciona la votación, cómo se engancha cada uno de los tres modos de juego y qué pasa cuando un handler lanza un error.

1. El problema

El juego tiene tres modos en seis mapas. El PvP en Abandoned Village, Desert Village y Training Arena es de último en pie. Las cacerías de zombies en Mystery Forest y Colosseum piden matar una cantidad de enemigos que crece con el número de jugadores. Mountain Top es una pelea contra un jefe. Cada modo termina y paga distinto, pero todos comparten el mismo marco: esperar, elegir un mapa, poner a todos en él, correr un reloj, devolver a todos. Los jugadores entran y salen en cualquier momento, se mueren a la mitad, y necesitan saber qué está pasando todo el tiempo, que en este juego era una línea de texto arriba de la pantalla, la pantalla de votación y un panel de objetivo.

Quería ese marco en un solo lugar, con lo que depende del modo reducido a ganchos pequeños, para que agregar un mapa fuera agregar una entrada a una tabla.

2. La máquina

2.1 Estados y transiciones

El tipo de estado está declarado arriba del servicio:

export type RoundState = "Intermission"|"Paused"|"Teleporting"|"Starting"|"Running"

y round.StatesRun asocia cada nombre con su handler. Es una máquina de estados finitos común [1] en la forma de tabla de handlers que los juegos usan mucho [2]. Escrita como tabla de transiciones, con t la cuenta regresiva en segundos, m el mapa elegido y ℓ el mapa de la ronda que acaba de terminar (round.ChosenMap y round.LastMap en el código):

Desde Condición Hacia
Intermission t≤0 Paused
Paused m≠nil y el modo puede empezar Teleporting
Paused m≠nil, PvP con menos de 2 jugadores Intermission
Paused m=nil Intermission
Teleporting t≤0 Starting
Starting t≤0 Running
Running t≤0 o quedan muy pocos jugadores Paused

Cada handler es el cuerpo de un paso. El bucle que los mueve es este:

local function RoundFunction(resolve, reject)
	while isDone do
		round.StatesRun[round.State]();
	end
end

Las transiciones solo pasan dentro de los handlers, y el resto del diseño se apoya en eso. En el flujo normal nadie fuera del bucle escribe round.State, así que un handler nunca encuentra que el estado cambió debajo de él en medio de su propio paso.

2.2 Un tick por llamada

Los cuatro estados con tiempo tienen la misma forma: mostrar la cuenta, esperar un segundo, restar uno y revisar si llegó a cero.

Teleporting = function()
	Display(round.Text.Teleporting, round.Time);
	task.wait(1);
	round.Time -= 1;

	if round.Time <= 0 then --> Once
		round:TeleportPlayers();
		round.Time = round.Settings.TimeToStartRound
		round.State = "Starting";
	end
end,

El comentario --> Once marca lo que solo corre en el último tick, que es donde viven las acciones de entrada al siguiente estado (teletransportar a los jugadores, repartir las espadas). Display formatea un texto y lo manda a todos los clientes, así que lo que ven es la cuenta del propio servidor y no hay un reloj en el cliente que pueda desfasarse.

El reloj del servidor sí se desfasa un poco. task.wait(1) reanuda en el primer Heartbeat después de que pasó el segundo, así que cada tick dura un segundo más hasta un frame, más lo que tarde el handler. A 60 Hz eso es como mucho unos 16.7 ms por tick, que en los 225 ticks de una ronda de zombies suman unos 4 segundos. Nadie lo nota en una cuenta regresiva, pero es la razón por la que el consejo habitual es calcular el tiempo restante a partir de una fecha límite en vez de decrementar un contador [3].

2.3 Paused es un cruce

Paused no tiene tiempo. Corre una vez, toma una decisión, hace unos segundos de trabajo y se va. Mira dos variables:

Paused(m,ℓ)={empezar una ronda en msi m≠nilcerrar la ronda en ℓsi m=nil, ℓ≠nilavisar que nadie votósi no

Cuando Running termina, pasa el mapa de m a ℓ y deja m en nil, así que la siguiente pasada por Paused toma la segunda rama. Esa rama dispara RoundEnded, paga con la función del modo, manda a los sobrevivientes al lobby, prepara una votación nueva y vuelve a Intermission. Durante ese intermedio la votación vuelve a fijar m, y la siguiente pasada toma la primera rama. Si nadie vota, m se queda en nil, y la tercera rama muestra “Players did not want to choose a map…” y abre otro intermedio.

Las dos ramas podrían haber sido estados propios (Loading y Closing, por ejemplo). Tenerlas en una función tenía sentido para la rama de inicio, que es un guion fijo con tres esperas, pero esconde la mitad de la máquina dentro de un if, y la tabla de la sección 2.1 tiene que decir lo que el código solo insinúa.

2.4 El bucle vive en una promesa

El bucle corre dentro de promise.new(RoundFunction), con la librería Promise de evaera. Eso da dos cosas. El bucle se puede cancelar: round:Stop() pone isDone en false y cancela la promesa. Y un error que lance cualquier handler, a cualquier profundidad, termina en un solo :catch, que es el tema de la sección 7.

3. Un ciclo completo

Con la configuración del servicio (30 s de intermedio, 3 s para teletransportar, 3 s para empezar y 3 s antes del siguiente intermedio) y las esperas escritas dentro de Paused, un ciclo dura

T=30⏟Intermission+7⏟Paused, inicio+3⏟Teleporting+3⏟Starting+Tmapa+3⏟Paused, cierre=46+Tmapa

segundos, más el desfase de la sección 2.2. La rama de inicio suma 7 s: 1 s mostrando el mapa elegido, 3 s mostrando “Loading” mientras el mapa se clona en workspace.GameMap, y 3 más después de avisarles a los clientes que viene el teletransporte. Con rondas de 200 s en PvP, 225 s en zombies y 300 s en el jefe, eso da 246, 271 y 346 segundos, entre cuatro y seis minutos por ronda.

4. La votación

4.1 Abrirla

La votación abre a mitad del intermedio. Cuelga de una señal conectada con :Once, y el intermedio la dispara en cada tick en que la cuenta está entre 13 y 15:

if (round.Time >= round.Settings.TimeToVote - 2) and (round.Time <= round.Settings.TimeToVote) then
	round.VoteSignal:Fire();
end

Como la conexión es Once, solo el primero de esos disparos hace algo y los demás no hacen nada. La ventana es más ancha de lo necesario, porque la cuenta siempre pasa exactamente por 15, pero significa que la votación abriría igual si algún tick se saltara un número. Después de cada ronda SetupVoteSignal crea una señal nueva y la vuelve a conectar, así que cada intermedio tiene exactamente una boleta.

La boleta tiene tres mapas sacados sin reemplazo de los seis jugables (el lobby queda fuera): elegir un índice al azar, tomar ese mapa, sacarlo de la lista y repetir. Es el paso de sorteo de un barajado de Fisher–Yates detenido después de tres pasos [4], así que cada una de las (63)=20 boletas posibles es igual de probable y cada mapa aparece en la mitad de ellas.

4.2 Contar

VoteMapCallback borra el voto anterior del jugador de todas las opciones, agrega el nuevo, cuenta los votos de cada opción y fija como mapa elegido el que tiene más votos:

m=opción(min⁡arg⁡maxi∈{1,2,3}⁡vi)

El min⁡ es el desempate. El bucle que busca el máximo solo lo reemplaza con un conteo estrictamente mayor, así que en un empate gana la opción que está más a la izquierda en la pantalla. Como m se actualiza con cada voto, el mapa elegido es siempre el que va ganando, y cuando termina el intermedio no queda nada por contar. Los conteos nuevos se mandan a todos los clientes, así que los números debajo de cada mapa se mueven mientras la gente vota.

La primera ronda de un servidor no tiene votación detrás. m empieza como "Mystery Forest", así que si nadie vota en el primer intermedio el servidor arranca con una cacería de zombies en vez de esperar otros 30 segundos.

5. Los modos como datos y ganchos

La entrada de un mapa en Classes/Maps trae todo lo que el bucle necesita de él: Mode, MapTime, MinPlayers, el texto del objetivo y, en los mapas de zombies, la configuración de aparición y la meta. El servicio tiene tres tablas indexadas por modo.

  • ModeBegin[modo](mapa) corre cuando termina Starting. Solo la usa la pelea contra el jefe, para hacerlo aparecer en Mountain Top.
  • ModeLoop[modo](mapa) corre en la rama de cierre de Paused y paga. En PvP, si queda exactamente un jugador, se lleva las monedas del mapa (75) y una entrada en sus estadísticas. En una cacería de zombies, cada jugador que sigue en la ronda debería llevarse las monedas del mapa (90 en Mystery Forest y 150 en Colosseum; la sección 8 explica por qué no pasa). La función del jefe está vacía.
  • Goals[modo](mapa) calcula el número que muestra el panel de objetivo.

En PvP y en el jefe la meta es 1. En una cacería de zombies crece con el número de jugadores n en el servidor:

g(n)=g0+(n−1)Δ

con g0=15 y Δ=4 en Mystery Forest, y g0=20 y Δ=7 en Colosseum.

La condición de cierre es compartida. Running termina la ronda antes de tiempo cuando el número de jugadores que siguen en ella baja a MinPlayers del mapa o menos. Los mapas de PvP tienen MinPlayers = 1, que es un sobreviviente. Los de zombies y el del jefe tienen 0, así que sus rondas solo terminan antes si murieron todos. El objetivo es solo visual: las muertes con espada contra enemigos suman al contador del panel, pero llegar a la meta no termina la ronda.

El PvP tiene una guarda más. Si un mapa de PvP gana la votación y el servidor tiene un solo jugador, Paused muestra “Not enough players in game”, espera dos segundos y abre un intermedio nuevo en vez de empezar una ronda que nadie puede ganar.

6. Poner a los jugadores en el mapa

TeleportPlayers corre en el último tick de Teleporting. Para cada jugador que no está marcado como AFK, clona el modelo de su skin equipado desde ReplicatedStorage.Assets.Characters, le pone el nombre del jugador, le copia los StarterCharacterScripts, pasa todas sus partes al grupo de colisión Player, lo coloca en un punto de aparición y lo asigna como personaje del jugador. Aplica la iluminación del mapa para ese jugador y guarda una conexión Died:Once, así que morir le quita la espada, esconde el objetivo y lo marca como fuera de la ronda. Después Starting le da una espada a cada jugador en la ronda.

La vuelta es al revés. SendPlayers2Lobby quita las espadas, desconecta los handlers de muerte, recarga los personajes de los jugadores que siguen en la ronda y destruye el mapa cinco segundos después.

7. Cuando un handler lanza un error

Cualquier error dentro del bucle rechaza la promesa. El :catch detiene el servicio, les dice a todos “Round ended to prevent cheaters.”, recarga todos los personajes, espera tres segundos y vuelve a arrancar el bucle desde Intermission. Hay un contador de fallos, y cuando llega a MAX_FAIL_COUNT, que es 1, el siguiente fallo además llama a ShutDownServer(0x2). Eso expulsa a todos con el código de error y conecta un handler que expulsa a cualquiera que entre después.

Mi razonamiento fue que un bucle que se rompió dos veces en el mismo servidor probablemente se va a volver a romper, y a los jugadores les conviene más otro servidor que quedarse en un lobby con la cuenta congelada. El contador nunca se reinicia, eso sí, así que en realidad significa “la segunda excepción en la vida de este servidor”, aunque las dos estén separadas por horas y no tengan nada que ver.

8. Limitaciones

El pago de los zombies nunca ocurre. ModeLoop.ZombieHunt paga a los jugadores que tienen el atributo IsInRound, pero la ronda escribe otro atributo, InRound, y nada en el repo pone IsInRound. La misma confusión tiene un segundo lado: InRound solo lo limpia el handler de muerte, así que los jugadores que sobreviven una ronda lo conservan después de volver al lobby, y cualquier cosa que le pregunte al servicio quién está en la ronda puede contar a alguien parado en el lobby.

El handler de la votación no revisa el estado. La pantalla de votación solo aparece durante el intermedio, pero el remote está siempre conectado, así que un cliente modificado puede votar en plena ronda. Eso cambia round.ChosenMap, y varias funciones buscan el modo actual a través de él (CheckMode, el chequeo de MinPlayers en Running), así que la ronda empezaría a seguir las reglas de otro mapa a la mitad.

EndRound existe pero nada lo llama, y menos mal. Escribe el estado desde fuera del bucle, justo lo que la sección 2.1 dice que nunca pasa. Si alguien lo llamara mientras Running está dentro de su task.wait(1), Running se despertaría con el mapa ya borrado, indexaría maps[nil], la promesa se rechazaría y la ronda terminaría por el camino del error.

Cosas menores: los puntos de aparición se indexan con la posición del jugador en Players:GetPlayers(), así que un mapa con menos puntos que jugadores falla en TeleportPlayers. Server/Settings.luau tiene una sección Round con números un poco distintos que nadie lee, porque el servicio usa su propia tabla. Y la cuenta se desfasa hasta un frame por tick.

9. Lo que me dejó

Una tabla de handlers indexada por el nombre del estado se lee muy fácil: abres el archivo y ahí están los cinco estados. Lo que peor envejeció es mezclar tiempo y lógica dentro de cada handler. Cada estado con tiempo espera, decrementa y revisa, y las ramas de Paused también esperan, así que el bucle es en realidad código secuencial disfrazado de máquina de estados. Si lo escribiera de nuevo, cada estado tendría una función de entrada y una fecha límite, un solo bucle compararía el reloj contra esa fecha, y las entradas remotas como los votos solo se aceptarían en los estados que las esperan. La confusión entre InRound e IsInRound me dejó otra cosa: un valor que leen cuatro sistemas debería tener un solo nombre definido en un solo lugar, y no un texto escrito a mano en cada uno.

Referencias

  1. D. Harel. “Statecharts: A Visual Formalism for Complex Systems”. Science of Computer Programming, 8(3), 1987.
  2. R. Nystrom. Game Programming Patterns, capítulo “State”. Genever Benning, 2014.
  3. G. Fiedler. “Fix Your Timestep!”. Gaffer On Games, 2004.
  4. R. Durstenfeld. “Algorithm 235: Random Permutation”. Communications of the ACM, 7(7), 1964.

Este texto de Mastedore. Todos los derechos reservados.