← Back to all projects

Conductor

Luau · Systems · Private

Conductor is the template my Roblox games start from, and the framework at the centre of it. The orchestrator itself is about 300 lines with no dependencies: it finds every ModuleScript under the folders you hand it, requires them all, runs onInit on each in priority order, then runs every onStart in its own thread. There is no registration step. A module takes part because of where it is and what it is called, which is what keeps the framework small and is also the source of every sharp edge in it.

Around that sits the part every game needs and nobody wants to write twice: the boot sequence, saved player data mirrored live to its owner, a typed network layer compiled from schema files, the controller and React split for UI, an admin console with server enforced roles, a sound layer, and one worked feature wired end to end as a shape to copy. It is deliberately almost empty otherwise.

The thesis of the whole thing is that this architecture fails silently. A service Conductor never starts, a network module older than its schema, a typo in an ignore list: none of those error, none of them fail to type check, and each produces a game that boots and is quietly missing something. So the template carries a verification loop that turns every one of them into a red line, in about ten seconds, without opening Studio.

Everything is pinned. Rojo, selene, StyLua, lune, luau-lsp and the Blink compiler come from Rokit at exact versions, packages come from pesde, and even the Roblox API surface the type checker reads is pinned to a commit, because an unpinned linter or analyzer moves the quality floor underneath you.

Systems I engineered

How these are organized, with short excerpts. Full source isn't public.

The lifecycle

Two phases. onInit runs sequentially, highest InitPriority first and ties broken by name, and must not yield: it holds the single boot thread, and everything behind it waits. onStart runs afterwards, once every onInit has returned, each in its own thread, so by then order no longer matters.

An error in either is caught, reported with a traceback and the offending module, and the boot carries on. A yield cannot be caught, so a watchdog warns after five seconds and names the module still sitting inside onInit. In practice that message is a WaitForChild without a timeout, or a datastore call in the wrong phase.

Sorting is by convention: a module counts as a service or controller when its filename says so and it returns both lifecycle methods. Get either half wrong and it is quietly treated as a plain library and never started, which is the first thing the checks go looking for.

  • No registration: a module takes part by name and location
  • onInit sequential by InitPriority, onStart concurrent after all of them
  • Errors caught per module, so one bad service cannot stop the boot
  • A watchdog names whichever module is blocking the boot thread
  • Server and client board the same way from their own entry points

A service is a plain table with two methods. InitPriority exists only for real ordering dependencies: the data service is 100 because anything touching a profile depends on it. Needing priorities on many modules is the signal to move that work into onStart instead.

src/server/Core/Data/DataService.luau
local DataService = {
    InitPriority = 100, -- default is 50; higher runs first
}

-- phase 1: sequential, must not yield, the boot thread is waiting
function DataService:onInit() end

-- phase 2: its own thread, every onInit has already returned
function DataService:onStart() end

return DataService

Typed networking with Blink

The protocol is written once as a Blink schema and compiled to one typed module per side, which is what keeps the two ends from drifting apart. Groups are just files: an event declared in Feedback.blink arrives as Network.Feedback.Notify on both sides. The compiled modules are committed, and a check fails when they are older than the source they came from.

Write validations are off by default in Blink and on here, because the failure they prevent is invisible. A bound like string(..200) also picks the width of the length prefix, so firing a 300 character string wraps that prefix to 44 while all 300 bytes are still written. The receiver reads 44, then decodes the remaining bytes as whatever event comes next. No error on either side. With validations on, the call errors instead, at the line that passed the bad value.

  • One schema, compiled to a typed module per side
  • Write validations on, so a bad payload fails at the call site
  • Groups are files, so a feature area is one schema and one namespace
  • A check fails when a compiled module is older than its schema
  • A schema nothing imports is also a failure, not a warning

An event declares its direction, reliability and payload. Blink compiles this into typed fire and listen functions for both sides, so the shape is enforced at build time rather than discovered in production.

src/shared/Network/Feedback.blink
--- How a toast is dressed. Travels as one byte.
enum Kind = { Info, Success, Warning, Error }

event Notify {
    from: Server,
    type: Reliable,
    call: ManySync,
    data: (string(..200), Kind)
}

Player data

A player's saved data is one profile table, stored by ProfileStore and mirrored live to its owner by Replica. Four files make up the chain: the template that defines the shape and the defaults, one way migrations for profiles saved in an older shape, the service that owns every write, and the client's read only observable copy.

The important link is the last one. The client boards once its profile has arrived, so a controller can read saved data during onInit and find it already there. That leaves one piece of duplication in the chain: the shape is declared twice, as the server template in ServerScriptService and as a matching type on the client, so the two edits travel in the same commit.

Two conventions keep a shared mutable table reasonable. Every field names its single writer in a comment, and the profile holds only what its owner is meant to see, since the whole table replicates to them. Anti-cheat counters and moderation notes live elsewhere.

  • ProfileStore for storage, Replica for mirroring to the owner
  • The client boots only once its profile is there, so onInit can read it
  • Adding a key takes only the template; Reconcile fills it in on load
  • One declared writer per field
  • The profile carries only what its owner is meant to see

