Skip to main content

Stage 2: Sphere Staircase + scaled rewards

Course progressStage 2 of 10
~45 min
Before you start

Finish Stage 1. You should have a climbing wall, a Stage 2 SpawnLocation that pays 1 coin, and a TycoonEconomy script inside ServerScriptService.

Build

a sphere staircase, the Stage 3 checkpoint, and a generalized leaderstats script that scans every SpawnLocation

Learn

how to loop over a folder of parts and read each one's attribute value to drive different behavior

Ship

a tycoon where reaching Stage 2 pays 2 coins and reaching Stage 3 pays 3 — automatically, with no per-pad code

Teacher demo

60-second demo before campers start:

  • Open a finished Stage 2 file. Press Play. The top-right shows Coins 0.
  • Touch the Stage 2 pad → Coins 2. Touch the Stage 3 pad → Coins 5 (2 + 3).
  • Explain: "Last stage you wrote one script for one pad. This stage that one script handles every pad in the obby — and pays each one differently based on its attribute."

The big idea

Stage 1's script knew about exactly one pad: Stage 2's. It had stage2Pad hardcoded. That works for one pad. It doesn't scale to ten.

Today you refactor — keep the same script, but replace the single-pad logic with a loop that wires every SpawnLocation it can find. The loop also reads each pad's StageNumber attribute and pays that many coins. So Stage 2 pays 2, Stage 3 pays 3, Stage 10 will pay 10. One script, ten pads, ten different rewards.

This is the moment the StageNumber attribute starts earning its keep. You set it in Setup ("just an attribute"); now it controls the entire payout schedule. Same gesture, completely new power.

New words
refactor
rewrite working code without changing what it does — usually to make it shorter, clearer, or easier to extend
ipairs
the Lua loop helper for going through a list of things in order (1, 2, 3, ...)
GetChildren
returns a list of every direct child of a part or service — useful for grabbing 'all SpawnLocations in Workspace'
table indexed by Player
a regular Lua table where each player object is a key — perfect for per-player state

Build it

Step 1 — Build the sphere staircase

Three spheres climbing up from the Stage 2 SpawnLocation. Same shape as the base obby's Stage 2.

A sphere staircase in Roblox Studio

Build this part

JumpSphere_Short

Sphere
Open recipe
Size
4 × 4 × 4
Color
Bright orange
Material
Neon
Anchored
✓ Yes
Place
In front of the Stage 2 red pad
Build this part

JumpSphere_Mid

Sphere
Open recipe
Size
4 × 4 × 4
Color
Bright orange
Material
Neon
Anchored
✓ Yes
Place
Next to JumpSphere_Short, 3 studs higher
Build this part

JumpSphere_Tall

Sphere
Open recipe
Size
4 × 4 × 4
Color
Bright orange
Material
Neon
Anchored
✓ Yes
Place
Next to JumpSphere_Mid, 3 more studs higher

Press ▶ Play and confirm you can climb all three. Round tops are unforgiving — aim for centers.

Step 2 — Wire the Stage 3 checkpoint

Build this part

SpawnLocation (Stage 3 — top of the sphere climb)

Block
Open recipe
Size
6 × 1 × 6
Color
Bright green
Material
Plastic
Anchored
✓ Yes
Place
On top of JumpSphere_Tall

Also: check AllowTeamChangeOnTouch. Uncheck Neutral. Set TeamColor to Bright green.

Tag this SpawnLocation with a StageNumber attribute set to 3 — same gesture as Stage 1.

In Teams, insert a new Team named Stage 3. Set its TeamColor to Bright green. Uncheck AutoAssignable.

Step 3 — Refactor TycoonEconomy to scan all SpawnLocations

Open TycoonEconomy in ServerScriptService. Stage 1's script knew about one pad. Today you'll grow it to handle every pad — but in three passes so you can see each piece light up before adding the next.

Pass 1 — Switch from a per-player flag to a nested record

Stage 1 used local hasReachedStage2 = {} — a simple per-player flag (one boolean per player). For multiple stages, you need per-player AND per-stage tracking. Find Stage 1's line:

local hasReachedStage2 = {}

Replace it with:

local rewardedStages = {} -- rewardedStages[player][stageNumber] = true once paid

In the PlayerAdded handler, find hasReachedStage2[player] = false and replace with:

rewardedStages[player] = {}

Add a PlayerRemoving cleanup so the table doesn't grow forever as players come and go. Put this after the PlayerAdded block:

Players.PlayerRemoving:Connect(function(player)
rewardedStages[player] = nil
end)

Press ▶ Play. Walk to the red Stage 2 pad. It still pays +1 (Stage 1's old Touched handler is still attached — we'll replace it in Pass 3). Open Output. You should see no red errors. Press Stop.

This pass changed the data structure but kept the behavior. The next pass replaces the lookup.

Pass 2 — Replace the single-pad lookup with a scan loop that prints every pad it finds

