Skip to content

2D games

A 2D game is a side view: players run left and right, jump, and fall, like a classic platformer. It uses the same Studio, the same objects and the same scripting API as a 3D game. Everything simply happens on a flat plane.

On the Games page, select Create a game. Under Dimension, pick 2D, or leave it on Auto: then a description that asks for 2D (such as “a 2D platformer” or “a side-scroller”) starts the game in 2D, and anything else starts in 3D. Under Start from, pick the Coin Dash template (or Lost Crypt, a top-down dungeon), or Blank to start from a strip of ground and a spawn point. Scripts can read the dimension from workspace.dimension, which is '2d' or '3d'.

A game can switch any time:

  • Ask the AI. Rally’s AI (in Studio or Build live) and your own AI app can switch the game when what you want needs the other one. They use the set_dimension tool.
  • Switch it yourself. On the game’s page, open Manage, and under Dimension choose 3D or 2D. Owners can switch from there; collaborators can ask the AI.

Switching keeps every object where it is and changes how the world works: in 2D, position.z becomes a draw layer and collisions ignore depth, so a 3D level usually needs rearranging (the AI does that after it switches). Everyone editing the game reloads into the new view, and Build live worlds restart. Players keep the published version, in its own dimension, until you publish again. Restoring an old version brings back the dimension it was published in, and a remix starts in the dimension of the version it copies.

3D 2D
Where things move Along X, Y and Z. Along X (left and right) and Y (up and down).
position.z A position like any other. Only decides what’s drawn in front. Higher Z is closer to the player.
Rotation Around X, Y and Z. Around Z only. rotation.x and rotation.y are ignored for parts.
Collisions In 3D. On the plane. Depth is ignored: parts collide whatever their Z.
Characters Run in any direction, turn to face where they go. Run left and right, face left or right.
Default controls W A S D or arrows to move, Space to jump, mouse to look. A D or ← → to run, Space, W or ↑ to jump.
Touch screens Joystick and Jump button. The same; pushing the joystick up also jumps.
Camera Orbits behind the character, with perspective. Looks straight at the plane from the side, with no perspective, and follows the character.
Shadows The sun casts shadows. None. Everything is lit from the front.

Units are the same in both: distances are in studs, and a character is a capsule 5 studs tall and 2 studs wide.

shape In 2D
box A rectangle.
sphere A circle whose diameter is size.x.
capsule A rectangle with round ends.
cylinder Looks and collides like a box. Use box instead.

A part’s depth (size.z) doesn’t matter: every part is drawn as a flat plate at its position.z.

position.z is a layer number. Characters are drawn just in front of layer 0:

  • Z of 0 is the play layer: ground, platforms, coins and hazards. Characters draw in front of it.
  • Negative Z is the background: hills, clouds, buildings in the distance.
  • Z of 1 or more draws in front of characters: foreground leaves, bars, a tunnel’s front wall.

Because collisions ignore depth, a background part at Z = -10 still blocks characters unless you tell it not to. For every background and foreground part, turn off canCollide (and canTouch, since nothing needs to detect it).

The 2D camera looks at the plane from the side and follows your character, aiming a little above it so players see more of what’s ahead and above. It tracks sideways quickly and up and down more gently, so jumps don’t jerk the view.

workspace.currentCamera.distance is half the visible height, in studs. The default is 14, so the screen shows 28 studs from top to bottom (the width depends on the window). Players can zoom with the mouse wheel between 4 and 60. A LocalScript can set distance itself, to anything from 2 to 200.

For a fixed view, such as a single-screen arena, set cameraType to 'scriptable' and put the camera’s position where the middle of the screen should be. distance still sets how much shows; lookAt and fieldOfView don’t matter in 2D.

This LocalScript zooms the camera out while the player is inside one of the parts in a CameraZones folder. Give each zone a zoom number attribute (the half-height to show), and make the zones invisible and non-colliding:

