Hello, I made this as the first feature of a game. It’s messy and needs a refactor.
What is it?
This script manages a wave system where you enter a part in workspace, wait for the countdown to end, then all players in said part get teleported to a map. Then the script spawns some monsters, when all monsters die you can touch the next wave part to proceed.
I want to improve the next_wave function and make the code more independent. Also there’s lots of for loops that check all players because I can’t put those in a single spot. Would love to see a smart way of optimizing that.
I tried refactoring a portion of the code as it had an awful attribute system before I knew how to set table attributes and also turned an if to a guard clause. As well as some more small improvements.
New features to keep in mind:
- At the end of every 5th wave the player can choose a buff. The server randomly rolls a table of items, sends it to the client, the client selects one of the items.
- Calculate total wave difficulty depending on the current wave and change every monster to have its own difficulty multiplier. Currently I do manual changes of monsters, their coin and xp reward attributes, damage, walk speed, monster spawn rate, etc. This lets me replace all attributes with calculation functions for every removed attribute that’d depend on the wave difficulty and monster difficulty.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
local run_each_x_seconds = ServerScriptService:WaitForChild("Run_Each_X_Seconds")
local wave_parties = script:WaitForChild("Wave_Parties") -- every time a new party is made copy the instance from below inside here
local default_wave_settings = script:WaitForChild("Default_Wave_Settings")
local monsters = ServerStorage:WaitForChild("Monsters") -- the place I copy monsters from
local lobby = workspace:WaitForChild("Lobby")
local wave_party_trigger = lobby:WaitForChild("Wave_Party_Trigger") -- if players are inside this part it'll create a new wave party when the cooldown ends
local party_trigger_cooldown_display = lobby:WaitForChild("Party_Trigger_Cooldown_Display")
local wave_settings = require(script:WaitForChild("Wave_Settings"))
local world_maps_names = require(script:WaitForChild("World_Maps"))
local script_library = require(ServerStorage:WaitForChild("Script_Library"))
local players_to_put_in_next_party = {} -- since multiple worlds can exist change this to be per trigger part
local default_seconds_until_start = 1 -- how long you wait for party to start, short for testing
local next_monster_id = 1 -- idk honestly, trying to serialize monsters for some reason
local function next_wave(current_wave_party, current_world)
current_wave_party:SetAttribute("Wave_Number", current_wave_party:GetAttribute("Wave_Number") + 1)
current_wave_party:SetAttribute("Wave_Start_Time", os.clock())
wave_settings.set_wave_settings(current_wave_party)
current_world:WaitForChild("Door_UI"):WaitForChild("Wave_Number"):WaitForChild("Surface_Gui"):WaitForChild("Text_Label").Text = current_wave_party:GetAttribute("Wave_Number") + 1
local function tp_player_to_map(player_in_party)
local character = player_in_party.Character
if character == nil then return end
local humanoid_root_part = character:WaitForChild("HumanoidRootPart")
if humanoid_root_part == nil then return end
humanoid_root_part.CFrame = current_world:WaitForChild("Player_Spawn"):WaitForChild("Spawn_Location").CFrame * CFrame.new(0, 5, 0)
local temporary_y_position = humanoid_root_part.Position.Y
task.wait(3) -- attempt to fix falling under map since laggy players receive the replicated map later
if temporary_y_position - humanoid_root_part.Position.Y > 5 then
humanoid_root_part.CFrame = current_world:WaitForChild("Player_Spawn"):WaitForChild("Spawn_Location").CFrame * CFrame.new(0, 5, 0)
end
end
local players_in_party = script_library.check_for_players_in_party(current_wave_party)
if #players_in_party == 0 then
current_wave_party:Destroy()
current_world:Destroy()
return
end
for _, player_in_party in players_in_party do
local tp_player_to_map_coroutine = coroutine.create(tp_player_to_map)
coroutine.resume(tp_player_to_map_coroutine, player_in_party)
player_in_party.Character:WaitForChild("Humanoid").Health = player_in_party.Character:WaitForChild("Humanoid").MaxHealth -- heal when new wave starts
script_library.update_wave_ui(player_in_party, true, current_wave_party:GetAttribute("Wave_Number"), 0)
end
local function spawn_monsters()
local spawned_monsters = 0
local killed_monsters = 0
local function spawn_monster()
local current_monster = monsters:WaitForChild("World_".. table.find(world_maps_names, current_world.Name)):WaitForChild("Rig"):Clone()
-- change this to be configurable from wave settings
current_monster:PivotTo(current_world:WaitForChild("Monster_Spawn"):WaitForChild("Spawn_Location").CFrame * CFrame.new(0, 5, 0))
local function set_monster_id()
current_monster:SetAttribute("MonsterId", next_monster_id)
next_monster_id += 1
if next_monster_id >= 1000000 then
next_monster_id = 1
warn("next_monster_id reached 1000000 and was reset to 1")
end
end
set_monster_id()
current_monster.Parent = workspace
local monster_sounds = {
npc_dead = monsters:WaitForChild("NPC_Dead"):Clone(), -- shouldn't be like this, got inspired by another system having multiple sounds
}
for _, monster_sound in monster_sounds do
monster_sound.Parent = current_monster
end
local humanoid_billboard = monsters:WaitForChild("Humanoid_Billboard"):Clone()
humanoid_billboard.Enabled = true
humanoid_billboard.Parent = current_monster
local monster_script = monsters:WaitForChild("Monster_Script"):Clone()
monster_script.Parent = current_monster
monster_script.Enabled = true
spawned_monsters += 1
-- when the spawned monster gets killed make it count as dead
local humanoid = current_monster:WaitForChild("Humanoid")
humanoid.Died:Once(function()
killed_monsters += 1
-- play sound
monster_sounds.npc_dead:Stop()
monster_sounds.npc_dead:Play()
-- update the ui of all players in party
local players_in_party = script_library.check_for_players_in_party(current_wave_party)
if #players_in_party == 0 then
current_wave_party:Destroy()
current_world:Destroy()
end
for _, player_in_party in players_in_party do
script_library.update_wave_ui(player_in_party, true, current_wave_party:GetAttribute("Wave_Number"), killed_monsters / current_wave_party:GetAttribute("Amount_Of_Monsters"))
end
if killed_monsters < current_wave_party:GetAttribute("Amount_Of_Monsters") then return end
local next_wave_part = current_world:FindFirstChild("Next_Wave")
if next_wave_part == nil then return end
-- setup next wave if all monsters are killed
for _, player_in_party in players_in_party do
ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Add_Popup"):FireClient(player_in_party, "Completed wave ".. tostring(current_wave_party:GetAttribute("Wave_Number")), "alert")
end
local next_wave_part_touched = nil
local triggered_next_wave = false
next_wave_part_touched = next_wave_part.Touched:Connect(function(touched_part) -- could do :Once and reconnect if touch was invalid
-- teleport whole party if touched is player from party
local touched_player = game:GetService("Players"):GetPlayerFromCharacter(touched_part.Parent)
if touched_player == nil then return end
for _, player_in_party in players_in_party do
if player_in_party ~= touched_player then continue end
next_wave_part_touched:Disconnect()
if triggered_next_wave == true then return end
-- this runs only once
triggered_next_wave = true
-- send all clients the perks they rolled and wait until all of them choose a perk
if current_wave_party:GetAttribute("Wave_Number") % 5 == 0 then
ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Add_Popup"):FireClient(player_in_party, "Select a perk", "alert")
-- connect to the selected perk event and wait until it gets fired
-- when that happens, add the selected perk to the player folder in the party, update player stats affected by the perk
-- flaw: this way only one player can choose a perk at a time
end
local next_wave_coroutine = coroutine.create(next_wave)
coroutine.resume(next_wave_coroutine, current_wave_party, current_world)
break
end
end)
end)
end
-- while spawned monsters are less than total
while spawned_monsters < current_wave_party:GetAttribute("Amount_Of_Monsters") do
task.wait(3)
for i = 1, current_wave_party:GetAttribute("Monsters_Spawned_Per_Loop") do
if spawned_monsters >= current_wave_party:GetAttribute("Amount_Of_Monsters") then break end
spawn_monster()
end
end
end
local spawn_monsters_coroutine = coroutine.create(spawn_monsters)
coroutine.resume(spawn_monsters_coroutine)
end
local function update_party_trigger_cooldown_display()
party_trigger_cooldown_display.SurfaceGui.TextLabel.Text = "Start in: ".. wave_party_trigger:GetAttribute("Seconds_Until_Start").. " sec"
end
local function remove_player_from_party(current_wave_party, user_id, current_world)
current_wave_party[tostring(user_id)]:Destroy()
local players_in_party = script_library.check_for_players_in_party(current_wave_party)
if #players_in_party == 0 then
current_wave_party:Destroy()
current_world:Destroy()
end
end
local function create_wave_party(players_to_put_in_next_party_copy)
if players_to_put_in_next_party_copy == {} then return end
local current_wave_party = default_wave_settings:Clone()
current_wave_party.Name = "Wave_Party_".. tostring(#wave_parties:GetChildren() + 1) -- could repeat names if a party is removed and new one appears
current_wave_party.Parent = wave_parties
local function create_world()
local local_current_world = ServerStorage:WaitForChild("World_Maps"):WaitForChild(world_maps_names[current_wave_party:GetAttribute("World_Index")]):Clone()
local_current_world:PivotTo(CFrame.new(Vector3.new(-300 + (200 * tonumber(string.gsub(current_wave_party.Name, "Wave_Party_", ""), 10)), 100, -300)))
local_current_world.Parent = workspace
while script_library.is_in_instance(local_current_world, workspace) == false do
task.wait(0.5)
end
return local_current_world
end
local current_world = create_world()
for player_index, player in players_to_put_in_next_party_copy do
local player_folder = Instance.new("Folder") -- this is where I'll be storing perk data
player_folder.Name = player.UserId
player_folder.Parent = current_wave_party
ServerScriptService:WaitForChild("Data_Store"):WaitForChild("Data_Update_Binds"):WaitForChild("Update_Weapons"):Fire(player)
player.Character:WaitForChild("Humanoid").Died:Once(function() -- TODO: check if not removing player if they leave makes a problem
remove_player_from_party(current_wave_party, player.UserId, current_world)
end)
end
next_wave(current_wave_party, current_world)
end
local function add_player_to_next_party(player)
if player == nil then return end
if table.find(players_to_put_in_next_party, player) ~= nil then return end -- if player has already been detected
table.insert(players_to_put_in_next_party, player)
end
local function update_wave_party()
players_to_put_in_next_party = {}
for _, part_in_wave_party_trigger in workspace:GetPartsInPart(wave_party_trigger, OverlapParams.new()) do
add_player_to_next_party(game:GetService("Players"):GetPlayerFromCharacter(part_in_wave_party_trigger.Parent))
end
if #players_to_put_in_next_party > 0 then
if wave_party_trigger:GetAttribute("Seconds_Until_Start") == -1 then
wave_party_trigger:SetAttribute("Seconds_Until_Start", default_seconds_until_start)
elseif wave_party_trigger:GetAttribute("Seconds_Until_Start") > 0 then
wave_party_trigger:SetAttribute("Seconds_Until_Start", wave_party_trigger:GetAttribute("Seconds_Until_Start") - 1)
elseif wave_party_trigger:GetAttribute("Seconds_Until_Start") == 0 then
wave_party_trigger:SetAttribute("Seconds_Until_Start", -1)
create_wave_party(players_to_put_in_next_party)
players_to_put_in_next_party = {}
end
elseif #players_to_put_in_next_party == 0 then
wave_party_trigger:SetAttribute("Seconds_Until_Start", -1)
end
end
local function run_each_1_second()
update_wave_party()
end
local function update_wave_ui(player)
script_library.update_wave_ui(player, false, 0, 0)
end
wave_party_trigger:GetAttributeChangedSignal("Seconds_Until_Start"):Connect(update_party_trigger_cooldown_display)
run_each_x_seconds:WaitForChild("1").Event:Connect(run_each_1_second)
ServerScriptService:WaitForChild("Player_Event_Detection"):WaitForChild("Player_Character_Died").Event:Connect(update_wave_ui)
Here’s how the explorer looks during runtime: