---
url: /intermediate/rojo-project-structure.md
description: Filesystem-first workflow, project files, realms, and the core Rojo commands.
---

# Rojo Project Structure

*Written: {{ $frontmatter.date }}*

## 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

* [Modules and Require](../luau-basics/modules-and-require.md)
* [Scripts and Execution](../roblox-fundamentals/scripts-and-execution.md)
* [Patterns and Boundaries](./patterns-and-boundaries.md)

## 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](https://raw.githubusercontent.com/rojo-rbx/rojo.space/master/docs/project-format.md) 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](./patterns-and-boundaries.md).

**ModuleScript in ReplicatedStorage named ShopConfig**

```luau
--!strict
local ShopConfig = {
	shopName = "Corner Shop",
	buyCooldown = 2,
}
table.freeze(ShopConfig)
return ShopConfig
```

Save 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 ShopService
```

Save 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](https://rojo.space/docs/v7/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](https://rojo.space/docs/v7/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](https://raw.githubusercontent.com/rojo-rbx/rojo.space/master/docs/project-format.md) 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](https://github.com/UpliftGames/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](https://docs.howmanysmall.com/Janitor/api/) and the ProfileStore session shape follows the [ProfileStore API](https://madstudioroblox.github.io/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](https://lune-org.github.io/docs/getting-started/2-command-line-usage/) command `lune run script-name` covers script running outside this loop.

```text
rojo serve
rojo build -o game.rbxlx
rojo sourcemap --output sourcemap.json
```

Serve 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 --help
```

Back 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](https://create.roblox.com/docs/en-us/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 ShopConfig
```

It 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

1. Draw a three-root tree for a shop game.
2. Name two files so Rojo builds one ModuleScript and one server Script.
3. Fix a double-run client entry.

::: details 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.
:::

::: details Solution 2

```text
src/shared/ShopMath.luau
src/server/ServerMain.server.luau
```

**ModuleScript 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 ShopMath
```

Expected behavior: the plain file builds a ModuleScript while the suffixed file builds runnable server code.
:::

::: details 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](../tooling/testing.md).
