Skip to content

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.

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:

  1. NaN rejects. NaN fails every comparison, so it would otherwise pass the Min/Max clamp untouched.
  2. OneOf: a value not in the list rejects.
  3. A parameter with Min or Max must be finite: math.huge and -math.huge reject. A parameter without a range accepts them.
  4. Min and Max clamp. A slider sending 999 applies 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.

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:

  1. Build only into targetContainer. Never touch workspace or the model directly; the engine moves the container’s contents in afterwards.
  2. Everything regenerates every time. The generated folder is rebuilt from scratch on each run, so edits to generated content are lost. Generate everything.
  3. params.Size is the model’s bounds, centred on its pivot. Lay geometry out between -Size / 2 and Size / 2.
  4. Derive all randomness from a Seed attribute. The generator reruns on every parameter change, and math.random would reshuffle the whole model when someone recolors one plank.
  5. 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. ForceGeneration runs under a hard timeout and does not pause.

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() differs
ExampleGenerator.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
end
end
return ExampleGenerator

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

ProceduralKit is a service kit. Its config is where parameters become enforced:

src/Server/Game/Config/Procedural.luau
--!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,
}
src/Server/Game/Registry.luau
--!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 }

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 values
Handle:set("Blocks", 999) -- applies as 16
Handle:set("Blocks", "lots") -- false, 'parameter "Blocks" expects number, got string'
-- patch(): several parameters, one rebuild when the batch flushes. Stops at the first rejection
Handle:patch({ BlockColor = Color3.fromRGB(60, 60, 70), Jitter = 0.4 })
-- resize(): a size change also rebuilds, so it shares the cooldown
Handle: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.Failed
local Built, Reason = Handle:waitForGeneration()
-- regenerate(): rebuild without changing anything (beta API, false where stubbed)
Handle:regenerate()
-- destroy(): removes the model; calling it twice is safe
Handle: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.

The kit owns no network channel. Declare an intent and use service:validate as its validator, so the kit’s rules are the gate:

src/Shared/Game/Channels.luau
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" } } },
},
})
src/Server/Game/Services/CrateService.luau
--!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()
end
end
return CrateService

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

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 clamping
end)
self.Kernel.Bus:subscribe("Procedural.Rejected", function(_topic, name, param, reason)
-- honest clients never send unknown names or wrong types: count these as strikes
end)
self.Kernel.Bus:subscribe("Procedural.Failed", function(_topic, name, model, reason)
-- generation failed: swap in prebuilt geometry
end)
self.Kernel.Bus:subscribe("Procedural.Removed", function(_topic, name, model) end)

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.

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

Its archetype pairs Style = { OneOf = { "Solid", "Gapped" } } with the string default. OneOf keeps enum-like parameters closed while numbers clamp.

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

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