Back to Projects

Tower Defense System | Bloons

WIP
Roblox
Luau
OOP
Game Systems

A 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.

server/Game.lua
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.

server/Tower.lua
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
26end

Client 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.

client/TowerClient.lua
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)
33end

Enemy 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.

server/Character.lua
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
23end

Data 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.

server/Game.lua
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)