ProceduralKit
Spawns Roblox ProceduralModels from the server with fail-closed parameter validation and rate-limited, batched regeneration.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .ProceduralKit - Since
- v0.3.0
- Source
- ProceduralKit/init.luau
ProceduralKit spawns and drives Roblox ProceduralModels: models whose generator ModuleScript rebuilds their geometry
whenever Size or an attribute changes. The peer that changes a parameter runs the generator. The kit writes
attributes on the server, so generation runs on the server and the results replicate as ordinary instances; the
generator modules never reach clients and cannot be decompiled. Around that engine feature the kit adds fail-closed
parameter validation (unknown names, wrong types and NaN reject, numbers clamp, OneOf is enforced) and a per-model
regeneration cooldown that batches writes into one rebuild.
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 seeds the spawned model’s attributes and also fixes the
allowed parameter names and their types. A write to a name not in Defaults, or with a typeof different from the
default’s, is rejected. Attackers probe attribute names, so anything undeclared fails.
A value that passes the name and type check then goes through, in order:
- NaN rejects. NaN fails every comparison, so it would otherwise pass the
Min/Maxclamp untouched. OneOf: a value not in the list rejects.- A parameter with
MinorMaxmust be finite:math.hugeand-math.hugereject. A parameter without a range accepts them. MinandMaxclamp. A slider sending999applies as the maximum.
Writes buffer, and one batch costs one rebuild. Each live model keeps a pending buffer and a NextApplyAt time.
Handle:set validates, puts the clean value in the buffer and tries to flush. A flush applies the whole buffer in one
attribute sweep (one rebuild) and sets the next window RegenSecondsPerModel (default 0.25 s) later. A write inside
the window stays in the buffer, where later writes to the same name replace earlier ones, and the first such write arms
one Scheduler after at Priority.Low for the moment the window opens. There is no sweep loop:
a model with nothing pending costs nothing. FlushIntervalSeconds is accepted and ignored.
Lifecycle runs both ways. Handle:destroy() removes the model and publishes Procedural.Removed. Game code may also
call Destroy() on the model directly; the kit watches Model.Destroying and releases its state and pending timer
either way. Pass Owner = session and the model is destroyed when that player leaves. All of one owner’s spawns share a
single session binding, so spawning many models for one player adds one cleanup. service:stop()
cancels pending timers and stops flushing; spawned models stay in the world.
The generator contract
Section titled “The generator contract”A generator is a ModuleScript, kept in ServerScriptService on purpose, that returns a table with an Attributes
declaration and an OnGenerate(params, targetContainer) callback the engine calls whenever Size or an attribute
changes. Five rules:
- Build only into
targetContainer. Never touchworkspaceor the model directly; the engine moves the container’s contents in afterwards. - Everything regenerates every time. The generated folder is rebuilt from scratch on each run, so edits to generated content are lost. Generate everything.
params.Sizeis the model’s bounds, centred on its pivot. Lay geometry out between-Size / 2andSize / 2.- Derive all randomness from a
Seedattribute. The generator reruns on every parameter change, andmath.randomwould reshuffle the whole model when someone recolors one plank. - Call
params:Pause()inside loops. It lets the engine split the work across frames and yields only when the frame budget runs out, so it costs almost nothing.ForceGenerationruns under a hard timeout and does not pause.
ExampleGenerator, walked through
Section titled “ExampleGenerator, walked through”The kit ships ProceduralKit/ExampleGenerator.luau, a minimal generator that shows rules 4 and 5. It builds a jittered
stack of blocks:
local ExampleGenerator = {}
-- Every parameter and its enforced type: these become attributes on the spawned-- ProceduralModel, and the kit rejects writes whose typeof() differsExampleGenerator.Attributes = { Blocks = 4, BlockColor = Color3.fromRGB(163, 119, 76), Jitter = 0.1, -- 0 to 1 of the footprint each block may offset Seed = 0,} :: { [string]: any }
function ExampleGenerator.OnGenerate(params: any, targetContainer: Instance) local Attributes = params.Attributes local Size: Vector3 = params.Size local Blocks = math.clamp(Attributes.Blocks, 1, 64) -- clamp again even though the kit validates local Rng = Random.new(Attributes.Seed) -- rule 4: all randomness comes 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, BlockHeight * (Index - 0.5) - Size.Y / 2, -- rule 3: centred bounds (Rng:NextNumber() - 0.5) * Size.Z * Attributes.Jitter ) Block.Parent = targetContainer -- rule 1: only the container params:Pause() -- rule 5: one yield point per iteration endend
return ExampleGeneratorSame Seed, same Blocks and same Size give the same Random stream and the same stack. Changing only BlockColor
keeps the jitter pattern, because the rebuild runs with an unchanged seed. The parameters are the identity of the
geometry.
Register archetypes
Section titled “Register archetypes”ProceduralKit is a service kit. Its config is where parameters become enforced:
--!strict-- Procedural: every runtime-generated model and its parameter rules.
local ServerScriptService = game:GetService("ServerScriptService")
return { Archetypes = { Crate = { Generator = ServerScriptService.ChloeKernelServer.Kits.ProceduralKit.ExampleGenerator, -- The allowed parameter set: any other name rejects, and each default's type is enforced Defaults = { Blocks = 4, Jitter = 0.1, Seed = 0, BlockColor = Color3.fromRGB(163, 119, 76) }, Size = Vector3.new(4, 4, 4), -- 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 and is still type-checked as a Color3 }, }, }, -- Least seconds between applied batches per model. Writes inside the window become one rebuild RegenSecondsPerModel = 0.25,}--!strict-- Every server service, one require per module in Services. ServiceManager-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local ProceduralKit = require(ServerScriptService.ChloeKernelServer.Kits.ProceduralKit)
return { require(script.Parent.Services.ComponentService), ProceduralKit.service(require(script.Parent.Config.Procedural)), require(script.Parent.Services.CrateService),} :: { any }Spawn and drive
Section titled “Spawn and drive”Every spawn option and handle method:
local Procedural = self.Kernel:getService("ProceduralKit")
local Handle = Procedural:spawn("Crate", { CFrame = CFrame.new(0, 2, -20), -- pivot; generated content centres on it Size = Vector3.new(6, 6, 6), -- overrides the archetype's bounds Params = { Blocks = 8, Seed = 42 }, -- validated; a bad value raises here Parent = workspace, -- the default Owner = session, -- optional: destroyed when this player leaves})
-- set(): validated, then applied or buffered inside the cooldown.-- (false, reason) for unknown names, wrong types, NaN, OneOf misses and infinite ranged valuesHandle:set("Blocks", 999) -- applies as 16Handle:set("Blocks", "lots") -- false, 'parameter "Blocks" expects number, got string'
-- patch(): several parameters, one rebuild when the batch flushes. Stops at the first rejectionHandle:patch({ BlockColor = Color3.fromRGB(60, 60, 70), Jitter = 0.4 })
-- resize(): a size change also rebuilds, so it shares the cooldownHandle:resize(Vector3.new(8, 10, 8))
-- waitForGeneration(): yields until the engine finishes the current rebuild.-- (false, reason) carries the generator's GenerationError and publishes Procedural.Failedlocal Built, Reason = Handle:waitForGeneration()
-- regenerate(): rebuild without changing anything (beta API, false where stubbed)Handle:regenerate()
-- destroy(): removes the model; calling it twice is safeHandle:destroy()The failure modes differ on purpose. spawn raises on a bad parameter, because spawn parameters come from server code
and a bad one is a bug that should fail at the call site. Handle:set returns false, reason and publishes
Procedural.Rejected, because runtime writes are where client-driven values arrive.
Player customization, fail-closed
Section titled “Player customization, fail-closed”The kit owns no network channel. Declare an intent and use service:validate as its validator, so the kit’s rules are
the gate:
return Registry.define({ -- Parameter name and numeric value. NumberF64 can carry NaN; validate() rejects it CrateStyle = { Kind = "Intent", Schema = { "String", "NumberF64" }, RateLimit = 5, Validate = { [1] = { OneOf = { "Blocks", "Seed", "Jitter" } } }, },})--!strict-- CrateService: one customizable crate per player.
local CrateService = { Name = "CrateService", Dependencies = { "ProceduralKit" },}
function CrateService.init(self: any, kernel: any) self.Kernel = kernel self.Crates = {} :: { [any]: any }end
function CrateService.start(self: any) local Procedural = self.Kernel:getService("ProceduralKit")
self.SessionConnection = self.Kernel:onSession(function(session: any) -- Owner = session: the kit destroys the model when the player leaves self.Crates[session] = Procedural:spawn("Crate", { CFrame = CFrame.new(0, 2, -20), Owner = session }) session:bind(function() self.Crates[session] = nil end) end)
self.Unhook = self.Kernel.Hooks:on("Intent.CrateStyle", function(context): boolean? if not self.Crates[context.Session] then return false end local Ok, Cleaned = Procedural:validate("Crate", { [context.Args[1]] = context.Args[2] }) if not Ok then return false -- unknown name, wrong type, NaN, infinite ranged value end context.Args[3] = Cleaned -- the handler receives the clamped batch return nil end, 10)
self.Net.CrateStyle.on(function(session: any, _param: string, _value: number, cleaned: { [string]: any }) local Crate = self.Crates[session] if Crate then -- The per-model cooldown still applies: a spammed intent batches into one rebuild Crate:patch(cleaned) end end)end
function CrateService.stop(self: any) if self.Unhook then self.Unhook() end if self.SessionConnection then self.SessionConnection:disconnect() endend
return CrateServiceThree limits protect the server together: 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 holds a
frame.
Watch it on the Bus
Section titled “Watch it on the Bus”self.Kernel.Bus:subscribe("Procedural.Spawned", function(_topic, name, model) end)self.Kernel.Bus:subscribe("Procedural.Updated", function(_topic, name, model, applied) -- applied: the exact batch that just flushed, after clampingend)self.Kernel.Bus:subscribe("Procedural.Rejected", function(_topic, name, param, reason) -- honest clients never send unknown names or wrong types: count these as strikesend)self.Kernel.Bus:subscribe("Procedural.Failed", function(_topic, name, model, reason) -- generation failed: swap in prebuilt geometryend)self.Kernel.Bus:subscribe("Procedural.Removed", function(_topic, name, model) end)The cost model
Section titled “The cost model”A rebuild reruns the whole generator and replaces the whole GeneratedFolder, and when the server generated it, every
generated instance replicates to every client. Size the three limits to your geometry:
| Limit | Bounds | Sizing |
|---|---|---|
RegenSecondsPerModel |
Rebuild and replication frequency per model | A 12-part fence can afford 0.25 s; a 500-part castle wants seconds. |
Intent RateLimit |
How often a client may ask | Match the UI. A color picker needs a few per second at most. |
params:Pause() |
How long one rebuild holds a frame | Once per loop iteration, always. |
Writing your own generator
Section titled “Writing your own generator”Start from ExampleGenerator, keep the five rules, and add validation for every parameter a player can reach. A fence whose parameters shape its structure:
-- 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 Attributes = params.Attributes local Size: Vector3 = params.Size local Rng = Random.new(Attributes.Seed) local Spacing = Size.X / math.max(1, Attributes.Posts - 1)
for Index = 1, Attributes.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 Attributes.Style == "Gapped" then 2 else 3 for Row = 1, Planks do local Plank = Instance.new("Part") Plank.Anchored = true Plank.Color = Attributes.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 keeps enum-like
parameters closed while numbers clamp.
Configuration
Section titled “Configuration”ProceduralKit.service(config)
Section titled “ProceduralKit.service(config)”| Field | Type | Default | Description |
|---|---|---|---|
Archetypes |
{ [string]: Archetype } |
required | Name to archetype. Each Generator is asserted to be an Instance when the service is created. |
RegenSecondsPerModel |
number? |
0.25 |
Least seconds between applied batches per model. |
SkipLoop |
boolean? |
false |
Arm no retry timers. Specs drive _flush() by hand. |
Clock |
(() -> number)? |
os.clock |
Time source for the cooldown. |
FlushIntervalSeconds |
number? |
none | Ignored since 0.9.0. Buffered writes flush on a per-model timer. |
Archetype
Section titled “Archetype”| Field | Type | Default | Description |
|---|---|---|---|
Generator |
ModuleScript |
required | The generator module. Keep it in ServerScriptService. |
Defaults |
{ [string]: any }? |
nil |
Seeds attributes at spawn and fixes the allowed names and types. No Defaults means no writable parameters. |
Size |
Vector3? |
Vector3.one |
Model bounds at spawn. |
Validate |
{ [string]: ParamRule }? |
nil |
Per-parameter rules. Parameters without a rule are still name- and type-checked, and NaN still rejects. |
ParamRule and SpawnOptions
Section titled “ParamRule and SpawnOptions”ParamRule field |
Effect |
|---|---|
Min, Max |
Numbers clamp into range. With either set, infinite values reject. |
OneOf |
The value must be in the list, otherwise it rejects. Checked before clamping. |
SpawnOptions field |
Default | Description |
|---|---|---|
CFrame |
nil |
Pivot. Generated content centres on it. |
Size |
archetype Size |
Bounds override. |
Params |
nil |
Validated at spawn. A bad value raises. |
Parent |
workspace |
Set last, after the attributes. |
Owner |
nil |
A session. Its models are destroyed when the player leaves, through one shared binding. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
ProceduralKit.service(config) -> service |
Returns a service definition named ProceduralKit. |
service:spawn(name, options?) -> Handle |
Creates, configures and parents a ProceduralModel, then publishes Procedural.Spawned. Raises on an unknown archetype or a bad parameter. |
service:validate(name, params) -> (boolean, cleanedOrReason) |
Validation for client intents: true, cleaned with clamped values, or false, reason. An unknown archetype fails. |
service:count() -> number |
Live model count. |
service:stop() |
Cancels pending retry timers and stops flushing. Models are not destroyed. |
Handle.Model, Handle.Archetype |
The ProceduralModel and its archetype name. |
Handle:set(param, value) -> (boolean, string?) |
Validate, buffer, flush if the window is open. A rejection publishes Procedural.Rejected. Fails after destroy. |
Handle:patch(params) -> (boolean, string?) |
set per entry, stopping at the first rejection. |
Handle:resize(size) |
Buffers a size change on the same cooldown. |
Handle:regenerate() -> boolean |
Rebuilds through ForceGeneration. false where the engine stubs it. |
Handle:waitForGeneration() -> (boolean, string?) |
Yields until the rebuild finishes. On failure returns false and the GenerationError (or the call’s error) and publishes Procedural.Failed. |
Handle:destroy() |
Removes the model and publishes Procedural.Removed. Safe to call twice. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Procedural.Spawned |
name, model |
spawn parented a model. |
Procedural.Updated |
name, model, applied |
A batch flushed. applied is the exact batch after clamping. |
Procedural.Rejected |
name, param, reason |
A runtime write failed validation. |
Procedural.Failed |
name, model, reason |
waitForGeneration found a failed or impossible rebuild. Fall back to prebuilt geometry. |
Procedural.Removed |
name, model |
Handle:destroy() or a direct Destroy() of the model. |