< Back 

Sword Masters: Rounds

2024-09-15

The loop every Sword Masters server runs: intermission, map vote, loading, teleport, countdown, the round and its rewards. It's a five-state machine driven by a table of handlers, one tick per second, inside a promise that restarts once before closing the server.

LuauRobloxGameplayBackend
Go to quick view

A round is five states and a clock.

Note

I didn’t try to enhance this codebase.

This text is my 2026 self analyzing my 2024 code, so I even point out the bad.

Abstract. Every Sword Masters server repeats the same loop: 30 seconds of intermission with a map vote halfway through, a few seconds to load the map and teleport everyone, a countdown, the round, the rewards, and back to the lobby. RoundService runs that loop as a finite state machine with five states, Intermission, Paused, Teleporting, Starting and Running. Each state is a function in a table, and a loop inside a promise calls the function of the current state over and over. Each call is one tick of about a second, and a state changes by assigning round.State. Paused is the odd one out: the machine goes through it twice per cycle, once to start a round and once to close it, and it decides which by looking at two variables. This article covers the states, the timing of a full cycle, how the vote works, how each of the three game modes hooks into the loop, and what happens when a handler throws.

1. The problem

The game has three modes on six maps. PvP on Abandoned Village, Desert Village and Training Arena is last one standing. The zombie hunts on Mystery Forest and Colosseum ask you to kill a number of enemies that grows with the player count. Mountain Top is a boss fight. Each mode ends and pays differently, but they all share the same frame: wait, pick a map, put everyone on it, run a clock, send everyone back. Players join and leave at any moment and die halfway through, and they need to know what’s going on at all times, which in this game meant a line of text at the top of the screen, the vote screen and an objective panel.

I wanted that frame in one place, with whatever depends on the mode reduced to small hooks, so that adding a map would mean adding an entry to a table.

2. The machine

2.1 States and transitions

The state type is declared at the top of the service:

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

and round.StatesRun maps each name to its handler. This is a plain finite state machine [1] in the table-of-handlers form that games use a lot [2]. Written as a transition table, with t the countdown in seconds, m the chosen map and ℓ the map of the round that just ended (round.ChosenMap and round.LastMap in the code):

From Condition To
Intermission t≤0 Paused
Paused m≠nil and the mode can start Teleporting
Paused m≠nil, PvP with fewer than 2 players Intermission
Paused m=nil Intermission
Teleporting t≤0 Starting
Starting t≤0 Running
Running t≤0 or too few players left Paused

Each handler is the body of one step. The loop that drives them is this:

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

Transitions only happen inside handlers, and the rest of the design leans on that. In the normal flow nobody outside the loop writes round.State, so a handler never finds the state changed under it in the middle of its own step.

2.2 One tick per call

The four timed states have the same shape: show the countdown, wait a second, subtract one, and check whether it reached zero.

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,

The --> Once comment marks what only runs on the last tick, which is where the entry actions of the next state live (teleporting players, handing out swords). Display formats a string and fires it to every client, so the text they see is the server’s own countdown and there’s no client clock that could drift away from it.

The server’s clock does drift a little. task.wait(1) resumes on the first Heartbeat after the second has passed, so each tick lasts a second plus up to one frame, plus whatever the handler does. At 60 Hz that’s at most about 16.7 ms per tick, which over the 225 ticks of a zombie round adds up to roughly 4 seconds. Nobody notices that in a round countdown, but it’s the reason the usual advice is to compute the remaining time from a deadline instead of decrementing a counter [3].

2.3 Paused is a junction

Paused isn’t a timed state. It runs once, makes a decision, does a few seconds of work and leaves. It looks at two variables:

