Skip to content

Scaffolding

tools/scaffold.py generates services, controllers, screens, components and the data layer in the shape the workspace expects, and tools/catalog.py generates the capability catalog.

tools/scaffold.py writes new game files from templates and registers them, so every system starts in the same shape: the header comment the catalog reads, the Name CK010 checks, the registry entry, and a spec. It never overwrites a file. tools/catalog.py goes the other way: it reads the header of every framework module and writes docs/agents/Catalog.md, the list agents search before building anything. Both are plain Python with no dependencies beyond the standard library (the scaffold runs StyLua when it can find it). Game code written after scaffolding follows Services and controllers, Components and Screens.

Terminal window
python tools/scaffold.py data # DataService + Data/Profile.luau + Data/Products.luau
python tools/scaffold.py service Round --deps DataService # Services/RoundService.luau + Registry + spec
python tools/scaffold.py controller Camera # Controllers/CameraController.luau + Registry + spec
python tools/scaffold.py screen Hud # src/Client/Game/Screens/Hud.luau
python tools/scaffold.py component Door # src/Server/Game/Components/Door.luau
python tools/scaffold.py component Beacon --client # src/Client/Game/Components/Beacon.luau
Subcommand Arguments Creates Updates
service <Name> [--deps A,B] src/Server/Game/Services/<Name>Service.luau, src/Server/Game/Tests/<Name>Service.spec.luau src/Server/Game/Registry.luau
controller <Name> [--deps A,B] src/Client/Game/Controllers/<Name>Controller.luau, src/Client/Game/Tests/<Name>Controller.spec.luau src/Client/Game/Registry.luau
screen <Name> src/Client/Game/Screens/<Name>.luau nothing
component <Name> [--client] src/Server/Game/Components/<Name>.luau, or src/Client/Game/Components/<Name>.luau with --client nothing
data none Services/DataService.luau, Data/Profile.luau, Data/Products.luau under src/Server/Game, each only if absent src/Server/Game/Registry.luau (DataService first)

Two options belong to the tool itself and go before the subcommand:

Option Effect
--root DIR Repository root to write into. Default: the checkout that contains the script.
--no-format Skip StyLua on the written files.
$ python tools/scaffold.py service Shop --no-format
usage: scaffold.py [-h] [--root ROOT] [--no-format]
{service,controller,screen,component,data} ...
scaffold.py: error: unrecognized arguments: --no-format
$ python tools/scaffold.py --no-format service Shop

The exit status is 0 on success (and when data finds nothing to do), 1 when the scaffold refuses, and 2 on a usage error.

Names must match ^[A-Z][A-Za-z0-9]*$. service and controller append Service or Controller when the name lacks it, so Round and RoundService produce the same file. The scaffold refuses:

Input Message
service shop scaffold: 'shop' is not PascalCase (expected something like ShopService)
service Service scaffold: 'Service' is not a usable module name
screen Vector3 scaffold: 'Vector3' is not a usable module name (Roblox datatype names such as CFrame, Color3, Instance, Enum, UDim2 and Vector3 are reserved)
A name whose file exists scaffold: refusing to overwrite existing src/Server/Game/Services/ShopService.luau

The overwrite check covers every file a command would create, and for services and controllers also the folder form (Services/ShopService/init.luau). It runs before anything is written, so a refused command leaves the tree untouched. A missing Registry.luau stops the command with src/Server/Game/Registry.luau not found: the game skeleton is missing (see AGENTS.md).

--deps takes a comma-separated list. Each entry must be PascalCase and resolves like this:

  • If the name as typed is already required in the registry, it is used as typed.
  • Otherwise, if the suffixed name (Data becomes DataService) is required in the registry or its module file exists, the suffixed name is used.
  • Otherwise the name is kept as typed.

Any resolved name the registry does not require gets a note: <Name> is not required in src/Server/Game/Registry.luau; make sure something in the Registry registers it (ServiceManager errors at boot on an unregistered dependency).

