Skip to content

Input and camera

Input — keys, mouse buttons, touches — happens on each player’s device, so you read it in LocalScripts with UserInputService. The camera is local too: each player’s device has its own. This page covers both, and how to turn what a player presses into something that happens in the shared world.

UserInputService works in LocalScripts. It exists on the server too, but its events never fire there. The server only learns what a player wants when a LocalScript tells it, through a RemoteEvent — see Send input to the server below.

Every game comes with controls for the player’s character and camera:

On Players can
Keyboard move with W A S D or the arrow keys, and jump with Space. In 2D games, A/D or ←/→ move, and W or ↑ also jumps.
Mouse (3D) look around by dragging with the right button, or click the game to lock the pointer and just move the mouse (Esc unlocks it); zoom with the scroll wheel
Mouse (2D) zoom with the scroll wheel
Touch move with the floating joystick on the left, jump with the Jump button in the bottom-right, drag the game to look around (3D) and pinch to zoom

Movement is relative to the camera in 3D: forward is the way the camera faces.

Set UserInputService.defaultControls = false to turn all of this off — movement, camera control and the on-screen touch controls — for a cutscene, a menu, or a game that handles input its own way. Your scripts still get every input event. Set it back to true to restore the controls.

The ` (backtick) key opens the developer console, so games don’t receive it.

inputBegan fires when a key or mouse button goes down, and inputEnded when it comes back up. Handlers get two things:

  • the input: kind ('key' or 'mouse'), key (which key or button), and position (where the mouse pointer is, in screen pixels from the top-left corner);
  • gameProcessed: true when the input went to the UI rather than the game — typing in a TextBox, clicking a button, or clicking anywhere outside the game view — and for Tab and Enter, which open Rally’s menu and chats (in Build live, Tab talks to Rallo: Playing games). Most handlers ignore those.
StarterPlayer/StarterPlayerScripts/Keys.client.ts
import { print, UserInputService } from '@rally/sdk';
UserInputService.inputBegan.connect((input, gameProcessed) => {
// gameProcessed is true when the UI used the input: typing in a TextBox, clicking a button.
if (gameProcessed) return;
if (input.key === 'KeyE') print('E pressed');
if (input.key === 'MouseLeft') print('clicked at', input.position); // screen pixels
});
UserInputService.inputEnded.connect((input) => {
if (input.key === 'KeyE') print('E released');
});

Keys are named by where they are on the keyboard, the same names browsers use:

Keys Names
Letters 'KeyA' … 'KeyZ'
Number row 'Digit0' … 'Digit9'
Space, Enter, Tab, Backspace 'Space', 'Enter', 'Tab', 'Backspace'
Shift, Control, Alt 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight'
Arrows 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'
Mouse buttons 'MouseLeft', 'MouseMiddle', 'MouseRight'

Because names follow the key’s position, 'KeyW' is the key above S on every keyboard layout — handy for movement keys.

A few more details:

  • inputBegan fires once per press: holding a key down doesn’t repeat it. To do something while a key is held, check UserInputService.isKeyDown('ShiftLeft') in a step event.
  • If the player switches to another window or tab, every key they were holding is released, and inputEnded fires for each.
  • The scroll wheel isn’t reported to scripts; it zooms the camera.
  • mouseLocation is where the pointer is, in pixels.
  • mouseMoved fires with how far the mouse moved, in pixels, as a Vector2 — whether or not the pointer is locked.
  • mouseLocked is true while the pointer is locked to the game. In a 3D game with the built-in controls on, clicking the game locks it and Esc unlocks it.

Pointer lock plus mouseMoved is how you build mouse look. This LocalScript makes a simple first-person view; the built-in controls keep moving the character, now in the direction the player looks:

StarterPlayer/StarterPlayerScripts/FirstPerson.client.ts
import { Players, RunService, UserInputService, Vector3, workspace } from '@rally/sdk';
// First person: click the game to lock the mouse, then move the mouse to look around.
const SENSITIVITY = 0.003; // radians per pixel
let yaw = 0;
let pitch = 0;
UserInputService.mouseMoved.connect((delta) => {
if (!UserInputService.mouseLocked) return;
yaw -= delta.x * SENSITIVITY;
pitch = Math.max(-1.4, Math.min(1.4, pitch - delta.y * SENSITIVITY));
});
RunService.renderStepped.connect(() => {
const camera = workspace.currentCamera;
const character = Players.localPlayer?.character;
if (!camera || !character || character.health <= 0) return;
const forward = new Vector3(-Math.sin(yaw), 0, -Math.cos(yaw)); // flat, for placing the eye
const look = new Vector3(
-Math.sin(yaw) * Math.cos(pitch),
Math.sin(pitch),
-Math.cos(yaw) * Math.cos(pitch),
);
// Eyes near the top of the 5-stud body, just in front of the face.
const eye = character.position.add(new Vector3(0, 1.8, 0)).add(forward.mul(0.35));
camera.cameraType = 'scriptable';
camera.position = eye;
camera.lookAt = eye.add(look);
});

Games work on phones and tablets without extra code. UserInputService.touchEnabled is true on devices with a touch screen, and there:

The player… Your scripts see…
holds the joystick the built-in movement — and KeyW / KeyA / KeyS / KeyD pressed, so scripts that read those keys work too
taps Jump the character jumps, and Space is pressed
drags the game view mouseMoved (with mouseLocked true while dragging), and the 3D camera orbits
pinches the game view the camera zooms
taps the game view MouseLeft pressed and released at that spot (input.position)
touches the screen Touch1, Touch2, … pressed and released (kind: 'touch'), and mouseLocation follows the finger
uses a button you didn’t make the key it stands for: see below

Buttons for your keys appear on their own. Rally reads your LocalScripts for the keys they compare against — 'KeyE', 'ShiftLeft', 'Digit1', 'Tab', … — and shows an on-screen button for each (up to six, most-used first) next to Jump. If your scripts use mouse-look (they read mouseMoved or mouseLocked), MouseLeft becomes a Fire button, since a tap can’t also be a click there. A press on a button fires inputBegan, and lifting the finger fires inputEnded, exactly like the key.

For keys your scripts build at run time, add the button yourself — and turn the automatic ones off if you’d rather place them all:

StarterPlayer/StarterPlayerScripts/Touch.client.ts
import { UserInputService } from '@rally/sdk';
// Buttons for keys Rally can't find in the script text:
UserInputService.addTouchButton('KeyF', 'Flash');
UserInputService.addTouchButton('MouseRight', 'Aim');
// Or take over completely:
// UserInputService.autoTouchButtons = false;
// UserInputService.touchJoystick = false; // use your own movement UI
// UserInputService.touchJumpButton = false;

On-screen buttons you make with a TextButton work too — its activated event fires for clicks and taps alike (see User interface).

A laptop with a touch screen and a tablet with a keyboard use both. UserInputService.lastInputType is 'touch' or 'keyboardMouse' — whichever the player used last — and lastInputTypeChanged fires when it changes. The on-screen controls show only while it is 'touch', so they never sit over a keyboard player’s game.

  • UserInputService.viewportSize is the size of the game view in pixels, and viewportChanged fires when it changes (turning a phone sideways, resizing the window).
  • UserInputService.safeAreaInsets is how many pixels at each edge are covered by a notch, rounded corners or the home bar. Keep important UI inside them.
  • Prefer sizeScale and positionScale over fixed pixels for big layouts, make touch targets at least 44 pixels, and don’t hide an action behind hover.

Also: the game lowers its render resolution on its own when a device can’t keep up, and a phone that was in the background reconnects with a Rejoin button.

On a player’s device, workspace.currentCamera is their camera. (On the server it’s null.) Each device has its own, and changes to it stay on that device.

Property What it does
cameraType 'follow' (the default), 'fixed' or 'scriptable'.
subject What the follow camera follows: a character or a part. null means your own character.
distance 3D: how far the follow camera stays back (default 18). 2D: half the height of the view. From 2 to 200; players change it with the scroll wheel.
fieldOfView 3D: how wide the view is, in degrees. Default 70, from 10 to 120.
position Where the camera is.
lookAt The point it looks at.
  • 'follow' orbits the subject in 3D — players drag to turn it and scroll to zoom, and it moves in when a wall is in the way — and tracks it from the side in 2D. Each frame it writes its position and lookAt, so scripts can read where it is.
  • 'fixed' and 'scriptable' put the camera at position, looking at lookAt, and leave both to your code. In 2D the view centers on position’s X and Y. Both work the same way; use whichever name reads better.

Switch views and follow other players:

StarterPlayer/StarterPlayerScripts/Camera.client.ts
import { Players, print, UserInputService, Vector3, workspace } from '@rally/sdk';
const camera = workspace.currentCamera;
if (camera) camera.distance = 26; // start a little farther back than usual
// C: switch between the normal follow camera and a fixed view over the map.
UserInputService.inputBegan.connect((input, gameProcessed) => {
if (gameProcessed || input.key !== 'KeyC' || !camera) return;
if (camera.cameraType === 'follow') {
camera.cameraType = 'scriptable';
camera.position = new Vector3(0, 120, 80);
camera.lookAt = Vector3.zero;
} else {
camera.cameraType = 'follow';
}
});
// V: follow another player's character (the follow camera can follow any character or part).
UserInputService.inputBegan.connect((input, gameProcessed) => {
if (gameProcessed || input.key !== 'KeyV' || !camera) return;
const others = Players.getPlayers().filter((p) => p !== Players.localPlayer && p.character);
const target = others[Math.floor(Math.random() * others.length)];
camera.subject = target?.character ?? null; // null follows your own character again
print(target ? `Watching ${target.displayName}` : 'Watching yourself');
});

To move a scriptable camera smoothly, set its position and lookAt every frame in RunService.renderStepped, which fires just before each frame is drawn. A top-down view that follows your character:

StarterPlayer/StarterPlayerScripts/TopDown.client.ts
import { Players, RunService, Vector3, workspace } from '@rally/sdk';
// A top-down camera: every frame, place it above your character, looking down at it.
RunService.renderStepped.connect(() => {
const camera = workspace.currentCamera;
const character = Players.localPlayer?.character;
if (!camera || !character) return;
camera.cameraType = 'scriptable';
camera.position = character.position.add(new Vector3(0, 40, 12));
camera.lookAt = character.position;
});

And a short fly-around when a player joins, with the controls off until it ends:

StarterPlayer/StarterPlayerScripts/Intro.client.ts
import { task, UserInputService, Vector3, workspace } from '@rally/sdk';
// A 3-second fly-around when you join. No walking or camera control until it ends.
const camera = workspace.currentCamera;
if (camera) {
UserInputService.defaultControls = false;
camera.cameraType = 'scriptable';
camera.lookAt = Vector3.zero;
task.spawn(async () => {
let t = 0;
while (t < 3) {
camera.position = new Vector3(Math.cos(t) * 60, 30, Math.sin(t) * 60);
t += await task.wait();
}
camera.cameraType = 'follow';
UserInputService.defaultControls = true;
});
}

A LocalScript can’t change the shared world: its changes stay on its own device. To make something happen for everyone, it asks the server through a RemoteEvent in ReplicatedStorage, and the server decides. The rule of thumb: send what the player wants, not the result, and check everything on the server — a player can send anything, any time.

Press E to open a chest. The client only says “E was pressed”; the server works out whether there’s a chest close enough:

ReplicatedStorage/Interact.ts
/** How close a player must be to open a chest (both sides use it). */
export const INTERACT_RANGE = 8;
StarterPlayer/StarterPlayerScripts/Interact.client.ts
import { RemoteEvent, ReplicatedStorage, task, UserInputService, waitForChild } from '@rally/sdk';
task.spawn(async () => {
const interact = await waitForChild(ReplicatedStorage, 'Interact', RemoteEvent);
UserInputService.inputBegan.connect((input, gameProcessed) => {
if (gameProcessed || input.key !== 'KeyE') return;
interact.fireServer(); // ask; the server decides
});
});
ServerScriptService/Interact.server.ts
import { BasePart, Color3, Folder, getChild, type Player, Players, RemoteEvent, ReplicatedStorage, task, workspace } from '@rally/sdk';
import { INTERACT_RANGE } from '@game/ReplicatedStorage/Interact';
const interact = getChild(ReplicatedStorage, 'Interact', RemoteEvent);
const chests = getChild(workspace, 'Chests', Folder);
const lastUse = new Map<Player, number>();
interact.onServerEvent.connect((player) => {
const character = player.character;
if (!character || character.health <= 0) return;
// A cooldown: modified clients can fire as fast as they like.
const now = task.time();
if (now - (lastUse.get(player) ?? -Infinity) < 0.5) return;
lastUse.set(player, now);
// Find the nearest chest in range, measured on the server.
for (const chest of chests.getChildren()) {
if (!(chest instanceof BasePart) || chest.getAttribute('open') === true) continue;
if (chest.position.distanceTo(character.position) > INTERACT_RANGE) continue;
chest.setAttribute('open', true);
chest.color = Color3.fromHex('#ffd23f');
return;
}
});
Players.playerRemoving.connect((player) => lastUse.delete(player));

Hold Shift to sprint. Speed is checked by the server, so the server sets it:

StarterPlayer/StarterPlayerScripts/Sprint.client.ts
import { RemoteEvent, ReplicatedStorage, task, UserInputService, waitForChild } from '@rally/sdk';
// Hold Shift to sprint. Speed is checked by the server, so the server sets it.
task.spawn(async () => {
const sprint = await waitForChild(ReplicatedStorage, 'Sprint', RemoteEvent);
UserInputService.inputBegan.connect((input, gameProcessed) => {
if (!gameProcessed && input.key === 'ShiftLeft') sprint.fireServer(true);
});
UserInputService.inputEnded.connect((input) => {
if (input.key === 'ShiftLeft') sprint.fireServer(false);
});
});
ServerScriptService/Sprint.server.ts
import { getChild, RemoteEvent, ReplicatedStorage, StarterPlayer } from '@rally/sdk';
const sprint = getChild(ReplicatedStorage, 'Sprint', RemoteEvent);
sprint.onServerEvent.connect((player, on) => {
const character = player.character;
if (!character || typeof on !== 'boolean') return;
character.walkSpeed = on ? 28 : StarterPlayer.characterWalkSpeed;
});

Click to throw a ball where you’re looking. The client sends a direction — taken from the camera — and the server checks it’s a real Vector3, enforces a cooldown, and throws from the character’s real position:

StarterPlayer/StarterPlayerScripts/Throw.client.ts
import { RemoteEvent, ReplicatedStorage, task, UserInputService, waitForChild, workspace } from '@rally/sdk';
// Click to throw toward where the camera is looking.
task.spawn(async () => {
const throwBall = await waitForChild(ReplicatedStorage, 'Throw', RemoteEvent);
UserInputService.inputBegan.connect((input, gameProcessed) => {
if (gameProcessed || input.key !== 'MouseLeft') return;
const camera = workspace.currentCamera;
if (!camera) return;
const direction = camera.lookAt.sub(camera.position).unit;
throwBall.fireServer(direction);
});
});
ServerScriptService/Throw.server.ts
import { getChild, Part, type Player, Players, RemoteEvent, ReplicatedStorage, task, Vector3, workspace } from '@rally/sdk';
const throwBall = getChild(ReplicatedStorage, 'Throw', RemoteEvent);
const lastThrow = new Map<Player, number>();
throwBall.onServerEvent.connect((player, direction) => {
const character = player.character;
if (!character || character.health <= 0) return;
if (!(direction instanceof Vector3) || direction.magnitude === 0) return; // check what arrives
const now = task.time();
if (now - (lastThrow.get(player) ?? -Infinity) < 0.4) return; // the server's own cooldown
lastThrow.set(player, now);
const aim = direction.unit;
const ball = new Part();
ball.shape = 'sphere';
ball.size = new Vector3(1.5, 1.5, 1.5);
ball.anchored = false;
ball.position = character.position.add(aim.mul(3));
ball.velocity = aim.mul(80);
ball.parent = workspace;
task.delay(5, () => ball.destroy());
});
Players.playerRemoving.connect((player) => lastThrow.delete(player));

Rally also drops remote calls from any player sending more than 60 a second. Your own cooldowns should be much stricter than that. Mirroring them in the LocalScript saves sending requests the server would ignore. Multiplayer covers remote events, remote functions and validation in depth.

  • User interface — buttons, labels and text boxes.
  • Multiplayer — remote events and functions, and never trusting the client.
  • Interaction — ways for players to use things in the world.