Skip to content

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.

  1. Sign in at rally.place and open Games in the left rail.

  2. Choose Create a game.

  3. Give it a name, such as Sky Obby. Leave Dimension on 3D, and under Start from, pick Sky Obby.

  4. Choose Create game. Rally publishes the template as your game’s first version and opens the game’s page.

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

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.

  1. Click main in ServerScriptService and press Delete.

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

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.

  1. In the Explorer, right-click HUD (inside StarterGui) and choose Insert Object > TextLabel.

  2. With the new label selected, press F2, type Timer and press Enter.

  3. In Properties, set these values. To find a property quickly, type its name in the Filter box.

    Property Value
    position X 16, Y 16
    size X 240, Y 44
    backgroundColor #0b1b36
    backgroundTransparency 0.2
    cornerRadius 12
    text 0:00
    textColor #ffffff
    textSize 20
    fontWeight bold
  4. Select Restart (also inside HUD) and set positionScale to X 0, Y 0, anchorPoint to X 0, Y 0, and position to X 16, Y 68.

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.

The server and the HUD need the same names and numbers. Put them in one ModuleScript that both import, so they can never disagree.

  1. Right-click ReplicatedStorage and choose Insert Object > ModuleScript.

  2. Press F2 and rename it ObbyConfig.

  3. Double-click it to open it in the script editor, select all of the starter code, and replace it with this listing.

ReplicatedStorage/ObbyConfig.ts
// 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_COUNT must match the number of checkpoints.
  • CHEER and RESTART are the names of the two RemoteEvents in ReplicatedStorage.
  • PROGRESS_STORE names the data store that keeps everyone’s progress, and SavedProgress describes what goes in it. Data stores hold JSON, so it’s plain numbers and null.
  • formatTime turns a number of seconds into text such as 1:15.3.
  1. Right-click ServerScriptService and choose Insert Object > Script.

  2. Rename it Hazards, open it, and replace its code with this listing.

ServerScriptService/Hazards.server.ts
// 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.
  • touched fires on the server whenever a character or another part starts touching the brick. When it’s a Character, kill() sets its health to 0. The character respawns after Players.respawnTime seconds, 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 with easing: 'linear' and repeatCount: -1 loops forever without a visible seam.
  • Each mover slides by the offset its attributes describe. reverses: true plays the tween backward after each forward play, so the platform goes back and forth, and repeatCount: -1 keeps 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.

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.

  1. Right-click ServerScriptService and choose Insert Object > Script.

  2. Rename it Obby, open it, and replace its code with this listing.

ServerScriptService/Obby.server.ts
// 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.

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.

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.

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.

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 a try/catch. Scripts can’t use await at the top level.
  • Loading clamps whatever comes back, since saved data might be from an older version of your game.
  • The loaded flag 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.
  • save runs 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.

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 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 only counts if your stage is STAGE_COUNT, so nobody can skip the course and jump straight onto the pad.
  • finishing stops 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 a Player (or any other instance), and it arrives as the same object on the other side.
  • fireworks creates parts in code: unanchored balls with a starting velocity, so physics throws them up and gravity brings them down. Each has a PointLight inside and is destroyed after 2.5 seconds. Setting canTouch = false keeps them from setting off any touched handlers.
  • After the celebration, the player goes back to stage 1, and player.loadCharacter() respawns them at the start.

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.

  1. In the Explorer, right-click StarterPlayerScripts (inside StarterPlayer) and choose Insert Object > LocalScript.

  2. Rename it Hud, open it, and replace its code with this listing.

StarterPlayer/StarterPlayerScripts/Hud.client.ts
// 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 use await at the top level.
  • waitForLocalPlayer() resolves with your own Player. On a player’s device, objects arrive from the server over time, so waitForChild waits 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’s sizeScale so 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') and getAttributeChangedSignal('BestTime') keep the timer label in sync with the server’s clock.
  • When Cheer arrives, who === me tells the script whether you’re the one who finished. The banner pops by tweening its textSize, and task.delay hides it after four seconds. task.delay returns a function that cancels it, which the script uses to restart the countdown when finishes come in quick succession.
  • The Restart button’s activated event fires restart.fireServer(), and the server decides.

Press Play and check that:

  • The timer counts up from 0:00 as 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.

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

  2. Choose the back arrow at the top-left of Studio to go to the game’s page.

  3. Under Settings, pick Unlisted (anyone with the link can play) or Public (listed in Games), then choose Save changes.

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

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 spinSeconds to make one spin.
  • More moving platforms. Duplicate a platform in Movers and change its offsetX, offsetY, offsetZ and seconds attributes.
  • A fifth stage. Build a new section of course. Duplicate Stage4, set the copy’s stage attribute to 5, and place it where the new section starts. Then move the FinishPad to the end, and change STAGE_COUNT to 5 in ObbyConfig.
  • Different movement. Select StarterPlayer and change characterWalkSpeed and characterJumpPower. Check that every jump is still possible.

Scripts read the folders when the game starts, so press Play again to see your changes.