Skip to content

How games are organized

Every Rally game has the same services at the top of its tree. Where you put an object decides whether players receive it and whether a script in it runs. This page explains what goes where, how scripts find each other, and a layout that holds up as a game grows.

Service What goes in it Sent to players? Scripts that run here
Workspace The world: parts, models, spawn points, terrain, lights and sounds. Characters appear here. Yes, except server Scripts Scripts
Players Filled in by the game: one Player per person Yes, but each PlayerGui only to its player —
Lighting Its own properties: sky color, sun, ambient light, fog, shadows Yes —
ReplicatedStorage Remote events, shared ModuleScripts, objects both sides need, shared game state Yes —
ServerScriptService Your server Scripts No Scripts
ServerStorage Templates to clone into the world, server-only ModuleScripts No —
StarterGui ScreenGuis (and LocalScripts inside them), copied to each player when they join Yes LocalScripts, in each player’s copy
StarterPlayer Character defaults, and StarterPlayerScripts for LocalScripts Yes LocalScripts in StarterPlayerScripts

You can’t add, move or delete services. In Studio, each one is a top-level row in the Explorer. In code, import them by name from @rally/sdk — workspace, Players, Lighting, ReplicatedStorage, ServerScriptService, ServerStorage, StarterGui and StarterPlayer — or look one up with game.getService('Players').

Everything that exists in the world: parts, models, SpawnLocations, terrain, and the lights, sounds, animations and constraints that belong to them. When players spawn, their characters appear here too, and each player’s device keeps its own camera here.

A Script inside an object in Workspace runs on the server, and script.parent gives it the object it’s in. That’s handy for self-contained objects such as a kill brick or a door. Server Scripts are never sent to players, even here.

Workspace’s own properties set world-wide physics: gravity (196.2 studs/s² by default) and fallenPartsDestroyHeight (-500 by default: unanchored parts that fall below it are destroyed, and characters die).

The game adds a Player here for each person who joins and removes it when they leave. You can’t put anything in it in Studio, but you can set its properties: respawnTime (seconds before a character comes back, 3 by default) and characterAutoLoads (whether characters spawn on their own, true by default). See Players and characters.

The look of the sky and light. Set skyColor, ambient, brightness, sunDirection, fogColor, fogEnd (0 means no fog) and shadows in Studio’s Properties, or change them from a script — for example, to make the sky darker as a round goes on.

Things the server and every player’s device both need:

  • RemoteEvents and RemoteFunctions, so clients and the server can talk. They have to be somewhere both sides can see, and here is the usual place. See Multiplayer.
  • Shared ModuleScripts: settings, names and helper functions that both server and client scripts import.
  • Shared state that every player should see, such as a Folder whose attributes hold the current round and time left. Late joiners get it automatically.
  • Templates both sides use, such as an effect a LocalScript clones.

Your server Scripts. Many games have just one here — a Main or Server script that starts everything else. Nothing in ServerScriptService is ever sent to players.

Server-only storage. Nothing in it is sent to players and nothing in it is drawn or simulated, so it’s the place for:

  • Templates the server clones into the world: enemies, pickups, map pieces.
  • Server-only ModuleScripts: game rules, round logic, saving data.
  • BindableEvents that let your server scripts talk to each other.

On-screen UI. Put ScreenGuis here, with frames, labels and buttons inside them. When a player joins, the game copies every ScreenGui into that player’s PlayerGui, where only they see it, and any LocalScripts inside the copies start running on their device. The copy happens once, when they join — not again when their character respawns.

Studio draws the UI in StarterGui over the viewport so you can see and arrange it; Hide UI in the toolbar hides it. See User interface and the UI editor.

Defaults for new characters — characterWalkSpeed (16), characterJumpPower (50) and characterMaxHealth (100) — applied each time a character spawns. Inside it, StarterPlayerScripts holds the LocalScripts that run on every player’s device when they join.

Players receive Workspace, Players, Lighting, ReplicatedStorage, StarterGui and StarterPlayer, including the code of the LocalScripts in them. They never receive ServerScriptService, ServerStorage, server Scripts (wherever they are), or other players’ PlayerGui.

Treat everything players receive as public. A player can read anything that reaches their device, including your LocalScripts’ code, so keep secrets, answers and game rules on the server.

A ModuleScript shares code with export, and other scripts import it by its place in the tree. There are two ways to write the path.

By tree path, starting with @game/ and the service, with names separated by /:

import { ROUND_SECONDS } from '@game/ReplicatedStorage/Shared/Config';
import * as Rounds from '@game/ServerStorage/Modules/Rounds';

Relative to the script’s own folder, starting with ./ (the same folder) or ../ (the folder above):

// In ServerStorage/Modules/Rounds — Coins is the ModuleScript next to it:
import { clearAll, spawnAll } from './Coins';
// In ReplicatedStorage/Client/Hud — go up to ReplicatedStorage, then into Shared:
import { REMOTES } from '../Shared/Config';

