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.
Input happens on the player’s device
Section titled “Input happens on the player’s device”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.
The built-in controls
Section titled “The built-in controls”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.
Keys and mouse buttons
Section titled “Keys and mouse buttons”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), andposition(where the mouse pointer is, in screen pixels from the top-left corner); gameProcessed:truewhen the input went to the UI rather than the game — typing in aTextBox, 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.
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:
inputBeganfires once per press: holding a key down doesn’t repeat it. To do something while a key is held, checkUserInputService.isKeyDown('ShiftLeft')in a step event.- If the player switches to another window or tab, every key they were holding is released, and
inputEndedfires for each. - The scroll wheel isn’t reported to scripts; it zooms the camera.
Mouse movement and pointer lock
Section titled “Mouse movement and pointer lock”mouseLocationis where the pointer is, in pixels.mouseMovedfires with how far the mouse moved, in pixels, as aVector2— whether or not the pointer is locked.mouseLockedistruewhile 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:
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 pixellet 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);});Touch screens
Section titled “Touch screens”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:
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).
Touch and keyboard on one device
Section titled “Touch and keyboard on one device”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.
Laying out UI for small screens
Section titled “Laying out UI for small screens”UserInputService.viewportSizeis the size of the game view in pixels, andviewportChangedfires when it changes (turning a phone sideways, resizing the window).UserInputService.safeAreaInsetsis how many pixels at each edge are covered by a notch, rounded corners or the home bar. Keep important UI inside them.- Prefer
sizeScaleandpositionScaleover 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.
The camera
Section titled “The camera”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 itspositionandlookAt, so scripts can read where it is.'fixed'and'scriptable'put the camera atposition, looking atlookAt, and leave both to your code. In 2D the view centers onposition’s X and Y. Both work the same way; use whichever name reads better.
Switch views and follow other players:
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:
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:
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; });}Send input to the server
Section titled “Send input to the server”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:
/** How close a player must be to open a chest (both sides use it). */export const INTERACT_RANGE = 8;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 });});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:
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); });});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:
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); });});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.
Next steps
Section titled “Next steps”- 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.