Appearance
Rojo Project Structure
Written: 2026-10-14
Learning objectives
- You can read a Rojo tree filesystem-first.
- You can map src folders through default.project.json.
- You can place server, client, and shared code correctly.
- You can predict script class from file suffix.
- You can avoid the duplicate client-run trap.
- You can serve, build, sourcemap, and syncback.
Prerequisites
Think filesystem first
Files are edited on disk. Rojo mirrors those files into Studio. The folder tree is the source of truth, and Studio shows the mirror. The Rojo project format defines how each node maps.
Three source roots cover the realms. src/server holds authority. src/client holds display and input. src/shared holds frozen config and pure helpers. Packages land outside src so nothing is vendored by hand.
json
{
"name": "ShopGame",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$path": "src/server"
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$path": "src/client"
}
},
"ReplicatedStorage": {
"$path": "src/shared"
}
}
}The place name appears once. Each service maps to one folder with $path. $className is set where the instance class differs from the key. Expected behavior: serving this project shows server files under ServerScriptService, client files under StarterPlayerScripts, and shared files under ReplicatedStorage.
Lay out src by realm
Files go by who may run them. Server-only code lives in src/server. Client-only code lives in src/client. Both-side code lives in src/shared. Check this placement before editing.
Secrets, DataStore code, and prices stay server side. The client cannot see ServerScriptService. Display, input, and intent signals stay client side. Frozen tuning and pure math stay shared. This split follows Patterns and Boundaries.
ModuleScript in ReplicatedStorage named ShopConfig
luau
--!strict
local ShopConfig = {
shopName = "Corner Shop",
buyCooldown = 2,
}
table.freeze(ShopConfig)
return ShopConfigSave this file as src/shared/ShopConfig.luau. The table stays frozen. Expected behavior: both server and client require one identical config.
ModuleScript in ServerScriptService named ShopService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local ShopService = {}
function ShopService.label(): string
return ShopConfig.shopName
end
function ShopService.start(_janitor: any, _deps: {}): ()
print(DataService ~= nil)
end
return ShopServiceSave this file as src/server/ShopService.luau. Shared plus a sibling come through the bootstrap pattern, with no sideways require at runtime. Expected behavior: the server reads the frozen shop name and sees its data accessor.
Read the file suffix
Rojo picks the instance class from the file name. Plain .luau becomes a ModuleScript. Suffixed files become runnable scripts. The suffix rule lives in the Rojo sync details: *.server.luau maps to a Script, *.client.luau to a LocalScript, plain files to ModuleScripts.
Bootstraps use suffixes. ServerMain.server.luau becomes server runnable code. ClientMain.client.luau becomes client runnable code. Shared helpers keep plain .luau so they stay modules.
Script in ServerScriptService
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
ShopService.start({}, {})
print(ShopService.label())Save this file as src/server/ServerMain.server.luau. The Service is required statically at the top and started once. Expected behavior: Output prints the shop label once at run.
LocalScript in StarterPlayerScripts
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
print(ShopConfig.shopName)Save this file as src/client/ClientMain.client.luau. Only Shared is required. Expected behavior: each client prints the same shop name once.
Dodge the duplicate client run
A client file in a Starter container can run twice when emitLegacyScripts is false. Rojo builds a Script with RunContext = Client instead of a LocalScript, per the Rojo project format. StarterPlayer copies the script to the player, so the original plus the copy both run.
The trap has two avoidances. Keep the default emitLegacyScripts: true while learning, or route the client entry through ReplicatedStorage and require it from one small starter stub. Verify the current default in the Rojo project format before flipping it.
json
{
"name": "ShopGame",
"emitLegacyScripts": true,
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$path": "src/server"
},
"ReplicatedStorage": {
"$path": "src/shared"
}
}
}Pin the flag explicitly while testing. Keep client runnable files out of Starter containers when the flag is false. Expected behavior: the client entry runs once per player instead of twice.
Keep Packages off the server path
Wally installs two realms. Shared packages land in Packages under ReplicatedStorage. Server-only packages land in ServerPackages under ServerScriptService. Install with Wally and require through WaitForChild.
Never require ServerPackages from client or Shared code. The client cannot see that container. Shared code runs on both sides, so it cannot depend on a server-only folder either.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
if ShopConfig == nil then
return
end
if Janitor == nil then
return
end
if ProfileStore == nil then
return
end
print("ready")Each container is fetched once. Requires sort by local name. Each package handle gets a guard. The Janitor exports follow the Janitor docs and the ProfileStore session shape follows the ProfileStore API. Expected behavior: Output prints ready when both realms installed correctly.
Serve, build, and sourcemap
Serve for live sync. Build for a place file. Emit a sourcemap for editor types. Run each command from the project root where default.project.json lives.
Start rojo serve and connect the Studio plugin. Edit on disk and watch Studio mirror the change. Run rojo build -o game.rbxlx for a one-shot file to open. Run rojo sourcemap --output sourcemap.json after adding files so types resolve. The Lune CLI command lune run script-name covers script running outside this loop.
text
rojo serve
rojo build -o game.rbxlx
rojo sourcemap --output sourcemap.jsonServe during edits. Build before sharing a file. Refresh the sourcemap after renames. Expected behavior: Studio mirrors disk edits, the build opens cleanly, and requires resolve in your editor.
Pull a place back once
An existing place ports with one syncback. Flags drift between Rojo versions, so read the installed toolchain first (rojo syncback --help), then run the project-root command it documents against your place file and review the diff. Only descendants of mapped tree nodes land on disk.
text
rojo syncback --helpBack up first. Run with a dry-run flag when the installed build supports one (check rojo syncback --help first). Move ported scripts into src/server, src/client, or src/shared by realm. Expected behavior: place descendants appear as files under your mapped folders without touching unmapped nodes.
Common mistakes
Mistake 1: you map one folder to the wrong service.
Wrong tree below. It points ReplicatedStorage at server code.
json
{
"name": "ShopGame",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$path": "src/server"
}
}
}Secrets replicate to every client. The client can read DataStore code. Fix it by mapping src/server under ServerScriptService and src/shared under ReplicatedStorage, as shown above.
Mistake 2: you run the client twice.
Wrong layout below. It drops a RunContext = Client script in a Starter container with emitLegacyScripts: false, which double-runs per the Starter container copy rule in the scripting locations guide.
json
{
"name": "ShopGame",
"emitLegacyScripts": false,
"tree": {
"$className": "DataModel",
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$path": "src/client"
}
}
}
}The original plus the player copy both run. UI doubles and intents fire twice. Fix it by keeping the default flag or by routing the entry through ReplicatedStorage.
Mistake 3: you require ServerPackages from Shared.
Wrong example below. It reaches server-only from both sides.
ModuleScript in ReplicatedStorage named ShopConfig
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
local ShopConfig = {
store = ProfileStore,
}
table.freeze(ShopConfig)
return ShopConfigIt fails on the client because ServerScriptService never replicates there. Fix it by keeping persistence requires inside src/server only and by passing data through start args or Shared values.
Exercises
- Draw a three-root tree for a shop game.
- Name two files so Rojo builds one ModuleScript and one server Script.
- Fix a double-run client entry.
Solution 1
json
{
"name": "ShopGame",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$path": "src/server"
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$path": "src/client"
}
},
"ReplicatedStorage": {
"$path": "src/shared"
}
}
}Expected behavior: each realm maps to exactly one source root.
Solution 2
text
src/shared/ShopMath.luau
src/server/ServerMain.server.luauModuleScript in ReplicatedStorage named ShopMath
luau
--!strict
local ShopMath = {}
function ShopMath.total(price: number, count: number): number
if count < 0 then
return 0
end
return math.floor(price * count)
end
return ShopMathExpected behavior: the plain file builds a ModuleScript while the suffixed file builds runnable server code.
Solution 3
json
{
"name": "ShopGame",
"emitLegacyScripts": true,
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$path": "src/shared"
}
}
}Expected behavior: the client entry runs once while you verify the flag default in the project format reference.
Summary
Read the tree first. Map each realm to one folder. Suffix runnable files and keep helpers plain. Pin emitLegacyScripts with intent. Respect the Packages split. Serve, build, sourcemap, and syncback from the root. Next, test small pieces in Testing.