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, no task.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, or SurfaceAppearance.
  • 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 thisWrite this
coins += 1coins = coins + 1
continueWrap the rest of the loop body in an if
local x: number = 5local 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 expressionx 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
end

APIs that don't exist yet

These are newer than the 2016 engine. Calling them throws an error, and the script stops right there:

  • FindFirstChildOfClass and FindFirstChildWhichIsA. Loop over GetChildren() and check IsA instead.
  • ContextActionService:BindActionAtPriority. Plain BindAction is fine.
  • ScreenGui.IgnoreGuiInset and GuiService:GetGuiInset().
  • Anything in the task library.

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
end

Controls

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:

ControllerWhat it doesKeyboard on XPlay
Left stickMoveW A S D
Right stickCameraMouse (click to lock it)
AJumpSpace or Enter
BBack out of menusF or Backspace
XFree for your gameE
YFree for your gameR
LB / RBSwitch toolsQ / C
RTUse the tool you're holdingLeft click
LTFree for your gameRight click or Z
D-padMove around menusArrow keys
Start (Menu)Roblox menu, reservedEsc
Select (View)Jump into your GUI, reservedTab

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
end

Always 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. TextScaled works 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 SelectionGained and SelectionLost if 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.

  • HumanoidDescription doesn't exist. Morphs and outfit systems built onApplyDescription fail 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,Accessory and CharacterMesh objects. 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.
  • ViewportFrame doesn't exist, so 3D item previews in your GUI will be blank. Use a decal or thumbnail image there.
  • Plain decals, textures, and TextureID on 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"
end

This 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.
Remember that XPlay players get their own servers, so a friend on PC won't be in the same server as you. Test with someone else on XPlay if you need to check multiplayer.