PPristone AcademyLearn · Mentor · Build
NeNAdventures course
Week 2 of 12Foundation

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

  1. 1Create an aura resource that drains when abilities fire and regenerates over time.
  2. 2Replace hardcoded attack values with an ability data table.
  3. 3Route contact, ranged, and movement abilities through the same execution pattern.
  4. 4Keep cooldown, aura, and damage decisions authoritative on the server.
  5. 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. 1

    Understand the one idea this week rests on

    10 minutes

    In 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. 2

    Give the player an aura meter

    35 minutes

    In 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 → AuraService
    local 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 AuraService

    What 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. 3

    Write the ability list

    30 minutes

    In 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 → AbilityData
    local 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 AbilityData

    What 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. 4

    Rewrite the server to run any ability

    60 minutes

    In 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 → CombatServer
    local 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. 5

    Bind the keys and show the aura bar

    30 minutes

    In 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 → AttackInput
    local 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. 6

    Prove the design works

    20 minutes

    In 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

-150-150-100-100-50-500050501001001501501234X studs →

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

  1. 01Duplicate your week 1 Dummy three times (Ctrl+D) and move each copy to the target positions above.
  2. 02Rename them TargetNear, TargetMid and TargetFar so the Output window tells you which one you hit.
  3. 03Build the Dash Lane as one long platform, then duplicate a single thin marker part five times along it.
  4. 04Add the Aura Font last, and set its Material to Neon so it is obviously interactive.
  5. 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.