Build a 2D platformer
In this tutorial you make a 2D side-scroller: run right, grab coins, avoid spikes, ride a lift and touch the flag. You start from Rally’s Coin Dash template, which gives you a finished level, then tune how movement feels, build a spring and a bonus coin, and write every script yourself.
By the end, your game has:
- Movement tuned to your taste: speed, jump height and gravity.
- A spring that launches you up to a bonus coin.
- Coins that everyone races for, which come back after a while.
- Spikes that kill you and make you drop the coins you’re carrying.
- A lift that slides back and forth.
- A goal flag that ends your run and saves your best.
You need a Rally account and a computer with a desktop browser. If you’ve done Build an obby, parts of this will feel familiar, but this tutorial stands on its own.
How 2D works in Rally
Section titled “How 2D works in Rally”A 2D game uses the same objects and scripts as a 3D one, laid out on a flat plane:
- Everything happens on the X/Y plane: X is left and right, and +Y is up. Physics ignores depth, so parts collide no matter their Z.
position.zonly decides what’s drawn in front. Parts with a lower Z draw behind, which is how the hills and clouds become a background.- Parts rotate around Z only.
- Players run with A and D (or the arrow keys) and jump with Space, W or the up arrow. The camera follows them from the side.
See 2D games for more.
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
Coin Dash. Under Dimension, pick 2D, and under Start from, pick Coin Dash. -
Choose Create game, then Open in Studio on the game’s page.
Look around
Section titled “Look around”Expand the services in the Explorer to see what the template gave you:
DirectoryWorkspace
- Start the SpawnLocation where everyone appears
- Terrain a Folder of ground, steps, ledges, a wall and the Lift
- Hazards three spike strips
- Coins Coin1 to Coin24
- Scenery hills and clouds, pushed back with a negative Z
- Goal a Model with the flag’s Pole and Flag
DirectoryReplicatedStorage
- Toast a RemoteEvent
- HighScores a Folder of StringValues for a high-score board
DirectoryServerScriptService
- main the template’s server script
DirectoryStarterGui
- HUD a ScreenGui with CoinPanel, Board and Toast
DirectoryStarterPlayer
DirectoryStarterPlayerScripts
- hud the template’s client script
The folder named Terrain is an ordinary Folder of parts, not Rally’s Terrain object. Click the Lift inside it and look at the Attributes section at the bottom of the Properties panel: travelX is how far it slides and seconds how long each trip takes. Your script will read them.
Press Play (F5) to try the level, and Stop (Shift+F5) when you’re done.
Clear out the template’s code
Section titled “Clear out the template’s code”The template’s two scripts are compiled JavaScript (templates are published without their TypeScript), and its high-score board is more than this tutorial needs. You’ll build a leaderboard properly in Make a co-op game with a leaderboard.
-
Click main in ServerScriptService and press Delete.
-
Expand StarterPlayer > StarterPlayerScripts, click hud and press Delete.
-
Expand StarterGui > HUD, click Board and press Delete.
-
In ReplicatedStorage, click HighScores and press Delete.
Tune the movement
Section titled “Tune the movement”How a platformer feels comes down to three numbers:
- StarterPlayer >
characterWalkSpeed: how fast characters run, in studs per second. They reach full speed at once and stop at once. - StarterPlayer >
characterJumpPower: how fast characters leave the ground when they jump. - Workspace >
gravity: how hard everything, characters included, is pulled down, in studs per second squared.
Together they decide how high a jump goes: about jumpPower² ÷ (2 × gravity) studs. The template uses a walk speed of 22, a jump power of 60 and the default gravity of 196.2, so a jump reaches about 9.2 studs.
-
Click StarterPlayer in the Explorer. In Properties, set
characterWalkSpeedto24andcharacterJumpPowerto62. -
Click Workspace and set
gravityto180. -
Press Play. Jumps now reach about 10.7 studs and hang in the air a little longer.
Keep experimenting, but make sure every gap and ledge in the level is still reachable. Set these values in Studio or from a server script: the server checks every move a player makes against its own values, so a LocalScript can’t make its character faster or jump higher.
Build a spring and a bonus coin
Section titled “Build a spring and a bonus coin”A spring is a pad that lets you jump much higher while you stand on it. Put one near the start, under a coin that’s out of reach without it.
-
Right-click Workspace and choose Insert Object > Folder. Press F2 and rename it
Springs. -
Right-click Springs and choose Insert Object > Part. Rename it
Spring. -
In Properties, set these values:
Property Value positionX 30, Y0.25, Z0sizeX 4, Y0.5, Z4color#ff4fa3materialneon -
Expand Coins, click Coin24 and press Ctrl+D (Cmd+D on a Mac) to duplicate it. Rename the copy
Coin25and set itspositionto X30, Y18, Z0.
Z doesn’t matter to physics in a 2D game, but it decides what’s drawn in front, so keep gameplay parts at 0, in front of the scenery. The spring sits on the first stretch of ground, and the coin floats high above it: a normal jump falls well short, but a jump from the spring sails past it.
How high a spring sends you is up to your script. To give one spring its own strength, add a number attribute named jumpPower to it: in Attributes, choose Add attribute, type jumpPower, pick number, choose Add, and enter a value.
Share settings in a ModuleScript
Section titled “Share settings in a ModuleScript”-
Right-click ReplicatedStorage and choose Insert Object > ModuleScript.
-
Rename it
CoinDashConfig, double-click it to open it, and replace its starter code with this listing.
// Settings the server and the HUD share.
/** RemoteEvent in ReplicatedStorage. Server → client: `(message: string)`, shown as a toast. */export const TOAST = 'Toast';
/** Seconds before a collected coin comes back. */export const COIN_RESPAWN_SECONDS = 12;
/** Jump power on a spring that has no `jumpPower` attribute of its own. */export const SPRING_JUMP_POWER = 110;
/** Saved best runs: key = user id, value = the most coins carried to the flag. */export const BEST_STORE = 'CoinDashBest';
/** How much of the level the camera shows: in 2D, half the view's height in studs. */export const CAMERA_DISTANCE = 18;A ModuleScript never runs on its own. The server script and the HUD both import from it with @game/ReplicatedStorage/CoinDashConfig, so they always agree on names and numbers.
Write the game’s rules
Section titled “Write the game’s rules”-
Right-click ServerScriptService and choose Insert Object > Script.
-
Rename it
CoinDash, open it, and replace its code with this listing.
// Coin Dash: the rules. Coins, hazards, springs, the lift and the goal flag. In a 2D game// everything happens on the X/Y plane; position.z only decides what draws in front.import { Character, createLeaderstats, DataStoreService, Folder, getChild, Model, type NumberValue, Part, type Player, Players, RemoteEvent, ReplicatedStorage, StarterPlayer, TweenService, task, Vector3, warn, workspace,} from '@rally/sdk';import { BEST_STORE, COIN_RESPAWN_SECONDS, SPRING_JUMP_POWER, TOAST,} from '@game/ReplicatedStorage/CoinDashConfig';
const toast = getChild(ReplicatedStorage, TOAST, RemoteEvent);const bestStore = DataStoreService.getDataStore(BEST_STORE);
interface Run { coins: NumberValue; best: NumberValue; /** True from touching the flag until the respawn, so one finish counts once. */ finishing: boolean;}const runs = new Map<Player, Run>();
/** The player and run behind whatever touched a part, if it's a living character. */function runFor(other: unknown): { player: Player; run: Run } | null { if (!(other instanceof Character) || other.health <= 0) return null; const player = Players.getPlayerFromCharacter(other); const run = player && runs.get(player); return player && run ? { player, run } : null;}
// ── Players ─────────────────────────────────────────────────────────────────────────────────
Players.playerAdded.connect((player) => { const stats = createLeaderstats(player, { Coins: 0, Best: 0 }); const run: Run = { coins: stats.Coins, best: stats.Best, finishing: false }; runs.set(player, run);
player.characterAdded.connect((character) => { run.finishing = false; character.died.connect(() => { if (run.coins.value > 0) toast.fireClient(player, `Ouch! You dropped ${run.coins.value} coins`); run.coins.value = 0; }); });
task.spawn(async () => { try { const saved = await bestStore.get<number>(player.userId); run.best.value = Math.max(run.best.value, saved ?? 0); } catch (e) { warn(`Couldn't load ${player.name}'s best run:`, e); } });});
Players.playerRemoving.connect((player) => runs.delete(player));
// ── Coins ───────────────────────────────────────────────────────────────────────────────────
for (const coin of getChild(workspace, 'Coins', Folder).getChildren()) { if (!(coin instanceof Part)) continue; coin.touched.connect((other) => { const hit = runFor(other); if (!hit || !coin.canTouch) return; hit.run.coins.value += 1; // Hide it for everyone, then bring it back. coin.canTouch = false; coin.transparency = 1; task.delay(COIN_RESPAWN_SECONDS, () => { coin.transparency = 0; coin.canTouch = true; }); });}
// ── Hazards ─────────────────────────────────────────────────────────────────────────────────
for (const spikes of getChild(workspace, 'Hazards', Folder).getChildren()) { if (!(spikes instanceof Part)) continue; spikes.touched.connect((other) => { if (other instanceof Character) other.kill(); });}
// ── Springs ─────────────────────────────────────────────────────────────────────────────────
for (const spring of getChild(workspace, 'Springs', Folder).getChildren()) { if (!(spring instanceof Part)) continue; const attribute = spring.getAttribute('jumpPower'); const power = typeof attribute === 'number' ? attribute : SPRING_JUMP_POWER; spring.touched.connect((other) => { if (other instanceof Character && other.health > 0) other.jumpPower = power; }); spring.touchEnded.connect((other) => { if (!(other instanceof Character)) return; // Keep the boost a moment longer, so the jump that takes you off the spring still counts. task.delay(0.4, () => { if (!other.destroyed && other.jumpPower === power) other.jumpPower = StarterPlayer.characterJumpPower; }); });}
// ── The lift ────────────────────────────────────────────────────────────────────────────────
const lift = getChild(getChild(workspace, 'Terrain', Folder), 'Lift', Part);const travel = lift.getAttribute('travelX');const seconds = lift.getAttribute('seconds');TweenService.create( lift, { time: typeof seconds === 'number' ? seconds : 3, easing: 'sineInOut', reverses: true, repeatCount: -1, delay: 0.5, }, { position: lift.position.add(new Vector3(typeof travel === 'number' ? travel : 16, 0, 0)) },).play();
// ── The goal ────────────────────────────────────────────────────────────────────────────────
function finish(player: Player, run: Run): void { run.finishing = true; const coins = run.coins.value; const newBest = coins > run.best.value; if (newBest) run.best.value = coins; toast.fireAllClients( newBest ? `${player.displayName} set a new best: ${coins} coins!` : `${player.displayName} reached the flag with ${coins} coins`, ); run.coins.value = 0; task.spawn(async () => { try { // `update` keeps the higher score, even if another server saved one in the meantime. await bestStore.update<number>(player.userId, (old) => old === undefined || coins > old ? coins : undefined, ); } catch (e) { warn(`Couldn't save ${player.name}'s best run:`, e); } }); task.delay(2, () => { if (runs.has(player)) player.loadCharacter(); });}
for (const piece of getChild(workspace, 'Goal', Model).parts()) { piece.touched.connect((other) => { const hit = runFor(other); if (hit && !hit.run.finishing) finish(hit.player, hit.run); });}This script runs once on the game server when the game starts, and decides everything that matters: who has which coins, who dies and who finishes. The API reference describes every class and function it uses.
Players and their runs
Section titled “Players and their runs”When someone joins, createLeaderstats(player, { Coins: 0, Best: 0 }) gives them two values that show in the player list in the top-right corner of the screen: the coins they’re carrying and their best run. The script keeps each player’s values in a Run object, stored in a Map until they leave.
runFor(other) answers the question every touched handler asks: is this a living character, and whose? It returns the player and their run, or null.
Each new character gets a died handler. Dying drops the coins you were carrying, and toast.fireClient(player, …) sends a message to that one player’s screen.
The player’s best run loads from a data store in the background, inside task.spawn, because scripts can’t use await at the top level, and data store calls can fail.
Each coin’s touched handler adds one to the toucher’s coins, then hides the coin for everyone: transparency = 1 makes it invisible, and canTouch = false stops it from firing touched again. task.delay brings it back after COIN_RESPAWN_SECONDS. Because the server owns the coins, two players can’t grab the same coin, and whoever touches it first gets it.
Hazards
Section titled “Hazards”Every part in the Hazards folder kills any character that touches it. kill() sets the character’s health to 0, which fires its died event, and the player respawns at the start after Players.respawnTime seconds (1 in this template).
Springs
Section titled “Springs”Touching a spring sets the character’s jumpPower to the spring’s strength: its jumpPower attribute if it has one, else SPRING_JUMP_POWER. touchEnded fires when the character leaves the spring. The script waits 0.4 seconds before setting the jump power back to StarterPlayer.characterJumpPower, so the jump that took you off the spring still gets the boost. The server changed the value, so it reaches the player’s device on its own: the next jump uses it.
The lift
Section titled “The lift”The lift slides back and forth with a tween, the same way as in the template: reverses: true plays it backward after each forward play, and repeatCount: -1 repeats it forever. Its distance and timing come from the travelX and seconds attributes. Moving platforms don’t carry characters along, so players have to walk with the lift.
The goal
Section titled “The goal”getChild(workspace, 'Goal', Model).parts() returns every part in the flag model, and touching any of them calls finish. The finishing flag makes sure one touch counts once.
finish announces the result to everyone with toast.fireAllClients, resets the coins and respawns the player at the start two seconds later. A new best run is saved with bestStore.update(...), which reads the stored value, lets you return a new one (or undefined to keep the old one), and writes it back without losing a higher score that another game server saved at the same moment.
Write the HUD
Section titled “Write the HUD”-
Right-click StarterPlayerScripts (inside StarterPlayer) and choose Insert Object > LocalScript.
-
Rename it
CoinDashHud, open it, and replace its code with this listing.
// Coin Dash: the HUD and camera. A coin counter that pops on every pickup, toasts from the server,// the camera zoom, and coins that bob up and down. The bobbing runs on each player's device: it// costs no network traffic, and the server still decides where the coins really are.import { Folder, Frame, NumberValue, Part, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, TextLabel, TweenService, task, Vector3, waitForChild, waitForLocalPlayer, workspace,} from '@rally/sdk';import { CAMERA_DISTANCE, TOAST } from '@game/ReplicatedStorage/CoinDashConfig';
// In a 2D game, the camera's distance is half the height of the view, in studs.const camera = workspace.currentCamera;if (camera) camera.distance = CAMERA_DISTANCE;
task.spawn(async () => { const me = await waitForLocalPlayer(); const hud = await waitForChild(await waitForChild(me, 'PlayerGui', PlayerGui), 'HUD', ScreenGui);
// The coin counter, with a little pop on every pickup. const coinLabel = await waitForChild( await waitForChild(hud, 'CoinPanel', Frame), 'Coins', TextLabel, ); const stats = await waitForChild(me, 'leaderstats', Folder); const coins = await waitForChild(stats, 'Coins', NumberValue); let shown = coins.value; const showCoins = () => { coinLabel.text = `● ${coins.value}`; if (coins.value > shown) { coinLabel.textSize = 34; TweenService.create(coinLabel, { time: 0.25, easing: 'quadOut' }, { textSize: 26 }).play(); } shown = coins.value; }; coins.getPropertyChangedSignal('value').connect(showCoins); showCoins();
// Toasts from the server. const toastLabel = await waitForChild(hud, 'Toast', TextLabel); const toast = await waitForChild(ReplicatedStorage, TOAST, RemoteEvent); let hide: (() => void) | null = null; toast.onClientEvent.connect((message) => { toastLabel.text = String(message); toastLabel.visible = true; hide?.(); hide = task.delay(3, () => { toastLabel.visible = false; }); });});
// Cosmetic: coins bob up and down, on this device only.task.spawn(async () => { const folder = await waitForChild(workspace, 'Coins', Folder); const bob = (coin: Part, i: number) => TweenService.create( coin, { time: 0.9, easing: 'sineInOut', reverses: true, repeatCount: -1, delay: (i % 6) * 0.15 }, { position: coin.position.add(new Vector3(0, 0.5, 0)) }, ).play(); folder.getChildren().forEach((child, i) => { if (child instanceof Part) bob(child, i); }); folder.childAdded.connect((child) => { if (child instanceof Part) bob(child, 0); });});This LocalScript runs on each player’s device when they join.
- The camera comes first.
workspace.currentCamerais the camera on this device, and in a 2D game itsdistanceis half the height of the view, in studs. RaiseCAMERA_DISTANCEto see more of the level, or lower it to zoom in. Players can still zoom with the scroll wheel. - The coin counter waits for your
Coinsvalue withwaitForChild, then updates whenever it changes. When the number goes up, the label jumps to a bigger text size and a tween shrinks it back, a small pop for every pickup. - Toasts from the server appear in the middle of the screen for three seconds.
- The last part makes the coins bob up and down. The tween runs on this device only: a change a LocalScript makes to the world stays on that device, so it costs no network traffic, and the server still decides where coins really are.
Test your game
Section titled “Test your game”Press Play and check that:
- The coin counter pops as you collect coins, and Coins goes up in the player list.
- Running into spikes kills you, shows a toast saying how many coins you dropped, and resets your count.
- Standing on the spring and jumping sends you high enough to grab Coin25.
- The lift slides back and forth.
- Touching the flag shows a toast, updates Best when you beat your record, and sends you back to the start.
Errors and anything your scripts print appear in the Output panel. To try the level with others, open the menu next to Play and pick a number of players under Test on a real game server. See Testing.
Publish
Section titled “Publish”-
Choose Publish in the top-right corner of Studio.
-
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.
Make it yours
Section titled “Make it yours”- More coins. Duplicate any coin and move it. Every part in the Coins folder counts, and the HUD makes new coins bob too.
- More springs. Duplicate the spring, move it, and give it a
jumpPowerattribute to make it stronger or weaker. - More hazards. Duplicate a spike strip and place it on the ground.
- A longer level. Delete the Wall at the end of the Terrain folder, duplicate ground parts and ledges to extend the level, and move the Goal model to the new end. Clicking any part of the flag in the viewport selects the whole Goal model, which you can then drag with the Move tool (2).
- Sprites. Parts can show a picture instead of a flat color. See 2D games.
Scripts read the folders when the game starts, so press Play again after changing the level.