Skip to content

Scripts and modules

Rally game code is TypeScript that lives in script objects in your game’s tree. This page covers the kinds of scripts, where each one runs, how scripts share code, and how to structure what’s inside them.

Kind Runs Starts Usual home
Script on the server when the server starts ServerScriptService, or inside an object in Workspace
LocalScript on each player’s device when the player joins StarterPlayer › StarterPlayerScripts, or inside a ScreenGui in StarterGui
ModuleScript inside every script that imports it never on its own ReplicatedStorage for shared code, ServerStorage for server-only code

Most games need all three: Scripts for the rules, LocalScripts for input, camera and UI, and ModuleScripts for code and settings that several scripts use.

A script’s place in the tree decides whether it runs:

  • A Script runs if it’s anywhere inside Workspace or ServerScriptService.
  • A LocalScript runs if it’s anywhere inside StarterPlayer › StarterPlayerScripts, or in a player’s own PlayerGui — which is where LocalScripts inside StarterGui end up, because StarterGui is copied to each player when they join.
  • A ModuleScript never runs by itself, wherever it is.

A script anywhere else stays put and does nothing. Studio warns you: the Explorer shows won’t run here next to it, and the bar under the script editor says Doesn’t run here (hover over it to see why). When it’s in the right place, the bar says Runs on the server, Runs on each player’s device, or, for a ModuleScript, Shared code — runs when a script imports it.

Scripts and LocalScripts also have an enabled property. One that’s turned off when the game starts doesn’t run; switching enabled while the game is running doesn’t start or stop a script.

Don’t count on the order scripts start in. If one script needs something another one creates, wait for it with waitForChild, or do both jobs in the same script.

Use Insert in the toolbar, under Scripts, or right-click an object in the Explorer and choose Insert Object. From the toolbar, Studio picks a sensible home:

  • A Script goes inside the selected object if it would run there (somewhere in Workspace or ServerScriptService), otherwise into ServerScriptService.
  • A LocalScript goes inside the selected object if it’s in StarterGui or StarterPlayerScripts — a ScreenGui, say — otherwise into StarterPlayerScripts.
  • A ModuleScript goes inside the selected object, otherwise into ReplicatedStorage.

New scripts start with a line or two of example code. Double-click a script to open it in a tab; the script editor page covers the editor itself.

Everything the engine offers comes from @rally/sdk: services such as Players and workspace, classes such as Part and RemoteEvent, value types such as Vector3 and Color3, helpers such as getChild, and print and warn. Import what you use by name:

ServerScriptService/Hello.server.ts
import { print } from '@rally/sdk';
print('Hello from the server!');

That’s the starter code of a new Script. A new LocalScript starts like this:

StarterPlayer/StarterPlayerScripts/Hello.client.ts
import { Players, print } from '@rally/sdk';
print('Hello, ' + (Players.localPlayer?.displayName ?? 'player') + '!');

Scripts can import three kinds of things, and nothing else:

  • @rally/sdk, the game API.
  • Other scripts’ ModuleScripts, by tree path: @game/ReplicatedStorage/Config.
  • ModuleScripts by relative path: ./DoorLogic, ../Shared/Config.

There are no npm packages. If you need a helper library, write it as a ModuleScript. Services lists everything in @rally/sdk, and the API reference covers every class.

A ModuleScript shares code by exporting it, like any TypeScript module. Here’s a door model with its logic in a module and a Script that uses it, both inside the model:

Workspace/Door/DoorLogic.ts
import { type Model, TweenService, Vector3 } from '@rally/sdk';
/** Slides every part of the door up by `height` studs over one second. */
export function open(door: Model, height = 8): void {
for (const part of door.parts()) {
TweenService.create(
part,
{ time: 1, easing: 'quadOut' },
{ position: part.position.add(new Vector3(0, height, 0)) },
).play();
}
}
Workspace/Door/Open.server.ts
import { Model, script, task } from '@rally/sdk';
import { open } from './DoorLogic';
const door = script.parent;
if (!(door instanceof Model)) throw new Error('Open must be inside the Door model');
task.delay(5, () => open(door));

