Three attacks, one script
Build the data-driven combat foundation that every later ability will share.
Weekly outcome
What exists by the end
A punch, fireball, and dash produced by one reusable code path, with their differences stored as data you can edit without touching a script.
Build sequence
This week's work
- 1Create an aura resource that drains when abilities fire and regenerates over time.
- 2Replace hardcoded attack values with an ability data table.
- 3Route contact, ranged, and movement abilities through the same execution pattern.
- 4Keep cooldown, aura, and damage decisions authoritative on the server.
- 5Prove the design by changing data instead of copying scripts.
Step by step
Do it in this order
No coding experience is assumed. Every script is written out in full — read the explanation underneath it, then type or paste it in. Do not skip the check at the end of a task; each one depends on the last.
- 1
Understand the one idea this week rests on
10 minutesIn Studio
- Read this before opening Studio. Right now, punch damage lives inside CombatServer as the number 10. If you add a fireball the obvious way, you copy that script and change the numbers. Add a dash and you copy it again.
- Three copies is annoying. Thirty copies — which is where the ability creator in week 3 is heading — is a project that cannot be finished.
- The fix is to move the numbers out of the script and into a list. The script stops being "the punch script" and becomes "the script that runs whatever ability it is handed".
- By the end of the week you will add a fourth attack by typing six lines into a list, with no new script at all. That is the test of whether this week worked.
You know it worked when
You can explain, out loud, why copying the punch script twice is the wrong move. If you cannot, re-read the four points above.
- 2
Give the player an aura meter
35 minutesIn Studio
- Insert a ModuleScript into ReplicatedStorage → Modules and rename it AuraService. A ModuleScript holds code other scripts borrow, instead of running on its own.
- Paste the code below into it.
- In ServerScriptService, insert a Script named AuraSetup with these two lines: local AuraService = require(game.ReplicatedStorage.Modules.AuraService) then game.Players.PlayerAdded:Connect(AuraService.setup).
- Press Play, click your own Player object in the Explorer, and look at the Attributes section at the bottom of the Properties panel. You should see Aura counting back up to 100.
ModuleScript — AuraServiceReplicatedStorage → Modules → AuraServicelocal Players = game:GetService("Players") local MAX_AURA = 100 local REGEN_PER_SECOND = 8 local AuraService = {} function AuraService.setup(player) player:SetAttribute("MaxAura", MAX_AURA) player:SetAttribute("Aura", MAX_AURA) end function AuraService.get(player) return player:GetAttribute("Aura") or 0 end -- Returns true and takes the aura, or returns false and takes nothing. function AuraService.spend(player, amount) local current = AuraService.get(player) if current < amount then return false end player:SetAttribute("Aura", current - amount) return true end task.spawn(function() while true do local elapsed = task.wait(0.25) for _, player in ipairs(Players:GetPlayers()) do local current = player:GetAttribute("Aura") local max = player:GetAttribute("MaxAura") if current and max and current < max then player:SetAttribute("Aura", math.min(max, current + REGEN_PER_SECOND * elapsed)) end end end end) return AuraServiceWhat this code is doing
- An Attribute is a custom value you attach to any object. Attributes on a Player copy themselves to that player's screen automatically, which is why the aura bar you build later needs no extra networking.
- AuraService.spend either succeeds completely or does nothing at all. Returning true or false lets the caller ask "could I afford that?" and act on the answer in one step.
- task.spawn runs the loop alongside everything else instead of freezing the script at the while line.
- task.wait(0.25) hands back how long it actually waited. Multiplying regen by that number keeps the refill rate correct even when the server is busy.
- return AuraService at the bottom is what makes require() hand this table back to whoever asks for it.
You know it worked when
Aura sits at 100, and if you manually type 20 into the attribute it climbs back to 100 over about ten seconds.
If it is stuck
If the attribute never appears, AuraSetup is not running. Confirm it is a Script in ServerScriptService, not a LocalScript.
- 3
Write the ability list
30 minutesIn Studio
- Insert a ModuleScript into ReplicatedStorage → Modules and rename it AbilityData.
- Paste the code below. Read it as a description of three abilities, not as code — because that is what it is.
- Notice that punch, fireball and dash all carry the same fields: a kind, an aura cost, a cooldown. The only real difference between them is the numbers and the kind.
- Change Fireball's damage to 60 and read it back. You have just rebalanced an attack without opening a script that does anything.
ModuleScript — AbilityDataReplicatedStorage → Modules → AbilityDatalocal AbilityData = { Punch = { displayName = "Punch", kind = "Contact", auraCost = 0, cooldown = 0.5, damage = 10, range = 12, }, Fireball = { displayName = "Fireball", kind = "Ranged", auraCost = 15, cooldown = 1.5, damage = 25, range = 120, speed = 90, color = Color3.fromRGB(255, 120, 40), }, Dash = { displayName = "Dash", kind = "Movement", auraCost = 10, cooldown = 2, damage = 0, distance = 40, speed = 110, }, } return AbilityDataWhat this code is doing
- The curly brackets make a table — Lua's word for a list of named values. AbilityData is a table holding three more tables.
- kind is the most important field. It decides which piece of code runs the ability, and it is the reason three very different attacks can share one script.
- Fields only appear where they mean something. A dash has no range but has distance; a punch has no speed.
- Everything a designer would want to tune is here, and nothing else is. When week 3 builds the ability creator, the creator's only job is to write new tables in exactly this shape.
You know it worked when
Nothing works yet. This file is a description, and descriptions do not run — the next task gives it a reader.
- 4
Rewrite the server to run any ability
60 minutesIn Studio
- In ReplicatedStorage → Remotes, add a second RemoteEvent named UseAbility, beside the AttackRequest you made in week 1.
- Open your CombatServer script from week 1. Select all of it and delete it. Replacing it is the point of the week.
- Paste the code below in its place.
- Press Play and try all three: left click punches, Q throws a fireball, E dashes. Watch the Aura attribute drop and refill.
Script — CombatServer (replaces week 1)ServerScriptService → CombatServerlocal Players = game:GetService("Players") local Debris = game:GetService("Debris") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Modules = ReplicatedStorage:WaitForChild("Modules") local AbilityData = require(Modules:WaitForChild("AbilityData")) local AuraService = require(Modules:WaitForChild("AuraService")) local UseAbility = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("UseAbility") local cooldowns = {} local function findTarget(character, range) local root = character:FindFirstChild("HumanoidRootPart") if not root then return nil end local closest = nil local closestDistance = range for _, model in ipairs(workspace:GetChildren()) do if model:IsA("Model") and model ~= character then local humanoid = model:FindFirstChildOfClass("Humanoid") local targetRoot = model:FindFirstChild("HumanoidRootPart") if humanoid and targetRoot and humanoid.Health > 0 then local distance = (targetRoot.Position - root.Position).Magnitude if distance < closestDistance then closest = humanoid closestDistance = distance end end end end return closest end -- One handler per kind. Adding a kind later means adding one function here. local handlers = {} function handlers.Contact(player, character, ability) local target = findTarget(character, ability.range) if target then target:TakeDamage(ability.damage) end end function handlers.Ranged(player, character, ability) local root = character:FindFirstChild("HumanoidRootPart") if not root then return end local projectile = Instance.new("Part") projectile.Name = "Projectile" projectile.Shape = Enum.PartType.Ball projectile.Size = Vector3.new(2, 2, 2) projectile.Material = Enum.Material.Neon projectile.Color = ability.color projectile.CanCollide = false projectile.CFrame = root.CFrame * CFrame.new(0, 0, -4) projectile.AssemblyLinearVelocity = root.CFrame.LookVector * ability.speed projectile.Parent = workspace local spent = false projectile.Touched:Connect(function(hit) if spent then return end local model = hit:FindFirstAncestorOfClass("Model") if not model or model == character then return end local humanoid = model:FindFirstChildOfClass("Humanoid") if humanoid and humanoid.Health > 0 then spent = true humanoid:TakeDamage(ability.damage) projectile:Destroy() end end) -- Clean up whether or not it hit anything. Debris:AddItem(projectile, ability.range / ability.speed) end function handlers.Movement(player, character, ability) local root = character:FindFirstChild("HumanoidRootPart") if not root then return end root.AssemblyLinearVelocity = root.CFrame.LookVector * ability.speed end UseAbility.OnServerEvent:Connect(function(player, abilityName) -- Never trust the name the client sent. Look it up; if it is not real, stop. local ability = AbilityData[abilityName] if not ability then return end local character = player.Character if not character then return end local humanoid = character:FindFirstChildOfClass("Humanoid") if not humanoid or humanoid.Health <= 0 then return end cooldowns[player] = cooldowns[player] or {} local now = os.clock() local readyAt = cooldowns[player][abilityName] or 0 if now < readyAt then return end if not AuraService.spend(player, ability.auraCost) then return end cooldowns[player][abilityName] = now + ability.cooldown local handler = handlers[ability.kind] if handler then handler(player, character, ability) end end) Players.PlayerRemoving:Connect(function(player) cooldowns[player] = nil end)What this code is doing
- require(...) runs a ModuleScript once and hands back whatever it returned. This is how CombatServer gets the ability list and the aura functions.
- handlers is a table of functions with one entry per kind. handlers[ability.kind] picks the right one at the moment it is needed — that single line is what replaces three separate scripts.
- OnServerEvent does the same five checks for every ability, in the same order: is it real, is the player alive, is it off cooldown, can they afford it, then run it. Getting that order right once means it is right for every ability you ever add.
- Looking the name up in AbilityData instead of trusting it is the security step. A modified client can send any text it likes; anything not in the list is ignored.
- CFrame.LookVector is the direction the character is facing. Multiplying it by a speed gives a velocity pointing that way.
- Debris:AddItem removes the fireball after a set time so missed shots do not pile up in Workspace forever.
- cooldowns is a table of tables: one entry per player, and inside it one entry per ability. That way a fireball on cooldown does not block a punch.
You know it worked when
All three abilities work, each has its own cooldown, and the fireball and dash refuse to fire when aura is empty.
If it is stuck
If nothing happens at all, open Output. "Infinite yield possible" means a name is misspelled — usually UseAbility or Modules.
- 5
Bind the keys and show the aura bar
30 minutesIn Studio
- Open your AttackInput LocalScript from week 1 and replace its contents with the code below. It now sends an ability name instead of a bare click.
- Insert a ScreenGui into StarterGui named HUD, with a Frame named AuraBack, and inside that a Frame named AuraFill — the same two-frame trick as the dummy's health bar.
- Add a LocalScript inside HUD that reads the player's Aura attribute and sets AuraFill's size, using player:GetAttributeChangedSignal("Aura"):Connect(...).
- Test it. The bar should drain the instant an ability fires and creep back up.
LocalScript — AttackInput (replaces week 1)StarterPlayer → StarterPlayerScripts → AttackInputlocal ReplicatedStorage = game:GetService("ReplicatedStorage") local UserInputService = game:GetService("UserInputService") local UseAbility = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("UseAbility") -- The whole input scheme, in one readable place. local BINDINGS = { [Enum.UserInputType.MouseButton1] = "Punch", [Enum.KeyCode.Q] = "Fireball", [Enum.KeyCode.E] = "Dash", } UserInputService.InputBegan:Connect(function(input, typingInChat) if typingInChat then return end local abilityName = BINDINGS[input.UserInputType] or BINDINGS[input.KeyCode] if abilityName then UseAbility:FireServer(abilityName) end end)What this code is doing
- BINDINGS is the same idea as AbilityData, applied to controls. Rebinding a key is now a one-line edit rather than a new if statement.
- The or in the lookup line handles both cases: a mouse click has a UserInputType, a keyboard press has a KeyCode.
- There is no cooldown check on the client any more. The server owns that now, which is where it belongs.
- The client sends a name and nothing else. It cannot ask for more damage, because damage is not a thing it knows about.
You know it worked when
Q, E and left click each fire the right ability, and the on-screen aura bar matches what the server thinks.
- 6
Prove the design works
20 minutesIn Studio
- Add a fourth ability by typing a new entry into AbilityData — call it Slam, kind Contact, damage 40, range 8, auraCost 25, cooldown 3.
- Add one line to BINDINGS: [Enum.KeyCode.R] = "Slam".
- Press Play. Slam works. You wrote no new script and touched no handler.
- Count the lines you added. Around eight. That number is the whole argument for this week, and it is what makes the ability creator in week 3 possible at all.
- Record the demo: fire all four abilities, then open CombatServer on screen and scroll through it to show there is only one.
You know it worked when
A fourth working attack exists, created entirely from data. If adding Slam required editing CombatServer, something is hardcoded — go back and find it.
If it is stuck
If Slam does nothing, its kind is probably misspelled. It must match a handler name exactly, including the capital letter.
Base shape blueprint
Training Grounds — Aura Additions
Four additions to the map you already built. Nothing moves; you are filling the Target Range you left empty in week 1 and adding a straight lane so the dash has somewhere to travel. Total build time is under an hour.
Top-down plan · 300 × 300 studs
North = +Z
Every number on this plan is a value you type straight into the Position and Size boxes in Studio. The place centre is 0, 0.
Zone legend
1. Aura Font
A glowing disc at the centre of the ring. Standing on it refills aura faster — the first place in the game where a map feature changes a rule.
2. Fireball Lane
The Target Range from week 1, now in use. Three target dummies stand at 30, 60 and 100 studs so you can feel how far a fireball actually reaches.
3. Dash Lane
A straight 120-stud strip with markers every 20 studs. It shows exactly how far the dash carries and makes tuning the distance a measurement, not a guess.
4. Aura Readout
A board beside the path showing the aura cost of each ability. It keeps the numbers visible while you tune them.
Height guide
- Aura Readout8 studs
- Aura Font1 studs
- Fireball Lane0.5 studs
- Dash Lane0.5 studs
Part list — type these exactly
- 1. Aura Font
- Part · Shape Cylinder · Size 1, 16, 16 · Rotation 0, 0, 90 · Position 0, 0.75, 0 · Anchored on · Material Neon · Colour Cyan
- 2. Fireball Lane
- Keep the week 1 platform. Add 3 target dummies at 70, 4, -60 · 95, 4, -60 · 118, 4, -60
- 3. Dash Lane
- Part · Size 24, 0.5, 120 · Position -95, 0.25, 20 · Anchored on · plus 6 marker Parts · Size 24, 0.2, 1 · every 20 studs along Z
- 4. Aura Readout
- Part · Size 30, 12, 1 · Position 0, 8, 78 · Anchored on · with a SurfaceGui and TextLabel on its front face
Landmark positions
Near target70, 4, -60
Inside fireball range and inside dash range. Should be easy.
Mid target95, 4, -60
Comfortable fireball range. This is the one you tune against.
Far target118, 4, -60
Near the edge of the 120-stud fireball range. Should feel like a stretch.
Dash start line-95, 1, -40
Stand here, dash north, and read off which marker you land past.
Build order
- 01Duplicate your week 1 Dummy three times (Ctrl+D) and move each copy to the target positions above.
- 02Rename them TargetNear, TargetMid and TargetFar so the Output window tells you which one you hit.
- 03Build the Dash Lane as one long platform, then duplicate a single thin marker part five times along it.
- 04Add the Aura Font last, and set its Material to Neon so it is obviously interactive.
- 05Group the new parts into the existing TrainingGrounds model.
Things that catch people out
- Do not move anything you built in week 1. Rebuilding a map you already tested wastes the week.
- The distances here are chosen to match the numbers in AbilityData. If you change the fireball range, move the far target to match — the map should always tell the truth about the code.
Show someone
The weekly checkpoint
Show three visibly different attacks, then show that all three come from one script.