About roblox-studio-emulation
roblox-studio-emulation is a testing skill for Roblox Studio workflows, aimed at developers who need to verify mobile, multiplayer, and edge-case behavior without relying solely on solo playtests. It explains Studio menu automation, remote-event testing, and how to report verification limits.
Key features
- 01Emulate touch and device conditions
- 02Drive Studio Test menu with AppleScript
- 03Test multiplayer flows through real remotes
- 04Set up scenarios with client attributes
- 05Distinguish live, logic, and untested results
Use cases
- 01Check touch-only UI behavior
- 02Exercise party state updates without a second client
- 03Test branches using synthetic inputs
MCP support
The skill is for use alongside the Roblox Studio MCP tools; it references `list_roblox_studios`, `set_active_studio`, and `start_stop_play` for reconnecting after emulator changes.
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ flatbedj/ roblox-skills --skill roblox-studio-emulationAfter installing, type/roblox-studio-emulationin Claude Code to confirm it loaded.
Details
From the project READMEEmulating conditions a solo playtest can't reach
Two whole classes of testing look impossible from the MCP tools alone, so it's tempting to punt them to the user. Both are self-serviceable. Check here before claiming you need the user to test something.
For the normal dev loop see roblox-studio-workflow; for the MCP toolbox see roblox-studio-mcp.
1. Device emulation (mobile / tablet / touch) — via Studio's menu bar
Studio exposes no windows to macOS Accessibility (count windows → 0), so you can't resize it.
But it exposes its entire menu bar, which is enough to drive the Test menu.
# one-time: the user must grant Terminal (or your host app) Accessibility permission
# System Settings → Privacy & Security → Accessibility
# symptom if missing: "osascript is not allowed assistive access. (-1719)"
osascript -e 'tell application "Roblox Studio" to activate'
osascript -e 'tell application "System Events" to tell process "RobloxStudio" \
to click menu item "Device Emulator" of menu 1 of menu bar item "Test" of menu bar 1'
Process name is RobloxStudio (not "Roblox Studio", not "RobloxStudioBeta").
Verify it took, don't assume — start Play and read the real values:
local cam = workspace.CurrentCamera
local UIS = game:GetService("UserInputService")
-- desktop: ~1920x1080, TouchEnabled=false
-- emulated: device-sized viewport, TouchEnabled=TRUE
return cam.ViewportSize, UIS.TouchEnabled
TouchEnabled = true is the signal that touch-only code paths (thumbstick clearance, touch layout
branches, POS_SHOWN offsets) are now live.
The full Test menu (useful items):
| Item | Notes |
|---|---|
Device Emulator | toggles emulation |
Controller Emulator / Player Emulator | gamepad / player-state emulation |
Start Test Session | required before Add Clients |
Add Clients | disabled in Play Solo; enabled inside a Test Session |
Toggle Client View, Pause Client, End Session | multi-client session control |
Enumerate + check enabled state before clicking:
osascript -e 'tell application "System Events" to tell process "RobloxStudio" \
to get name of every menu item of menu 1 of menu bar item "Test" of menu bar 1'
osascript -e 'tell application "System Events" to tell process "RobloxStudio" \
to get enabled of menu item "Add Clients" of menu 1 of menu bar item "Test" of menu bar 1'
Limits
- Picking a specific device (iPhone vs iPad vs …) uses an in-window dropdown that is NOT AX-exposed. You get whatever device is currently selected. Ask the user to set it once if you need a particular width — narrow phones are where layout actually breaks.
- Toggling the emulator ends the current Play session and can drop the MCP link. Re-point with
list_roblox_studios→set_active_studio, thenstart_stop_play. - Toggle it back OFF when done, or every later screenshot is a touch device.
2. Multiplayer features WITHOUT a second client — fire the real remotes
This is usually faster and more precise than launching a multi-client session. The Server and
Client VMs share the DataModel, so a FireClient from the Server VM reaches the real client
modules and drives everything downstream — signal handlers, cached state, UI refreshes.
-- datamodel_type = "Server"
local Remotes = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").Remotes)
local plr = game.Players:GetPlayers()[1]
-- pretend the server told us we joined someone's party
Remotes.event("PartyUpdated"):FireClient(plr, {
ownerUserId = 99999999, ownerName = "TestHost",
members = { { userId = plr.UserId, name = plr.DisplayName } },
})
Then assert on observable instance state from the Client VM (never module internals — separate
VM, require gives a phantom copy):
-- datamodel_type = "Client"
local on = 0
for _, p in game:GetService("CollectionService"):GetTagged("Plot") do
for _, d in p:GetDescendants() do
if d:IsA("ProximityPrompt") and d.Name == "PartyPrompt" and d.Enabled then on += 1 end
end
end
return on
Always test the round trip — fire the "joined" state, assert, then fire the "left" state
(FireClient(plr, nil)) and assert it reverts. A one-way test hides stuck state, which is the
scarier bug (a prompt stuck hidden = the feature is dead for that player).
Faking the preconditions
Client-side attribute writes are enough to set up a scenario, because client code reads the same
attributes and GetAttributeChangedSignal fires locally:
-- datamodel_type = "Client" — pretend another player owns this plot
workspace.Map.Plots.Plot1:SetAttribute("OwnerUserId", 99999999)
-- ... run the test ...
workspace.Map.Plots.Plot1:SetAttribute("OwnerUserId", nil) -- ALWAYS clean up
When this does NOT work
Code that requires the faked user to be a real online Player can't be faked this way. Example:
a poll loop that does Players:GetPlayerByUserId(owner) and tears down anything failing that check
will immediately undo your setup. Symptom: the thing you created vanishes within a second.
Fall back to either:
Test ▸ Start Test Session ▸ Add Clientsfor a genuine second player, or- unit-test the pure logic with synthetic inputs in the Client VM — mirror the shipped guard/branch verbatim and table-test it:
local cases = {
{ "nil payload", nil, true },
{ "empty string", "", true },
{ "garbage base64", "!!!not-base64!!!", true },
{ "short buffer", Base64.encode(short),true },
{ "valid buffer", Base64.encode(full), false },
}
-- assert blocked == expected for each; report N/N
That's a real, reportable result — just be explicit that it verifies the logic, not the full render path.
Reporting honestly
Distinguish these three, every time:
- Verified live — drove it and observed the effect.
- Logic-verified — table-tested the branch with synthetic inputs; full path untested.
- Untested — say so, and say exactly what it needs.
Never let "it compiles and looks right" be reported as verified.