“The script’s own folder” means its parent. A script whose ModuleScripts are inside it — Rounds with a Timer module as its child — imports them as ./Rounds/Timer.

A few rules:

  • Only ModuleScripts can be imported. Importing a Script or LocalScript is a compile error.
  • LocalScripts — and the modules they import — can’t import ModuleScripts in ServerStorage or ServerScriptService, since their code would be sent to players. Studio reports an error.
  • Scripts can import @rally/sdk, @game/... paths and relative paths. There are no other packages.
  • Names are paths, so they can’t contain /. Studio won’t let you type one.
  • A ModuleScript must have a name of its own among the scripts next to it. Two scripts with the same name in the same place, when one is a ModuleScript, stop the game from compiling — an import couldn’t tell them apart. Rename one.
  • Renaming or moving a ModuleScript changes its path. Update the imports that point at it.

The editor checks imports as you type, so a wrong path is underlined right away. Scripts and modules explains how modules are built into scripts.

Put the numbers you’ll want to tune, and the names both sides must agree on, in one ModuleScript in ReplicatedStorage. A typo in a remote event’s name then becomes an editor error instead of a bug you find while playing:

ReplicatedStorage/Shared/Config.ts
// Tuning numbers and names that both the server and players' devices use.
// Change them here and every script that imports this file picks them up.
export const ROUND_SECONDS = 60;
export const INTERMISSION_SECONDS = 10;
export const COIN_VALUE = 1;
/** RemoteEvents in ReplicatedStorage/Remotes. */
export const REMOTES = {
/** Server → everyone: `(text: string)` for the status line. */
announce: 'Announce',
} as const;

Keep secrets out of it: anything a LocalScript imports reaches players.

Here’s how Last Stand at Outpost 9 — a four-player co-op survival game built in Studio, with weapons, perks, doors to buy and waves of enemies — lays out its tree:

Workspace
├─ Building Floors, Walls, Roof, Fence, Props: the level, grouped in Folders
├─ Interactables Doors, WallBuys, Perks, Windows, Crates, …: Models tagged with attributes
├─ Lights
├─ NavNodes points enemies find their way between
├─ Spawns
└─ Terrain
Lighting a dark sky and fog
ReplicatedStorage
├─ Remotes RemoteEvents: Fire, Reload, Interact, Announce, …
├─ GameState a Folder whose attributes are the public game state (round, phase, …)
├─ Shared ModuleScripts both sides import: Config, Net, Weapons, Perks, MathUtil
└─ Client ModuleScripts only the client script imports: Hud, Gunplay, Look, …
ServerScriptService
└─ Server the one server Script: starts every system
ServerStorage
├─ Templates enemy Models, cloned into the world
└─ Modules server-only ModuleScripts: Rounds, Zombies, Combat, Persistence, …
StarterGui
└─ HUD one ScreenGui with every on-screen element
StarterPlayer
└─ StarterPlayerScripts
└─ Client the one LocalScript: starts the client modules

Why it works:

  • One entry script per side. Server imports the server modules and starts them in order; Client does the same for the client modules. Start-up order is easy to follow, and each module’s state exists exactly once — two scripts importing the same module would each get their own copy.
  • Names and numbers in one place. Shared/Net holds the remote event names and Shared/Config every tuning number, so both sides always agree.
  • Rules stay on the server. Only the Server script imports ServerStorage/Modules, so none of that code ever reaches players — Studio wouldn’t let a LocalScript import it anyway.
  • The level is data. Each door, perk machine and wall weapon is a Model with attributes such as kind and cost. Scripts find them by those attributes, so you can add a door or change a price in Studio without touching code.

Here’s the pattern as a complete small game: coins appear on marker parts each round, and the player who grabs the most wins. Besides the scripts, it uses these objects:

Workspace
├─ SpawnLocation
├─ CoinSpots invisible Parts marking where coins appear
└─ Coins an empty Folder the coins go in
ReplicatedStorage
├─ Remotes
│ └─ Announce RemoteEvent
├─ Shared
│ └─ Config ModuleScript (above)
└─ Client
└─ Hud ModuleScript
ServerScriptService
└─ Main Script
ServerStorage
├─ Templates
│ └─ Coin a Part: the coin to clone
└─ Modules
├─ Coins ModuleScript
└─ Rounds ModuleScript
StarterGui
└─ HUD ScreenGui
└─ Status TextLabel
StarterPlayer
└─ StarterPlayerScripts
└─ Main LocalScript

The server’s entry script sets up players and starts the round loop:

ServerScriptService/Main.server.ts
// The game's one server Script: sets up players, then starts the systems in ServerStorage/Modules.
import { createLeaderstats, Players } from '@rally/sdk';
import { scores } from '@game/ServerStorage/Modules/Coins';
import * as Rounds from '@game/ServerStorage/Modules/Rounds';
Players.playerAdded.connect((player) => {
const stats = createLeaderstats(player, { Coins: 0 });
scores.set(player, stats.Coins);
});
Players.playerRemoving.connect((player) => scores.delete(player));
Rounds.start();