So --deps Data,Round becomes Dependencies = { "DataService", "RoundService" } once both exist, and a kit dependency such as --deps RoundKit stays "RoundKit" with the note, because kit entries are not require(script.Parent.Services.X) lines. A system cannot depend on itself (<Name> cannot depend on itself), and duplicates collapse.

The new require(script.Parent.Services.<Name>) goes on the line after the last of its dependencies that the registry lists, or at the end when none are listed. data puts DataService first. When the registry already requires the name, it is left unchanged with a note. Boot order does not depend on registry order (the ServiceManager sorts by name and Dependencies); the placement only keeps the file readable. If the registry table is not written one entry per line, the scaffold rewrites it that way.

$ python tools/scaffold.py service Shop --deps Data,Round
created src/Server/Game/Services/ShopService.luau
created src/Server/Game/Tests/ShopService.spec.luau
updated src/Server/Game/Registry.luau (ShopService after RoundService)
next steps:
1. Fill in the header comment and init/start/stop in src/Server/Game/Services/ShopService.luau. Channel handles are on self.Net (Game/init.luau injects them; declare channels in src/Shared/Game/Channels.luau).
2. Replace the placeholder test in src/Server/Game/Tests/ShopService.spec.luau with behavior tests.
3. Run python tools/check.py until it prints CHECK PASSED.

screen prints the ScreenGui authoring rules as next steps, component prints the tagging steps, and data prints where to set defaults and grants. Every command ends with the python tools/check.py step.

After writing, the tool runs StyLua on every created and updated file, using .tools/bin/stylua (installed by python tools/setup.py) or one on PATH, with the repository’s stylua.toml. When StyLua is missing it prints note: stylua not found; run python tools/setup.py, then python tools/check.py --fix and leaves the files as generated.

