Skip to content

CraftingKit

CraftingKit is recipes over InventoryKit: a recipe consumes Inputs and grants Output, either instantly or after Seconds on a timer. The design decision that defines it: inputs check atomically before any take runs. canCraft verifies every input count, the station, the busy state, and the hook first; only then does craft start taking. On a single server thread nothing can interleave between the check and the takes, so a craft either fully charges or touches nothing — there is no partially-consumed recipe state to repair.

craft is canCraft plus commitment:

  1. canCraft walks the refusal ladderUnknownRecipe, Busy (one active timed craft per session), WrongStation, MissingInputs, then fires the Craft.CanCraft hook (Vetoed). The first failure returns (false, reason); no side effects anywhere on this path.
  2. Take the inputs. Every count was just verified, and InventoryKit’s take is all-or-nothing, so these cannot fail.
  3. Instant or timed. Seconds of 0 (the default) completes immediately. Otherwise the session gets an active-craft record — RecipeId plus FinishAt = Clock() + Seconds — and Craft.Started publishes.
  4. A sweep loop finishes timed crafts. Every TickSeconds (default 0.25) a scheduler task at Priority.Low completes any craft whose FinishAt has passed: outputs grant, OnComplete runs, Craft.Completed publishes.

The reason strings are the API for craft UI — pipe them straight to the button tooltip:

Reason Meaning
UnknownRecipe No recipe with that id.
Busy The session already has a timed craft running.
WrongStation The recipe names a Station and the caller passed a different one (or none).
MissingInputs At least one input count is short.
Vetoed A Craft.CanCraft handler returned false — proximity failed, level too low, forge closed.

The ladder order means the hook fires only for otherwise-legal crafts: a Craft.CanCraft handler never sees a request that would have failed on inputs anyway.

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local CraftingKit = require(Root.Kits.CraftingKit)
local Crafting = CraftingKit.attach(Kernel, {
Inventory = Inventory, -- the attached InventoryKit; required
Recipes = {
HealthPotion = {
Inputs = { Herb = 2, Water = 1 },
Output = { Potion = 1 },
Seconds = 3,
},
IronSword = {
Inputs = { Iron = 3 },
Output = { Sword = 1 },
Station = "Forge",
OnComplete = function(session, recipeId)
playForgeFanfare(session)
end,
},
},
})
Field Type Default Description
Inputs { [itemId]: count } required Consumed atomically at craft start.
Output { [itemId]: count } required Granted at completion through InventoryKit.
Seconds number? 0 Craft time. 0 completes inside the craft call; above 0 queues a timed craft.
Station string? nil Required station id. nil crafts anywhere.
OnComplete (session, recipeId) -> () Runs in its own task at completion, after outputs grant.
Option Type Default Description
Recipes { [string]: Recipe } required The recipe book.
Inventory InventoryKit required Where inputs come from and outputs go.
TickSeconds number? 0.25 Sweep cadence for timed crafts. Completion lands on the first sweep after FinishAt — up to one tick late.
Clock () -> number os.clock Injectable time source; the specs drive crafts with a fake clock.
Intents boolean? true Wire CR_Craft/CR_Cancel. Requires the kernel to have networking.
SkipLoop boolean? Skip the sweep loop (tests, manual stepping).
-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local InventoryKit = require(Root.Kits.InventoryKit)
local CraftingKit = require(Root.Kits.CraftingKit)
local CollectionService = game:GetService("CollectionService")
return function(kernel)
local Inventory = InventoryKit.attach(kernel, {
Items = {
Herb = { Stack = 10 },
Water = { Stack = 10 },
Potion = { Stack = 10, OnUse = function(session)
healCharacter(session, 25)
return true
end },
},
})
CraftingKit.attach(kernel, {
Inventory = Inventory,
Recipes = {
HealthPotion = { Inputs = { Herb = 2, Water = 1 }, Output = { Potion = 1 }, Seconds = 3 },
},
})
-- The client CLAIMS a station id; the hook is where the claim meets reality.
kernel.Hooks:on("Craft.CanCraft", function(context)
local Station = context.Station
if not Station then
return -- stationless recipe; nothing to verify
end
local Character = context.Session.Player.Character
local RootPart = Character and Character:FindFirstChild("HumanoidRootPart")
if not RootPart then
return false
end
for _, Part in CollectionService:GetTagged("CraftStation") do
if Part:GetAttribute("StationId") == Station
and (Part.Position - RootPart.Position).Magnitude <= 12 then
return -- close enough to a real station with that id
end
end
return false -- claimed a station they are not standing at
end)
end