./DoorLogic means “the ModuleScript named DoorLogic in the same place as this script”. From anywhere else, the same module is @game/Workspace/Door/DoorLogic. How games are organized covers the path rules and where to keep modules.

When you press Play or Publish, Studio compiles each Script and LocalScript, together with every ModuleScript it imports, into one self-contained program. That has four consequences worth knowing.

1. Module state isn’t shared between scripts. Each script gets its own copy of every module it imports, and a module’s top-level code runs once per copy:

ReplicatedStorage/Counter.ts
let count = 0;
export function next(): number {
count += 1;
return count;
}
ServerScriptService/First.server.ts
import { print } from '@rally/sdk';
import { next } from '@game/ReplicatedStorage/Counter';
print('First:', next(), next()); // First: 1 2
ServerScriptService/Second.server.ts
import { print } from '@rally/sdk';
import { next } from '@game/ReplicatedStorage/Counter';
print('Second:', next()); // Second: 1 — not 3: this script has its own copy

Modules imported by the same script do share: if Main imports Rounds, and both import Scores, there’s one Scores in Main’s program. That’s why many games have just one Script and one LocalScript, which import everything else.

2. To share state between scripts, use the tree. Values (NumberValue, StringValue, …) and attributes are visible to every script on the same side, and replicate to players. A BindableEvent lets scripts on the same side send each other messages — see Events and signals.

3. script inside a module is the importing script. The module’s code runs as part of the script that imported it, so script and script.parent refer to that script, not to the ModuleScript.

4. Modules imported by a LocalScript are sent to players. They become part of the LocalScript’s code, which every player’s device downloads. That’s why a LocalScript can’t import a module from ServerStorage or ServerScriptService (Studio reports “a LocalScript can’t import … which players never get”), and why modules in ReplicatedStorage shouldn’t hold game rules, answers or anything secret.

The code at the top level of a script runs once, when the script starts. Use it to find objects, set things up and connect events. Event handlers run later, every time their event fires, and they can be async:

ServerScriptService/Greeter.server.ts
import { Players, print, task } from '@rally/sdk';
// Top-level code runs once, as soon as the server starts. Set things up and connect events.
print('Greeter is ready');
// Event handlers run later, every time the event fires. They may be async.
Players.playerAdded.connect(async (player) => {
await task.wait(2);
print(`Welcome, ${player.displayName}! You've been here 2 seconds.`);
});
// Long-running work goes in task.spawn — top-level `await` isn't allowed.
task.spawn(async () => {
for (;;) {
await task.wait(30);
print(`${Players.getPlayers().length} player(s) online`);
}
});

Keep these in mind:

  • No await at the top level. Scripts run as plain functions, so a top-level await is a compile error. Wrap async code in task.spawn(async () => { … }), or await inside an event handler.
  • Keep top-level code short. All of a game’s scripts start together, and the game can’t begin until their top-level code has finished. Loops and waiting belong in task.spawn or in events.
  • Errors don’t stop the game. If top-level code throws, the rest of that script’s top-level code is skipped — events it already connected keep working. If an event handler throws, the error is logged and the handler still runs next time. Other scripts aren’t affected.
  • Nothing should run for long without waiting. Your code shares the game with everything else; a loop that never waits freezes the game. See Tasks and timing.

A Script inside an object in the Workspace can use script.parent to get that object. This makes self-contained objects you can copy around in Studio, each with its own behavior:

Workspace/Lava/KillOnTouch.server.ts
import { Character, Part, script } from '@rally/sdk';
const lava = script.parent;
if (!(lava instanceof Part)) throw new Error('KillOnTouch must be inside a Part');
lava.touched.connect((other) => {
if (other instanceof Character) other.kill();
});

Duplicate the lava part in Studio and each copy gets its own copy of the script. A Script inside a model you clone into the Workspace while the game runs starts running too — but copies made by a script’s top-level code, while scripts are still starting, don’t start their scripts, so make such copies from an event handler or after await task.wait(). With many copies, one script that manages all of them is usually simpler and faster than a script in each.

Settings and names. Constants that both the server and clients use — round lengths, prices, remote event names. as const keeps the names exact:

