< Back 

Sword Masters: Boss

2024-09-15

The Mountain Guardian fight came from the earlier version of Sword Masters, and my job was to integrate it. It decides with several loops on independent timers, a set of attribute flags and countdown cooldowns, and it picks its attacks as a Markov chain that never repeats a move twice in a row.

LuauRobloxGameplayAI
Go to quick view

A boss that decides with timers and flags.

Warning

Sword Masters was abandoned in October 2024, and most of its animations, sounds and images belong to the group that owned it, so the game can’t run as it did and this system can’t be shown on video.

Abstract. Mountain Top, one of the six maps in Sword Masters, is a fight against the Mountain Guardian, a boss that kicks the ground, fires rotating lasers from a platform and fills the arena with spikes. I didn’t write that code. It came from the earlier version of the game, written by another developer, and what I did was integrate it into the rewrite: I wrapped it in a Knit service, connected it to the round loop, pointed it at the new map folder and marked it as deprecated so it would get rewritten. To integrate it I first had to understand how it decides what to do, and the answer is that it’s not a single state machine. It’s several loops running in parallel on their own timers, reading and writing flags stored as attributes on the boss model, gated by cooldowns that count down second by second. This article describes that design as I found it: the loops, the states they imply, the move selection as a Markov chain, the break after three attacks, an odd semantic in the cooldowns, and what the integration left half done.

1. What was mine and what wasn’t

The boss lives in Server/Providers/BossProviderDeprecated, and the name is deliberate. My part was this:

  • BossFight became a Knit service, so it starts with the rest of the providers.
  • The round spawns it. RoundService.ModeBegin.Bossfight calls bossfight.new():SpawnBoss() when the round starts on Mountain Top.
  • The attack code looks for the map in workspace.GameMap.Map, the folder the new round uses.
  • KnitStart prints a warning on every boot saying the module is deprecated and should be rewritten as soon as possible.

The behavior, meaning what the boss does and when, isn’t mine, and I didn’t change it. Everything that follows is an analysis of inherited code.

2. Spawning

SpawnBoss scales the health with the number of players in the round n:

HP(n)=1000+425(n−1)

from 1,000 solo to 4,825 with ten players. It disables the Humanoid’s Dead state, so the boss doesn’t fall apart when its health reaches zero, and handles death on its own when HealthChanged reports 0 or less: it freezes the boss, marks it as dead and plays the death animation. It hides the model’s parts for 0.8 s while the boss appears, plays a spawn animation whose Landing marker kicks up a cloud of particles, and sends the health bar to every player in the round.

When the spawn animation ends, SpawnBoss enables a script inside the boss’s own model, which checks that the map’s mode is Bossfight and starts the loops.

3. How it decides

3.1 Flags on the model

The boss’s state isn’t a variable. It’s six attributes on the model: State (a text such as "Idle", "Attacking" or "Break"), IsAttacking, InBreak, RandomAction, IsDead and GetDamage. A helper module, BossBody, wraps the reads and writes (IsAttacking(), ChangeBreakStatus(true)…). Each loop reads whichever flags it cares about and writes the ones of the action it starts. It’s a blackboard: a shared space that several independent processes read and write.

3.2 Five loops

Loop Period What it does
Chase 0.3 s if it isn’t attacking, on a break or doing an action, walks to the nearest player
Trees 0.2 s launches every tree within 66 studs
Attacks random, 7 to 10 s waits for the current action and the cooldowns to finish, then attacks
Actions 6 s if it’s idle, draws between the MEGAKICK and doing nothing
Leg event when a player enters the leg’s zone, kicks them away

The leg’s zone uses ZonePlus [4], a third-party module that fires events when players enter or leave a volume. The MEGAKICK, which launches every tree on the map at once, only happens once per fight.

3.3 The states nobody wrote down

From the flags and the loops you can reconstruct the states the boss goes through:

Implied state Flags
Chasing none set
Attacking with move m IsAttacking, State = "Attacking"
Doing an action RandomAction, State = "Attacking"
On a break InBreak, State = "Break"
Dead IsDead

A state machine would have those five states and a transition table [1]. Here the transitions are the moments when a loop finds the flags in the right combination and changes them, and nothing guarantees that two loops don’t both find them that way in the same instant. The guards (if not IsAttacking() and not IsInBreak() ...) try to prevent it, but they’re repeated with small variations in each loop, and they’re not always the same. A behavior tree [2] or an explicit machine would have put the priority between attacking, acting and resting in one place.

4. Picking a move

The attack loop calls Attack(), which draws one of three moves uniformly and, if it’s the same as the previous one, draws again until it isn’t:

local RandomNumber = math.random(1,3)
if self.OldMove == RandomNumber then
	repeat
		RandomNumber = math.random(1,3)
	until self.OldMove ~= RandomNumber
end

That’s a Markov chain [3] over the three moves, with transition matrix

P=(012121201212120)

