Appearance
Jump Pad System
Written: 2026-10-14
You build a jump pad across service, controller, and shared, with physics on the server and prompts on the client.
Learning objectives
You own pads and physics in a service, render prompts in a controller, define a small Comm protocol, order init correctly, and clean up when a player leaves.
Prerequisites
Read server authority, remotes and networking, services and events, utility library, and modules and require.
Specification
You place 4 pads in a line. Each pad launches a player upward with power 60 and has a 1 second per-player cooldown. The service applies velocity. The controller shows a prompt and a flash effect.
Acceptance:
- Shared config defines pad positions, size, power, and cooldown.
- Service creates pads and owns Touched logic.
- Service validates character and cooldown before launch.
- Service fires one RemoteEvent to the launched player.
- Controller shows a billboard prompt near each pad.
- Controller plays a flash when the server confirms launch.
- Player leave clears cooldown and effect state.
- Init order is config then service then controller.
Only the server changes physics. The client sends no physics intent. See server authority and remote events.
Architecture
You keep three layers. Shared holds pad data. Service holds truth and physics. Controller holds display. Comm owns one server-to-client signal named PadLaunched. No client-to-server remote exists, so no rate table is needed on this path. Bootstrap starts the service first, then the controller. The server clears cooldowns on PlayerRemoving; the client clears each prompt flag when its billboard is destroyed. This keeps one domain per module.
Implementation
Follow these steps in order.
- Create
Shared/PadConfigunderReplicatedStorage. - Create
Remotesfolder underReplicatedStoragewith RemoteEventPadLaunched. - Create
PadServiceScript underServerScriptService. - Build no pads by hand in Studio.
- Create
PadControllerLocalScript underStarterPlayerScripts. - Press Play and step on each pad.
Container: ReplicatedStorage/Shared/PadConfig as a ModuleScript. Behavior: you share pad layout and tuning. Expect server and client to agree on 4 pads and power 60.
luau
--!strict
export type PadEntry = {
name: string,
position: Vector3,
size: Vector3,
power: number,
cooldown: number,
}
local pads: { PadEntry } = {
{ name = "Pad1", position = Vector3.new(-12, 1, 0), size = Vector3.new(4, 1, 4), power = 60, cooldown = 1 },
{ name = "Pad2", position = Vector3.new(-4, 1, 0), size = Vector3.new(4, 1, 4), power = 60, cooldown = 1 },
{ name = "Pad3", position = Vector3.new(4, 1, 0), size = Vector3.new(4, 1, 4), power = 60, cooldown = 1 },
{ name = "Pad4", position = Vector3.new(12, 1, 0), size = Vector3.new(4, 1, 4), power = 60, cooldown = 1 },
}
local PadConfig = {}
PadConfig.pads = pads
return table.freeze(PadConfig)Prose walkthrough: list each pad with a name and a position, keeping power and cooldown per pad. Freeze the outer table.
Container: ServerScriptService/PadService as a Script. Behavior: you build pads, launch players, and notify the launched player. Expect stepping on a pad to fling you up. Expect immediate re-step to do nothing until cooldown ends.
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local PadConfig = require(Shared:WaitForChild("PadConfig"))
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local launchedEvent: RemoteEvent = Remotes:WaitForChild("PadLaunched") :: RemoteEvent
local pads: { any } = (PadConfig :: any).pads
local lastLaunch: { [number]: { [string]: number } } = {}
local function buildPad(name: string, position: Vector3, size: Vector3): Part
local part = Instance.new("Part")
part.Name = name
part.Anchored = true
part.CanCollide = false
part.Size = size
part.Position = position
part.BrickColor = BrickColor.new("Bright green")
part.Parent = Workspace
return part
end
local function launch(player: Player, padName: string, power: number, padPart: Part): ()
local character: Model? = player.Character
if character == nil then
return
end
local humanoid: Humanoid? = (character :: Model):FindFirstChildOfClass("Humanoid")
if humanoid == nil then
return
end
if humanoid.Health <= 0 then
return
end
local root: BasePart? = (character :: Model):FindFirstChild("HumanoidRootPart") :: BasePart?
if root == nil then
return
end
(root :: BasePart).AssemblyLinearVelocity = Vector3.new(0, power, 0)
(padPart :: Part).BrickColor = BrickColor.new("Bright yellow")
task.delay(0.2, function(): ()
(padPart :: Part).BrickColor = BrickColor.new("Bright green")
end)
launchedEvent:FireClient(player, padName, power)
end
local function onTouch(player: Player, padName: string, power: number, cooldown: number, padPart: Part): ()
local now: number = os.clock()
local perPlayer: { [string]: number }? = lastLaunch[player.UserId]
if perPlayer == nil then
perPlayer = {}
lastLaunch[player.UserId] = perPlayer
end
local bucket: { [string]: number } = perPlayer :: { [string]: number }
local last: number? = bucket[padName]
if last ~= nil and now - last < cooldown then
return
end
bucket[padName] = now
launch(player, padName, power, padPart)
end
local function watchPad(padPart: Part, power: number, cooldown: number, padName: string): ()
padPart.Touched:Connect(function(hit: BasePart): ()
local character: Model? = hit:FindFirstAncestorOfClass("Model")
if character == nil then
return
end
local player: Player? = Players:GetPlayerFromCharacter(character :: Model)
if player == nil then
return
end
onTouch(player :: Player, padName, power, cooldown, padPart)
end)
end
for _, entry in pads do
local record: any = entry
local padPart: Part = buildPad(record.name, record.position, record.size)
watchPad(padPart, record.power, record.cooldown, record.name)
end
Players.PlayerRemoving:Connect(function(player: Player): ()
lastLaunch[player.UserId] = nil
end)Prose walkthrough: build each pad with Parent last. Connect Touched on the server only. Resolve the player from the character. Check per-player per-pad cooldown. Set upward velocity on the server. Flash the pad color. Fire one signal to that player. Comm follows remote events. Boundary rules follow client-server boundary.
Container: StarterPlayerScripts/PadController as a LocalScript. Behavior: you add a prompt above each pad and you flash the screen on launch. Expect a label above each pad. Expect a brief flash after each launch.
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local PadConfig = require(Shared:WaitForChild("PadConfig"))
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local launchedEvent: RemoteEvent = Remotes:WaitForChild("PadLaunched") :: RemoteEvent
local pads: { any } = (PadConfig :: any).pads
local player = Players.LocalPlayer
if player == nil then
return
end
local guiNames: { [string]: boolean } = {}
local screen = Instance.new("ScreenGui")
screen.Name = "PadHud"
screen.ResetOnSpawn = false
screen.Parent = player:WaitForChild("PlayerGui")
local flash = Instance.new("TextLabel")
flash.Name = "PadFlash"
flash.Size = UDim2.new(0, 220, 0, 40)
flash.Position = UDim2.new(0, 16, 0, 70)
flash.Text = "Ready"
flash.Parent = screen
local function addPrompt(padName: string): ()
local target: Instance? = Workspace:FindFirstChild(padName)
if target == nil then
target = Workspace:WaitForChild(padName, 10)
end
if target == nil then
return
end
if guiNames[padName] then
return
end
guiNames[padName] = true
local billboard = Instance.new("BillboardGui")
billboard.Name = `Prompt{padName}`
billboard.Size = UDim2.new(0, 120, 0, 40)
billboard.Adornee = target :: BasePart
billboard.Parent = target
local text = Instance.new("TextLabel")
text.Name = "PromptText"
text.Size = UDim2.new(1, 0, 1, 0)
text.Text = "Step to jump"
text.BackgroundTransparency = 0.4
text.Parent = billboard
billboard.Destroying:Connect(function(): ()
guiNames[padName] = nil
end)
end
local function onLaunched(padName: unknown, power: unknown): ()
if typeof(padName) ~= "string" then
return
end
if typeof(power) ~= "number" then
return
end
flash.Text = `{padName :: string}: +{math.floor(power :: number)}`
task.delay(1, function(): ()
flash.Text = "Ready"
end)
end
launchedEvent.OnClientEvent:Connect(onLaunched)
for _, entry in pads do
local record: any = entry
addPrompt(record.name)
task.delay(2, function(): ()
addPrompt(record.name)
end)
endProse walkthrough: read the same config and wait for each server pad. Attach one billboard per pad with Parent last. Update the flash label on server confirmation. Clear each prompt flag when its billboard is destroyed.
Init order: config loads on require. Service builds pads in Play. Controller waits for pads with a timeout then retries. Cleanup order: server clears cooldowns, client clears each prompt flag on billboard destroy.
Testing it
- Press Play with 1 player.
- Walk onto
Pad1. - Confirm you launch upward and HUD shows
Pad1: +60. - Step back on within 1 second.
- Confirm no second launch occurs.
- Wait 2 seconds and step on again.
- Confirm you launch again.
- Stop Play, press Play with 2 players, launch player 1.
- Confirm player 2 HUD stays
Ready.
Common mistakes and failures
You apply velocity on the client. Move it to the service. You connect Touched on the client and trust it. Connect on the server and resolve the player. You forget cooldown cleanup and memory grows. Clear server cooldowns on PlayerRemoving and client prompt flags on billboard destroy. You start the controller before pads exist. Wait then retry. You fire to all clients for one launch. Fire to the launched player only.
Exercises and extensions
- Add pad power tiers in config with three levels.
- Add a server counter that tracks launches per pad.
- Add a client toggle that hides prompts.
- Add a no-launch zone you validate on the server.
- Link coin rewards from gameplay mechanic to pad launches through shared config only.
Solution 1
Add a tier per pad and derive power from it so tuning lives in config, not code.
ModuleScript in ReplicatedStorage/Shared named PadTiers
luau
--!strict
export type PadEntry = {
name: string,
tier: number,
power: number,
cooldown: number,
}
local powers: { [number]: number } = { 40, 60, 90 }
local PadTiers = {}
PadTiers.pads = {
{ name = "Pad1", tier = 1, power = powers[1], cooldown = 1 },
{ name = "Pad2", tier = 2, power = powers[2], cooldown = 1 },
{ name = "Pad3", tier = 3, power = powers[3], cooldown = 2 },
} :: { PadEntry }
return table.freeze(PadTiers)Expected behavior: tier 1 launches softly and tier 3 launches hardest, while the handler reads power and never branches on tier names.
Solution 2
Count launches per pad on the server for balancing.
Script in ServerScriptService named PadCounter
luau
--!strict
local PadCounter = {}
local launches: { [string]: number } = {}
function PadCounter.record(padName: string): ()
if #padName < 2 then
return
end
launches[padName] = (launches[padName] or 0) + 1
end
function PadCounter.read(padName: string): number
return launches[padName] or 0
end
return PadCounterExpected behavior: each launch increments its pad, and read returns 0 for pads nobody touched.
Solution 3
Keep a client flag that hides prompts without touching the server.
LocalScript in StarterPlayerScripts named PadPromptToggle
luau
--!strict
local Players = game:GetService("Players")
local player = Players.LocalPlayer
local promptsVisible: boolean = true
local function applyPrompts(root: Instance): ()
for _, descendant in root:GetDescendants() do
if descendant:IsA("ProximityPrompt") then
descendant.Enabled = promptsVisible
end
end
end
local function setPromptsVisible(visible: boolean): ()
promptsVisible = visible
local character = (player :: Player).Character
if character ~= nil then
applyPrompts(character)
end
applyPrompts(workspace)
end
setPromptsVisible(false)
setPromptsVisible(true)
print("PadPromptToggle ready")Expected behavior: prompts hide and return on toggle, while launches keep working because visibility never gates the server.
Solution 4
Reject launches from inside a no-launch zone on the server.
Script in ServerScriptService named PadSafeZone
luau
--!strict
local Workspace = game:GetService("Workspace")
local PadSafeZone = {}
local safe = Workspace:WaitForChild("SafeRoom") :: BasePart
local safeRadius: number = 12
function PadSafeZone.blocked(player: Player): boolean
local character = player.Character
if character == nil then
return true
end
local root = character:FindFirstChild("HumanoidRootPart")
if root == nil or not root:IsA("BasePart") then
return true
end
local distance: number = (root.Position - safe.Position).Magnitude
if not math.isfinite(distance) then
return true
end
return distance <= safeRadius
end
return PadSafeZoneExpected behavior: players inside the safe room never launch, while missing, NaN, and outside callers pass through to the normal pad checks.
Solution 5
Pay coins per launch by reading one shared reward table; the controller never sends amounts.
ModuleScript in ReplicatedStorage/Shared named PadRewards
luau
--!strict
local PadRewards = {
Pad1 = 1,
Pad2 = 1,
Pad3 = 2,
}
table.freeze(PadRewards)
return PadRewardsScript in ServerScriptService named PadPayout
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local PadRewards = require(Shared:WaitForChild("PadRewards"))
local function payoutFor(padName: unknown): number
if typeof(padName) ~= "string" then
return 0
end
local amount = PadRewards[padName]
if typeof(amount) ~= "number" then
return 0
end
if not math.isfinite(amount) then
return 0
end
return math.floor(amount)
end
assert(payoutFor("Pad3") == 2, "tier three pays two")
assert(payoutFor("PadX") == 0, "unknown pays zero")
print("PadPayout passed")Expected behavior: Output prints PadPayout passed because rewards come from frozen shared config and unknown pads pay nothing.
Summary
You split physics and display and wired one signal. Next build client server protocol.