The shape is declared twice on purpose: this server template is the source of truth, and the client carries a matching type beside it. One commit covers both.

src/server/Core/Data/ProfileTemplate.luau
local ProfileTemplate = {
    Currency = {
        --- Written only by CoinService.
        Coins = 0,
    },
    Settings = {} :: { [string]: any },
}

UI: controllers own state, React draws

The line that matters runs between a controller, which holds plain data and never touches or receives an Instance, and a component, which renders the props it is given and never requires a controller, the player's data or the network. They meet in exactly one place: an observable value passed as a prop.

That separation is what buys the story. A component that needs nothing but props opens in UI Labs with no server, no data and no Conductor running, which is the closest thing client code gets to a unit test.

Two traps in the observable are worth naming, because both are silent. A table constructor never stores a nil, so patching a key to nil is an empty patch: nothing changes and whatever was on screen stays there, which is why removal needs an explicit sentinel. And the setter compares by reference, so mutating the table you already handed it and setting again is a no-op that renders nothing.

  • Controllers hold data, components render props, one observable between them
  • Components open in UI Labs with no game running
  • A hook for discrete state, a binding for values that change every frame
  • Mounting is asynchronous, so nothing reaches for Instances afterwards

Removing a key needs the sentinel, because a table constructor cannot carry a nil. This is the kind of bug the architecture produces: no error, no type failure, the old card simply stays on screen.

src/client/Core/UI/UIValue.luau
state:set({ toasts = table.clone(toasts) }) -- replace; must be a NEW table
state:patch({ open = true })                -- shallow merge into a fresh clone
state:patch({ card = UIValue.None })        -- remove a key
state:patch({ card = nil })                 -- an EMPTY patch; clears nothing

The verification loop

One command runs every check. It needs no Studio, touches no place file and publishes nothing, and CI runs exactly the same script, so a green run means the same thing on a laptop and on a pull request.

The steps are formatting, lints, the network schema compiling and matching its committed output, the type checker, the Conductor invariants, a guard on sound calls that throw before the sound schema has arrived, a module index that cannot go stale, and the specs. Order matters in one place: the network step runs before the type checker, because the generated modules are what every call site is checked against.

The baselines are ratchets rather than zeroes. A file may not get worse than its recorded count, and a file the baseline has never seen starts at zero. The template ships at zero everywhere, so today it is simply a zero tolerance gate. The ratchet earns its keep the day a dependency bump lands fifty diagnostics at once and the real choice is a gate that tolerates those while still stopping new ones, or no gate at all.

Several guards exist because of a real incident rather than a hypothetical. A skipped check counts as a failure under CI instead of a pass, because a green run that checked nothing is worse than a red one. The script proves each tool actually runs rather than trusting that it answers on the path, since a stale shim will answer and then fail on use. And it refuses a sourcemap with too few instances, because an empty tree makes the analyzer report zero errors having read nothing at all.

  • One command, about ten seconds, no Studio and nothing published
  • Catches a service that is never started, or a network module older than its schema
  • Catches an ignore entry naming nothing, and two boarded modules sharing a name
  • Baselines ratchet downward only, and never upward to silence a new error
  • A skip counts as a failure in CI, never as a pass
  • A module index plus one written line per service, both kept honest by the check

Three entry points, and only one of them is dangerous. Updating the baseline locks in an improvement; running it to make an error you just introduced go away is the single way to defeat the whole mechanism.

tools/check.sh
tools/check.sh                    # every check, about 10s, exit 0 only when all pass
tools/check.sh --fix              # format and recompile the network first
tools/check.sh --update-baseline  # accept the current counts as the new floor

Because Conductor guarantees module names are unique per side, a name match is the answer rather than a lead. The lookup joins a generated index with a hand written line per service saying what it owns, and the check keeps both honest: the index cannot go stale, and a purpose cannot be forgotten or left pointing at a file that moved.

tools/where.sh
$ tools/where.sh --event Notify

NotificationService  .  service  .  server
   src/server/Core/Feedback/NotificationService.luau
   the one place a server -> player toast is fired from
   events   Feedback.Notify

Shipping it

Rojo owns the code and the place file owns the world. That split is the template's default and the reason there are two possible pipelines. The one running in production splices a fresh build into a saved copy of the place and publishes that copy, so shipping a branch never requires opening Studio, and the checks have to be green before anything is published.

The warning both pipelines are designed around: publishing a partially managed build straight to a real place produces a perfectly valid place file and an upload that succeeds. The place is then an empty world with your scripts in it. Neither pipeline ships configured in the template, because a deploy needs your universe, your place ids and an API key, and a half configured publish step is worse than none.

  • Rojo ownership is decided per node, code in git and the world in the place
  • The production pipeline splices code into a saved copy, then publishes it
  • Checks must pass before a deploy runs
  • A documented path to full management, where a build is the whole game
← Back to all projects