Seconds above 0 turns craft into a queue-one operation: the inputs are taken immediately (they are “held by the craft”), the session’s active record is set, and the sweep finishes it later. The invariants:

  • One active craft per session. A second craft while one runs returns (false, "Busy"). Build craft queues in game code if you want them — the kit keeps exactly one in flight, which keeps cancel/refund semantics unambiguous.
  • cancel refunds the inputs by granting them back, publishes Craft.Cancelled, and returns false when nothing was active.
  • activeCraft(session) returns the recordRecipeId and FinishAt — or nil. FinishAt is in the kit clock’s timebase; send it to the client for a progress bar.
  • Completion is swept, not scheduled per craft. One Priority.Low loop scans all active crafts every TickSeconds; a craft lands on the first sweep at or past its FinishAt, so worst-case completion is TickSeconds late. Low priority means a saturated frame defers crafting before it defers combat — nobody notices a potion arriving 250ms late.

A recipe with Station = "Forge" starts only when the caller passes "Forge". For server-initiated crafts that is trivially trustworthy. For the CR_Craft intent, the station id is a client claim — the intent schema is two strings, and nothing about the transport verifies the player is near a forge.

That verification belongs in the Craft.CanCraft hook, which fires with Session, RecipeId, and Station in the context. The Server tab above shows the standard pattern: resolve the claimed id against real tagged station parts and distance-check the character. Zones or an InteractionKit prompt-driven flow work equally well — anything that derives “actually at the station” from server-side state.

The hook is declared FailOpen = true: a crashing handler does not veto, only an explicit return false does. The security boundary is the fail-closed intent chain (which runs canCraft, including this hook, before the handler); the hook itself is a game rule slot, and a bug in one game rule should not disable every recipe on the server. Note the consequence: if you never register a Craft.CanCraft handler, station claims go unverified — the kit checks that the claimed string matches the recipe, and nothing checks the claim against the world. Station gating without a hook handler is cosmetic.

Completion grants each output through Inventory:grant. Whatever does not fit publishes Craft.Overflow with the remainder map and is lost — the kit does not hold outputs in escrow:

Kernel.Bus:subscribe("Craft.Overflow", function(_, session, recipeId, remainder)
for Id, Count in remainder do
mailToPlayer(session, Id, Count) -- your redelivery policy
end
end)

Two ways to make overflow unreachable instead: size MaxSlots so the largest recipe’s output always fits after its inputs free their slots, or gate crafts in Craft.CanCraft when the bag is too full. The taken inputs free slots before outputs grant, which usually covers it — the spec that exercises this fills the bag deliberately.

Both intents ride the fail-closed chain with kit validators at priority 50:

Intent Schema Rate limit The priority-50 validator
CR_Craft String, String (recipeId, claimed station; "" means none) 5/s Runs the full canCraft ladder — recipe exists, not busy, station matches, inputs present, hook passes.
CR_Cancel none 5/s The session has an active craft.

canCraft runs twice on the intent path — once as the validator, once inside craft — which is deliberate redundancy: the API stays safe for direct server calls, and the chain gets its cheap early reject. Both runs are pure reads until everything passes.

Member Description
CraftingKit.attach(kernel, options) -> kit Asserts Recipes and Inventory. Defines Craft.CanCraft, starts the sweep loop, wires intents.
kit:canCraft(session, recipeId, station?) -> (boolean, string?) Legality without side effects. Returns (false, reason) — see the ladder.
kit:craft(session, recipeId, station?) -> (boolean, string?) Check, take inputs, then complete instantly or queue. Same reasons as canCraft.
kit:cancel(session) -> boolean Refund and clear the active timed craft. false when none is active.
kit:activeCraft(session) -> { RecipeId, FinishAt }? The in-flight craft, or nil.
kit:destroy() Cancels the sweep loop and clears all active crafts — without refunds. Shutdown path, not a gameplay one.
Topic Payload When
Craft.Started session, recipeId, finishAt A timed craft queued (inputs already taken). Instant crafts skip this — they publish only Craft.Completed.
Craft.Completed session, recipeId, output Outputs granted. output is the recipe’s full Output table, even if some of it overflowed.
Craft.Cancelled session, recipeId cancel refunded an active craft.
Craft.Overflow session, recipeId, remainder Some output did not fit. remainder maps item id to the lost count.

Craft.Completed is the natural feed for QuestKit craft objectives and crafting analytics; Craft.Overflow is the one topic you should always have a subscriber on if bags can fill.

  • Instant crafts never appear “active”. Seconds = 0 completes inside the craft call — activeCraft never shows it, Busy cannot trigger on it, and it cannot be cancelled. A rapid-fire instant recipe is throttled only by inputs and the 5/s intent rate limit.
  • OnComplete runs in its own task (task.spawn), after outputs grant. A crash in it cannot corrupt kit state, and if it yields, Craft.Completed can arrive before it finishes — do not rely on its side effects being complete when you receive the bus event.
  • The clock is injectable. Clock plus SkipLoop and manual sweeps is how the spec suite drives 5-second crafts in microseconds; the same seam works for your own deterministic tests.
  • Timed-craft state is server memory, not profile data. An active craft does not survive a server shutdown any more than it survives a leave. The soft-shutdown window is short; the same “keep Seconds short” advice applies.