Paused(m,ℓ)={start a round on mif m≠nilclose the round on ℓif m=nil, ℓ≠nilsay that nobody votedotherwise

When Running ends, it moves the map from m to ℓ and sets m to nil, so the next pass through Paused takes the second branch. That branch fires RoundEnded, pays out with the mode’s function, sends the survivors to the lobby, prepares a new vote and goes back to Intermission. During that intermission the vote sets m again, and the next pass takes the first branch. If nobody votes, m stays nil, and the third branch shows “Players did not want to choose a map…” and starts another intermission.

Both branches could have been their own states (Loading and Closing, say). Keeping them in one function made sense for the start branch, which is a fixed script with three waits, but it hides half of the machine inside an if, and the table in section 2.1 has to spell out what the code only implies.

2.4 The loop lives in a promise

The loop runs inside promise.new(RoundFunction), using evaera’s Promise library. That gives two things. The loop can be cancelled: round:Stop() sets isDone to false and cancels the promise. And an error thrown by any handler, at any depth, ends up in a single :catch, which section 7 covers.

3. A full cycle

With the service’s settings (30 s of intermission, 3 s to teleport, 3 s to start and 3 s before the next intermission) and the waits written into Paused, a cycle lasts

T=30⏟Intermission+7⏟Paused, start+3⏟Teleporting+3⏟Starting+Tmap+3⏟Paused, close=46+Tmap

seconds, plus the drift from section 2.2. The start branch adds 7 s: 1 s showing the chosen map, 3 s showing “Loading” while the map is cloned into workspace.GameMap, and 3 more after telling clients the teleport is coming. With rounds of 200 s for PvP, 225 s for zombies and 300 s for the boss, that’s 246, 271 and 346 seconds, between four and six minutes per round.

4. The vote

4.1 Opening it

The vote opens halfway through the intermission. It hangs off a signal connected with :Once, and the intermission fires it on every tick where the countdown is between 13 and 15:

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

Since the connection is Once, only the first of those fires does anything and the rest are no-ops. The window is wider than it needs to be, because the countdown always goes through exactly 15, but it means the vote would still open if a tick ever skipped a number. After each round SetupVoteSignal creates a new signal and connects it again, so every intermission gets exactly one ballot.

The ballot has three maps drawn without replacement from the six playable ones (the lobby is left out): pick a random index, take that map, remove it from the list, repeat. It’s the draw step of a Fisher–Yates shuffle stopped after three steps [4], so each of the (63)=20 possible ballots is equally likely and each map shows up on half of them.

4.2 Counting

VoteMapCallback removes the player’s previous vote from every option, adds the new one, counts the votes per option and sets the chosen map to the one with the most votes:

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

The min⁡ is the tie-break. The loop that looks for the maximum only replaces it on a strictly larger count, so in a tie the option further to the left on screen wins. Since m is updated on every vote, the chosen map is always the current leader, and when the intermission ends there’s nothing left to count. The new counts go out to every client, so the numbers under each map move as people vote.

The first round of a server has no vote behind it. m starts as "Mystery Forest", so if nobody votes in the first intermission the server opens with a zombie hunt instead of waiting another 30 seconds.

5. Modes as data and hooks

A map’s entry in Classes/Maps carries everything the loop needs from it: Mode, MapTime, MinPlayers, the objective text, and for zombie maps the spawn settings and the goal. The service has three tables keyed by mode.

  • ModeBegin[mode](map) runs when Starting finishes. Only the boss fight uses it, to spawn the boss on Mountain Top.
  • ModeLoop[mode](map) runs in the closing branch of Paused and pays out. In PvP, if exactly one player is left, they get the map’s coins (75) and a stat entry. In a zombie hunt, every player still in the round is supposed to get the map’s coins (90 on Mystery Forest and 150 on Colosseum; section 8 explains why that doesn’t happen). The boss fight’s function is empty.
  • Goals[mode](map) computes the number shown on the objective panel.

For PvP and the boss the goal is 1. In a zombie hunt it grows with the number of players n on the server:

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

with g0=15 and Δ=4 on Mystery Forest, and g0=20 and Δ=7 on Colosseum.

The end condition is shared. Running ends the round early when the number of players still in it drops to the map’s MinPlayers or below. PvP maps have MinPlayers = 1, which means one survivor. Zombie and boss maps have 0, so their rounds only end early when everyone has died. The objective is only displayed: sword kills against enemies add to the counter on the panel, but reaching the goal doesn’t end the round.

PvP has one more guard. If a PvP map wins the vote and the server has a single player, Paused shows “Not enough players in game”, waits two seconds and opens a new intermission instead of starting a round nobody can win.

6. Putting players on the map

TeleportPlayers runs on the last tick of Teleporting. For every player who isn’t marked as AFK, it clones the model of their equipped skin from ReplicatedStorage.Assets.Characters, names it after the player, copies the StarterCharacterScripts into it, moves every part into the Player collision group, places it on a spawn point and assigns it as the player’s character. It applies the map’s lighting for that player and stores a Died:Once connection, so dying takes the sword away, hides the objective and marks the player as out of the round. Starting then gives a sword to everyone in the round.

Going back is the reverse. SendPlayers2Lobby takes the swords away, disconnects the death handlers, reloads the characters of the players still in the round and destroys the map five seconds later.

7. When a handler throws

Any error inside the loop rejects the promise. The :catch stops the service, tells everyone “Round ended to prevent cheaters.”, reloads every character, waits three seconds and starts the loop again from Intermission. There’s a failure counter, and once it reaches MAX_FAIL_COUNT, which is 1, the next failure also calls ShutDownServer(0x2). That kicks everyone with the error code and connects a handler that kicks anyone who joins afterwards.

My reasoning was that a loop that broke twice on the same server will probably break again, and players are better off on another server than stuck in a lobby with a frozen countdown. The counter never resets, though, so it really means “the second exception in this server’s lifetime”, even if the two are hours apart and have nothing to do with each other.

8. Limitations

The zombie payout never happens. ModeLoop.ZombieHunt pays the players whose IsInRound attribute is set, but the round writes a different attribute, InRound, and nothing in the repo sets IsInRound. The same mix-up has a second side: InRound is only cleared by the death handler, so players who survive a round keep it after being sent to the lobby, and anything that asks the service who’s in the round can count someone standing in the lobby.

The vote handler doesn’t check the state. The vote screen only appears during the intermission, but the remote is always connected, so a modified client can vote in the middle of a round. That changes round.ChosenMap, and several functions look up the current mode through it (CheckMode, the MinPlayers check in Running), so the round would start following another map’s rules halfway through.

EndRound exists but nothing calls it, which is lucky. It writes the state from outside the loop, the thing section 2.1 says never happens. If it were called while Running is inside its task.wait(1), Running would wake up with the map already cleared, index maps[nil], the promise would reject, and the round would end through the error path.

Smaller things: spawn points are indexed by the player’s position in Players:GetPlayers(), so a map with fewer spawns than players errors out in TeleportPlayers. Server/Settings.luau has a Round section with slightly different numbers that nothing reads, because the service uses its own table. And the countdown drifts by up to a frame per tick.

9. What I took away

A table of handlers keyed by the state’s name is very easy to read: you open the file and the five states are right there. What aged worst is mixing time and logic inside each handler. Every timed state waits, decrements and checks, and the branches of Paused wait too, so the loop is really sequential code dressed as a state machine. If I wrote it again, each state would have an entry function and a deadline, a single loop would compare the clock against that deadline, and remote input such as votes would only be accepted in the states that expect it. The InRound and IsInRound mix-up taught me something else: a value read by four systems should have a single name defined in one place, not a string typed by hand in each of them.

References

  1. D. Harel. “Statecharts: A Visual Formalism for Complex Systems”. Science of Computer Programming, 8(3), 1987.
  2. R. Nystrom. Game Programming Patterns, chapter “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.

This text by Mastedore. All rights reserved.