Architecture¶
How the Lua Learning source tree is organized, why it's organized that way, and where reality bends the rules. If you're adding a feature, moving a module, or trying to figure out where something should live, start here.
Companion docs: the per-remote networking surface is in
network.md; day-to-day commands and conventions are inCLAUDE.md.
1. The two organizing axes¶
The codebase is sliced along two axes at once:
- Vertically, by feature. A feature is a product domain a user would name — Quests,
Tutorials, Settings, the AI Tutor. Each feature owns its own server, client, and shared
code together in one folder under
src/features/<X>/. You can read, change, or delete a feature mostly in one place. - Horizontally, by foundation. A kernel/foundation layer is a horizontal primitive
that many features consume — the networking layer, the theme, shared UI components, global
state, the app shell. These live outside
src/features/because they belong to no single feature.
The test for where new code goes:
Is it a vertical product domain a user would name? → it's a feature. Is it a horizontal primitive every capability consumes? → it's foundation (
shared/ kernel).
When in doubt, prefer a feature. Foundation is for things that are genuinely cross-cutting.
2. Directory layout¶
src/
├── features/ ← vertical slices; one folder per product domain
│ └── <Feature>/
│ ├── server/ → ServerScriptService.server.<Feature>
│ ├── client/ → ReplicatedStorage.client.<Feature>
│ └── shared/ → ReplicatedStorage.shared.<Feature> (incl. remotes.luau)
│
├── shared/ @shared → ReplicatedStorage (root) — Remotes, Data, Util
├── kernel/ @kernel → ServerScriptService.Kernel — player data, FFlags, messages, boot
├── serverstorage/ @serverstorage → ServerStorage — Secrets, global stores, data versions
├── first/ @first → ReplicatedFirst — ClientLoader (loading + bootstrap)
│
├── client/ ← the client, split three ways:
│ ├── app/ @app → ReplicatedStorage.App — app shell (AppLoader + *App roots)
│ ├── ui/ @ui → ReplicatedStorage.UI — Components, theme, settings
│ └── state/ @state → ReplicatedStorage.State — sharedState (global Fusion Values)
│
└── PackagesCustom/ @custompkg → ReplicatedStorage.PackagesCustom — vendored/forked packages
Feature anatomy¶
A feature folder contains up to three slices. None are mandatory — a feature includes only the slices it needs:
| Slice | Maps to | Contains |
|---|---|---|
server/ |
ServerScriptService.server.<Feature> |
Remote handlers, persistence, server logic. Self-initializes when required (see boot flow). |
client/ |
ReplicatedStorage.client.<Feature> |
Fusion UI, usually subdivided by device (Desktop/, Mobile/, ClassroomDesktop/). |
shared/ |
ReplicatedStorage.shared.<Feature> |
Code both sides need — most importantly remotes.luau, where the feature declares the remotes it owns. |
Current slice coverage (a useful map of which features are full-stack vs. one-sided):
| Feature | server | client | shared |
|---|---|---|---|
| Analytics, Classroom, Feedback, Lessons, Moderation, Monetization, Onboarding, Profile, Quests, Settings, TutorAI, Tutorials | ✓ | ✓ | ✓ |
| CodeRunning, Deeplinking, IDE | ✓ | — | ✓ |
| Credits | — | ✓ | ✓ |
| Multiplayer | — | ✓ | — |
Client foundation, in detail¶
The client is itself split into three foundation layers under src/client/:
app/(@app→ReplicatedStorage.App) — the app shell.AppLoaderpicks one of six top-level apps from a 2-axis matrix (device × server-type) and mounts it:DesktopApp,MobileApp,ConsoleApp,ClassroomDesktopApp,ClassroomMobileApp,ShutdownApp. The shell composes feature client slices; it is the top of the client dependency graph.ui/(@ui→ReplicatedStorage.UI) — reusableComponents/, the reactivetheme/,settings, andSpecialKeys. Every component reads theme from here; nothing here knows about a specific feature.state/(@state→ReplicatedStorage.State) —sharedState, a table of global FusionValues (open section, current lesson/quest, coins, popup state…). A pure leaf: it imports nothing and is the bottom of the dependency graph.
3. DataModel mapping¶
default.project.json (the Rojo tree) is generated, not hand-written — see the build
pipeline below. Two naming conventions coexist in the generated tree:
- Kernel nodes are
PascalCaseand come from the hand-writtentemplate.project.json:ReplicatedStorage.App / .UI / .State / .Packages,ServerScriptService.Kernel,ReplicatedStorageitself (= src/shared). - Feature slices are grouped under
lowercasefolders injected by rogen, named after the slice folder:ReplicatedStorage.client.<Feature>,ReplicatedStorage.shared.<Feature>,ServerScriptService.server.<Feature>.
The lowercase/PascalCase distinction is deliberate — it's how you tell "kernel" from "feature slice" at a glance in the Studio explorer.
4. Aliases and the build pipeline¶
Modules never hardcode DataModel paths. They require by alias:
local Remotes = require("@shared/Remotes")
local theme = require("@ui/theme")
local QuestData = require("@features/Quests/shared/QuestData")
| Alias | Path | Alias | Path | |
|---|---|---|---|---|
@features |
src/features |
@app |
src/client/app |
|
@shared |
src/shared |
@ui |
src/client/ui |
|
@kernel |
src/kernel |
@state |
src/client/state |
|
@serverstorage |
src/serverstorage |
@first |
src/first |
|
@pkg |
Packages |
@custompkg |
src/PackagesCustom |
|
@serverpkg |
ServerPackages |
@devpkg |
DevPackages |
Aliases are declared in three places that must stay in sync:
.luaurc— so the Luau LSP / type-checker andseleneresolve requires while editing..darklua.json5(release) and.darklua.dev.json5(dev/test) — so the build can rewrite them.
Roblox cannot resolve .luaurc aliases at runtime, so every build runs a preprocess
pipeline (lune/utils/preprocess.luau):
- rogen regenerates
default.project.jsonfromtemplate.project.json+ the feature folders. (It prunes any$pathnode whose target is missing, sowally installmust run first.) - rojo sourcemap is generated from that tree.
- darklua mirrors
src/→dist/and rewrites everyrequire("@alias/…")into RobloxWaitForChildnavigation using the sourcemap. It also injects__DEV__and (in release) minifies. - A
dist/-pointing Rojo project is emitted and that is what gets built/served.
So dist/ holds the real shippable code; src/ is the alias-using authoring tree.
5. Dependency rules¶
Allowed dependency direction (top depends on bottom):
@app (app shell — composes features)
│
features/<X> ───────────► other features (@features/Y)
│
@ui (components, theme, settings)
│
@shared · @state · packages (foundation leaves)
- Foundation never depends on features.
@shared,@state,@ui, and packages must notrequire("@features/…"). If foundation seems to need a feature, the dependency is usually backwards — invert it or move the code into the feature. @appis the top. Only the bootstrapper (@first) reaches up into@app. Features do not require the app shell.@stateis the bottom. It imports nothing — it's a pure Fusion leaf so any layer can observe it without creating a cycle.- Features may depend on each other via
@features/<Other>/…, but keep this deliberate; heavy cross-feature coupling is a sign two features should merge or a primitive should move to foundation.
On the server, feature server/ slices depend on the @kernel (e.g.
@kernel/PlayerStore, which owns the DataStore2.Combine call) plus @shared and other
features — never the reverse.
6. Boot flow¶
Client (@first/ClientLoader):
1. Show the loading screen.
2. require("@features/Analytics/client") (telemetry up early).
3. require ReplicatedStorage.App.PerformanceOpts, then ReplicatedStorage.App.AppLoader.
4. AppLoader resolves device × server-type, enumerates App:GetChildren() for the *App
modules, and mounts the matching one. Each *App root pulls in the feature client slices
it needs via @features/<X>/client/….
Server (@kernel → ServerScriptService.Kernel, an init script):
1. Auto-require every ModuleScript child of Kernel (skipping _-prefixed names), each in
its own task.spawn. Server modules self-initialize as a side effect of being required
— there is no central registry.
2. Auto-require every ModuleScript under the ServerScriptService.server folder the same
way — this is how migrated feature server/ slices boot.
3. Require the Content system.
So: adding a feature server module is enough to load it; you never edit a registry.
7. Cross-cutting conventions¶
- Remotes are declared per-feature, aggregated centrally. Each feature lists the remotes
it owns in its
shared/remotes.luau;src/shared/Remotesmerges those plus an app-wideCoreset into the single flat definition table Net expects (and errors on duplicate names). Callers are unchanged:Remotes.Server:Create("Name")/Remotes.Client:Get("Name"). Outside a running game it returns a no-op mock for stories. - Features are consumed through a device entry point. A feature's client UI is imported via
@features/<X>/client/{Desktop,Mobile,ClassroomDesktop}— aninit.luaureturning the public component (or a table of them) — so app shells and other features never reach into a deeper module path. A feature's internal layout can then change without breaking its consumers. init.luaureturns a context-appropriate implementation. Many modules (Content, Remotes, severalData/modules) branch onRunServiceto return a Server, Client, or story-mock table from theirinit. Follow this for anything that must behave differently across the client/server boundary.- Theme over hardcoding. Every color/size is a Fusion
Computedkeyed off the selected theme (@ui/theme); changing the theme restyles reactively. Never hardcode colors. .story.luaufiles are Fusion component previews;.spec.luaufiles are BoatTEST tests. Both are stripped/handled by the release and test tooling.
8. Exceptions and known warts¶
Honest list of places the rules above don't hold cleanly:
- darklua only rewrites
require("@alias/…")strings. It does not touch raw DataModel navigation (ReplicatedStorage:WaitForChild("UI"),:GetChildren(), dot-access). A handful of these exist on purpose —AppLoaderenumeratesApp:GetChildren()to discover*Appmodules; the Settings theme picker enumeratesUI.theme's children;ClientLoaderwalks toApp. These are name-coupled and must be updated by hand when a node moves — the alias sweep and the headless test will not catch a stale one (it surfaces only as a runtime infinite-yield in Studio). This class of reference already caused one load regression. - Case collisions were eliminated by renaming both kernels. The DataModel once had
Client/clientandServer/serverclashes — a PascalCase kernel node sitting next to a lowercase rogen feature-grouping folder, distinguished only by case. Both kernels were renamed to remove the ambiguity: the client shell becameAppand the server kernel becameKernel. The lowercaseclient/server/sharedgrouping folders now have no PascalCase twin. settingslives in@ui, not as a feature. Theme and settings are mutually dependent and every component reads them, so they're foundation rather than a vertical slice — even though "Settings" is also a user-facing feature (its UI screens and persistence are theSettingsfeature; the global reactive settings/theme primitives are@ui).- Player-data kernel stays in
@kernel.PlayerStore(ownsDataStore2.Combine) and theCoreremotes are app-wide, not owned by one feature, so they remain in the server kernel; feature servers depend on@kernel/PlayerStore. src/kernel/Archived/holds intentionally-retained dead code. Harmless; not loaded.- Vendored packages in
src/PackagesCustom/(Fusion, DataStore2, Romarkable, Lexers, MarkdownConversion, MPTT, LuauParser) are forked/unpublished and edited in-tree, mounted atReplicatedStorage.PackagesCustomalongside the WallyPackagesfolder. They get their own mount rather than being listed as children ofPackagesbecause rogen collapses a node whose children all point into one directory down to a single$pathon that directory, which would silently replace the Wally mount and drop every published package from the build. They're excluded from lint/format. - Incomplete slices are normal, not a smell. Several features are intentionally one-sided (Multiplayer is client-only; CodeRunning/Deeplinking/IDE have no client slice).