StarterPlayer/StarterPlayerScripts/ZoomZones.client.ts
import {
BasePart, Folder, RunService, task, type Tween, TweenService, type Vector3, waitForChild,
waitForLocalPlayer, workspace,
} from '@rally/sdk';
const NORMAL = 14;
/** Whether `p` is inside the zone's rectangle (ignoring depth and rotation). */
function inside(zone: BasePart, p: Vector3): boolean {
return (
Math.abs(p.x - zone.position.x) <= zone.size.x / 2 &&
Math.abs(p.y - zone.position.y) <= zone.size.y / 2
);
}
task.spawn(async () => {
const me = await waitForLocalPlayer();
const zones = await waitForChild(workspace, 'CameraZones', Folder);
let want = NORMAL;
let tween: Tween | null = null;
RunService.renderStepped.connect(() => {
const camera = workspace.currentCamera;
const character = me.character;
if (!camera || !character) return;
let zoom = NORMAL;
for (const zone of zones.getChildren()) {
const z = zone.getAttribute('zoom');
if (zone instanceof BasePart && typeof z === 'number' && inside(zone, character.position)) zoom = z;
}
if (zoom === want) return;
want = zoom;
tween?.cancel();
tween = TweenService.create(camera, { time: 0.8, easing: 'sineInOut' }, { distance: zoom });
tween.play();
});
});

Characters reach full speed at once, jump straight up at jumpPower studs per second, and fall under workspace.gravity. That makes jumps easy to plan:

  • Height: about jumpPower² ÷ (2 × gravity) studs.
  • Time in the air: about 2 × jumpPower ÷ gravity seconds.
  • Distance: about walkSpeed × time in the air studs.

With the defaults (walk speed 16, jump power 50, gravity 196.2), a running jump rises about 6 studs and carries about 8 studs forward. Leave some margin: gaps a little shorter and ledges a little lower than the limit feel fair. Set characterWalkSpeed and characterJumpPower on StarterPlayer, and gravity on Workspace.

Studio shows a 2D game from the side, the way players see it. See Build your world for every tool; in 2D:

  • Scroll zooms at the cursor. Right-drag, middle-drag or W A S D pan, and Q / E zoom out and in.
  • Move drags along X and Y, Rotate turns around Z, and Scale resizes width and height. Depth stays as it is.
  • To put a part on another layer, type its Z in position in Properties.
  1. Lay out the ground and platforms at Z 0 with Part. Long, flat boxes make good floors.

  2. Add a SpawnLocation where players should start.

  3. Add hazards and pickups at Z 0, with canCollide off so characters pass through them and scripts get touched.

  4. Add scenery at negative Z with canCollide and canTouch off. Bigger, paler parts further back read as distance.

  5. Select Play to try your jumps, and adjust.

Set a part’s image to the URL of a picture and it’s drawn on the part, stretched to the part’s width and height. Transparent areas of a PNG stay see-through, so a character-shaped picture on a box looks like a cut-out. Match the part’s size to the picture’s proportions to avoid stretching. The part still collides as its shape, not as the picture.

Terrain works in 2D too: its middle row of heights becomes a hilly ground line that characters walk on, drawn as solid ground below the line, just behind layer 0.

The API is the same as in 3D. Things to keep in mind:

  • Ignore Z in gameplay. Raycasts and getPartsInRadius work on the plane, whatever the layer. distanceTo does count Z, so to measure from a character (always at Z 0) to a part on another layer, flatten it first: character.position.distanceTo(part.position.withZ(0)).
  • Rotate with rotation.z. A full turn is new Vector3(0, 0, 360).
  • Velocities have no Z. An unanchored part’s velocity.z is always 0, and its Z (its layer) never changes.
  • Hinges always turn around Z, whatever their axis, and constraint pivots ignore Z.
  • Which way a character faces: rotation.y is 0 when it faces right (+X) and 180 when it faces left.

This pair of scripts throws a fireball the way the character faces when the player presses F. Add a RemoteEvent named Shoot to ReplicatedStorage:

