Developer Docs
Getting your game ready for XPlay, the Xbox web player
What XPlay is
XPlay is the 2016 Roblox Xbox One app running right in the browser. Players open it from the website, pick a game, and play with a controller, or with a keyboard and mouse that we turn into a controller behind the scenes.
The thing to keep in mind is that it's a 2016 client. When someone joins your game from XPlay, they get a 2016 game server running your place, not the 2020 or 2021 server PC players use. That has two side effects you should know about:
- XPlay players and PC players end up on separate servers. The clients speak different network protocols, so there's no way to put them together.
- Your place runs on a 2016 engine for those players. Anything Roblox added after 2016 (new script syntax, new instance types, new avatar features) either breaks or quietly does nothing.
You don't have to rebuild your game. Most of what follows is about avoiding a handful of newer features, and making sure someone holding a controller can actually play.
Quick checklist
If you only read one section, read this one.
- No
continue, no+=, no type annotations, notask.wait. Plain Lua 5.1 only. - Every action a player needs has a controller button, not just a keyboard key.
- Every button a player needs to press is
Selectable, and opening a menu selects something in it. - Text is big enough to read from a couch.
- Nothing important sits right at the edge of the screen.
- No
ViewportFrame,HumanoidDescription, bundles, orSurfaceAppearance. - Meshes aren't Draco-compressed (see Meshes and textures).
- You've actually joined your game from XPlay and walked around in it.
Scripts
The 2016 engine runs standard Lua 5.1. Luau, the faster Roblox Lua with extra syntax, came years later. A script that uses any Luau-only syntax fails to compile, and the whole script is skipped, not just the line. This is the most common reason a game "half works" on XPlay.
Syntax to avoid
| Instead of this | Write this |
|---|---|
coins += 1 | coins = coins + 1 |
continue | Wrap the rest of the loop body in an if |
local x: number = 5 | local x = 5 |
`Hello {name}` | "Hello " .. name |
task.wait(1) | wait(1) |
task.spawn(fn) | spawn(fn) or coroutine.wrap(fn)() |
task.delay(2, fn) | delay(2, fn) |
if x then a else b as an expression | x and a or b (careful if a can be false) |
Here's a loop that uses continue, rewritten so it runs on 2016:
for _, part in pairs(workspace.Coins:GetChildren()) do
if part:IsA("BasePart") and part.Transparency < 1 then
part.Touched:connect(function(hit)
-- someone touched it, see if it was a player
local player = game.Players:GetPlayerFromCharacter(hit.Parent)
if player then
giveCoin(player)
part.Transparency = 1
end
end)
end
endAPIs that don't exist yet
These are newer than the 2016 engine. Calling them throws an error, and the script stops right there:
FindFirstChildOfClassandFindFirstChildWhichIsA. Loop overGetChildren()and checkIsAinstead.ContextActionService:BindActionAtPriority. PlainBindActionis fine.ScreenGui.IgnoreGuiInsetandGuiService:GetGuiInset().- Anything in the
tasklibrary.
Things that do work: TweenService, CollectionService,RunService.Heartbeat, GetPropertyChangedSignal, RemoteEvent andRemoteFunction. Both :connect and :Connect are fine.
If you're not sure whether something exists, guard it with pcall so a missing feature doesn't take the rest of the script down with it:
local ok = pcall(function()
game:GetService("TweenService")
end)
if ok then
fadeInWithTween(frame)
else
frame.BackgroundTransparency = 0
endControls
On XPlay, everything comes in as a controller. Even a player on a keyboard shows up to your scripts as Gamepad1. That means checks like input.KeyCode == Enum.KeyCode.E never fire for XPlay players. If an action only has a keyboard key, those players simply can't do it.
The default controls are already handled for you:
| Controller | What it does | Keyboard on XPlay |
|---|---|---|
| Left stick | Move | W A S D |
| Right stick | Camera | Mouse (click to lock it) |
| A | Jump | Space or Enter |
| B | Back out of menus | F or Backspace |
| X | Free for your game | E |
| Y | Free for your game | R |
| LB / RB | Switch tools | Q / C |
| RT | Use the tool you're holding | Left click |
| LT | Free for your game | Right click or Z |
| D-pad | Move around menus | Arrow keys |
| Start (Menu) | Roblox menu, reserved | Esc |
| Select (View) | Jump into your GUI, reserved | Tab |
Don't bind Start or Select. The engine owns those. X, Y, LT and the D-pad (outside of menus) are the safe ones to use for your own stuff.
Binding an action to both a key and a button
ContextActionService lets you give one action several inputs. Bind each action to its keyboard key and a controller button, and it works everywhere:
local ContextActionService = game:GetService("ContextActionService")
local function openDoor(actionName, inputState, inputObject)
if inputState == Enum.UserInputState.Begin then
tryOpenNearestDoor()
end
end
ContextActionService:BindAction("OpenDoor", openDoor, false,
Enum.KeyCode.E, Enum.KeyCode.ButtonX)When the player walks away from the door, call UnbindAction("OpenDoor") so X goes back to doing nothing.
Menus and GUIs
There's no mouse cursor on XPlay, so nobody can click your buttons. Instead, players move a highlight between buttons with the D-pad and press A to "click" whatever is highlighted. Roblox calls this GUI selection, and it works with your existing MouseButton1Click handlers. You just need to set it up.
Make your buttons selectable
TextButton and ImageButton are selectable by default. Frames and labels aren't, and shouldn't be. If you built a "button" out of a Frame with an InputBegan handler, swap it for a real button, or a controller can't reach it.
Select something when a menu opens
By default the player has to press Select (Tab on a keyboard) to start moving through your GUI. For a shop or a team picker that just popped up, that's confusing. Put the highlight on the first button yourself:
local GuiService = game:GetService("GuiService")
local UserInputService = game:GetService("UserInputService")
local function openShop()
shopFrame.Visible = true
if UserInputService.GamepadEnabled then
GuiService.SelectedObject = shopFrame.BuyButton
end
end
local function closeShop()
shopFrame.Visible = false
GuiService.SelectedObject = nil
endAlways clear SelectedObject when you close a menu. If you don't, the highlight stays stuck on an invisible button and the player can't move.
Let B close it
local ContextActionService = game:GetService("ContextActionService")
ContextActionService:BindAction("CloseShop", function(_, state)
if state == Enum.UserInputState.Begin then
closeShop()
end
end, false, Enum.KeyCode.ButtonB)Bind that when the menu opens, and unbind it when it closes.
Fixing weird jumps between buttons
The engine guesses which button is "up" or "left" based on position. For grids and odd layouts it sometimes guesses wrong. Set NextSelectionUp, NextSelectionDown,NextSelectionLeft and NextSelectionRight on a button to point it at exactly the button you want.
Make it readable on a TV
- Text people need to read should be at least 18 to 24 pixels.
TextScaledworks fine too. - Keep important things a little away from the screen edges. Some TVs crop the edges off.
- Don't rely on hover effects. There's no hover without a mouse. Use
SelectionGainedandSelectionLostif you want a button to react when it's highlighted. - Typing goes through an on-screen keyboard, so keep text boxes for things that really need typing.
Layout helpers like UIGridLayout, UIScale andUIAspectRatioConstraint don't exist in 2016. Position things with UDim2 scale values instead, so they stretch with the screen.
Characters and avatars
Missing body parts are almost always caused by newer avatar features. The 2016 client knows R6, and the early version of R15, but none of the avatar systems Roblox added after that.
HumanoidDescriptiondoesn't exist. Morphs and outfit systems built onApplyDescriptionfail outright. Clone a model into the character, or swap clothing and accessories by hand.- Bundles and layered clothing don't work. Players wearing them show up with missing pieces.
- R6 is the safest choice. If your game doesn't need R15, stick with R6. It looks the same on every client and avoids a whole category of bugs.
- If you build characters in a script, use classic
Shirt,Pants,AccessoryandCharacterMeshobjects. Those have been around forever.
Meshes and textures
MeshPart and SpecialMesh both work, and the client can read mesh files up to version 7. The exception is version 7 meshes compressed with Draco, which newer Studio versions sometimes produce. Those show up as invisible parts. If a mesh is missing on XPlay but fine on PC, re-upload it from an older export, or as an .obj, so it's stored without that compression.
A few other things to know:
SurfaceAppearance(PBR materials) isn't supported. The mesh renders with just its basic texture.ViewportFramedoesn't exist, so 3D item previews in your GUI will be blank. Use a decal or thumbnail image there.- Plain decals, textures, and
TextureIDon meshes all work normally.
Detecting XPlay players
Sometimes you want to show different hints, or a bigger GUI, to controller players. The 2016 Xbox client reports itself as a "ten foot" interface, which is Roblox's name for a TV you're sitting across the room from:
local GuiService = game:GetService("GuiService")
if GuiService:IsTenFootInterface() then
hintLabel.Text = "Press X to open the door"
else
hintLabel.Text = "Press E to open the door"
endThis only works in a LocalScript, since it describes the player's screen, not the server. For a check that also covers PC players holding a controller, use UserInputService.GamepadEnabled.
Controller rumble
Rumble is a small touch that makes a game feel a lot more like a console game. A short buzz when the player takes damage or lands a hit goes a long way. Don't leave it running, though. Nobody likes a controller that won't stop shaking.
local HapticService = game:GetService("HapticService")
local function buzz(strength, seconds)
local pad = Enum.UserInputType.Gamepad1
if not HapticService:IsMotorSupported(pad, Enum.VibrationMotor.Large) then
return
end
HapticService:SetMotor(pad, Enum.VibrationMotor.Large, strength)
delay(seconds, function()
HapticService:SetMotor(pad, Enum.VibrationMotor.Large, 0)
end)
end
humanoid.HealthChanged:connect(function()
buzz(0.6, 0.2)
end)Whether it actually rumbles depends on the player's browser and controller. Everyone else just doesn't feel it.
Testing your game
There's no substitute for joining your own game from XPlay. Open the Web Player from the More menu, find your game, and play through it the way a new player would.
- Try it with a controller if you have one. If not, the keyboard works like a controller (see the table under Controls).
- Press Tab to jump into your GUI, use the arrow keys to move around, and Space to press buttons. If you can't reach something, controller players can't either.
- Watch for anything that's missing compared to PC: a part, a GUI, a sound. That's usually a script that failed to compile, or one of the newer features listed above.