In short
Turn off CharacterAutoLoads on the Players service so nobody spawns on join. A LocalScript in StarterPlayerScripts builds the menu (a title, a Play button and a black frame for the fades) in ScreenGuis with ResetOnSpawn off, points the camera at a part named MenuCamera with CameraType Scriptable, and on Play fires a RemoteEvent. A Script in ServerScriptService answers with Player:LoadCharacterAsync(), and the LocalScript hands the camera back (CameraType Custom, CameraSubject the Humanoid) and removes the menu.
- Use
Activated, notMouseButton1Click, so Play works with a click, a tap and a controller, and select the button for controller players so they can press it. - With CharacterAutoLoads off, StarterGui is only copied into a player's PlayerGui when their character spawns, so the menu is built by a LocalScript in StarterPlayerScripts instead.
- With CharacterAutoLoads off, Roblox also stops respawning players, so the server script respawns them after a death or a fall off the map, waiting
Players.RespawnTime(5 seconds by default).
On this page
How a main menu works
A menu has two sides. The player's device draws the title and the button and moves the camera. The server decides when a character exists. The menu itself is only a picture on one screen: the real change happens when the server spawns the character, and the server is the only side that can do that.
| Piece | Where it lives | What it does |
|---|---|---|
MenuService Script | ServerScriptService | Turns off automatic spawning, creates PlayRequest, spawns a player who presses Play, and respawns players after a death or a fall. |
MainMenu LocalScript | StarterPlayerScripts | Builds the title, the Play button and the fade, runs the camera shot, asks the server for a character and hands the camera back. |
PlayRequest RemoteEvent | ReplicatedStorage (MenuService creates it, so do not add one by hand) | Carries one message to the server: this player pressed Play. It carries no values. |
MenuCamera Part | Workspace (you place it) | Where the menu camera sits and which way it looks. |
CharacterAutoLoads | A property of the Players service | Turned off, so nobody spawns until the server calls LoadCharacterAsync. |
Workspace
MenuCamera Part new
ReplicatedStorage
PlayRequest RemoteEvent new
ServerScriptService
MenuService Script new
StarterPlayer
StarterPlayerScripts
MainMenu LocalScript new
Step 1: Turn off auto spawning and place the menu camera
- In the Explorer, select Players. In the Properties window, untick CharacterAutoLoads. MenuService turns it off too, but the box makes sure nobody spawns in the moment before the script runs.
- Hover over Workspace, click the + button and insert a Part. Rename it
MenuCamera(names are case-sensitive) and keep it directly in Workspace, not inside a folder or model. - In its Properties, tick Anchored, untick CanCollide and set Transparency to 1. Anchoring matters: an unanchored part that collides with nothing falls through the map, and Roblox removes parts that fall below
Workspace.FallenPartsDestroyHeight. - Move and rotate the part so its front face points at the view you want. The camera takes the part's position and looks the same way as its front face.
The quickest way to aim it: fly Studio's camera to the shot you want, open the Command Bar (on the Script tab, or Window, then Script) and run workspace.MenuCamera.CFrame = workspace.CurrentCamera.CFrame. In edit mode, CurrentCamera is the view you are looking through, so the part jumps to exactly that shot.
Leave Workspace.StreamingEnabled as it is. New places have instance streaming on, and the scripts below are written for it.
Step 2: Spawn players when they press Play
Hover over ServerScriptService, insert a Script, name it MenuService and paste this in:
-- Script in ServerScriptService named MenuService.
-- Players join with no character. The server spawns one when the menu's Play button
-- asks, and respawns players after they die, since automatic spawning is turned off.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Also untick CharacterAutoLoads on the Players service in the Properties window,
-- so nobody spawns in the moment before this script runs.
Players.CharacterAutoLoads = false
-- This script makes PlayRequest itself, so do not add one to ReplicatedStorage by hand
local playRequest = Instance.new("RemoteEvent")
playRequest.Name = "PlayRequest"
playRequest.Parent = ReplicatedStorage
local spawning: { [Player]: boolean } = {}
local function spawnCharacter(player: Player)
if spawning[player] then
return -- a second Play click while the first spawn is still loading
end
spawning[player] = true
local oldCharacter = player.Character
local ok, err = pcall(function()
player:LoadCharacterAsync()
end)
spawning[player] = nil
if not ok then
warn(`MenuService: could not spawn {player.Name}: {err}`)
-- A failed respawn would leave a dead player stuck, so try again after a short wait.
-- A failed first spawn needs no retry: the menu's button says Try again.
if oldCharacter then
task.delay(Players.RespawnTime, function()
local current = player.Character
if player.Parent == Players and (current == oldCharacter or current == nil) then
spawnCharacter(player)
end
end)
end
elseif oldCharacter and oldCharacter ~= player.Character then
oldCharacter:Destroy() -- releases the old character's connections
end
end
-- Only a player still in the menu (no character yet) can ask to spawn. A dead
-- player still has a character, so they cannot use this to skip the respawn wait.
playRequest.OnServerEvent:Connect(function(player: Player)
if player.Character == nil then
spawnCharacter(player)
end
end)
-- With CharacterAutoLoads off, nothing respawns players after they die, so do it here
local function onCharacterAdded(player: Player, character: Model)
local respawning = false
local function respawnLater()
if respawning then
return
end
respawning = true
task.wait(Players.RespawnTime)
-- Respawn only if they are still here and nothing has spawned them since
local current = player.Character
if player.Parent == Players and (current == character or current == nil) then
spawnCharacter(player)
end
end
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
humanoid.Died:Connect(respawnLater)
-- Falling off the map can remove the character from Workspace, and Died only fires
-- while the Humanoid is in Workspace, so this catches that case too
character.AncestryChanged:Connect(function()
if not character:IsDescendantOf(workspace) then
respawnLater()
end
end)
end
local function onPlayerAdded(player: Player)
player.CharacterAdded:Connect(function(character: Model)
onCharacterAdded(player, character)
end)
-- With streaming on, the map streams in around a player's character, and a player in
-- the menu has none yet. Add a replication focus at the menu camera until they spawn.
local menuCamera = workspace:FindFirstChild("MenuCamera")
if menuCamera and menuCamera:IsA("BasePart") then
player:AddReplicationFocus(menuCamera)
player.CharacterAdded:Once(function()
player:RemoveReplicationFocus(menuCamera) -- from now on the character is the focus
end)
end
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
onPlayerAdded(player)
end
Players.PlayerRemoving:Connect(function(player: Player)
spawning[player] = nil
end)
PlayRequest carries no values. Roblox passes the player who fired it as the first argument of OnServerEvent, and that is all the server needs to know.
Who may spawn
The PlayRequest handler spawns a player only if player.Character is nil, which is the case for a player still in the menu. A player who has died keeps their character until MenuService respawns them, so they cannot use the remote to skip the wait.
The spawning table stops a second request from starting a second spawn while the first is still loading. LoadCharacterAsync waits until the character has loaded, and Roblox's reference advises against calling it again for the same player before their CharacterAppearanceLoaded event has fired. Each call runs inside pcall, so a spawn that fails, for example because the player left while it loaded, prints a warning naming the player instead of an error. If a respawn fails, MenuService waits RespawnTime and tries again, so a failed respawn does not leave a dead player stuck. A first spawn that fails is not retried by the server: the menu's button says Try again, and the player can press it.
Respawning after a death or a fall
With CharacterAutoLoads off, Roblox respawns nobody. Players.RespawnTime (5 seconds by default) only applies on its own while CharacterAutoLoads is on, so MenuService reads that value, waits itself and calls LoadCharacterAsync again. The new character appears at a SpawnLocation as usual, and the script destroys the old one, which also disconnects that character's connections.
Falling off the map needs a second check. Roblox removes parts that fall below Workspace.FallenPartsDestroyHeight, and removes a model too once its last part has gone, so a fallen character can leave Workspace, and Humanoid.Died only fires while the Humanoid is in Workspace. MenuService therefore also respawns a character that has left Workspace (AncestryChanged), and the respawning flag makes sure each character causes one respawn at most. After the wait, it respawns the player only if they are still in the game and player.Character is still that character or is nil, so a player who has been spawned some other way in the meantime is not spawned twice.
Streaming and the menu camera
With instance streaming on, each client receives the parts around its replication focus, which by default is its character's PrimaryPart. A player in the menu has no character, and Roblox's streaming techniques page warns that a client whose character has not spawned has no replication focus, so the map may never stream in. The fix that page gives is Player:AddReplicationFocus(), which MenuService calls with the MenuCamera part when the player joins (Roblox says to call it only on the server). When the character first spawns, MenuService calls RemoveReplicationFocus, so the character is the only focus again: each extra focus adds streaming work for the server. Step 5 includes a playtest with streaming on to confirm that the map shows behind the menu.
What the server decides, and what a cheater can do
A cheater can run their own code on their device. They can fire PlayRequest whenever they like, with any values, and change anything on their own screen. This is what each of those gets them.
| A cheater | What happens |
|---|---|
| Fires PlayRequest without pressing Play | They spawn, exactly as if they had pressed Play. Skipping a menu gains nothing. |
| Fires PlayRequest hundreds of times | One spawn. While it loads, the spawning flag ignores the rest; after it, they have a character, so every request is refused with a single check. That is why this remote needs no rate limit. |
| Fires PlayRequest while dead, to respawn early | Refused. A dead player keeps their character until MenuService respawns them. |
| Sends extra values with PlayRequest | Ignored. The handler never reads them. |
| Deletes the menu or moves their own camera | Only their own screen changes. They still have no character until the server spawns one. |
Stated plainly: the menu is a screen, not a lock. It decides when a player spawns and nothing else. If a menu button ever gives something, such as a skin, a VIP door or coins, a server Script must check it, the way the shop GUI guide checks every purchase. The RemoteEvents guide covers checking what a remote receives.
Step 4: Make it look like your game
The scripts draw a plain menu on purpose, so the logic is easy to follow. Everything below changes the GUI part of MainMenu; leave the camera and Play code as it is.
- A logo instead of the title text. Upload your logo image in Studio or Creator Hub, then replace the title
TextLabelwith anImageLabelwhoseImageisrbxassetid://followed by the image's asset id. Give itBackgroundTransparency1 andScaleTypeFit so it keeps its shape. - A blurred background. Create a
BlurEffectin MainMenu and parent it toworkspace.CurrentCamera. Roblox suggests this for menus: an effect under the camera shows only to that player. ItsSizeis the blur radius in pixels. Destroy it in the Play handler, next togui:Destroy(). - Colour. Add a
UIGradientto the button, or aUIStrokefor an outline. Keep the textTextScaledso it fits small screens. - More buttons. Put the buttons in a
Framewith aUIListLayoutand somePadding, and give each one anActivatedhandler and its ownUISizeConstraint. A Settings or Help button can open a panel on the client alone. A Shop button can open the window from the shop GUI guide, whose purchases still happen on the server.
Step 5: Test it
- Open the Output window (Window menu, then Output) and press Play. The screen fades in on your title and the Play button over the MenuCamera shot, and the player list and backpack are hidden. In the Explorer, Workspace has no model with your name: you have no character yet.
- Press the Play button. It says "Loading...", the screen fades to black, then fades in behind your character with the normal camera, and the player list comes back. Under
Players, your name,PlayerGui, there is no MainMenu or MenuFade left. - Reset your character from the in-game menu. You respawn after about 5 seconds, and the menu does not come back.
- Walk or jump off the edge of the map. Once your character has fallen out of the world, you respawn about 5 seconds later.
- Check a phone screen with Studio's Device Simulator (a beta at the time of writing: turn on New Device Simulator under File, Beta Features, and restart Studio). On a small phone the Play button stays at least 160 by 56 pixels and can be tapped.
- Check a controller with the Controller Emulator (Controller, on the Emulation tab) during a playtest. Press a button on the emulated pad: Play is selected, and pressing A starts the game. If Play is not selected, press the pad's Select button, which selects a button while
GuiService.AutoSelectGuiEnabledis on, and then A. - With instance streaming on, which new places have, check that the map around MenuCamera shows behind the menu, not only sky.
Output should show no red errors from MenuService or MainMenu at any point. If something does not match, see Common errors.
Using the menu with other systems
Turning off CharacterAutoLoads changes things for every other script in the game. If you followed other guides on this site, check these.
| System | What changes | What to do |
|---|---|---|
| Round system | Players still in the menu count towards MIN_PLAYERS, because the round counts everyone in the server, but they are never teleported: startRound only takes players with a living character. Knocked-out players are respawned by MenuService instead of by Roblox. | Nothing is required. If players in the menu keep a round from starting ("Not enough players, starting over"), count only players whose Character is not nil in runIntermission. |
| Obby checkpoints | ObbyCheckpoints also turns off CharacterAutoLoads, and it spawns each player as soon as their stage has loaded. With both scripts, players spawn at their checkpoint behind the menu, and Play only removes the menu. | Use one spawner. Keep ObbyCheckpoints and delete MenuService. In ObbyCheckpoints, delete the spawnAtStage(player) line at the end of onPlayerAdded. Then, at the top level of the script, just above Players.PlayerAdded:Connect(onPlayerAdded) and not inside onPlayerAdded, copy MenuService's ReplicatedStorage line and the lines that create PlayRequest, and connect its OnServerEvent handler. In the handler, call spawnAtStage(player) only when player.Character is nil, stageValues[player] exists and no spawn is already running for that player (a table like MenuService's spawning). A player who presses Play before their stage has loaded sees Try again and can press it again. Its respawns stay as they are. If your place uses streaming, also copy MenuService's MenuCamera lines (AddReplicationFocus and RemoveReplicationFocus) to the start of onPlayerAdded. |
| Leaderboard and DataStore scripts | They run on join, while the player is in the menu. | Nothing. The player's data starts loading while they look at the menu. |
| Shop GUI | ShopClient builds its window as soon as the player joins, so its Shop button would sit next to the menu. | In ShopClient, add if not player.Character then player.CharacterAdded:Wait() end just before the line gui.Parent = player:WaitForChild("PlayerGui"), so the shop appears after Play. |
| ScreenGuis in StarterGui | They are copied to the player only when the character spawns. | Nothing. They appear after Play, which is usually what you want for a HUD. |
The split between scripts in StarterPlayerScripts and StarterGui is covered in Script vs LocalScript vs ModuleScript.
Common errors and fixes
Open the Output window (Window menu, then Output) before you press Play. Most menu problems print a warning or an error there, naming the script and the line.
The camera stays on the menu shot after Play
MainMenu did not reach the step that hands the camera back, or another script changed the camera afterwards. Look in Output for a red error from MainMenu. If one of your own scripts also sets CameraType, make sure it does not set Scriptable once the player is in the game.
The button says "Loading..." and then "Try again"
MainMenu found PlayRequest, so MenuService is running, but no character with a Humanoid arrived within 15 seconds. If you added a PlayRequest to ReplicatedStorage yourself, delete it: MenuService makes its own, and with two of the same name the button can fire one while the server listens to the other. Then look in Output for MenuService: could not spawn followed by the reason. If you use a custom StarterCharacter, its Humanoid must be named Humanoid, because both scripts wait for a child with that name. If the spawn was only slow, pressing Try again finishes it.
Infinite yield possible on 'ReplicatedStorage:WaitForChild("PlayRequest")'
MainMenu is waiting for the RemoteEvent that MenuService creates, so MenuService is not running: it is disabled, in the wrong place, or stopped by an error. The script errors guide explains this warning.
My character spawns behind the menu
Something else spawns players. Untick CharacterAutoLoads on the Players service, and look for another script that calls LoadCharacterAsync on join, such as an obby checkpoint script: see Using the menu with other systems.
The menu shows only sky
The camera is using the fallback view, or the map has not streamed in. Check that the part is named exactly MenuCamera, sits directly in Workspace (not in a folder or model), is anchored, and that its front face points at the map.
After dying I never respawn
MenuService is the only thing that respawns players now, so check Output for an error from it. A respawn that fails prints MenuService: could not spawn and is tried again after RespawnTime. If your game turns off the Humanoid's Dead state, Died never fires, so respawn the player from your own death code instead.
My HUD from StarterGui only appears after Play
That is expected. With CharacterAutoLoads off, Roblox copies StarterGui into the player's PlayerGui only when the character spawns. For something that must show in the menu, build it from a LocalScript in StarterPlayerScripts, as MainMenu does.
Build it with RoCode
If you would rather not wire this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the feature in its web chat, and its Studio plugin creates the part and the scripts in your Explorer, so you are not copying code out of a chat window.
Add a main menu: players should not spawn until they press Play. Show my game's title and a Play button over a slow camera shot from a part named MenuCamera, fade to black, spawn the player from the server, and give the normal camera back. It has to work on phones and with a controller.
- Can check your place first with Search Scripts, for example for a script that already turns off CharacterAutoLoads or respawns players (an obby checkpoint script does), so the two do not fight
- Creates the MenuCamera part with Create Instance and turns off
Players.CharacterAutoLoadswith Set Property - Writes MenuService and MainMenu with Create Script
- Is told to check each script it writes with Check Script, which compiles it inside Studio without running it, and sets an undo point before and after each batch of changes
RoCode cannot upload a logo image or menu music for you: upload them in Studio or Creator Hub and give RoCode the asset ids. With plugin 3.9 or newer RoCode can run a short playtest and read the errors, but it does not press buttons or move a character, so you click through the menu yourself. For the order to build a whole game in, see making a Roblox game with AI, system by system. How RoCode connects to Studio.
Questions
How do I make the player not spawn until they press Play?
Untick CharacterAutoLoads on the Players service, then spawn the player from a server Script with player:LoadCharacterAsync() when the Play button's RemoteEvent arrives. Respawns after a death then need the same call, which MenuService makes.
Why does my main menu come back every time I die?
Its ScreenGui has ResetOnSpawn on, so Roblox gives the player a new copy at every spawn. Turn it off and remove the menu after Play, as MainMenu does.
How do I make the camera show my map behind the menu?
In a LocalScript, set workspace.CurrentCamera.CameraType to Scriptable and set the camera's CFrame to that of a part you placed. After Play, set it back to Custom and make the Humanoid the CameraSubject.
How do I add a loading screen before the menu?
Roblox's loading screen guide uses a LocalScript in ReplicatedFirst, which reaches the player before anything else. It builds a ScreenGui, calls ReplicatedFirst:RemoveDefaultLoadingScreen(), waits until game:IsLoaded() and then removes its screen. Give that ScreenGui a DisplayOrder above 11 so it covers this menu until it goes.
How do I add music to my main menu?
Use audio you have permission to use, such as a free track from the Creator Store or audio you uploaded yourself. Put a Sound with that id in SoundService, tick Looped, call :Play() on it from MainMenu when the menu appears and :Stop() in the Play handler. A sound played from a LocalScript is heard only by that player while SoundService.RespectFilteringEnabled is on, which is the default.
Can I use a free model main menu?
You can, but read its scripts first. Roblox warns that Creator Store models can contain backdoor scripts that give their makers access to your game. A menu needs only the two short scripts in this guide.
Sources and further reading
- Roblox Engine API: Players. CharacterAutoLoads and RespawnTime
- Roblox Engine API: Player. LoadCharacterAsync, its load order, and AddReplicationFocus
- Roblox Creator Docs: On-screen UI containers. when StarterGui is copied, screen insets and display order
- Roblox Engine API: LayerCollector. ResetOnSpawn
- Roblox Engine API: Camera. Focus, the point the camera is looking at
- Roblox Creator Docs: Customize the camera. scripting the camera with CameraType Scriptable
- Roblox Creator Docs: Streaming techniques and conversion. why a client with no character may receive no map, and replication focus
- Roblox Engine API: GuiButton. Activated for a click, a tap and a controller button
- Roblox Creator Docs: Loading screens. custom loading screens in ReplicatedFirst
- Roblox Creator Docs: Vulnerabilities from third-party assets. backdoor scripts in free models
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 13 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.