Skip to content

Client usage

Drive rodeo from Luau via the rvy/rodeo Lune client. This is the same surface the rodeo CLI uses internally — anything you can do from rodeo run is one method call away.

Terminal window
pesde add rvy/rodeo --target lune
pesde install

Then in your script:

local rodeo = require("@pkg/rodeo")

Start the server first (see CLI usage):

Terminal window
rodeo serve --port 44872

Then:

local rodeo = require("@pkg/rodeo")
-- Blocks until the server is reachable (default 30s timeout).
local client = rodeo.connect({ port = 44872 })
-- Snapshot of registered backends and VMs.
local state = client.getState()
print(state.vms)
-- Shut down the client (closes the daemon subprocess).
client.close()

Override the wait with readyTimeoutMs if you need a different deadline:

local client = rodeo.connect({ port = 44872, readyTimeoutMs = 5000 })

backend.open() boots a blank Studio (no place file) and returns once the edit VM is connected.

local backend = client.getLocalStudio()
local studio = backend.open({ background = true })
local result = studio.editVm.runCode({
source = "return 1 + 1",
showReturn = true,
})
print(result.output) --> "2"
print(result.ok) --> true
studio.close()
backend.open({
background = true, -- launch off-screen (default: foreground)
noHud = false, -- hide Studio's HUD panels
fflags = {}, -- FFlag overrides for this Studio
profile = false, -- attach microprofiler
logs = "./logs", -- capture Studio logs to this directory
})

If multiple Studios are connected (e.g. across machines), pick one by id or name:

local backend = client.getStudio("studio-a")

getLocalStudio resolves the Studio on the same machine as rodeo serve.

By place ID:

local studio = backend.openPlace({
placeId = 72824109308551,
background = true,
})
local result = studio.editVm.runCode({
source = "return game.PlaceId",
showReturn = true,
})
print(result.output) --> "72824109308551"
studio.close()

By file path:

local studio = backend.openFile("./my-place.rbxl", { background = true })
studio.editVm.runCode({ source = 'print("editing my-place.rbxl")' })
studio.close()

After script-driven edits, save the place back out:

local result = studio.save()
if result.saved then
print("saved to", result.path)
end

startMultiplayerTest launches an isolated play-test server process and lets you connect simulated clients. Each is its own VM that you target via the returned handles.

local backend = client.getLocalStudio()
-- Launch the server VM. No edit Studio required.
local server = backend.startMultiplayerTest({})
-- Run code on the server.
local sr = server.runCode({
source = "return game:GetService('RunService'):IsRunning()",
showReturn = true,
})
print(sr.output) --> "true"
-- Spawn a client and run code on it.
local client1 = server.connectClient()
local cr = client1.runCode({
source = "return game:GetService('Players').LocalPlayer ~= nil",
showReturn = true,
})
print(cr.output) --> "true"
-- Server sees the connected player.
local sr2 = server.runCode({
source = "return #game:GetService('Players'):GetPlayers()",
showReturn = true,
})
print(sr2.output) --> "1"
-- Add more clients as needed; each is its own VM.
local client2 = server.connectClient()
-- Tear down (clients disconnect implicitly when the server closes).
server.close()

By default startMultiplayerTest uses a blank place. Override with placeId or placeFile:

local server = backend.startMultiplayerTest({
placeId = 12345,
-- or:
-- placeFile = "./my-place.rbxl",
})

To drop a specific client mid-test (e.g. simulate a leave):

client1.disconnect()

Other clients stay connected until server.close().

vm.runCode accepts an options table that controls source, targeting, return capture, log routing, and more.

Pass either inline source or a file path — exactly one:

vm.runCode({ source = 'print("inline")' })
vm.runCode({
file = "./script.luau",
sourcemap = "./sourcemap.json", -- optional, for instance resolution
})

Return data from your Luau script back to the host:

local result = vm.runCode({
source = "return { name = 'Frank', score = 99 }",
showReturn = true,
})
print(result.output)
--> '{ name = "Frank", score = 99 }'

For machine-parseable output, write to a file. .luau / .lua extensions emit Luau source; any other extension emits JSON:

vm.runCode({
source = "return { pos = Vector3.new(1, 2, 3) }",
returnFile = "./out.json", -- or "./out.luau"
})

Stream Studio logs to disk:

vm.runCode({
source = 'print("hello")',
logs = "./.rodeo/logs",
})

Per-run .log files land in that directory.

Filter which categories the runtime captures:

vm.runCode({
source = 'warn("careful")',
logFilter = {
enableWarn = true,
enableError = true,
enableInfo = false,
enableOutput = true,
enableLogs = true,
},
})

Override the VM mode / dom / identity:

vm.runCode({
source = "return game:GetService('Workspace')",
target = "edit:plugin", -- default — DataModel access, plugin identity
})

Common targets:

target stringWhat it runs as
edit:pluginEdit-mode Studio, plugin identity
edit:elevatedEdit-mode Studio, command-bar identity (debugger APIs, @rodeo runtime)
run:serverStandalone server (no clients)
test:serverPlay-test server
test:clientPlay-test client with LocalPlayer
play:clientMulti-client play, client identity

When you hold a multiplayer handle, routing is implicit — you don’t need to set target.

Anything in scriptArgs is available to the script via process.args from the @rodeo runtime:

vm.runCode({
source = [[
local process = require("@rodeo/process")
print(process.args[1])
]],
scriptArgs = { "hello" },
})
local result = vm.runCode({
source = 'print("logging"); return { ok = true }',
target = "run:server",
showReturn = true,
returnFile = "./out.json",
logs = "./logs",
scriptArgs = { "--mode", "ci" },
cacheRequires = true,
})
if not result.ok then
error(`run failed: {result.output}`)
end