Stage 5: Fireball Cannon + cooldowns
Finish Stage 4. Your fireball flies on F and deals damage. Today an enemy fires back, and you stop the player from spamming the power.
a cannon turret that fires at the player, the Stage 6 checkpoint, and a cooldown on the fireball power
how an enemy finds the nearest player and shoots, and how a cooldown timer stops an ability from being spammed
a turret that pressures the player with shots, and a fireball that has to recharge between casts
90-second demo:
- Press Play. Walk into the cannon's range. Every couple of seconds it fires a red shot that tracks you and chips your health.
- Now mash F. The fireball fires once, then refuses until it recharges — Output says it's charging.
- Explain: "Until now nothing could hurt you. The turret changes that — your health finally matters. And a power you can spam isn't fun to balance, so we give it a cooldown."
The big idea
Two halves of "fair combat" land today. First, the world fights back. The base obby's fireball cannon becomes an enemy turret: it finds the nearest player and fires a shot that damages your Humanoid. Until now your health was decoration — the turret makes it real, and suddenly the checkpoints you respawn at matter for a new reason.
Second, you tame your own power. Right now you can press F as fast as your finger moves, spraying fireballs forever. Every real ability has a cooldown — a short wait before you can use it again. You'll add one, and you'll add it in two places: on the server (the honest enforcer, so a cheater can't bypass it) and on the client (for instant feedback, so the player isn't confused). Two cooldowns, one rule.
- turret
- a stationary enemy that aims at a target and fires; here it's the rebuilt cannon
- Magnitude
- the length of a vector; subtract two positions and take .Magnitude to get the distance between them
- cooldown
- a forced wait after using an ability before it can be used again
- os.clock
- returns a constantly rising number of seconds; subtract two readings to measure how much time passed
- authoritative
- the trusted, final say; the server's cooldown is authoritative, the client's is just for feel
- while true loop
- repeats forever; with a wait() inside, it's how the turret fires on a steady beat
Build it
Step 1 — Build the cannon turret
Same cannon parts as the base obby — a base and a barrel on a short path — now wired to shoot at you.

