CraftingKit
Recipes over InventoryKit with checked input takes and rollback, timed crafts recorded in the profile, station gating through a fail-open hook, and fail-closed client intents.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .CraftingKit - Since
- v0.5.0
- Source
- Kits/CraftingKit.luau
CraftingKit is recipes over InventoryKit: a recipe consumes Inputs and grants Output, at once or after Seconds on a timer. It is a Kit.attach kit, attached in the same service as the inventory it uses. LootKit and ShopKit are its siblings over the same bag.
The decision that defines it: a craft either takes all of its inputs or keeps none of them. canCraft checks every input count, the station and the hook first. craft then takes the inputs one at a time, and if any take fails, it grants back the inputs it already took. A timed craft is written into the player’s profile when it starts, so its inputs survive a leave, a teleport or a server shutdown.
Mental model
Section titled “Mental model”craft is canCraft plus commitment:
canCraftwalks the refusal ladder:UnknownRecipe,Busy(one active timed craft per session),WrongStation,Loading(a timed recipe while the profile loads),MissingInputs, then theCraft.CanCrafthook (Vetoed). The first failure returns(false, reason). Nothing on this path has side effects.Craft.CanCrafthandlers may yield (a distance query, a datastore read), and while one is suspended another craft can start or spend the inputs. After the hook returns,craftchecksBusyagain, and nothing between that check and the takes yields.- Each input goes through InventoryKit’s all-or-nothing
take. If one fails because another action spent it during the hook, the inputs already taken are granted back andcraftreturns(false, "MissingInputs"). Two overlapping requests cannot both craft from one set of inputs. - A recipe with
Secondsof 0 (the default) completes at once. Otherwise the craft is recorded in the profile and scheduled, andCraft.Startedpublishes. - On completion the profile record is cleared, outputs grant,
OnCompleteruns on its own thread,Craft.Completedpublishes, and anything that did not fit publishesCraft.Overflow.
The reason strings are the API for craft UI. Send them to the button’s 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. |
Loading |
The recipe is timed and session.ProfilePending is true: the profile the craft would be recorded in has not loaded yet. |
MissingInputs |
At least one input count is short, or an input was spent while the hook yielded. |
Vetoed |
A Craft.CanCraft handler returned false: too far from the station, level too low, forge closed. |
The ladder order means the hook fires only for otherwise legal crafts.
Defining recipes
Section titled “Defining recipes”--!strict-- Recipes: CraftingKit recipe book.
return { HealthPotion = { Inputs = { Herb = 2, Water = 1 }, Output = { Potion = 1 }, Seconds = 3, }, IronSword = { Inputs = { Iron = 3 }, Output = { Sword = 1 }, Station = "Forge", Seconds = 600, -- ten minutes: survives a leave and resumes on the next join },}Recipe fields
Section titled “Recipe fields”| Field | Type | Default | Description |
|---|---|---|---|
Inputs |
{ [itemId]: count } |
required | Taken 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 starts a timed craft. |
Station |
string? |
nil |
Required station id. nil crafts anywhere. |
OnComplete |
(session, recipeId) -> () |
Runs on its own thread at completion, after outputs grant. |
attach options
Section titled “attach options”| Option | Type | Default | Description |
|---|---|---|---|
Recipes |
{ [string]: Recipe } |
required | The recipe book. |
Inventory |
InventoryKit | required | Where inputs come from and outputs go. |
Field |
string? |
"Crafting" |
Profile field that holds the active timed craft. |
Clock |
(() -> number)? |
os.clock |
In-memory deadlines and the FinishAt values the kit reports. |
WallClock |
(() -> number)? |
os.time |
Deadlines saved in the profile, which must mean the same thing on another server tomorrow. |
Intents |
boolean? |
true |
Wire CR_Craft and CR_Cancel. Requires a kernel with networking. |
SkipLoop |
boolean? |
No deadline timers. Specs drive completion with an injected Clock and _sweep(). |
TickSeconds was removed in 0.9.0. Timed crafts finish on their own deadline, and the option is ignored if passed.
--!strict-- EconomyService: the inventory and the recipe book, with station claims checked-- against parts tagged "CraftStation" in the Workspace.
local CollectionService = game:GetService("CollectionService")local ServerScriptService = game:GetService("ServerScriptService")
local CraftingKit = require(ServerScriptService.ChloeKernelServer.Kits.CraftingKit)local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)local InventoryConfig = require(script.Parent.Parent.Config.Inventory)local Recipes = require(script.Parent.Parent.Config.Recipes)
local StationReach = 12
local EconomyService = { Name = "EconomyService", Dependencies = { "DataService" },}
function EconomyService.init(self: any, kernel: any) self.Kernel = kernel self.Inventory = InventoryKit.attach(kernel, InventoryConfig) self.Crafting = CraftingKit.attach(kernel, { Recipes = Recipes, Inventory = self.Inventory })end
function EconomyService.start(self: any) -- The client claims a station id; this hook checks the claim against the world self.Unhook = self.Kernel.Hooks:on("Craft.CanCraft", function(context: any): boolean? local Station = context.Station if not Station then return nil -- a recipe that needs no station end local Character = context.Session.Player.Character local Root = Character and Character:FindFirstChild("HumanoidRootPart") :: BasePart? if not Root then return false end for _, Part in CollectionService:GetTagged("CraftStation") do if Part:IsA("BasePart") and Part:IsDescendantOf(workspace) -- a stored prefab is not a station and Part:GetAttribute("StationId") == Station and (Part.Position - Root.Position).Magnitude <= StationReach then return nil end end return false -- claimed a station they are not standing at end)end
function EconomyService.stop(self: any) if self.Unhook then self.Unhook() end self.Crafting:destroy() self.Inventory:destroy()end
return EconomyService--!strict-- CraftingController: sends craft and cancel requests for the crafting screen.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local CraftingController = { Name = "CraftingController",}
function CraftingController.init(self: any, _kernel: any) local Net = NetClient.new() self.CraftIntent = Net:intent("CR_Craft", { "String", "String" }) self.CancelIntent = Net:intent("CR_Cancel", {})end
-- The second string is the claimed station; "" means nonefunction CraftingController.craft(self: any, recipeId: string, stationId: string?) self.CraftIntent.fire(recipeId, stationId or "")end
function CraftingController.cancel(self: any) self.CancelIntent.fire()end
return CraftingControllerThe kit sends no crafting state to clients. Inventory changes arrive through INV_Sync; a crafting screen reads them from the InventoryClient a controller created at boot. For a progress bar, publish the remaining time from a Craft.Started subscriber through your own State channel.
Timed crafts
Section titled “Timed crafts”Seconds above 0 makes craft start a craft that finishes later. The inputs are taken at once and held by the craft. Then:
- The profile records it as
profile.Data[Field] = { RecipeId, FinishAt }, whereFinishAt = WallClock() + Secondson the wall clock. It saves with the rest of the profile. - One
Scheduler:after(Seconds)timer atPriority.Lowcompletes it. There is no sweep loop, and nothing runs while nobody is crafting. - Leaving keeps it. When the session ends, the in-memory craft and its timer are dropped and the profile record stays. When the profile next loads on any server, the kit reads the record: a craft that finished while the player was away completes at once, and one still running is armed for the time it has left. A record for a recipe that no longer exists is dropped with a warning.
- A session has at most one active craft. A second
craftwhile one runs returns(false, "Busy"). Build queues in game code; one craft in flight keeps cancel and refund rules unambiguous. cancelclears the record and the timer, grants the inputs back, and publishesCraft.Cancelled. Inputs that no longer fit publishCraft.Overflowwith the remainder. It returnsfalsewhen nothing was active.activeCraft(session)returns{ RecipeId, FinishAt }ornil. ThisFinishAtis on the kit’sClock(serveros.clockby default), which clients do not share. Send remaining seconds instead.- A timed craft started before the profile loads would record into session data that is thrown away, so while
session.ProfilePendingistrue, timed recipes refuse withLoading. Instant recipes still work: their inputs and outputs go through InventoryKit, which merges early changes into the profile.
Before 0.9.0 a timed craft lived only in server memory, and its inputs were lost when the player left, teleported or the server closed before it finished.
Stations
Section titled “Stations”A recipe with Station = "Forge" starts only when the caller passes "Forge". For server-initiated crafts that is trustworthy. For the CR_Craft intent, the station id is a client claim: the schema is two strings, and nothing in the transport shows the player is near a forge.
That check belongs in the Craft.CanCraft hook, which fires with Session, RecipeId and Station. The server example above looks up the claimed id among tagged station parts in the Workspace and checks the distance to the character. Zones or an InteractionKit prompt work too: anything that decides “at the station” from server state.
The hook is fail-open: an erroring handler does not veto, only an explicit false does. The security boundary is the fail-closed intent chain, which runs canCraft (and this hook) before the handler. The hook is a game rule slot, and a bug in one rule should not disable every recipe. The consequence: without a Craft.CanCraft handler, station claims go unchecked. The kit compares the claimed string with the recipe and nothing compares the claim with the world.
Output overflow
Section titled “Output overflow”Completion grants each output through Inventory:grant. Whatever does not fit publishes Craft.Overflow(session, recipeId, remainder) and is lost: the kit does not hold outputs in escrow.
Kernel.Bus:subscribe("Craft.Overflow", function(_topic, session, recipeId, remainder) for Id, Count in remainder do mailToPlayer(session, Id, Count) -- your redelivery policy endend)To make overflow unreachable instead, size MaxSlots so the largest recipe’s output fits after its inputs free their slots, or refuse crafts in Craft.CanCraft when Inventory:space(session, id) is too small. InventoryKit also publishes Inventory.Overflow for anything that did not fit.
Client intents
Section titled “Client intents”Both intents go through 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 per second | Runs the full canCraft ladder, including the hook. |
CR_Cancel |
none | 5 per second | The session has an active craft. |
canCraft runs twice on the intent path: once as the validator and once inside craft. The API stays safe for direct server calls, and the chain gets its early reject. Both runs are reads until everything passes, but a Craft.CanCraft handler fires twice per request, so keep it free of side effects.
API reference
Section titled “API reference”| Member | Description |
|---|---|
CraftingKit.attach(kernel, options) -> kit |
Asserts Recipes and Inventory. Defines Craft.CanCraft, subscribes to Kernel.ProfileLoaded to resume recorded crafts, and wires the 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?) |
Checks, takes the inputs with rollback, then completes at once or starts a timed craft. Same reasons as canCraft. |
kit:cancel(session) -> boolean |
Refunds and clears the active timed craft and its profile record. false when none is active. |
kit:activeCraft(session) -> { RecipeId, FinishAt }? |
The craft in flight, with FinishAt on the kit Clock, or nil. |
kit:destroy() |
Unhooks both intent validators and makes their handlers inert, disconnects the profile and session listeners, and cancels every timer. Profile records stay and resume on the next load. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Craft.Started |
session, recipeId, finishAt |
A timed craft started, with inputs already taken. finishAt is on the kit Clock. Instant crafts 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 |
Outputs at completion, or refunded inputs at cancel, did not fit. remainder maps item id to the lost count. |
Craft.Completed feeds QuestKit objectives and crafting analytics. Craft.Overflow is the topic to always subscribe to if bags can fill.
Design notes
Section titled “Design notes”- Instant crafts are never active.
Seconds = 0completes inside thecraftcall:activeCraftnever shows it, it cannot causeBusy, and it cannot be cancelled. A fast instant recipe is limited only by its inputs and the 5 per second intent rate. OnCompleteruns on its own thread, after outputs grant. An error in it cannot corrupt kit state, and if it yields,Craft.Completedcan publish before it finishes. Do not assume its side effects are done when the topic arrives.- A craft that finished while the player was away completes inside the kit’s own
Kernel.ProfileLoadedsubscriber. ItsCraft.Completedcan publish before subscribers registered later have seen the profile. - Without a DataDriver there is no profile to record into. Timed crafts then live in memory only, and their inputs are lost if the player leaves before completion.
- The clocks are injectable.
Clock,WallClockandSkipLoopwith manual_sweep()calls are how the specs run ten-minute crafts in microseconds.