Templates live in tools/scaffold_templates/*.luau.tmpl. {{Name}}, {{DependenciesLiteral}} and {{Folder}} are the only placeholders, and a template with a leftover placeholder is refused.

src/Server/Game/Services/ShopService.luau
--!strict
-- ShopService: <one line: what this service owns>.
local ShopService = {
Name = "ShopService",
Dependencies = { "DataService", "RoundService" },
}
function ShopService.init(self: any, kernel: any)
self.Kernel = kernel
end
function ShopService.start(_self: any) end
function ShopService.stop(_self: any) end
return ShopService

A controller is the same template under Controllers/ with the Controller suffix. Replace the placeholder header: tools/catalog.py does not read game modules, but reviewers and agents do, and AGENTS.md requires a one-line -- Name: what it owns. header on every file.

src/Server/Game/Tests/ShopService.spec.luau
--!nonstrict
local ShopService = require(script.Parent.Parent.Services.ShopService)
return function(T)
local describe, it, expect = T.describe, T.it, T.expect
describe("ShopService", function()
it("declares a matching Name", function()
expect(ShopService.Name).toBe("ShopService")
end)
end)
end

The spec runs on every Play in Studio before the kernel boots (see The Game layer). Screens and components get no spec: put their rules in plain modules and test those.

The screen template carries the ScreenGui authoring rules in its header and returns a new(context, gui) constructor with Modal = false, StartsOpen = false, and open, close and destroy that toggle gui.Enabled and destroy a scope. Screens shows it in full.

src/Server/Game/Components/Door.luau
--!strict
-- Door component: behavior for instances tagged CKMount with MountType "Door".
return function(kernel: any, _instance: Instance, _attributes: { [string]: any }): (() -> ())?
local Scope = kernel:createScope()
return function()
Scope:destroy()
end
end

data creates the persistence layer once per game:

src/Server/Game/Services/DataService.luau
--!strict
-- DataService: owns player persistence (DataDriver) and developer product receipts.
-- Other services read and change profile data through session.Profile.Data only.
local ServerScriptService = game:GetService("ServerScriptService")
local DataDriver = require(ServerScriptService.ChloeKernelServer.Data)
local Products = require(script.Parent.Parent.Data.Products)
local Profile = require(script.Parent.Parent.Data.Profile)
local Receipts = require(ServerScriptService.ChloeKernelServer.Commerce.Receipts)
local DataService = {
Name = "DataService",
}
function DataService.init(self: any, kernel: any)
self.Kernel = kernel
self.Driver = DataDriver.new(Profile :: any)
self.Driver:attach(kernel)
self.Receipts = Receipts.attach(kernel, {
GetProfile = function(player: Player)
local Session = kernel:getSession(player)
return Session and Session.Profile
end,
})
for ProductId, Grant in Products do
self.Receipts:onProduct(ProductId, Grant)
end
end
function DataService.start(_self: any) end
function DataService.stop(_self: any) end
return DataService
src/Server/Game/Data/Profile.luau
--!strict
-- Player profile for DataDriver. Change Defaults, SchemaVersion, Migrations and Validate together.
-- Never reset progress for invalid data; add a migration before bumping SchemaVersion.
-- Validate detects corrupt records: check types and shape, not game rules. A save it rejects is
-- refused (Data.SaveRefused) and progress stays unsaved until the data passes again.
local RunService = game:GetService("RunService")
return {
Name = "PlayerData",
Defaults = {
Coins = 0,
},
SchemaVersion = 1,
Migrations = {},
Validate = function(data: any): boolean
return type(data.Coins) == "number"
end,
-- Studio has no DataStores unless the place is published with API access enabled.
-- Delete this line to test real saves in Studio.
Backend = if RunService:IsStudio() then "Memory" else "DataStore",
}
src/Server/Game/Data/Products.luau
--!strict
-- Developer product grants: [productId] = function(session, receiptInfo).
-- Grants must not yield and may only change session.Profile.Data; Receipts saves before acknowledging.
return {} :: { [number]: (session: any, receiptInfo: any) -> () }

Services that read profile data then declare Dependencies = { "DataService" }. DataDriver and Receipts document the profile fields and grant rules. Validate checks types and shape only: a game rule there (Coins >= 0) refuses every save once a bug breaks it.

docs/agents/Catalog.md lists every framework module with its require path, the side it runs on and one sentence about what it does. Agents read it in step 2 of the agent workflow to map features to modules. It is generated, never edited by hand.

Terminal window
python tools/catalog.py # rewrite docs/agents/Catalog.md
python tools/catalog.py --check # exit 1 with a diff summary when the file is stale
python tools/catalog.py --stdout # print the catalog instead of writing it

--root DIR points it at another checkout. The generator:

  1. Walks every *.luau under src/Shared/ChloeKernel and src/Server/ChloeKernelServer, skipping Tests, TestKit, Benches and Simulation folders and any file with a dot in its base name (specs).
  2. Builds each module id from its path, with init.luau standing for its folder, and the require path by prefixing ReplicatedStorage or ServerScriptService.
  3. Reads the header comment: the comment lines after any --! directives, up to the first blank comment line. It strips a leading Name: or Name plus em dash, and keeps the first sentence, cut at 200 characters.
  4. Marks the side as Shared or Server by folder, or Client for the shared modules listed in its CLIENT_ONLY set (AudioKit, CameraKit, InputDriver, the client halves under Net, and others).
  5. Assigns an area from AREA_MAP, where the first matching pattern wins: Core, Networking, Persistence and live ops, Commerce, Gameplay systems, Genre kits, Presentation (client), Debug and diagnostics. A module that matches nothing lands in an “Other” section and the tool prints catalog: <path> has no area; add it to AREA_MAP in tools/catalog.py.

At 0.9.1 with the Unreleased fixes the catalog has 142 modules in eight areas. A module’s catalog line is only as good as its header, which is why AGENTS.md requires every file’s second line to be -- Name: what it owns. Game modules in src/*/Game are not cataloged.

python tools/check.py runs python tools/catalog.py --check as its catalog step (quick and full), so a framework change that edits a header or adds a module fails the check until the catalog is regenerated. The scaffold has its own tests in tools/test_scaffold.py, run by the check’s tooling step. See Check.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.