Find Stage 1's pad-finding block:

local stage2Pad = nil
for _, part in ipairs(workspace:GetChildren()) do
if part:IsA("SpawnLocation") and part:GetAttribute("StageNumber") == 2 then
stage2Pad = part
break
end
end

print("Found Stage 2 pad:", stage2Pad and stage2Pad.Name or "NOT FOUND")

Delete that whole block. Replace it with a scanner that lists every tagged pad — no Touched handler yet, just a print so you can confirm what the loop finds:

for _, part in ipairs(workspace:GetChildren()) do
if part:IsA("SpawnLocation") and part:GetAttribute("StageNumber") then
print("Found pad with StageNumber:", part:GetAttribute("StageNumber"))
end
end

Press ▶ Play. Output prints something like:

Found pad with StageNumber: 1
Found pad with StageNumber: 2
Found pad with StageNumber: 3

(Order may vary.) If a pad is missing, its StageNumber attribute isn't set — go back to Step 2.1.

Also delete Stage 1's Touched handler — the stage2Pad.Touched:Connect(...) block. (Without it, no pad pays anything yet. Pass 3 fixes that.) Press Stop.

Pass 3 — Inside the loop, wire each pad's Touched handler with the per-stage payout

Extend the loop. For each pad it finds, connect a Touched handler that pays stageNumber coins the first time that pad is reached:

for _, part in ipairs(workspace:GetChildren()) do
if part:IsA("SpawnLocation") and part:GetAttribute("StageNumber") then
local stageNumber = part:GetAttribute("StageNumber")

part.Touched:Connect(function(otherPart)
local character = otherPart.Parent
local player = Players:GetPlayerFromCharacter(character)
if not player then return end
if not rewardedStages[player] then return end
if rewardedStages[player][stageNumber] then return end

rewardedStages[player][stageNumber] = true
player.leaderstats.Coins.Value = player.leaderstats.Coins.Value + stageNumber
end)
end
end

Press ▶ Play. Climb the wall to the red pad — Coins 0 → 2. Climb the spheres to the green pad — Coins 2 → 5. Reset. Coins stay at 5; both pads are claimed for this session.

Reaching Stage 1's pad doesn't pay anything — you spawn there, you don't touch it after spawning. That's correct. Stage 1 is the starting point; only stages 2 onward award coins.

Understand it

The rewardedStages table is a nested table. The outer keys are players. Each player's value is its own table, where the keys are stage numbers and the values are true once that stage paid out. Why nested? Because each player needs their own record — without that, the first player to reach Stage 2 would "use up" the reward for everyone.

The GetAttribute check inside the loop is important. If you put a part in Workspace that isn't a SpawnLocation — or a SpawnLocation that doesn't have a StageNumber — the loop skips it. The script doesn't crash; it just ignores anything that doesn't match the pattern. That makes adding new stages (Stage 3, 4, 5, ...) zero-cost: tag the SpawnLocation, the script picks it up.

The payout is stageNumber itself. The attribute value is the price. Stage 2 → 2 coins. Stage 10 → 10 coins. If you wanted Stage 10 to pay 100, you wouldn't change the script — you'd change Stage 10's attribute. Code stays generic; data drives behavior. This pattern (data + generic code) is how almost every real game economy works.

The early returns (if not player then return end, etc.) are guard clauses. Each one says "if this isn't the situation we care about, bail out before doing anything." Lua doesn't have early return keywords — you just write return from the function. Reading top to bottom, the function says: "is this a player? is their record set up? have they already been paid? If all three are no/no/no, pay them."

Script anatomy

How this script wires every pad in one pass

One PlayerAdded handler. One PlayerRemoving cleanup. One loop that connects every SpawnLocation it finds. The loop is what makes the script generic — adding Stage 3, 4, 5 needs zero new code.

local Players = game:GetService("Players")

local rewardedStages = {}

Players.PlayerAdded:Connect(function(player)
local stats = Instance.new("Folder")
stats.Name = "leaderstats"
stats.Parent = player

local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = 0
coins.Parent = stats

rewardedStages[player] = {}
end)

Players.PlayerRemoving:Connect(function(player)
rewardedStages[player] = nil
end)

for _, part in ipairs(workspace:GetChildren()) do
if part:IsA("SpawnLocation") and part:GetAttribute("StageNumber") then
local stageNumber = part:GetAttribute("StageNumber")