Build this partCannonPath
BlockOpen recipe
CannonPath
Block- Size
- 8 × 1 × 24
- Color
- Dark stone grey
- Material
- Concrete
- Anchored
- ✓ Yes
- Place
- Stretching forward from the Stage 5 yellow pad
Build this partCannonBase
CylinderOpen recipe
CannonBase
Cylinder- Size
- 4 × 4 × 4
- Color
- Dark stone grey
- Material
- Metal
- Anchored
- ✓ Yes
- Place
- Beside the middle of CannonPath, where it can see the walkway
Build this partCannonBarrel
BlockOpen recipe
CannonBarrel
Block- Size
- 2 × 2 × 6
- Color
- Black
- Material
- Metal
- Anchored
- ✓ Yes
- Place
- On top of CannonBase, pointing across the path
The turret script goes inside CannonBarrel — the barrel is where shots come from.
Right-click CannonBarrel → Insert Object → Script. Name it Turret. Type:
local barrel = script.Parent
local Players = game:GetService("Players")
local Debris = game:GetService("Debris")
local function nearestPlayerRoot()
local closest, closestDist = nil, 120
for _, player in ipairs(Players:GetPlayers()) do
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if root then
local dist = (root.Position - barrel.Position).Magnitude
if dist < closestDist then
closest = root
closestDist = dist
end
end
end
return closest
end
while true do
task.wait(2)
local target = nearestPlayerRoot()
if target then
local shot = Instance.new("Part")
shot.Shape = Enum.PartType.Ball
shot.Size = Vector3.new(2, 2, 2)
shot.Color = Color3.fromRGB(255, 60, 0)
shot.Material = Enum.Material.Neon
shot.CanCollide = false
shot.Position = barrel.Position
shot.Parent = workspace
local offset = target.Position - barrel.Position
if offset.Magnitude < 1 then
shot:Destroy()
continue
end
local direction = offset.Unit
shot.AssemblyLinearVelocity = direction * 60
local hasHit = false
shot.Touched:Connect(function(otherPart)
if hasHit then return end
local humanoid = otherPart.Parent:FindFirstChildOfClass("Humanoid")
local player = Players:GetPlayerFromCharacter(otherPart.Parent)
if humanoid and player then
hasHit = true
humanoid:TakeDamage(20)
shot:Destroy()
end
end)
Debris:AddItem(shot, 4)
end
end
Press Play and walk into range. Every 2 seconds the turret fires a red shot at you. Take a few hits and your health drops; enough hits and you respawn at the checkpoint.
Step 2 — Wire the Stage 6 checkpoint
Build this partSpawnLocation (Stage 6 — past the cannon)
BlockOpen recipe
SpawnLocation (Stage 6 — past the cannon)
Block- Size
- 6 × 1 × 6
- Color
- Bright violet
- Material
- Plastic
- Anchored
- ✓ Yes
- Place
- At the far end of CannonPath
Add StageNumber = 6. Check AllowTeamChangeOnTouch. Uncheck Neutral. Set TeamColor to Bright violet.
Same gesture: StageNumber = 6 attribute and a Stage 6 Team (Bright violet, AutoAssignable unchecked).
Step 3 — Put a cooldown on the fireball
Right now F can be spammed. You'll fix it in three passes — server first (the real rule), then client (the feel).
Pass 1 — The server enforces the cooldown
Open FireballServer. Add a cooldown table near the top, and a time check at the start of OnServerEvent:
local COOLDOWN = 2
local lastCast = {}
castFireball.OnServerEvent:Connect(function(player, targetPosition)
local now = os.clock()
if lastCast[player] and now - lastCast[player] < COOLDOWN then
return -- still recharging; ignore this cast
end
lastCast[player] = now
-- (the rest of your fireball code stays exactly the same)
end)
Press Play and mash F. No matter how fast you press, a fireball only launches every 2 seconds. The server simply ignores casts that come too soon. Stop.
Pass 2 — The client gives instant feedback
The server rule works, but mashing F feels broken — nothing tells the player why nothing happened. Add a matching cooldown on the client so it doesn't even bother the server, and prints a hint.
Open FireballClient. Add a cooldown above the input handler and a check inside it:
local COOLDOWN = 2
local lastCast = 0
UserInputService.InputBegan:Connect(function(input, typing)
if typing then return end
if input.KeyCode == Enum.KeyCode.F then
local now = os.clock()
if now - lastCast < COOLDOWN then
print("Power charging...")
return
end
lastCast = now
castFireball:FireServer(mouse.Hit.Position)
end
end)
Press Play. Now mashing F prints "Power charging..." until the power is ready. The fireball fires on a clean 2-second rhythm.
Pass 3 — Prove the server is still the boss
You don't add code here — you understand why both halves exist. The client cooldown is a courtesy; the server cooldown is the law. If a cheater deleted the client check, the server would still refuse early casts. Test it in your head: which check could a hacker remove, and which one still protects the game? (Answer in Understand it.)
Understand it
The turret's targeting loop is your first taste of enemy logic. Every 2 seconds it scans all players, measures distance with Magnitude, keeps the nearest one within range, and fires at them. That "find the nearest player" function is the seed of real enemy AI — in Stage 7 you'll reuse the exact same idea to make an enemy chase instead of just shoot.
The two cooldowns answer a question every multiplayer game faces: where do you enforce a rule? The server check is authoritative — it's the trusted machine, so its "no" is final. The client check is cosmetic — it makes the game feel responsive and shows the "charging" hint, but a cheater could delete it. That's exactly why you can't only have the client check: remove it and the server still holds the line. Real games put the rule on the server and the feedback on the client, every time.
os.clock() is the right clock here because it always moves forward at a steady rate. You read it when the power fires, store that moment, and compare against it next time. If less than COOLDOWN seconds have passed, the power isn't ready.
How the turret picks a target and fires
The turret loops forever: every couple of seconds it finds the nearest player in range and launches a tracking shot at them.
local function nearestPlayerRoot()
local closest, closestDist = nil, 120
for _, player in ipairs(Players:GetPlayers()) do
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if root then
local dist = (root.Position - barrel.Position).Magnitude
if dist < closestDist then
closest = root
closestDist = dist
end
end
end
return closest
end
while true do
task.wait(2)
local target = nearestPlayerRoot()
if target then
local shot = Instance.new("Part")
shot.Position = barrel.Position
shot.Parent = workspace
local offset = target.Position - barrel.Position
if offset.Magnitude < 1 then
shot:Destroy()
continue
end
local direction = offset.Unit
shot.AssemblyLinearVelocity = direction * 60
end
end
Lines 1–2Start with no target and a max range.
closest is nil until we find someone. closestDist starts at 120 — the turret's reach. Anyone farther than that is ignored.
Lines 3–13Measure distance to every player.
For each player with a character, subtract positions and take .Magnitude to get the distance. Keep whoever is closest and within range.
Lines 17–19Fire on a steady beat.
while true with task.wait(2) is a heartbeat. Every 2 seconds it tries to fire — but only if nearestPlayerRoot found someone.
Lines 20–26Aim and launch.
Spawn a shot at the barrel, point it at the target with a unit direction, and set its velocity. Same projectile idea as your fireball, fired by the enemy.
Try this
Try this
Three short experiments. Predict before you run, then test your guess.
Predict what happens if you change the turret's task.wait(2) to task.wait(0.3). Then try it. Is the faster turret more fun or just unfair? What does that tell you about pacing an enemy?
Delete the client cooldown (Pass 2) but keep the server cooldown. Press Play and mash F. Compare how it feels versus having both. The fireball still obeys the 2-second limit — so what exactly did the client cooldown add?
The turret's nearestPlayerRoot finds the closest player. In Stage 7 an enemy will use that to decide who to chase. What would you change so the enemy only reacts to players within, say, 40 studs instead of 120?
Test your stage
- Press ▶ Play. Walk into the cannon's range; it fires a red shot at you every 2 seconds.
- The shots chip your health; enough hits respawn you at a checkpoint.
- Reach the Stage 6 violet pad past the cannon.
- Mash F — the fireball only fires every 2 seconds, and Output prints "Power charging..." in between.
- Walk out of the cannon's 120-stud range; it stops firing at you.
- Design check. Is the turret fair? It should pressure the player, not feel impossible. Tune the fire rate and the 20 damage until crossing the path is tense but winnable.
The big idea here is "enforce on the server, feedback on the client." It's abstract — make it concrete with the Compare beat: deleting the client cooldown proves the server still holds the rule.
- Watch for turrets that never fire: usually the script is in CannonBase instead of CannonBarrel, or the player is outside the 120-stud range.
- A
while trueloop with notask.waitwill freeze Studio. If a camper's game hangs on Play, that's the first thing to check. - This is the first stage where the player can "lose" (respawn). Reassure campers that respawning at a checkpoint is the design, not a bug.
- Total time: 55 minutes. Cannon + turret script 20, checkpoint 5, two-place cooldown 30.