Skip to content

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 in CLAUDE.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/ (@appReplicatedStorage.App) — the app shell. AppLoader picks 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/ (@uiReplicatedStorage.UI) — reusable Components/, the reactive theme/, settings, and SpecialKeys. Every component reads theme from here; nothing here knows about a specific feature.
  • state/ (@stateReplicatedStorage.State)sharedState, a table of global Fusion Values (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 PascalCase and come from the hand-written template.project.json: ReplicatedStorage.App / .UI / .State / .Packages, ServerScriptService.Kernel, ReplicatedStorage itself (= src/shared).
  • Feature slices are grouped under lowercase folders 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 and selene resolve 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):

  1. rogen regenerates default.project.json from template.project.json + the feature folders. (It prunes any $path node whose target is missing, so wally install must run first.)
  2. rojo sourcemap is generated from that tree.
  3. darklua mirrors src/dist/ and rewrites every require("@alias/…") into Roblox WaitForChild navigation using the sourcemap. It also injects __DEV__ and (in release) minifies.
  4. 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 not require("@features/…"). If foundation seems to need a feature, the dependency is usually backwards — invert it or move the code into the feature.
  • @app is the top. Only the bootstrapper (@first) reaches up into @app. Features do not require the app shell.
  • @state is 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 (@kernelServerScriptService.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/Remotes merges those plus an app-wide Core set 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} — an init.luau returning 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.luau returns a context-appropriate implementation. Many modules (Content, Remotes, several Data/ modules) branch on RunService to return a Server, Client, or story-mock table from their init. Follow this for anything that must behave differently across the client/server boundary.
  • Theme over hardcoding. Every color/size is a Fusion Computed keyed off the selected theme (@ui/theme); changing the theme restyles reactively. Never hardcode colors.
  • .story.luau files are Fusion component previews; .spec.luau files 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 — AppLoader enumerates App:GetChildren() to discover *App modules; the Settings theme picker enumerates UI.theme's children; ClientLoader walks to App. 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/client and Server/server clashes — 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 became App and the server kernel became Kernel. The lowercase client / server / shared grouping folders now have no PascalCase twin.
  • settings lives 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 the Settings feature; the global reactive settings/theme primitives are @ui).
  • Player-data kernel stays in @kernel. PlayerStore (owns DataStore2.Combine) and the Core remotes 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 at ReplicatedStorage.PackagesCustom alongside the Wally Packages folder. They get their own mount rather than being listed as children of Packages because rogen collapses a node whose children all point into one directory down to a single $path on 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).