Skip to content

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.

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:

Workspace/Coins/Pickup.server.ts
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:

ServerScriptService/Leaderstats.server.ts
import { createLeaderstats, Players } from '@rally/sdk';
// Every player gets a Coins counter on the leaderboard.
Players.playerAdded.connect((player) => {
createLeaderstats(player, { Coins: 0 });
});

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.

  1. Add a Folder named Interactables to the Workspace, and put the parts players can use in it.

  2. Give each part attributes (select it, then Add attribute at the bottom of Properties):

    Attribute Type What it’s for
    prompt string What the prompt says, such as open the chest. An empty prompt means “nothing to do here right now”.
    action string What the server does: chest, lamp or generator in this example.
    holdSeconds number Optional. How long to hold the key. Leave it out for a single press.
  3. For the lamp, put a PointLight inside the part.

  4. Add a RemoteEvent named Interact to ReplicatedStorage (Insert > Events > RemoteEvent).

  5. Add the ModuleScript, LocalScript and Script below. The chest pays out into the Coins leaderstat from the previous section.

The range lives in a ModuleScript both sides import, so they always agree:

ReplicatedStorage/Interaction.ts
/** 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;

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.

StarterPlayer/StarterPlayerScripts/Prompts.client.ts
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 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.

ServerScriptService/Interact.server.ts
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 prompt while 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.

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:

StarterPlayer/StarterPlayerScripts/PaintAim.client.ts
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.

ServerScriptService/Paint.server.ts
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;
});

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.

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.