Skip to content

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.

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.

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:

  1. Build only into targetContainer — never touch workspace or the model directly; the engine migrates the container’s contents in afterward.
  2. 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.
  3. params.Size is the model’s normalized bounds, centered on its pivot. Lay geometry out in -Size/2 .. +Size/2 space.
  4. Derive ALL randomness from a Seed attribute. The generator reruns on every parameter change; math.random would reshuffle the whole model each time someone recolors one plank.
  5. Call params:Pause() inside loops. The engine kills generators that work too long without yielding (“Script Timeout”); Pause is nearly free and usually returns immediately.

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
end
end
return ExampleGenerator

Read 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.

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,
}))
end

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 flushes
Handle:patch({ BlockColor = Color3.fromRGB(60, 60, 70), Jitter = 0.4 })
-- resize(): Size changes regenerate too, so they ride the same cooldown
Handle: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).

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 clients
Net: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 handler
end)
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.

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 types
end)
kernel.Bus:subscribe("Procedural.Removed", function(_, name, model) end)

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.

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()
end
end
return Fence

Its archetype pairs Style = { OneOf = { "Solid", "Gapped" } } with the string default — OneOf is how enum-shaped parameters stay closed while numbers clamp.

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.
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 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.
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.
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().