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/ (@app → ReplicatedStorage.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/ (@ui → ReplicatedStorage.UI) — reusable Components/, the reactive theme/, settings, and SpecialKeys. Every component reads theme from here; nothing here knows about a specific feature.
  • state/ (@state → ReplicatedStorage.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 (@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/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).