ReplicatedStorage/Config.ts
export const ROUND_SECONDS = 90;
export const SHOP_PRICES = { speed: 10, jump: 15 } as const;
export const REMOTES = { buy: 'Buy', announce: 'Announce' } as const;

Helpers. Small functions with no state, safe to import anywhere:

ReplicatedStorage/Format.ts
/** 75 → "1:15" */
export function formatClock(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}

Systems. A module that owns some state and the functions that change it, started by your entry script. Only one script imports it, so there’s exactly one copy of its state:

ServerStorage/Modules/Scoring.ts
// A "system" module: state plus the functions that change it. Only the Main script imports it,
// so there is exactly one copy of `points`.
import { createLeaderstats, type NumberValue, type Player, Players } from '@rally/sdk';
const points = new Map<Player, NumberValue>();
export function init(): void {
Players.playerAdded.connect((player) => {
points.set(player, createLeaderstats(player, { Points: 0 }).Points);
});
Players.playerRemoving.connect((player) => points.delete(player));
}
export function award(player: Player, amount: number): void {
const value = points.get(player);
if (value) value.value += amount;
}

Types. Shared types cost nothing at runtime. Import them with import type:

ReplicatedStorage/Types.ts
// Types only: imported with `import type`, they add no code to the scripts that use them.
export type Phase = 'waiting' | 'playing' | 'ended';
export interface RoundInfo {
phase: Phase;
secondsLeft: number;
}

Studio’s editor checks your code against the real game API as you type, in strict mode: autocomplete, documentation on hover, and a red underline under anything wrong. A few habits make it most useful.

Narrow with instanceof. Most lookups return a general Instance. Check the class before you use class-specific properties — TypeScript then knows the type. The getChild and waitForChild helpers from @rally/sdk take the class and do the check for you.

Check unknown values. Arguments that arrive from players through a RemoteEvent are unknown, because a player can send anything. Check them before you use them. The same goes for attributes, which can hold several types:

ServerScriptService/Main.server.ts
import { getChild, Part, Players, RemoteEvent, ReplicatedStorage, workspace } from '@rally/sdk';
import type { RoundInfo } from '@game/ReplicatedStorage/Types';
import * as Scoring from '@game/ServerStorage/Modules/Scoring';
Scoring.init();
const lava = getChild(workspace, 'Lava', Part);
const shout = getChild(ReplicatedStorage, 'Shout', RemoteEvent);
// Arguments from players are `unknown`: check them before you use them.
shout.onServerEvent.connect((player, message) => {
if (typeof message !== 'string' || message.length === 0 || message.length > 80) return;
shout.fireAllClients(player.displayName, message);
Scoring.award(player, 1);
});
const info: RoundInfo = { phase: 'waiting', secondsLeft: 0 };
lava.setAttribute('phase', info.phase);
// Attributes can hold several types: narrow them with typeof.
const damage = lava.getAttribute('damage');
const amount = typeof damage === 'number' ? damage : 100;
lava.touched.connect((other) => {
const player = Players.getPlayerFromCharacter(other);
if (player?.character) player.character.takeDamage(amount);
});

Know what stops a game from running. Play and Publish only stop for errors that keep the code from compiling: syntax mistakes, imports that can’t be found, a top-level await. Type errors don’t block them — but they’re almost always real bugs, so fix them. The bar under the editor counts the problems in a script, and its Check button compiles every script without starting a playtest.

Scripts run in a sandbox. Standard JavaScript is all there — Math, JSON, Date, Map, Set, Promise, classes, async/await — but browser and server APIs aren’t. Use these instead:

To… Use
wait, repeat, or run later task.wait, task.delay, task.spawn, RunService.heartbeat — there’s no setTimeout or setInterval
log print and warn (the editor doesn’t know console)
make random numbers Math.random()
make unique ids HttpService.generateGuid()
save data between sessions DataStoreService, on the server — see Data stores
show UI ScreenGui, TextLabel and friends — there’s no document or window
call a web API not available: games can’t make web requests

A single script can hold up to 200,000 characters; the bar under the editor says when one is too long to save. Whole games have limits on scripts and objects too — see Limits.