StarterPlayer/StarterPlayerScripts/Fireball.client.ts
import { RemoteEvent, ReplicatedStorage, task, UserInputService, waitForChild } from '@rally/sdk';
task.spawn(async () => {
const shoot = await waitForChild(ReplicatedStorage, 'Shoot', RemoteEvent);
UserInputService.inputBegan.connect((input, gameProcessed) => {
if (!gameProcessed && input.key === 'KeyF') shoot.fireServer();
});
});
ServerScriptService/Fireball.server.ts
import {
Character, Color3, getChild, Part, type Player, Players, RemoteEvent, ReplicatedStorage, task,
Vector3, workspace,
} from '@rally/sdk';
const shoot = getChild(ReplicatedStorage, 'Shoot', RemoteEvent);
const lastShot = new Map<Player, number>();
shoot.onServerEvent.connect((player) => {
const character = player.character;
if (!character || character.health <= 0) return;
const now = task.time();
if (now - (lastShot.get(player) ?? -Infinity) < 0.4) return; // cooldown
lastShot.set(player, now);
// 1 when facing right, -1 when facing left.
const facing = Math.cos((character.rotation.y * Math.PI) / 180) >= 0 ? 1 : -1;
const fireball = new Part();
fireball.shape = 'sphere';
fireball.size = new Vector3(1.2, 1.2, 1.2);
fireball.color = Color3.fromHex('#ff7a1a');
fireball.material = 'neon';
fireball.anchored = false;
fireball.restitution = 0.6; // bounces along the ground
fireball.position = character.position.add(new Vector3(facing * 2, 0.5, 1)); // Z 1: in front
fireball.velocity = new Vector3(facing * 50, 15, 0);
fireball.touched.connect((other) => {
if (other instanceof Character && other !== character) other.takeDamage(25);
});
fireball.parent = workspace;
task.delay(3, () => fireball.destroy());
});
Players.playerRemoving.connect((player) => lastShot.delete(player));

Background layers that drift slower than the camera make a 2D world feel deep. The camera’s position follows the view, so a LocalScript can move scenery a little each frame. This one moves each part in a Scenery folder by a share of the camera’s movement that grows with its distance behind the play layer:

StarterPlayer/StarterPlayerScripts/Parallax.client.ts
import { BasePart, Folder, RunService, task, Vector3, waitForChild, workspace } from '@rally/sdk';
task.spawn(async () => {
const scenery = await waitForChild(workspace, 'Scenery', Folder);
const layers = scenery
.getChildren()
.filter((c): c is BasePart => c instanceof BasePart)
// Z -20 or further back follows the camera at half speed; Z 0 doesn't move.
.map((part) => ({ part, home: part.position, share: Math.min(0.5, Math.max(0, -part.position.z / 40)) }));
RunService.renderStepped.connect(() => {
const camera = workspace.currentCamera;
if (!camera) return;
for (const { part, home, share } of layers) {
part.position = new Vector3(home.x + camera.position.x * share, home.y, home.z);
}
});
});

It’s a LocalScript, so the scenery only moves on each player’s own screen, and costs nothing on the network.

Coin Dash is a complete 2D side-scroller to learn from and build on. Players run right, grab coins, dodge spikes, ride a lift and touch the flag. Each run’s coins go on a high-score board.

What’s in it:

Where What
Workspace › Start The SpawnLocation.
Workspace › Terrain A Folder of parts: ground, steps, ledges, a wall and the Lift, a platform that slides back and forth. Its travelX and seconds attributes set how far and how fast. This is an ordinary Folder, not a Terrain object.
Workspace › Hazards Spikes that kill on touch (canCollide off).
Workspace › Coins Coins that come back 12 seconds after someone takes them.
Workspace › Scenery Hills at Z -10 and clouds at Z -6, with collisions off.
Workspace › Goal The flag. Touching it ends the run and records it.
ReplicatedStorage Toast, a RemoteEvent for messages, and HighScores, StringValues that hold the board.
StarterGui › HUD The coin counter, the board and the message banner.
ServerScriptService › main The game’s rules.
StarterPlayerScripts › hud The HUD, and coins that bob on each player’s screen.

It also changes a few settings: walk speed 22 and jump power 60 on StarterPlayer, a respawn time of 1 second on Players, fallenPartsDestroyHeight of -30 on Workspace, and no shadows on Lighting.

Templates are published without their TypeScript, so main and hud open as compiled JavaScript. They work as they are, but they’re hard to edit. To make the game your own, replace them with your own scripts: Build a 2D platformer walks you through it, starting from this template.

A 2D game is normally seen from the side: characters run along X and jump. Lost Crypt shows how to make a top-down game instead, where you walk in four directions:

  • Workspace has gravity 0, so nothing falls.
  • Its movement LocalScript turns off the built-in controls (UserInputService.defaultControls = false), reads WASD, the arrow keys or the touch joystick, and moves your character itself, using raycasts so you slide along walls instead of walking through them. The server still checks every move, as it does in any game.
  • Floors are parts with collisions off (behind everything, at Z -2); walls, pillars and locked doors collide.

Its keys and doors are data: parts in Keys and Doors with a key attribute (Red, Blue, Gold), and enemies appear at the markers in EnemySpawns (kind: slime, bat or skeleton).