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.
The three kinds of scripts
Section titled “The three kinds of scripts”| 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.
Where scripts run
Section titled “Where scripts run”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.
Adding scripts in Studio
Section titled “Adding scripts in Studio”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.
Importing the engine API
Section titled “Importing the engine API”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:
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:
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.
Sharing code with ModuleScripts
Section titled “Sharing code with ModuleScripts”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:
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(); }}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.
How modules are built into scripts
Section titled “How modules are built into scripts”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:
let count = 0;
export function next(): number { count += 1; return count;}import { print } from '@rally/sdk';import { next } from '@game/ReplicatedStorage/Counter';
print('First:', next(), next()); // First: 1 2import { print } from '@rally/sdk';import { next } from '@game/ReplicatedStorage/Counter';
print('Second:', next()); // Second: 1 — not 3: this script has its own copyModules 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.
Top-level code and events
Section titled “Top-level code and events”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:
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
awaitat the top level. Scripts run as plain functions, so a top-levelawaitis a compile error. Wrap async code intask.spawn(async () => { … }), orawaitinside 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.spawnor 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.
Scripts inside objects
Section titled “Scripts inside objects”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:
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.
Patterns for modules
Section titled “Patterns for modules”Settings and names. Constants that both the server and clients use — round lengths, prices,
remote event names. as const keeps the names exact:
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:
/** 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:
// 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:
// 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;}Working with TypeScript
Section titled “Working with TypeScript”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:
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.
What’s available in scripts
Section titled “What’s available in scripts”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 |
Limits
Section titled “Limits”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.
Next steps
Section titled “Next steps”- Instances and the tree — find, create and change objects.
- Events and signals — react to what happens in the game.
- Tasks and timing — waits, delays and game loops.