The matrix is doubly stochastic (its columns also add up to one), so its stationary distribution is uniform: in the long run, each move takes up a third of the attacks. What changes compared to drawing without memory is the sequences. A move never repeats twice in a row, and in each cycle of three attacks the third one is the same as the first with probability 1/2, so half of the cycles show all three moves and the other half show an A, B, A pattern. The repeat loop needs on average 1+13⋅32=1.5 draws per pick.

The three moves:

  • In the ground kick, the boss stays put and kicks the ground one to three times. Each kick, at a marker in the animation, sends out a shockwave that grows to 1200×0.1×1200 studs in 5 s.
  • For the lasers, the boss walks back to its platform and is placed 40 studs above it, the platform transforms, and two lasers grow out of it and spin for about 15 s. The spin speed starts at 0.02 radians per frame and rises by 0.0014 every second, which at 60 Hz is about 1.2 rad/s at the start and 2.4 at the end.
  • For the spikes, the boss marks between 425 and 475 random points in the arena from its platform (ZonePlus picks a point inside the arena’s volume and a ray down finds the floor), and then spikes come up from all of them at once. Each spike deals 30 damage per touch, at most once per second per player.

Two numbers behave differently from what the settings suggest. The number of ground kicks, NumberOfAttackGroundKick = math.random(1,3), is evaluated once, when the settings module loads, so every ground kick on that server uses the same number for as long as the server lives. The same goes for the number of spikes. And the spikes are spawned with task.wait(0.001) between each one, but task.wait can’t wait less than a frame, so placing 450 spikes takes about 450 frames, 7.5 seconds at 60 Hz, and not the 0.45 the number suggests.

5. The break

After three attacks, the boss takes a break. It waits for the attack cooldown, marks InBreak, walks back to its platform, plays the BreakIn animation on a loop and stays like that for 10 seconds. During the break it sets a DamageMultiplier value in ServerStorage to 2, the idea being that players hit twice as hard, though nothing in the repo reads that value. Then it plays BreakOut, pushes the players near it, goes back to normal speed and starts a 5-second break cooldown.

There’s an arithmetic bug in it. To move a block out of the way during the break, the code multiplies its position by a vector:

block.Position = block.Position * Vector3.new(0, -30, 0)

In Luau, the product of two Vector3s is component-wise, so the block doesn’t go down 30 studs: it goes to (0, −30y, 0), somewhere far below the origin. It works because the intent was to move it away and it ends up very far away, and three seconds after the break it goes back to its saved position.

6. Cooldowns that don’t go back

Each cooldown is a number c that starts at 5. StartCoolDown(name, v) sets it to v and subtracts one per second down to zero. There are two predicates:

IsFinished(c)⟺c≤0,IsRunning(c)⟺c<5

Before the first use, c=5: it’s not running and it’s not finished either. During the countdown it’s running. And after it, c=0 and it stays at 0, so it’s finished and, at the same time, still running, forever. The loops that wait with “repeat until it’s finished” work. The one that asks “is it running?” doesn’t: the action loop only acts if the break cooldown isn’t running, so after the first break the boss never draws a random action again. Since the MEGAKICK only happens once and the other option is to do nothing, the effect in game is small, but it’s the kind of semantic that breaks the next person who adds an action.

7. What the integration left half done

The most serious problem is in BossBody.IsDied:

function Boss:IsDied()
	if workspace.GameMap:FindFirstChild("MapRound") then
		...
	else
		return true
	end
end

MapRound is the name the earlier version used for the round’s map. The new round clones the map with the name Map, and nothing in the code creates a MapRound inside GameMap. Unless the place has one placed by hand, IsDied() always returns true, and since every loop starts with if IsDied() then break end, they all exit on their first iteration: the boss appears, shows its health bar and stays still. I adapted the attack code to the new folder and missed this function, which is the one every loop asks first.

The boss also doesn’t close the round when it dies (the call that did is commented out) and doesn’t pay anything: the mode’s reward function is empty and the coins for a boss are 0. The chase looks for the nearest player on the whole server, including the ones in the lobby. And the module uses global variables, _G.Pause, _G.Time and _G.partspeed, which any script on the server can step on.

8. What I took away

The most useful lesson was about inheriting code. I integrated the boss by fixing what failed on the first run, and the function that didn’t fail but returned the wrong thing slipped through. Now, before connecting inherited code, I look for every read of global state (paths in the workspace, _G, attributes) and check each one against the new structure. As for the design, behavior that emerges from timers and flags is hard to reason about even with the code in front of you: I needed two tables to describe it. If I rewrote it, it would be a single machine with the five states from section 3.3, a single scheduler that decides what to do next, cooldowns stored as timestamps (os.clock() >= readyAt) and the same no-repeat draw, which is the part I’d keep as is.

References

  1. I. Millington, J. Funge. Artificial Intelligence for Games, 2nd ed. Morgan Kaufmann, 2009.
  2. D. Isla. “Handling Complexity in the Halo 2 AI”. Game Developers Conference, 2005.
  3. J. R. Norris. Markov Chains. Cambridge University Press, 1997.
  4. ForeverHD. ZonePlus. https://github.com/1ForeverHD/ZonePlus

This text by Mastedore. All rights reserved.