---
url: /projects/service-controller-system.md
description: Wire a jump-pad feature across service, controller, and shared config cleanly.
---

# Jump Pad System

*Written: {{ $frontmatter.date }}*

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](../roblox-fundamentals/server-authority.md), [remotes and networking](../roblox-fundamentals/remotes-and-networking.md), [services and events](../roblox-fundamentals/services-and-events.md), [utility library](utility-library.md), and [modules and require](../luau-basics/modules-and-require.md).

## 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](https://create.roblox.com/docs/en-us/projects/server-authority) and [remote events](https://create.roblox.com/docs/en-us/scripting/events/remote).

## 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.

1. Create `Shared/PadConfig` under `ReplicatedStorage`.
2. Create `Remotes` folder under `ReplicatedStorage` with RemoteEvent `PadLaunched`.
3. Create `PadService` Script under `ServerScriptService`.
4. Build no pads by hand in Studio.
5. Create `PadController` LocalScript under `StarterPlayerScripts`.
6. 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](https://create.roblox.com/docs/en-us/scripting/events/remote). Boundary rules follow [client-server boundary](https://create.roblox.com/docs/en-us/scripting/security/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)
end

```

Prose 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

1. Press Play with 1 player.
2. Walk onto `Pad1`.
3. Confirm you launch upward and HUD shows `Pad1: +60`.
4. Step back on within 1 second.
5. Confirm no second launch occurs.
6. Wait 2 seconds and step on again.
7. Confirm you launch again.
8. Stop Play, press Play with 2 players, launch player 1.
9. 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

1. Add pad power tiers in config with three levels.
2. Add a server counter that tracks launches per pad.
3. Add a client toggle that hides prompts.
4. Add a no-launch zone you validate on the server.
5. Link coin rewards from [gameplay mechanic](gameplay-mechanic.md) to pad launches through shared config only.

::: details 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.
:::

::: details 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 PadCounter
```

Expected behavior: each launch increments its pad, and `read` returns 0 for pads nobody touched.
:::

::: details 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.
:::

::: details 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 PadSafeZone
```

Expected behavior: players inside the safe room never launch, while missing, NaN, and outside callers pass through to the normal pad checks.
:::

::: details 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 PadRewards
```

**Script 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](client-server-protocol.md).
