Tower Defense System | Bloons
WIPA fully custom Roblox tower defense game built with a modular client-server architecture, wave-based enemy spawning, persistent encrypted player data, and a layered security system. Every core system — towers, enemies, plots, waves — is decoupled and communicates through a shared module registry.
Wave spawner
The server drives waves through a per-plot spawner loop. Enemy count scales with wave number using randomized multipliers, boss waves trigger at fixed checkpoints, and difficulty increments every five waves. When a player rejoins, the run resumes from the nearest completed boss wave — not from scratch.
1local BOSS_WAVES = {10, 50, 100, 200, 500}
2
3local function GetCheckpointWave(topWave)
4 local checkpoint = 1
5 for _, bw in BOSS_WAVES do
6 if topWave >= bw and bw > checkpoint then
7 checkpoint = bw
8 end
9 end
10 return checkpoint
11end
12
13rt.spawnerThread = task.spawn(function()
14 while rt.running do
15 local wave = rt.currentWave
16 local isBoss = IsBossWave(wave)
17 local enemyCount = isBoss and 1
18 or wave * rng:NextInteger(2, 4) + math.floor(wave / 3)
19
20 for i = 1, enemyCount do
21 if not rt.running then break end
22 local charName = GetCharacterForWave(wave, rng)
23 shared.Character.new(charName, false, plot, pathData)
24 shared.Task:Wait(rng:NextInteger(spawnDelayMin, spawnDelayMax))
25 end
26
27 while rt.running and shared.Character.getAliveCountOnPlot(plot) > 0 do
28 shared.Task:Wait(5)
29 end
30
31 rt.currentWave += 1
32 if rt.currentWave % 5 == 0 then rt.difficulty += 1 end
33 UpdatePlayerLeaderstat(player, rt.currentWave)
34 end
35end)Tower placement and targeting
On placement, the server raycasts against the tower's range indicator across each path segment to determine waypoint coverage automatically. Towers only receive tick callbacks when enemies are in range, and targeting always picks the furthest enemy along the path.
1-- Raycast each path segment to compute waypoint coverage on placement
2for index, direction in pathData.Directions do
3 local result = worldModel:Raycast(
4 pathData.Positions[index], direction, raycastParams
5 )
6 if result then
7 local waypoint = pathData:GetWaypoint(
8 pathData.Distances[index] + result.Distance
9 )
10 table.insert(waypointIndices, waypoint.Index)
11 end
12end
13
14-- Target the enemy furthest along the path
15local function pickTarget(tower)
16 local best, bestScore
17 for _, victim in shared.Character.getAllOnPlot(tower.Plot) do
18 if isInRange(tower, victim) then
19 local score = victim.CFrameIndex or 0
20 if not bestScore or score > bestScore then
21 best, bestScore = victim, score
22 end
23 end
24 end
25 return best
26endClient visual layer
The client runs a full visual layer with zero authority over game state. Turrets interpolate toward the nearest target every Heartbeat, barrel-spin rigs accelerate and decelerate with configurable physics, and beam towers step through charge, fire, and stop states with grace-period timers to prevent flickering. All state is wiped on rejoin through a ClientReset registry.
1-- Smooth turret rotation in turret-base local space
2local function turretTick(dt)
3 for id, rig in pairs(turretRigs) do
4 local targetYaw
5 if rig.shootTargetPos
6 and (now - rig.shootTargetTime) < SHOOT_TARGET_EXPIRY then
7 local rel = (hull.CFrame * CFrame.new(rig.basePos))
8 :PointToObjectSpace(rig.shootTargetPos)
9 targetYaw = math.atan2(-rel.X, -rel.Z)
10 else
11 -- Idle sweep when no target is present
12 local t = (now / IDLE_SWEEP_PERIOD) * math.pi * 2 + rig.idlePhase
13 targetYaw = math.sin(t) * IDLE_SWEEP_HALF_ANGLE
14 end
15
16 local diff = (targetYaw - rig.currentYaw + math.pi) % (math.pi*2) - math.pi
17 rig.currentYaw += diff * math.clamp(dt * TURRET_TURN_SPEED, 0, 1)
18 rig.motor.C0 = CFrame.new(rig.basePos)
19 * CFrame.Angles(0, rig.currentYaw, 0) * rig.baseRot
20 end
21end
22
23-- Beam state machine with grace-period to prevent flicker
24if state == "fire" then
25 for _, beam in ipairs(beams) do beam.Enabled = true end
26 TF._startLoopSFX(rootPart, "TowerPlasmaFire", 0.04, "PlasmaFire_" .. towerId)
27elseif state == "stop" then
28 beamOffTimers[towerId] = task.delay(BEAM_OFF_GRACE, function()
29 beamStateTracker[towerId] = nil
30 for _, beam in ipairs(beams) do beam.Enabled = false end
31 end)
32 TF._stopLoopSFX("PlasmaFire_" .. towerId)
33endEnemy pathing
Enemies move by incrementing a continuous float index into a precomputed CFrame array every tick — no physics, no steering. When the index crosses a waypoint boundary, the character notifies any towers tracking it. Reaching the end of the array deals damage equal to the enemy's remaining health and destroys it.
1function Step(instance)
2 instance.CFrameIndex += instance.Speed
3
4 local waypointIndex = pathData:GetWaypointIndex(instance.CFrameIndex)
5 while instance.WaypointIndex ~= waypointIndex do
6 instance.Waypoint:RemoveCharacter(instance)
7 if instance.Destroyed then return end
8 instance.WaypointIndex += 1
9 instance.Waypoint = pathData.Waypoints[instance.WaypointIndex]
10 if not instance.Waypoint then instance:Destroy() return end
11 instance.Waypoint:AddCharacter(instance)
12 if instance.Destroyed then return end
13 end
14
15 -- Reached the end — deal damage and destroy
16 if pathData.CFrames[math.floor(instance.CFrameIndex)] == nil then
17 local newHp = math.max(shared.Game.PlotBaseHealth[instance.Plot] - instance.Health, 0)
18 shared.Game.PlotBaseHealth[instance.Plot] = newHp
19 Action.Hit:FireClient(player, newHp, shared.Game.MaxBaseHealth, instance.Health)
20 if newHp <= 0 then shared.Game:StopForPlot(instance.Plot) end
21 instance:Destroy()
22 end
23endData persistence
Player progress is encrypted before being written to DataStore. On load, data is validated and automatically recovered from a backup if corrupted. Every money transaction is logged with a before and after balance, and a shutdown handler gives in-flight saves up to 25 seconds to complete before the server closes.
1-- Encrypted UpdateAsync — keeps the higher wave across sessions
2topWaveStore:UpdateAsync("Player_" .. playerKey, function(oldStored)
3 local oldWave = 0
4 if oldStored and Encryption.IsValidStructure(oldStored) then
5 local oldData = Encryption.Decrypt(oldStored, playerKey)
6 if oldData then oldWave = oldData.topWave or 0 end
7 end
8 return Encryption.Encrypt({
9 topWave = math.max(oldWave, topWave),
10 money = currentMoney,
11 lastSave = os.time(),
12 userId = player.UserId,
13 }, playerKey)
14end)
15
16-- Graceful shutdown — wait up to 25s for all saves to finish
17game:BindToClose(function()
18 self.serverClosing = true
19 local done, total = 0, #Players:GetPlayers()
20 for _, player in Players:GetPlayers() do
21 task.spawn(function() SavePlayerData(player); done += 1 end)
22 end
23 local elapsed = 0
24 while done < total and elapsed < 25 do
25 task.wait(0.5); elapsed += 0.5
26 end
27 BackupService.SaveAllBackups()
28end)