Skip to content

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.

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.z only 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.

  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 Coin Dash. Under Dimension, pick 2D, and under Start from, pick Coin Dash.

  4. Choose Create game, then Open in Studio on the game’s page.

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.

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.

  1. Click main in ServerScriptService and press Delete.

  2. Expand StarterPlayer > StarterPlayerScripts, click hud and press Delete.

  3. Expand StarterGui > HUD, click Board and press Delete.

  4. In ReplicatedStorage, click HighScores and press Delete.

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.

  1. Click StarterPlayer in the Explorer. In Properties, set characterWalkSpeed to 24 and characterJumpPower to 62.

  2. Click Workspace and set gravity to 180.

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

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.

  1. Right-click Workspace and choose Insert Object > Folder. Press F2 and rename it Springs.

  2. Right-click Springs and choose Insert Object > Part. Rename it Spring.

  3. In Properties, set these values:

    Property Value
    position X 30, Y 0.25, Z 0
    size X 4, Y 0.5, Z 4
    color #ff4fa3
    material neon
  4. Expand Coins, click Coin24 and press Ctrl+D (Cmd+D on a Mac) to duplicate it. Rename the copy Coin25 and set its position to X 30, Y 18, Z 0.

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.

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

  2. Rename it CoinDashConfig, double-click it to open it, and replace its starter code with this listing.

ReplicatedStorage/CoinDashConfig.ts
// 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.

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

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

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

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.

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

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

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.

  1. Right-click StarterPlayerScripts (inside StarterPlayer) and choose Insert Object > LocalScript.

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

StarterPlayer/StarterPlayerScripts/CoinDashHud.client.ts
// 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.currentCamera is the camera on this device, and in a 2D game its distance is half the height of the view, in studs. Raise CAMERA_DISTANCE to 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 Coins value with waitForChild, 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.

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.

  1. Choose Publish in the top-right corner of Studio.

  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.

  • 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 jumpPower attribute 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.