Interaction
Interactions are how players act on your world: grabbing a coin, opening a chest, flipping a switch, repairing a machine, painting a wall. Rally doesn’t have ready-made prompt or click-detector objects. You build interactions from a few pieces you already have:
| Piece | What it gives you |
|---|---|
| Touch events | Something happens when a character walks into a part. Runs on the server. |
| UserInputService | Keys and mouse buttons on the player’s device. |
| UI buttons | TextButton.activated fires when a button is clicked or tapped. |
| Raycasts | What’s in front of the camera or between two points. |
| Remote events | The player’s device asks the server to do something. |
The rule for all of them: the player’s device asks, the server decides. The server checks every request before anything changes.
Walk into it
Section titled “Walk into it”The simplest interaction needs no input at all: something happens when a character touches a part. Touch events fire on the server, so a device can’t send one. All it can do is move its character, and the server checks that movement.
This script makes every part in a Coins folder a coin: touching one gives a coin, and it comes
back 10 seconds later. Put the script in the folder:
import { BasePart, Character, NumberValue, type Player, Players, script, task } from '@rally/sdk';
const folder = script.parent;if (!folder) throw new Error('Put this script in the folder of coins');
function addCoins(player: Player, amount: number): void { const coins = player.findFirstChild('leaderstats')?.findFirstChild('Coins'); if (coins instanceof NumberValue) coins.value += amount;}
for (const coin of folder.getChildren()) { if (!(coin instanceof BasePart)) continue; coin.canCollide = false; coin.touched.connect((other) => { if (!coin.canTouch || !(other instanceof Character) || other.health <= 0) return; const player = Players.getPlayerFromCharacter(other); if (!player) return; addCoins(player, 1); coin.canTouch = false; // hide it for everyone, then bring it back coin.transparency = 1; task.delay(10, () => { coin.transparency = 0; coin.canTouch = true; }); });}The examples on this page count coins in each player’s leaderstats, which this script sets up:
import { createLeaderstats, Players } from '@rally/sdk';
// Every player gets a Coins counter on the leaderboard.Players.playerAdded.connect((player) => { createLeaderstats(player, { Coins: 0 });});Press a key near something
Section titled “Press a key near something”Most games want a prompt: walk up to a chest, see Press E to open the chest, press E. You’ll build one that works for any number of objects, with three pieces:
- The objects say what they are with attributes, so you can add more in Studio without touching code.
- A LocalScript finds the nearest object, shows the prompt, and asks the server when the player presses E (or taps a button on a touch screen).
- A Script checks the request and does the work.
It also supports holding: an object with a holdSeconds attribute needs the key held that long,
like repairing a generator.
Set it up in Studio
Section titled “Set it up in Studio”-
Add a Folder named
Interactablesto the Workspace, and put the parts players can use in it. -
Give each part attributes (select it, then Add attribute at the bottom of Properties):
Attribute Type What it’s for promptstring What the prompt says, such as open the chest. An empty prompt means “nothing to do here right now”.actionstring What the server does: chest,lamporgeneratorin this example.holdSecondsnumber Optional. How long to hold the key. Leave it out for a single press. -
For the
lamp, put a PointLight inside the part. -
Add a RemoteEvent named
Interactto ReplicatedStorage (Insert > Events > RemoteEvent). -
Add the ModuleScript, LocalScript and Script below. The chest pays out into the
Coinsleaderstat from the previous section.
The range lives in a ModuleScript both sides import, so they always agree:
/** The folder in the Workspace that holds everything players can use. */export const FOLDER = 'Interactables';/** How close (in studs) a character has to be to use something. */export const RANGE = 8;The prompt, on each player’s device
Section titled “The prompt, on each player’s device”This LocalScript checks ten times a second for the nearest object in range that has a prompt, and shows the prompt at the bottom of the screen. On touch screens it shows a Use button too, because taps on the game view don’t reach scripts.
import { BasePart, Color3, Folder, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, task, TextButton, TextLabel, UserInputService, Vector2, waitForChild, waitForLocalPlayer, workspace,} from '@rally/sdk';import { FOLDER, RANGE } from '@game/ReplicatedStorage/Interaction';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui); const folder = await waitForChild(workspace, FOLDER, Folder); const interact = await waitForChild(ReplicatedStorage, 'Interact', RemoteEvent);
// The prompt, centered near the bottom of the screen, and a button for touch screens. const screen = new ScreenGui(); screen.name = 'Prompts'; const hint = new TextLabel(); hint.anchorPoint = new Vector2(0.5, 1); hint.positionScale = new Vector2(0.5, 1); hint.position = new Vector2(0, -140); hint.size = new Vector2(360, 44); hint.backgroundColor = Color3.fromHex('#10141f'); hint.backgroundTransparency = 0.25; hint.cornerRadius = 10; hint.textColor = Color3.white; hint.visible = false; hint.parent = screen; const useButton = new TextButton(); useButton.text = 'Use'; useButton.anchorPoint = new Vector2(1, 1); useButton.positionScale = new Vector2(1, 1); useButton.position = new Vector2(-24, -180); useButton.size = new Vector2(88, 88); useButton.cornerRadius = 44; useButton.fontWeight = 'bold'; useButton.visible = false; useButton.parent = screen; screen.parent = gui;
let target: BasePart | null = null;
task.spawn(async () => { for (;;) { target = null; const character = me.character; if (character && character.health > 0) { let nearest = RANGE; for (const part of folder.getDescendants()) { if (!(part instanceof BasePart)) continue; const prompt = part.getAttribute('prompt'); if (typeof prompt !== 'string' || prompt === '') continue; const distance = part.position.distanceTo(character.position); if (distance <= nearest) { nearest = distance; target = part; } } } if (target) { const prompt = String(target.getAttribute('prompt')); const hold = Number(target.getAttribute('holdSeconds') ?? 0) > 0; hint.text = UserInputService.touchEnabled ? prompt : `${hold ? 'Hold' : 'Press'} E to ${prompt}`; } hint.visible = target !== null; useButton.visible = target !== null && UserInputService.touchEnabled; await task.wait(0.1); } });
UserInputService.inputBegan.connect((input, gameProcessed) => { // gameProcessed: the key went to the UI, like typing in a text box. if (gameProcessed || input.key !== 'KeyE' || !target) return; interact.fireServer('begin', target); }); UserInputService.inputEnded.connect((input) => { if (input.key === 'KeyE') interact.fireServer('end'); }); useButton.activated.connect(() => { if (target) interact.fireServer('begin', target); });});The rules, on the server
Section titled “The rules, on the server”The Script trusts nothing the device sends. It checks that the target is really a part in the folder, that it has a prompt right now, that the player’s character is alive and close enough, and that the player isn’t sending requests too fast. Holds are timed on the server, and they’re cancelled if the player lets go, walks away or dies.
import { BasePart, Color3, Folder, getChild, NumberValue, type Player, Players, PointLight, RemoteEvent, ReplicatedStorage, RunService, task, workspace,} from '@rally/sdk';import { FOLDER, RANGE } from '@game/ReplicatedStorage/Interaction';
const folder = getChild(workspace, FOLDER, Folder);const interact = getChild(ReplicatedStorage, 'Interact', RemoteEvent);
interface Hold { part: BasePart; /** Game time when the hold completes. */ doneAt: number;}const holds = new Map<Player, Hold>();const lastRequest = new Map<Player, number>();
function addCoins(player: Player, amount: number): void { const coins = player.findFirstChild('leaderstats')?.findFirstChild('Coins'); if (coins instanceof NumberValue) coins.value += amount;}
/** True if the player's character is alive and close enough to `part`. */function inReach(player: Player, part: BasePart): boolean { const character = player.character; if (!character || character.health <= 0) return false; return character.position.distanceTo(part.position) <= RANGE + 2; // a little slack for lag}
/** What each kind of object does. */function use(player: Player, part: BasePart): void { const action = part.getAttribute('action'); if (action === 'chest') { const prompt = part.getAttribute('prompt'); part.setAttribute('prompt', ''); // nothing to do here until it refills addCoins(player, 25); task.delay(30, () => part.setAttribute('prompt', prompt)); } else if (action === 'lamp') { const light = part.findFirstChildWhichIsA<PointLight>('PointLight'); if (light) light.enabled = !light.enabled; } else if (action === 'generator') { part.color = Color3.fromHex('#3ddc97'); part.setAttribute('prompt', ''); // fixed for good }}
interact.onServerEvent.connect((player, request, target) => { if (request === 'end') { holds.delete(player); return; } if (request !== 'begin') return; // Check everything the device sent. if (!(target instanceof BasePart) || !target.isDescendantOf(folder)) return; const prompt = target.getAttribute('prompt'); if (typeof prompt !== 'string' || prompt === '') return; if (!inReach(player, target)) return; const now = task.time(); if (now - (lastRequest.get(player) ?? -Infinity) < 0.25) return; // too fast lastRequest.set(player, now);
const seconds = Number(target.getAttribute('holdSeconds') ?? 0); if (seconds > 0) holds.set(player, { part: target, doneAt: now + seconds }); else use(player, target);});
// Finish or cancel holds as time passes.RunService.heartbeat.connect(() => { const now = task.time(); for (const [player, hold] of holds) { if (!inReach(player, hold.part)) holds.delete(player); else if (now >= hold.doneAt) { holds.delete(player); use(player, hold.part); } }});
Players.playerRemoving.connect((player) => { holds.delete(player); lastRequest.delete(player);});Things to notice:
- Attributes do double duty. The server clears
promptwhile a chest refills, and because attributes replicate, every player’s prompt disappears with it. - The device chooses, the server checks. The LocalScript picks the nearest object only to show the prompt. The server accepts any object in range, so lag can’t make a fair request fail.
- Touch players hold by staying close. A button has no “released” event, so a hold started from the Use button completes when the time is up, as long as the player stays in range.
Click what you’re aiming at
Section titled “Click what you’re aiming at”In 3D games with the default controls, clicking the game locks the mouse for mouse-look, and the
middle of the screen is where the player aims. workspace.currentCamera tells a LocalScript where
the camera is (position) and the point it looks at (lookAt), so a ray from one through the other
finds what’s under the middle of the screen.
This game lets players paint any part in a Paintable folder by clicking it. It needs a RemoteEvent
named Paint in ReplicatedStorage. The LocalScript draws a crosshair and sends the clicked part:
import { BasePart, Color3, Frame, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, task, UserInputService, Vector2, waitForChild, waitForLocalPlayer, workspace,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui); const paint = await waitForChild(ReplicatedStorage, 'Paint', RemoteEvent);
// A small dot in the middle of the screen. const screen = new ScreenGui(); screen.name = 'Crosshair'; const dot = new Frame(); dot.positionScale = new Vector2(0.5, 0.5); dot.anchorPoint = new Vector2(0.5, 0.5); dot.size = new Vector2(6, 6); dot.cornerRadius = 3; dot.backgroundColor = Color3.white; dot.parent = screen; screen.parent = gui;
UserInputService.inputBegan.connect((input, gameProcessed) => { if (gameProcessed || input.key !== 'MouseLeft') return; if (!UserInputService.mouseLocked) return; // this click only locked the mouse const camera = workspace.currentCamera; const character = me.character; if (!camera || !character) return; const aim = camera.lookAt.sub(camera.position).unit; const hit = workspace.raycast(camera.position, aim.mul(200), { ignore: [character] }); if (hit && hit.instance instanceof BasePart) paint.fireServer(hit.instance); });});The server checks the request with its own raycast: the part has to be within 60 studs of the character, with nothing in the way.
import { BasePart, Color3, Folder, getChild, type Player, Players, RemoteEvent, ReplicatedStorage, task, Vector3, workspace,} from '@rally/sdk';
const paintable = getChild(workspace, 'Paintable', Folder);const paint = getChild(ReplicatedStorage, 'Paint', RemoteEvent);const MAX_DISTANCE = 60;const lastPaint = new Map<Player, number>();let joined = 0;
Players.playerAdded.connect((player) => { // A different color for everyone, spread around the color wheel. player.teamColor = Color3.fromHSV((joined++ * 0.618) % 1, 0.7, 1);});Players.playerRemoving.connect((player) => lastPaint.delete(player));
paint.onServerEvent.connect((player, target) => { if (!(target instanceof BasePart) || !target.isDescendantOf(paintable)) return; const character = player.character; if (!character || character.health <= 0) return; const now = task.time(); if (now - (lastPaint.get(player) ?? -Infinity) < 0.2) return; lastPaint.set(player, now);
// Close enough, and nothing solid between the character's eyes and the part? const eye = character.position.add(new Vector3(0, 1.5, 0)); const toTarget = target.position.sub(eye); if (toTarget.magnitude > MAX_DISTANCE) return; const hit = workspace.raycast(eye, toTarget.unit.mul(MAX_DISTANCE), { ignore: [character] }); if (hit?.instance !== target) return; target.color = player.teamColor;});On-screen buttons
Section titled “On-screen buttons”Buttons are the most reliable interaction on every device: TextButton.activated fires for a
mouse click and for a tap. Use them for shops, menus, emotes and anything else that doesn’t depend
on where the player stands. The button only asks; the server still decides, usually through a
RemoteFunction so the device hears back. Multiplayer
has a complete shop.
Keyboard interactions already work on touch screens: every key your LocalScripts listen for gets an
on-screen button, and a tap on the game view is a MouseLeft click (see
Touch screens). Add a button of your own where a label or
placement matters, as the prompt example does.
Checklist for the server side
Section titled “Checklist for the server side”Whatever the interaction, the Script that receives the request should check:
- Types. Is the argument really a
BasePart(instanceof), a number, a short string? - Where it is. Is the part where you expect, for example
isDescendantOf(folder)? - Whether it’s allowed now. Is the character alive? Is the object usable right now?
- Distance. Is the character close enough? Add a little slack for lag.
- Line of sight, when it matters, with a server-side raycast.
- Rate. Ignore requests that come faster than a player could really make them.
Then do the work on the server: award the coins, open the door, change the color. Never let the device tell the server the outcome. See Multiplayer for more.