part.Touched:Connect(function(otherPart)
local character = otherPart.Parent
local player = Players:GetPlayerFromCharacter(character)
if not player then return end
if not rewardedStages[player] then return end
if rewardedStages[player][stageNumber] then return end

rewardedStages[player][stageNumber] = true
player.leaderstats.Coins.Value = player.leaderstats.Coins.Value + stageNumber
end)
end
end
  1. Line 3Nested table for per-player progress.

    Outer keys are Player objects. Inner keys will be stage numbers. So rewardedStages[player][3] tells you whether THIS player has been paid for Stage 3 yet.

  2. Line 16Initialize the inner table on join.

    rewardedStages[player] = {} creates an empty record. Without this line, the script would crash the first time a player touched a pad (nil indexing).

  3. Lines 19–21Cleanup when the player leaves.

    Setting rewardedStages[player] = nil lets Lua garbage-collect the player's record. In a long-running server with many joins, skipping this would slowly leak memory.

  4. Lines 23–25The generic loop.

    Walk every direct child of Workspace. Skip anything that isn't a SpawnLocation. Skip SpawnLocations without a StageNumber. Anything that survives both filters is a pad we care about. This runs ONCE when the script starts.

  5. Lines 28–31Three guard clauses.

    Bail out if: the touching thing wasn't a character with a player, the player's record isn't set up yet (they haven't joined fully), or this player has already been paid for this stage. All three protect the line below.

  6. Lines 33–34Mark and pay.

    Flip the rewarded flag to true (so future touches do nothing), then add stageNumber to the player's Coins. The amount = the attribute value. Same line works for Stage 2, Stage 5, Stage 10.

Try this

Learning beat

Try this

Three short experiments. Predict before you run, then test your guess.

Predict first

Change the Stage 3 SpawnLocation's StageNumber attribute from 3 to 100. Press Play. Climb to the green pad. Predict what the Output / coin counter shows. Were you right?

Compare

Comment out line 19-21 (the PlayerRemoving block). Press Play. Walk around. Stop. Press Play again. Walk to the pads. Does anything visibly break? (Hint: it wouldn't until a player rejoins many times — but the memory leak is real. Why is the cleanup still worth writing?)

Connect

Right now coins reset when you stop the game. In Stage 4 you'll save them between sessions with DataStoreService. Look at the script. Which one line will need to change so that loading a saved Coins value happens before the player can start earning more?

Test your stage

  • Press ▶ Play from the Start Platform. Top-right shows Coins 0.
  • Climb to the red pad. Counter flips to Coins 2.
  • Climb the spheres to the green pad. Counter flips to Coins 5 (2 + 3, not 2 + 1).
  • Reset. Counter stays at 5. Both pads are "done" for this session.
  • Check Stage 1's pad on the Start Platform — it does NOT pay anything when you spawn. (You spawn at it; you don't reach it.)
  • Design check. Does the bigger reward at Stage 3 feel earned? If Stage 2 felt easier than Stage 3 but paid less, the economy reads correctly. If not, tune the obstacles, not the payout.

If it breaks

  • Stage 2 still pays 1 coin instead of 2. You left the old Stage 1 script next to the new one. Open ServerScriptService — there should be ONE script named TycoonEconomy. If there are two, delete the older one.
  • Output prints attempt to index nil with 'leaderstats'. A player touched a pad before PlayerAdded finished setting up their leaderstats. The guard if not rewardedStages[player] then return end should prevent this — re-check that line exists.
  • Stage 3 pad pays the right amount but the player respawns at Stage 1. Color mismatch — same bug as the base obby. Open Stage 3's SpawnLocation, the Stage 3 Team, confirm both TeamColors are exactly Bright green.
  • Stage 3's pad does NOTHING (no coin, no respawn). Probably missing AllowTeamChangeOnTouch checked OR Neutral still checked. Re-check those two SpawnLocation properties.
  • The loop only finds Stage 2, not Stage 3. Stage 3's SpawnLocation is parented somewhere other than direct-child-of-Workspace. The loop uses workspace:GetChildren() — that only returns direct children. If you placed Stage 3 inside a Model or Folder, either move it to Workspace or change GetChildren to GetDescendants (which scans everything).
Coach notes

The conceptual jump for Stage 2 is the nested tablerewardedStages[player][stageNumber]. Some 10–13s have never seen a table-of-tables. Walk every camper through what's in rewardedStages after one player joins and reaches Stage 2:

rewardedStages = {
[PlayerObject] = { [2] = true }
}

Two players, both reach Stage 2, only one reaches Stage 3:

rewardedStages = {
[Player1] = { [2] = true, [3] = true },
[Player2] = { [2] = true }
}
  • The script feels like less work than Stage 1 (it's shorter), but it's secretly the more powerful version. Tell campers this explicitly — they're doing real engineering, not just typing more code.
  • Common typo: :GetAttribute("Stagenumber") (lowercase n) or "stagenumber". Lua is case-sensitive on attribute names. The error will be nil returned from GetAttribute and the if filter will quietly skip the pad.
  • This is the first stage where deleting the OLD script matters. Some campers will keep adding Scripts and end up with stage1Script + stage2Script both connecting to Touched — coins will pay twice per pad. Walk the room and check Explorer.
  • 45 minutes total. Sphere build + checkpoint takes 15. Script refactor + testing takes 30.