Overview
Folder layouts that work well with Rogen, and when to pick each.
Core Concepts explains how Rogen reads a tree. These pages are about which tree to write. Each one describes a layout that works well for a Roblox game, where it comes from, and what it costs.
Two Axes
A Roblox codebase can be split along two axes:
- Where code runs: server, client or both. Roblox requires this split at runtime.
- What code is about: inventory, combat, trading.
Rojo maps folders one-to-one onto instances, so without Rogen the first axis has to be the top-level split. Rogen reverses that. Every layout on these pages puts features (or layers, then features) at the top, and puts Server/, Client/ and Shared/ inside them.
Comparison
| Feature folders | Layered | ECS by feature | |
|---|---|---|---|
| Top level | One folder per feature | base, platform, core, features, app | One folder per feature |
| Inside it | Server/, Client/, Shared/ | Features, each with Server/, Client/, Shared/ | Components in Shared/, systems per side |
| Dependency rule | Features stay independent | Layers only require layers below | Systems read components, not each other |
| Extra config | None | Optional invisible layer folders | None |
| Costs | Shared code needs a home | More folders, a decision per module | A new model to learn |
Pick This If…
- Feature folders, if you're starting out or migrating from
server/client/shared. It's the baseline the other two build on, and the smallest change to how you already write code. - Layered, if your codebase has grown enough that utilities, infrastructure and gameplay blur together, and you want a rule that says which module may require which.
- ECS by feature, if you already use Jecs or Matter, or your game has many entities sharing behaviour: mobs, projectiles, vehicles.
The layouts combine. A layered codebase has feature folders in core/ and features/, and an ECS codebase can sit inside features/.
Prior Art
Rogen's layout wasn't invented for Roblox. Large codebases that run in several environments already put the feature first and the environment second:
- VS Code organises its code in layers, and inside each layer by the runtime it targets. Workbench features live in
contrib/<feature>/and are split again by runtime:contrib/terminalhascommon/,browser/andelectron-browser/folders (see the source code organization wiki page). ReadcommonasShared,browserasClientandnodeasServer, and that's a Rogen feature folder. - Nevermore, Quenty's Roblox package library, puts
Client/,Server/andShared/inside every package (for exampleragdoll). Its loader splits them at runtime: "Modules will be split between client/server/shared based upon their parent". Rogen does the same split at build time, so Rojo syncs every script straight to its service.
Enforcing the Rules
Every rule on these pages is a rule about paths: platform doesn't require features, one feature doesn't require another, Shared doesn't require Server. Feature folders put the layer, the feature and the side in every file's path, so a linter can check each rule by comparing two paths.
Technical grouping can't express these rules. InventoryController sits in client/Controllers/ beside every other controller, and nothing in its path says it belongs to the inventory, so "the inventory doesn't require trading" can't be written down, let alone checked.
Rogen routes files. It doesn't read your require calls, so the checking is up to your tools. Roblox enforces one rule for you: ServerScriptService isn't replicated, so a client can't require server code at all. A shared module that requires server code only fails when a client loads it, and a feature that requires another's internals works fine until someone changes them.
roblox-ts
ESLint's import/no-restricted-paths checks imports against folders. This config enforces the layered rules, with a zone per feature for the sideways rule:
import { readdirSync } from "node:fs";
import importPlugin from "eslint-plugin-import";
const features = readdirSync("src/features");
export default [
{
files: ["src/**/*.ts"],
plugins: { import: importPlugin },
settings: { "import/resolver": { typescript: true } },
rules: {
"import/no-restricted-paths": ["error", {
zones: [
// Layers only import the layers below them.
{ target: "./src/base", from: ["./src/platform", "./src/core", "./src/features", "./src/app"] },
{ target: "./src/platform", from: ["./src/core", "./src/features", "./src/app"] },
{ target: "./src/core", from: ["./src/features", "./src/app"] },
{ target: "./src/features", from: "./src/app" },
// Features don't import each other.
...features.map((name) => ({
target: `./src/features/${name}`,
from: "./src/features",
except: [`./${name}`],
})),
// Shared code runs on both sides.
{ target: "./src/**/shared/**", from: ["./src/**/server/**", "./src/**/client/**"] },
{ target: "./src/**/server/**", from: "./src/**/client/**" },
{ target: "./src/**/client/**", from: "./src/**/server/**" },
],
}],
},
},
];The resolver setting needs eslint-import-resolver-typescript. For feature folders, the side zones work as they are. Features there may import the helper features that hold shared code, so give each feature a zone whose except lists the helpers it may use.
Luau
Luau has no common linter for requires yet. The check is the same comparison, though: the path of the requiring file against the path it requires. A short script in CI can do it, and until you have one, state the rules in your AGENTS.md and in review.
Why It Pays Off
- Smaller blast radius. When features can't require each other, a change inside
features/Shopcan only break the shop. A pull request's file list tells you what it can affect, before you read a line. - Deleting a feature is safe. Nothing outside a feature requires it, so removing its folder can't break another one.
- Teams work in parallel. Two people on two features touch different folders, and a crossed boundary fails in CI instead of surfacing in review weeks later.
- Agents stay inside the lines. An agent runs the linter, sees the boundary it crossed, and fixes it before you review.
AGENTS.mdstates the intent, and the linter checks it. See Working with agents. - The architecture doesn't erode. Rules that live only in people's heads get broken one shortcut at a time. Rules in a lint config hold.
About the examples
The trees use the routes rogen init writes for Luau. In a roblox-ts project the routing folders are lowercase (server/, client/, shared/) and the files end in .ts. Client modules go to StarterPlayerScripts because that's where init routes them; point the Client route at a folder in ReplicatedStorage if you'd rather keep them there.