Build an obby
In this tutorial you make an obby: a 3D obstacle course that players race through, hopping across platforms and dodging lava. You start from Rally’s Sky Obby template, which gives you a finished course, and then write every script yourself, one at a time.
By the end, your game has:
- Lava strips and a spinning bar that send you back to your last checkpoint.
- Platforms that slide back and forth.
- Checkpoints that remember your progress, even after you leave and come back.
- A run timer and your best time.
- Your stage and number of wins in the in-game player list.
- A finish line with fireworks and a banner that everyone sees.
- A Restart button.
You need a Rally account and a computer with a desktop browser. Some TypeScript helps, but every script is explained.
Create the game
Section titled “Create the game”-
Sign in at rally.place and open Games in the left rail.
-
Choose Create a game.
-
Give it a name, such as
Sky Obby. Leave Dimension on 3D, and under Start from, pick Sky Obby. -
Choose Create game. Rally publishes the template as your game’s first version and opens the game’s page.
-
Choose Open in Studio.
New games are Private: only you can see and play them. You’ll change that when you publish at the end.
Look around
Section titled “Look around”The Explorer, on the right side of Studio, shows everything in your game as a tree. Expand the services to see what the template gave you:
DirectoryWorkspace
- Checkpoints four SpawnLocations, Stage1 to Stage4
- Course the platforms you jump across
- KillBricks four lava strips and a spinning bar
- Movers three platforms that slide back and forth
- FinishPad the glowing pad at the end
- …
DirectoryReplicatedStorage
- Cheer a RemoteEvent
- Restart a RemoteEvent
DirectoryServerScriptService
- main the template’s server script
DirectoryStarterGui
- HUD a ScreenGui with a stage panel, a Restart button and a hidden banner
DirectoryStarterPlayer
DirectoryStarterPlayerScripts
- hud the template’s client script
Click Stage2 in the Checkpoints folder and look at the bottom of the Properties panel, under Attributes: it has an attribute named stage set to 2. Attributes are your own data on any object. The Spinner in KillBricks has a spinSeconds attribute, and each platform in Movers has offsetZ and seconds. The scripts you write read these attributes, so the level stays data: later you can add lava or platforms in Studio without changing any code.
Only Stage1 has enabled turned on. The other checkpoints are disabled SpawnLocations, so every character appears at the start, and your script moves it on to its checkpoint.
Press Play (F5) to try the course, and Stop (Shift+F5) when you’re done.
Now double-click main to open it. Studio warns that it’s compiled JavaScript from a published version: templates are published without their TypeScript source, so they’re hard to edit. You’ll replace both template scripts with your own.
Remove the template’s scripts
Section titled “Remove the template’s scripts”-
Click main in ServerScriptService and press Delete.
-
Expand StarterPlayer, then StarterPlayerScripts. Click hud and press Delete.
Press Play again. The course is still there, but the lava is harmless and nothing moves. Time to bring it to life.
Add a timer and move the Restart button
Section titled “Add a timer and move the Restart button”Every Rally game shows a player list in the top-right corner of the screen, drawn on top of your own UI. The template’s Restart button is in that corner, hidden behind the list, so move it to the top-left corner, below a new timer.
-
In the Explorer, right-click HUD (inside StarterGui) and choose Insert Object > TextLabel.
-
With the new label selected, press F2, type
Timerand press Enter. -
In Properties, set these values. To find a property quickly, type its name in the Filter box.
Property Value positionX 16, Y16sizeX 240, Y44backgroundColor#0b1b36backgroundTransparency0.2cornerRadius12text0:00textColor#fffffftextSize20fontWeightbold -
Select Restart (also inside HUD) and set
positionScaleto X0, Y0,anchorPointto X0, Y0, andpositionto X16, Y68.
position and size are in pixels. positionScale and sizeScale add a fraction of the parent’s size, and anchorPoint picks which point of the object sits at that position. The template pinned the button to the right edge with a positionScale X of 1; with all three values changed, it hangs from the top-left corner like the timer. See User interface for the full layout rules.
Share settings in a ModuleScript
Section titled “Share settings in a ModuleScript”The server and the HUD need the same names and numbers. Put them in one ModuleScript that both import, so they can never disagree.
-
Right-click ReplicatedStorage and choose Insert Object > ModuleScript.
-
Press F2 and rename it
ObbyConfig. -
Double-click it to open it in the script editor, select all of the starter code, and replace it with this listing.
// Settings the server and the HUD share. Scripts import them by tree path:// import { STAGE_COUNT } from '@game/ReplicatedStorage/ObbyConfig';
/** How many checkpoints the course has (the SpawnLocations in Workspace/Checkpoints). */export const STAGE_COUNT = 4;
/** RemoteEvents in ReplicatedStorage. */export const CHEER = 'Cheer'; // server → everyone: (player, wins, seconds or null)export const RESTART = 'Restart'; // client → server: no arguments
/** The data store that remembers progress: one entry per player, keyed by user id. */export const PROGRESS_STORE = 'ObbyProgress';
export type SavedProgress = { stage: number; wins: number; /** Fastest full run in seconds, or null before the first one. */ best: number | null;};
/** Seconds on the finish pad before you're sent back to the start. */export const CELEBRATE_SECONDS = 6;
/** The color checkpoints turn on your screen once you've reached them. */export const REACHED_COLOR = '#3ddc97';
/** 75.3 → "1:15.3", or "1:15" without tenths. */export function formatTime(seconds: number, tenths = true): string { const total = Math.max(0, Math.round(seconds * 10)); // tenths of a second const minutes = Math.floor(total / 600); const secs = Math.floor((total % 600) / 10); const text = `${minutes}:${String(secs).padStart(2, '0')}`; return tenths ? `${text}.${total % 10}` : text;}A ModuleScript never runs on its own. Other scripts import from it by its place in the tree, @game/ReplicatedStorage/ObbyConfig, and Rally compiles a copy into each script that imports it.
STAGE_COUNTmust match the number of checkpoints.CHEERandRESTARTare the names of the two RemoteEvents in ReplicatedStorage.PROGRESS_STOREnames the data store that keeps everyone’s progress, andSavedProgressdescribes what goes in it. Data stores hold JSON, so it’s plain numbers andnull.formatTimeturns a number of seconds into text such as1:15.3.
Make hazards deadly and platforms move
Section titled “Make hazards deadly and platforms move”-
Right-click ServerScriptService and choose Insert Object > Script.
-
Rename it
Hazards, open it, and replace its code with this listing.
// Hazards and moving parts. Every part in Workspace/KillBricks kills on touch (and spins if it// has a `spinSeconds` attribute). Every part in Workspace/Movers slides back and forth by its// `offsetX`, `offsetY` and `offsetZ` attributes, taking `seconds` each way.import { Character, Folder, getChild, Part, TweenService, Vector3, workspace } from '@rally/sdk';
/** A number attribute, or `fallback` when the part doesn't have it. */function numberAttribute(part: Part, name: string, fallback: number): number { const value = part.getAttribute(name); return typeof value === 'number' ? value : fallback;}
for (const brick of getChild(workspace, 'KillBricks', Folder).getChildren()) { if (!(brick instanceof Part)) continue; brick.touched.connect((other) => { if (other instanceof Character) other.kill(); }); const spinSeconds = brick.getAttribute('spinSeconds'); if (typeof spinSeconds === 'number') { // A full turn, forever. 360° looks the same as 0°, so the loop is seamless. TweenService.create( brick, { time: spinSeconds, easing: 'linear', repeatCount: -1 }, { rotation: brick.rotation.add(new Vector3(0, 360, 0)) }, ).play(); }}
for (const mover of getChild(workspace, 'Movers', Folder).getChildren()) { if (!(mover instanceof Part)) continue; const offset = new Vector3( numberAttribute(mover, 'offsetX', 0), numberAttribute(mover, 'offsetY', 0), numberAttribute(mover, 'offsetZ', 0), ); TweenService.create( mover, { time: numberAttribute(mover, 'seconds', 2), easing: 'sineInOut', reverses: true, repeatCount: -1, }, { position: mover.position.add(offset) }, ).play();}Scripts in ServerScriptService run once, on the game server, when the game starts. Every class and function they use is described in the API reference. Here’s what this one does:
getChild(workspace, 'KillBricks', Folder)finds the folder and checks that it really is a Folder. If someone renames or deletes it, the script stops with an error that names it, instead of failing in a confusing way later.touchedfires on the server whenever a character or another part starts touching the brick. When it’s aCharacter,kill()sets its health to 0. The character respawns afterPlayers.respawnTimeseconds, which this template sets to 1.5.- The Spinner turns with a tween:
TweenService.create(part, info, goals)animates properties toward the goals. A full 360° turn witheasing: 'linear'andrepeatCount: -1loops forever without a visible seam. - Each mover slides by the offset its attributes describe.
reverses: trueplays the tween backward after each forward play, so the platform goes back and forth, andrepeatCount: -1keeps it going. - The server changes these parts, so every player sees the same motion. Moving platforms don’t carry characters along, so players have to time their jumps and walk with them.
Press Play. The lava kills you now, the bar spins and the platforms move. When you die, though, you’re back at the very start. That’s next.
Write the game’s rules
Section titled “Write the game’s rules”This script owns everyone’s progress: their stage, their wins and their best time. It moves new characters to their checkpoint, times runs, saves progress, runs the finish line and answers the Restart button.
-
Right-click ServerScriptService and choose Insert Object > Script.
-
Rename it
Obby, open it, and replace its code with this listing.
// Sky Obby: the rules. This script owns every player's progress (stage, wins and best time). It// moves new characters to their checkpoint, times runs, saves progress, runs the finish line and// answers the Restart button. The HUD only shows what this script decides.import { Character, Color3, createLeaderstats, DataStoreService, Folder, getChild, type NumberValue, Part, type Player, Players, PointLight, print, RemoteEvent, ReplicatedStorage, SpawnLocation, task, Vector3, warn, workspace,} from '@rally/sdk';import { CELEBRATE_SECONDS, CHEER, PROGRESS_STORE, RESTART, type SavedProgress, STAGE_COUNT,} from '@game/ReplicatedStorage/ObbyConfig';
const progressStore = DataStoreService.getDataStore(PROGRESS_STORE);const cheer = getChild(ReplicatedStorage, CHEER, RemoteEvent);const restart = getChild(ReplicatedStorage, RESTART, RemoteEvent);const finishPad = getChild(workspace, 'FinishPad', Part);
/** The checkpoints, by their `stage` attribute. */const checkpoints = new Map<number, SpawnLocation>();for (const child of getChild(workspace, 'Checkpoints', Folder).getChildren()) { const stage = child.getAttribute('stage'); if (child instanceof SpawnLocation && typeof stage === 'number') checkpoints.set(stage, child);}
// ── Players ─────────────────────────────────────────────────────────────────────────────────
interface PlayerState { stage: NumberValue; wins: NumberValue; best: number | null; /** Game time when the current run started, or null when this run isn't timed. */ runStart: number | null; /** True once saved progress has loaded, so we never save over data we haven't read. */ loaded: boolean; finishing: boolean; lastRestart: number;}const states = new Map<Player, PlayerState>();
/** Where a character should appear to stand on top of a part. */function standOn(part: Part): Vector3 { return part.position.add(new Vector3(0, part.size.y / 2 + Character.height / 2 + 0.1, 0));}
/** Moves a player's character to the checkpoint they've reached. */function sendToCheckpoint(player: Player): void { const state = states.get(player); const checkpoint = state && checkpoints.get(state.stage.value); const character = player.character; if (checkpoint && character && character.health > 0) character.teleport(standOn(checkpoint));}
/** Saves a player's progress in the background. */function save(player: Player): void { const state = states.get(player); if (!state?.loaded) return; const data: SavedProgress = { stage: state.stage.value, wins: state.wins.value, best: state.best }; task.spawn(async () => { try { await progressStore.set(player.userId, data); } catch (e) { warn(`Couldn't save ${player.name}'s progress:`, e); } });}
Players.playerAdded.connect((player) => { const stats = createLeaderstats(player, { Stage: 1, Wins: 0 }); const state: PlayerState = { stage: stats.Stage, wins: stats.Wins, best: null, runStart: null, loaded: false, finishing: false, lastRestart: -Infinity, }; states.set(player, state);
// Characters appear on Stage1, the only enabled SpawnLocation. One step later (after the spawn // itself has gone out), move them to their checkpoint, or start a fresh timed run. player.characterAdded.connect(async () => { await task.wait(); if (state.stage.value > 1) { sendToCheckpoint(player); } else { state.runStart = task.time(); player.setAttribute('RunTime', 0); } });
task.spawn(async () => { try { const saved = await progressStore.get<Partial<SavedProgress>>(player.userId); state.stage.value = Math.min(STAGE_COUNT, Math.max(1, Math.floor(saved?.stage ?? 1))); state.wins.value = Math.max(0, Math.floor(saved?.wins ?? 0)); state.best = typeof saved?.best === 'number' ? saved.best : null; } catch (e) { warn(`Couldn't load ${player.name}'s progress:`, e); } state.loaded = true; if (state.best !== null) player.setAttribute('BestTime', state.best); if (state.stage.value > 1) { // Picking up from a saved checkpoint: this run doesn't get a time. state.runStart = null; player.setAttribute('RunTime', undefined); print(`${player.name} continues from stage ${state.stage.value}`); sendToCheckpoint(player); } });});
Players.playerRemoving.connect((player) => { save(player); states.delete(player);});
// Touching a checkpoint further along than yours moves you up to it.for (const [stage, checkpoint] of checkpoints) { checkpoint.touched.connect((other) => { if (!(other instanceof Character) || other.health <= 0) return; const player = Players.getPlayerFromCharacter(other); const state = player && states.get(player); if (!player || !state || stage <= state.stage.value) return; state.stage.value = stage; save(player); });}
// ── The run clock ───────────────────────────────────────────────────────────────────────────
// Each timed run's whole seconds go in a RunTime attribute on the player, which the HUD shows.// Setting an attribute to the value it already has does nothing, so only one change a second// goes out per player.task.spawn(async () => { for (;;) { for (const [player, state] of states) { if (state.runStart !== null && !state.finishing) player.setAttribute('RunTime', Math.floor(task.time() - state.runStart)); } await task.wait(0.25); }});
// ── The finish ──────────────────────────────────────────────────────────────────────────────
/** A burst of glowing balls that fall with physics and clean themselves up. */function fireworks(at: Vector3, color: Color3): void { for (let i = 0; i < 12; i++) { const spark = new Part(); spark.name = 'Spark'; spark.shape = 'sphere'; spark.size = new Vector3(0.8, 0.8, 0.8); spark.material = 'neon'; spark.color = i % 2 === 0 ? color : Color3.fromHSV(Math.random(), 0.7, 1); spark.canCollide = false; spark.canTouch = false; spark.anchored = false; spark.position = at.add(new Vector3(0, 2, 0)); const angle = (i / 12) * Math.PI * 2; spark.velocity = new Vector3( Math.cos(angle) * 14, 55 + Math.random() * 20, Math.sin(angle) * 14, ); const glow = new PointLight(); glow.color = spark.color; glow.range = 8; glow.parent = spark; spark.parent = workspace; task.delay(2.5, () => spark.destroy()); }}
finishPad.touched.connect((other) => { if (!(other instanceof Character) || other.health <= 0) return; const player = Players.getPlayerFromCharacter(other); const state = player && states.get(player); // Only a real run counts: you must have reached the last checkpoint. if (!player || !state || state.finishing || state.stage.value < STAGE_COUNT) return; state.finishing = true; state.wins.value += 1; let seconds: number | null = null; if (state.runStart !== null) { seconds = Math.round((task.time() - state.runStart) * 10) / 10; if (state.best === null || seconds < state.best) { state.best = seconds; player.setAttribute('BestTime', seconds); } } save(player); print(`${player.name} finished (${state.wins.value} wins)`); cheer.fireAllClients(player, state.wins.value, seconds); fireworks(finishPad.position, player.teamColor); task.delay(CELEBRATE_SECONDS, () => { if (!states.has(player)) return; state.finishing = false; state.stage.value = 1; save(player); player.loadCharacter(); });});
// ── The Restart button ──────────────────────────────────────────────────────────────────────
restart.onServerEvent.connect((player) => { const state = states.get(player); // Anyone can send this at any time: ignore it mid-celebration, and at most every 2 seconds. if (!state || state.finishing || task.time() - state.lastRestart < 2) return; state.lastRestart = task.time(); state.stage.value = 1; save(player); player.loadCharacter();});It’s the longest script in the game, so take it section by section.
Finding things
Section titled “Finding things”The script looks up the RemoteEvents and the finish pad with getChild, then builds a Map from stage number to checkpoint by reading each SpawnLocation’s stage attribute. Nothing in the code depends on how many checkpoints there are or where they are.
Each player’s state
Section titled “Each player’s state”When someone joins, Players.playerAdded fires with their Player. The script calls createLeaderstats(player, { Stage: 1, Wins: 0 }), which adds a leaderstats folder with two NumberValues. Rally shows every value in a player’s leaderstats in the player list, and the HUD reads Stage from there too.
Everything else the server tracks about a player goes in a PlayerState object, kept in a Map that’s filled in playerAdded and emptied in playerRemoving. Always clean up when players leave, or the map grows for as long as the server runs.
Spawning at your checkpoint
Section titled “Spawning at your checkpoint”player.characterAdded fires each time the player gets a new character: when they join, after they die, and when the script calls player.loadCharacter(). Every character appears on Stage1, the only enabled SpawnLocation. The handler waits one step with await task.wait(), so the spawn has finished before the script teleports the character to its checkpoint with character.teleport(...). A character that spawns while you’re on stage 1 starts a new timed run instead.
Saving progress
Section titled “Saving progress”DataStoreService.getDataStore('ObbyProgress') returns a data store: saved data that outlives any game server. The key is player.userId, which never changes, unlike a player’s name.
- Data store calls are asynchronous and can fail, so each one runs inside
task.spawn(async () => …)with atry/catch. Scripts can’t useawaitat the top level. - Loading clamps whatever comes back, since saved data might be from an older version of your game.
- The
loadedflag stops the script from saving before it has read the saved data. Otherwise a failed load followed by a save would wipe out a player’s real progress. saveruns whenever something important changes (a new checkpoint, a finish, a restart) and when the player leaves.- A player who comes back continues from their saved checkpoint. That run isn’t timed, because it didn’t start at stage 1.
Checkpoints
Section titled “Checkpoints”Each checkpoint’s touched handler moves a player up to that stage, but never back down. Checkpoints are solid, so standing on one counts as touching it.
The run clock
Section titled “The run clock”The server keeps the official time with task.time(), the game’s clock in seconds. Every quarter second, a loop writes each timed run’s whole seconds into an attribute named RunTime on the player. Attributes replicate to every player’s device, just like properties. Setting an attribute to the value it already has does nothing, so only one change per second goes out.
A finish records the time to a tenth of a second, and a new record goes into a BestTime attribute and the saved progress.
The finish line
Section titled “The finish line”- The finish only counts if your stage is
STAGE_COUNT, so nobody can skip the course and jump straight onto the pad. finishingstops a second touch from counting the same finish twice.cheer.fireAllClients(player, wins, seconds)sends a message to every player’s device. You can send aPlayer(or any other instance), and it arrives as the same object on the other side.fireworkscreates parts in code: unanchored balls with a startingvelocity, so physics throws them up and gravity brings them down. Each has aPointLightinside and is destroyed after 2.5 seconds. SettingcanTouch = falsekeeps them from setting off anytouchedhandlers.- After the celebration, the player goes back to stage 1, and
player.loadCharacter()respawns them at the start.
The Restart button
Section titled “The Restart button”The button lives on the player’s screen, so the HUD tells the server about clicks through the Restart RemoteEvent, and the server decides what happens. Anyone can fire a RemoteEvent at any time, from a modified client too, so the handler ignores requests during a celebration and accepts at most one every two seconds.
Press Play and try it: reach Stage2, fall into the lava, and you’re back on Stage2. Your stage shows in the player list. The HUD still says Stage 1 / 4 and the timer doesn’t move, because nothing updates the screen yet.
Write the HUD
Section titled “Write the HUD”-
In the Explorer, right-click StarterPlayerScripts (inside StarterPlayer) and choose Insert Object > LocalScript.
-
Rename it
Hud, open it, and replace its code with this listing.
// Sky Obby: the HUD. Shows your stage, run clock and best time, colors the checkpoints you've// reached (on your screen only), cheers when someone finishes and passes the Restart button on to// the server. It never changes the game itself: the server decides everything.import { Color3, Folder, Frame, NumberValue, Player, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, SpawnLocation, TextButton, TextLabel, TweenService, task, Vector2, waitForChild, waitForLocalPlayer, workspace,} from '@rally/sdk';import { CHEER, formatTime, REACHED_COLOR, RESTART, STAGE_COUNT,} from '@game/ReplicatedStorage/ObbyConfig';
task.spawn(async () => { const me = await waitForLocalPlayer(); const hud = await waitForChild(await waitForChild(me, 'PlayerGui', PlayerGui), 'HUD', ScreenGui); const panel = await waitForChild(hud, 'Panel', Frame); const stageLabel = await waitForChild(panel, 'Stage', TextLabel); const fill = await waitForChild(await waitForChild(panel, 'Bar', Frame), 'Fill', Frame); const timer = await waitForChild(hud, 'Timer', TextLabel); const banner = await waitForChild(hud, 'Banner', TextLabel); const restartButton = await waitForChild(hud, 'Restart', TextButton); const stats = await waitForChild(me, 'leaderstats', Folder); const stage = await waitForChild(stats, 'Stage', NumberValue); const checkpoints = await waitForChild(workspace, 'Checkpoints', Folder);
// The stage label, progress bar and checkpoint colors follow your Stage stat. const reached = Color3.fromHex(REACHED_COLOR); const startColors = new Map<SpawnLocation, Color3>(); const showStage = () => { stageLabel.text = `Stage ${stage.value} / ${STAGE_COUNT}`; TweenService.create( fill, { time: 0.4, easing: 'quadOut' }, { sizeScale: new Vector2(stage.value / STAGE_COUNT, 1) }, ).play(); for (const checkpoint of checkpoints.getChildren()) { const n = checkpoint.getAttribute('stage'); if (!(checkpoint instanceof SpawnLocation) || typeof n !== 'number' || n === 1) continue; if (!startColors.has(checkpoint)) startColors.set(checkpoint, checkpoint.color); // A LocalScript's change stays on this device: everyone sees their own progress. checkpoint.color = n <= stage.value ? reached : (startColors.get(checkpoint) ?? reached); } }; stage.getPropertyChangedSignal('value').connect(showStage); checkpoints.childAdded.connect(showStage); showStage();
// The clock: the server keeps your RunTime and BestTime attributes up to date. const showTime = () => { const run = me.getAttribute('RunTime'); const best = me.getAttribute('BestTime'); timer.text = (typeof run === 'number' ? formatTime(run, false) : '-:--') + (typeof best === 'number' ? ` Best ${formatTime(best)}` : ''); }; me.getAttributeChangedSignal('RunTime').connect(showTime); me.getAttributeChangedSignal('BestTime').connect(showTime); showTime();
// A banner when anyone finishes. The server sends the Player itself: instance references // arrive as the same object on every device. const cheer = await waitForChild(ReplicatedStorage, CHEER, RemoteEvent); let hideBanner: (() => void) | null = null; cheer.onClientEvent.connect((who, wins, seconds) => { const time = typeof seconds === 'number' ? ` in ${formatTime(seconds)}` : ''; if (who === me) banner.text = `You finished${time}! Win #${String(wins)}`; else banner.text = `${who instanceof Player ? who.displayName : 'Someone'} finished${time}!`; banner.visible = true; banner.textSize = 12; TweenService.create(banner, { time: 0.5, easing: 'backOut' }, { textSize: 40 }).play(); hideBanner?.(); hideBanner = task.delay(4, () => { banner.visible = false; }); });
// The Restart button only asks. The server decides whether to send you back. const restart = await waitForChild(ReplicatedStorage, RESTART, RemoteEvent); restartButton.activated.connect(() => restart.fireServer());});LocalScripts in StarterPlayerScripts run on each player’s device when they join. This one only displays things and passes on button clicks.
- Everything happens inside
task.spawn(async () => …), because scripts can’t useawaitat the top level. waitForLocalPlayer()resolves with your ownPlayer. On a player’s device, objects arrive from the server over time, sowaitForChildwaits for each one and checks its class.- Each player gets their own copy of everything in StarterGui, in their
PlayerGui. That’s where the script finds the HUD. stage.getPropertyChangedSignal('value')fires whenever the server changes your Stage stat. The script updates the label and tweens the bar’ssizeScaleso it slides to its new width.- It also colors the checkpoints you’ve reached. A change that a LocalScript makes to the world stays on that device, so each player sees their own progress in green, and nobody else’s.
getAttributeChangedSignal('RunTime')andgetAttributeChangedSignal('BestTime')keep the timer label in sync with the server’s clock.- When
Cheerarrives,who === metells the script whether you’re the one who finished. The banner pops by tweening itstextSize, andtask.delayhides it after four seconds.task.delayreturns a function that cancels it, which the script uses to restart the countdown when finishes come in quick succession. - The Restart button’s
activatedevent firesrestart.fireServer(), and the server decides.
Test your game
Section titled “Test your game”Press Play and check that:
- The timer counts up from
0:00as soon as you spawn. - Touching a checkpoint updates the stage label and the bar, turns the checkpoint green, and updates Stage in the player list.
- Lava and the spinning bar send you back to your last checkpoint.
- Reaching the finish pad from Stage4 shows the banner and fireworks, adds a win, and shows your best time next to the clock. After six seconds you’re back at the start.
- Restart sends you back to stage 1.
Play runs the whole game in your browser, with you as the only player. Saved data lives in memory and is gone when you stop. Anything your scripts print, and any errors, show up in the Output panel: click an error to jump to the line that caused it.
To see what other players see, open the menu next to Play and choose a number of players under Test on a real game server. Each test player gets their own view, side by side. Finish the course with one of them and watch the banner appear for the other. Test servers don’t keep saved data either.
If a script has a mistake, Studio underlines it in red as you type, and Check, in the bar under the script editor, compiles every script and lists any errors. See Testing for more.
Publish
Section titled “Publish”-
Choose Publish in the top-right corner of Studio. Studio confirms with the version number, version 2 in this case, since the template was version 1.
-
Choose the back arrow at the top-left of Studio to go to the game’s page.
-
Under Settings, pick Unlisted (anyone with the link can play) or Public (listed in Games), then choose Save changes.
-
Choose Play and race through your own obby.
Players get the new version the next time they join. In the published game, progress is saved for real, so players can leave and pick up where they left off. To learn about versions and rolling back, see Publishing and versions.
Make it yours
Section titled “Make it yours”Because the scripts read the level from folders and attributes, most changes need no code at all:
- More lava. Select a lava strip, press Ctrl+D (Cmd+D on a Mac) to duplicate it, and move the copy with the Move tool (2). Every part in KillBricks kills. Add a number attribute named
spinSecondsto make one spin. - More moving platforms. Duplicate a platform in Movers and change its
offsetX,offsetY,offsetZandsecondsattributes. - A fifth stage. Build a new section of course. Duplicate Stage4, set the copy’s
stageattribute to5, and place it where the new section starts. Then move the FinishPad to the end, and changeSTAGE_COUNTto5in ObbyConfig. - Different movement. Select StarterPlayer and change
characterWalkSpeedandcharacterJumpPower. Check that every jump is still possible.
Scripts read the folders when the game starts, so press Play again to see your changes.