The server modules do the work. Coins clones the template onto every marker and scores pickups; Rounds runs the loop and announces what’s happening:

ServerStorage/Modules/Coins.ts
// Server only: spawns coins on the spots in Workspace/CoinSpots and scores pickups.
import {
Character,
Folder,
getChild,
type NumberValue,
Part,
type Player,
Players,
ServerStorage,
workspace,
} from '@rally/sdk';
import { COIN_VALUE } from '@game/ReplicatedStorage/Shared/Config';
const template = getChild(getChild(ServerStorage, 'Templates', Folder), 'Coin', Part);
const coins = getChild(workspace, 'Coins', Folder);
const spots = getChild(workspace, 'CoinSpots', Folder)
.getChildren()
.filter((spot): spot is Part => spot instanceof Part);
/** Each player's coin counter (their leaderstats value), set up by the Main script. */
export const scores = new Map<Player, NumberValue>();
export function spawnAll(): void {
for (const spot of spots) {
const coin = template.clone();
coin.position = spot.position;
coin.touched.connect((other) => {
if (!(other instanceof Character) || coin.destroyed) return;
const player = Players.getPlayerFromCharacter(other);
const score = player && scores.get(player);
if (!score) return;
score.value += COIN_VALUE;
coin.destroy();
});
coin.parent = coins;
}
}
export function clearAll(): void {
coins.clearAllChildren();
}
ServerStorage/Modules/Rounds.ts
// Server only: the round loop. Each round spawns coins, counts down, then picks a winner.
import { Folder, getChild, Players, RemoteEvent, ReplicatedStorage, task } from '@rally/sdk';
import { INTERMISSION_SECONDS, REMOTES, ROUND_SECONDS } from '@game/ReplicatedStorage/Shared/Config';
import { clearAll, scores, spawnAll } from './Coins';
const announce = getChild(getChild(ReplicatedStorage, 'Remotes', Folder), REMOTES.announce, RemoteEvent);
async function playRound(): Promise<void> {
for (const score of scores.values()) score.value = 0;
spawnAll();
for (let t = ROUND_SECONDS; t > 0; t--) {
announce.fireAllClients(`Grab the coins! ${t}`);
await task.wait(1);
}
clearAll();
let winner = '';
let best = 0;
for (const [player, score] of scores) {
if (score.value > best) {
best = score.value;
winner = player.displayName;
}
}
announce.fireAllClients(winner ? `${winner} wins with ${best}!` : 'Nobody scored this time.');
await task.wait(INTERMISSION_SECONDS);
}
export function start(): void {
task.spawn(async () => {
for (;;) {
if (Players.getPlayers().length === 0) {
await task.wait(1);
continue;
}
await playRound();
}
});
}

Main and Rounds both import Coins, and they share one scores map: everything Main imports is built into one program. On the player’s side, the entry LocalScript starts the client modules:

StarterPlayer/StarterPlayerScripts/Main.client.ts
// The game's one LocalScript: runs on each player's device and starts the client modules.
import { task, waitForLocalPlayer } from '@rally/sdk';
import * as Hud from '@game/ReplicatedStorage/Client/Hud';
task.spawn(async () => {
const me = await waitForLocalPlayer();
await Hud.init(me);
});
ReplicatedStorage/Client/Hud.ts
// Client only: shows what the server announces in the HUD's Status label.
import {
Folder,
type Player,
PlayerGui,
RemoteEvent,
ReplicatedStorage,
ScreenGui,
TextLabel,
waitForChild,
} from '@rally/sdk';
import { REMOTES } from '@game/ReplicatedStorage/Shared/Config';
export async function init(me: Player): Promise<void> {
const gui = await waitForChild(me, 'PlayerGui', PlayerGui);
const hud = await waitForChild(gui, 'HUD', ScreenGui);
const status = await waitForChild(hud, 'Status', TextLabel);
const remotes = await waitForChild(ReplicatedStorage, 'Remotes', Folder);
const announce = await waitForChild(remotes, REMOTES.announce, RemoteEvent);
announce.onClientEvent.connect((text) => {
status.text = String(text);
});
}

The client code waits for everything it uses with waitForChild, because on a player’s device objects arrive from the server over time. The server code uses getChild, because when server scripts start, the world is already loaded.

  • Group with Folders and Models. Folders organize; Models group parts that belong together and move as one. In Studio, select objects and press Ctrl+G (⌘+G on a Mac) to group them into a Model.
  • Give things you look up from code unique names among their siblings. findFirstChild returns the first match, so two parts called Door in one folder is a bug waiting to happen.
  • Mark things with attributes, not names. A kind attribute or a folder like CoinSpots lets scripts find everything of one sort, however many you add.
  • Keep markers invisible. Parts that only mark positions can have transparency 1 and canCollide and canTouch turned off. You can still find and select them in the Explorer.