ProceduralKit
ProceduralKit is parametric geometry end to end: server-authoritative runtime geometry on Roblox ProceduralModels — parameter-driven models whose generator ModuleScript rebuilds their geometry whenever Size or an attribute changes. The defining decision is where generation happens: the peer that changes a parameter generates. The kit writes attributes server-side, so generation runs server-side and the results replicate as ordinary instances — generator modules never reach clients and cannot be decompiled. Around that engine primitive the kit adds the two things runtime geometry needs to be safe and affordable: fail-closed parameter validation (unknown names and wrong types reject, numbers clamp, OneOf enforced) and per-model regeneration rate limiting with batched flushes.
Mental model
Section titled “Mental model”An archetype is a named, enforced parameter surface. Each entry in Archetypes binds a Generator ModuleScript to a Defaults table and optional Validate rules. Defaults does double duty: it seeds the spawned model’s attributes and it fixes the allowed parameter names and their types — a write to any name not in Defaults, or with a typeof differing from the default’s, is rejected outright. Fail-closed, because attackers probe attribute names.
Validation order for a value that passes the name/type gate: OneOf membership first (non-members reject), then numeric Min/Max — which clamp instead of rejecting. A slider spamming 999 applies as the max; a probe sending "lots" where a number belongs dies with a reason.
Writes buffer; a batch costs one rebuild. Every live model carries a pending-parameter buffer and a cooldown stamp (NextApplyAt). Handle:set validates, drops the clean value into the buffer, and tries to flush. A flush only proceeds when the cooldown (RegenSecondsPerModel, default 0.25) has expired; otherwise values sit in the buffer, later writes to the same name overwrite earlier ones, and the whole batch applies in one attribute sweep = one rebuild when the window opens. A sweep loop on the scheduler (every(FlushIntervalSeconds), default 0.1, at Priority.Low — cosmetic work yields to gameplay under the frame budget) retries pending buffers, so a buffered batch flushes within one sweep interval of the cooldown expiring.
Lifecycle is bidirectional. Handle:destroy() removes the model and publishes Procedural.Removed — but game code may also Destroy() the model directly, so the kit watches Model.Destroying and cleans its own state either way; state never outlives the instance. Pass Owner = session at spawn and the model is destroyed when that player leaves (bound through the session). service:stop() cancels the sweep loop only — spawned models survive the service.
The generator contract
Section titled “The generator contract”A generator is a ModuleScript (in ServerScriptService, on purpose) returning a table with an Attributes declaration and an OnGenerate callback the engine invokes whenever Size or any attribute changes. Five rules, all load-bearing:
- Build only into
targetContainer— never touchworkspaceor the model directly; the engine migrates the container’s contents in afterward. - Everything regenerates every time. The generated folder is rebuilt from scratch on each run — manual edits to generated content are lost by design, so generate everything.
params.Sizeis the model’s normalized bounds, centered on its pivot. Lay geometry out in-Size/2 .. +Size/2space.- Derive ALL randomness from a
Seedattribute. The generator reruns on every parameter change;math.randomwould reshuffle the whole model each time someone recolors one plank. - Call
params:Pause()inside loops. The engine kills generators that work too long without yielding (“Script Timeout”);Pauseis nearly free and usually returns immediately.
ExampleGenerator, walked through
Section titled “ExampleGenerator, walked through”The kit ships ProceduralKit/ExampleGenerator.luau — the minimal generator demonstrating the two survival habits (rules 4 and 5). It builds a jittered stack of blocks:
local ExampleGenerator = {}
-- Declares every parameter AND its enforced type: these become real Roblox-- attributes on the spawned ProceduralModel, and the kit rejects writes whose-- typeof() differs from the default's.ExampleGenerator.Attributes = { Blocks = 4, BlockColor = Color3.fromRGB(163, 119, 76), Jitter = 0.1, -- 0..1 of the footprint each block may offset Seed = 0,}
function ExampleGenerator.OnGenerate(params: any, targetContainer: Instance) local Attributes = params.Attributes local Size: Vector3 = params.Size local Blocks = math.clamp(Attributes.Blocks, 1, 64) -- defense in depth: clamp again even though the kit validates local Rng = Random.new(Attributes.Seed) -- rule 4: ALL randomness flows from Seed local BlockHeight = Size.Y / Blocks
for Index = 1, Blocks do local Block = Instance.new("Part") Block.Anchored = true Block.Color = Attributes.BlockColor Block.Size = Vector3.new(Size.X, BlockHeight, Size.Z) Block.CFrame = CFrame.new( (Rng:NextNumber() - 0.5) * Size.X * Attributes.Jitter, -- seeded jitter: stable per Seed BlockHeight * (Index - 0.5) - Size.Y / 2, -- rule 3: normalized space, -Size.Y/2 .. +Size.Y/2 (Rng:NextNumber() - 0.5) * Size.Z * Attributes.Jitter ) Block.Parent = targetContainer -- rule 1: only ever the container params:Pause() -- rule 5: yield point per iteration endend
return ExampleGeneratorRead the determinism guarantee off the structure: same Seed, same Blocks, same Size → the Random.new(Seed) stream produces identical offsets, so the rebuild is byte-for-byte the same stack. Change only BlockColor and the jitter pattern is preserved, because the color write reruns the generator with an unchanged seed. That is what “parametric” buys you — parameters are the identity of the geometry.
Register archetypes
Section titled “Register archetypes”Config is where parameters become enforced:
local ServerScriptService = game:GetService("ServerScriptService")local ProceduralKit = require(ServerScriptService.ChloeKernelServer.Kits.ProceduralKit)
return function(kernel: any) kernel:registerService(ProceduralKit.service({ Archetypes = { Crate = { Generator = ServerScriptService.ChloeKernelServer.Kits.ProceduralKit.ExampleGenerator, -- Defaults define the parameter set: a set() for any name not in -- here is rejected outright, and each default's type is the enforced type Defaults = { Blocks = 4, Jitter = 0.1, Seed = 0, BlockColor = Color3.fromRGB(163, 119, 76) }, Size = Vector3.new(4, 4, 4), -- default model bounds at spawn Validate = { Blocks = { Min = 1, Max = 16 }, -- numbers CLAMP into range Jitter = { Min = 0, Max = 1 }, Seed = { Min = 0, Max = 2 ^ 31 }, -- BlockColor has no rule: still type-checked (must be a Color3) }, }, }, -- Minimum seconds between applied parameter batches PER MODEL. Writes inside -- the window buffer and flush together as ONE rebuild. RegenSecondsPerModel = 0.25, }))endSpawn and drive
Section titled “Spawn and drive”Every option and every handle method:
local Procedural = kernel:getService("ProceduralKit")
local Handle = Procedural:spawn("Crate", { CFrame = CFrame.new(0, 2, -20), -- pivot; generated content centers on it Size = Vector3.new(6, 6, 6), -- overrides the archetype default bounds Params = { Blocks = 8, Seed = 42 }, -- validated; garbage here errors LOUDLY (server code bug) Parent = workspace, -- default Owner = session, -- optional: destroyed when this player leaves})
-- set(): validated then applied — or buffered if inside the regen cooldown.-- Returns (false, reason) for unknown names / wrong types / OneOf misses;-- numbers clamp instead of failing.local Ok, Reason = Handle:set("Blocks", 999) -- applies as 16 (clamped to Max)Handle:set("Blocks", "lots") -- (false, 'parameter "Blocks" expects number, got string')
-- patch(): several parameters, still ONE rebuild when the batch flushesHandle:patch({ BlockColor = Color3.fromRGB(60, 60, 70), Jitter = 0.4 })
-- resize(): Size changes regenerate too, so they ride the same cooldownHandle:resize(Vector3.new(8, 10, 8))
-- waitForGeneration(): yields until the engine finishes the current rebuild.-- (false, err) surfaces the generator's own GenerationError — a crashing-- generator leaves a placeholder, not geometry, so check this in tooling.local Built, Err = Handle:waitForGeneration()
-- regenerate(): force a rebuild without changing anything (beta API; returns-- false where the engine stubs it). Rarely needed — parameter writes are the trigger.Handle:regenerate()
Handle:destroy() -- removes the model; safe to call twice. The kit also cleans-- up if game code Destroy()s Handle.Model directly.Note the deliberate asymmetry in failure modes: spawn errors on a bad param (spawn params come from server code — a bad one is a bug that should crash at the call site), while Handle:set returns (false, reason) and publishes Procedural.Rejected (runtime writes are where client-driven values arrive — rejection is data, not a crash).
Player customization, fail-closed
Section titled “Player customization, fail-closed”The kit deliberately owns no wire channel. You wire an intent and make service:validate the validator, so the kit’s rules are the gate:
local Net = kernel:net()-- parameter name + value as a string (parse server-side); keep payloads-- typed-narrow — never accept arbitrary tables from clientsNet:defineIntent("CrateStyle", { "String", "String" }, { RateLimit = 5 })
kernel.Hooks:on("Intent.CrateStyle", function(context) local Crate = context.Session.Data.Crate -- the Handle stored at spawn if not Crate then return false end local Param, Raw = context.Args[1], context.Args[2] local Value: any = Raw if Param == "Blocks" or Param == "Seed" then Value = tonumber(Raw) if Value == nil then return false end end local Ok, CleanedOrReason = kernel:getService("ProceduralKit"):validate("Crate", { [Param] = Value }) if not Ok then return false -- unknown name, wrong type, OneOf miss — all die here end context.Args[3] = CleanedOrReason -- pass the CLAMPED batch forward to the handlerend)
Net:onIntent("CrateStyle", function(session, _param, _raw, cleaned) -- only reached when every validator passed; the per-model cooldown still -- applies underneath — a spammed intent inside the window batches into one rebuild session.Data.Crate:patch(cleaned)end)Three stacked limits protect the server here, and it’s worth seeing them as one system: the channel RateLimit bounds how often a client may ask, RegenSecondsPerModel bounds how often a model may rebuild, and params:Pause() bounds how long one rebuild may hog the frame.
Watch it on the bus
Section titled “Watch it on the bus”Every lifecycle edge publishes:
kernel.Bus:subscribe("Procedural.Spawned", function(_, name, model) end)kernel.Bus:subscribe("Procedural.Updated", function(_, name, model, applied) -- applied = the exact parameter batch that just flushed (post-clamp)end)kernel.Bus:subscribe("Procedural.Rejected", function(_, name, param, reason) -- a validated write failed — wire this to your anti-exploit strikes: -- honest clients never send unknown params or wrong typesend)kernel.Bus:subscribe("Procedural.Removed", function(_, name, model) end)The cost model
Section titled “The cost model”So the knobs make sense: a rebuild reruns the whole generator and replaces the whole GeneratedFolder; when the server generated it, every generated instance replicates to every client. The three bounds again, sized to your geometry:
| Knob | Bounds | Sizing rule |
|---|---|---|
RegenSecondsPerModel |
Rebuild + replication frequency per model | A 12-part fence can afford a 0.25 window; a 500-part castle wants seconds. |
Intent RateLimit |
How often a client may even ask | Match your UI — a color picker needs a few per second at most. |
params:Pause() |
Single-rebuild frame occupancy | Per loop iteration, always. The engine enforces this one with a timeout. |
Writing your own generator
Section titled “Writing your own generator”Start from ExampleGenerator, keep the five contract rules, and declare validation for every parameter a player can reach. A fence that shows parameters shaping structure (not just decoration):
-- ServerScriptService.Generators.Fence (ModuleScript)local Fence = {}
Fence.Attributes = { Posts = 6, PlankColor = Color3.fromRGB(140, 100, 60), Style = "Solid", -- validated against OneOf in the archetype Seed = 0,}
function Fence.OnGenerate(params: any, targetContainer: Instance) local Attrs = params.Attributes local Size: Vector3 = params.Size local Rng = Random.new(Attrs.Seed) local Spacing = Size.X / math.max(1, Attrs.Posts - 1)
for Index = 1, Attrs.Posts do local Post = Instance.new("Part") Post.Anchored = true Post.Size = Vector3.new(0.4, Size.Y, 0.4) Post.CFrame = CFrame.new( -Size.X / 2 + Spacing * (Index - 1), 0, (Rng:NextNumber() - 0.5) * 0.1 ) Post.Parent = targetContainer params:Pause() end
local Planks = if Attrs.Style == "Gapped" then 2 else 3 for Row = 1, Planks do local Plank = Instance.new("Part") Plank.Anchored = true Plank.Color = Attrs.PlankColor Plank.Size = Vector3.new(Size.X, Size.Y / (Planks * 2), 0.2) Plank.CFrame = CFrame.new(0, Size.Y * (Row / (Planks + 1)) - Size.Y / 2, 0) Plank.Parent = targetContainer params:Pause() endend
return FenceIts archetype pairs Style = { OneOf = { "Solid", "Gapped" } } with the string default — OneOf is how enum-shaped parameters stay closed while numbers clamp.
Configuration
Section titled “Configuration”ProceduralKit.service(config) — KitConfig
Section titled “ProceduralKit.service(config) — KitConfig”| Field | Type | Default | Description |
|---|---|---|---|
Archetypes |
{ [string]: Archetype } |
required | Name → archetype. Each Generator is asserted to be an Instance at construction. |
RegenSecondsPerModel |
number? |
0.25 |
Minimum seconds between applied parameter batches per model. |
FlushIntervalSeconds |
number? |
0.1 |
Pending-buffer sweep cadence. |
SkipLoop |
boolean? |
false |
Skip the sweep loop; specs drive _flush manually. |
Clock |
(() -> number)? |
os.clock |
Injectable for deterministic cooldown tests. |
Archetype
Section titled “Archetype”| Field | Type | Default | Description |
|---|---|---|---|
Generator |
ModuleScript |
required | The generator module. Keep it in ServerScriptService. |
Defaults |
{ [string]: any }? |
— | Seeds attributes at spawn and fixes allowed param names + types. No Defaults = no writable params. |
Size |
Vector3? |
Vector3.one |
Default model bounds at spawn. |
Validate |
{ [string]: ParamRule }? |
— | Per-param rules. Unruled params are still name- and type-checked. |
ParamRule and SpawnOptions
Section titled “ParamRule and SpawnOptions”ParamRule field |
Effect |
|---|---|
Min / Max |
Numbers clamp into range (never reject). |
OneOf |
Value must be a member; non-members reject. Checked before clamping. |
SpawnOptions field |
Default | Description |
|---|---|---|
CFrame |
— | Pivot; generated content centers on it. |
Size |
archetype Size |
Bounds override. |
Params |
— | Validated at spawn; a bad param errors. |
Parent |
workspace |
Parented last, after attributes are set. |
Owner |
— | A session; the model is destroyed when that player leaves. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
ProceduralKit.service(config: KitConfig) |
Returns a service definition for kernel:registerService. |
service:spawn(name: string, options: SpawnOptions?) -> Handle |
Creates, configures, and parents a ProceduralModel; publishes Procedural.Spawned. Errors on unknown archetypes and bad params. |
service:validate(name: string, params) -> (boolean, cleanedOrReason) |
Public validation for client intents: (true, cleaned) with clamped values, or (false, reason). Unknown archetypes fail. |
service:count(): number |
Live model count. |
service:stop() |
Cancels the sweep loop. Models are not destroyed. |
Handle.Model / Handle.Archetype |
The ProceduralModel instance and its archetype name. |
Handle:set(param, value) -> (boolean, string?) |
Validate → buffer → flush-if-cool. Rejections publish Procedural.Rejected. Fails after destroy. |
Handle:patch(params) -> (boolean, string?) |
set per entry; stops at the first rejection. One rebuild per flushed batch. |
Handle:resize(size: Vector3) |
Buffers a size change; rides the same regen cooldown (size changes rebuild too). |
Handle:regenerate(): boolean |
Force a rebuild via ForceGeneration (beta; false where stubbed). |
Handle:waitForGeneration() -> (boolean, string?) |
Yields until the rebuild finishes; (false, GenerationError) when the generator failed. |
Handle:destroy() |
Removes the model, publishes Procedural.Removed. Idempotent. |
Bus topics
Section titled “Bus topics”| Topic | Args | When |
|---|---|---|
Procedural.Spawned |
name, model |
spawn parented a model. |
Procedural.Updated |
name, model, applied |
A parameter batch flushed; applied is the exact post-clamp batch. |
Procedural.Rejected |
name, param, reason |
A runtime write failed validation — an anti-exploit signal worth counting. |
Procedural.Removed |
name, model |
Handle:destroy() or a direct Model:Destroy(). |