Skip to content

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.

craft is canCraft plus commitment:

  1. canCraft walks the refusal ladder: UnknownRecipe, Busy (one active timed craft per session), WrongStation, Loading (a timed recipe while the profile loads), MissingInputs, then the Craft.CanCraft hook (Vetoed). The first failure returns (false, reason). Nothing on this path has side effects.
  2. Craft.CanCraft handlers 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, craft checks Busy again, and nothing between that check and the takes yields.
  3. 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 and craft returns (false, "MissingInputs"). Two overlapping requests cannot both craft from one set of inputs.
  4. A recipe with Seconds of 0 (the default) completes at once. Otherwise the craft is recorded in the profile and scheduled, and Craft.Started publishes.
  5. On completion the profile record is cleared, outputs grant, OnComplete runs on its own thread, Craft.Completed publishes, and anything that did not fit publishes Craft.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.

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

src/Server/Game/Services/EconomyService.luau
--!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

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

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 }, where FinishAt = WallClock() + Seconds on the wall clock. It saves with the rest of the profile.
  • One Scheduler:after(Seconds) timer at Priority.Low completes 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 craft while one runs returns (false, "Busy"). Build queues in game code; one craft in flight keeps cancel and refund rules unambiguous.
  • cancel clears the record and the timer, grants the inputs back, and publishes Craft.Cancelled. Inputs that no longer fit publish Craft.Overflow with the remainder. It returns false when nothing was active.
  • activeCraft(session) returns { RecipeId, FinishAt } or nil. This FinishAt is on the kit’s Clock (server os.clock by 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.ProfilePending is true, timed recipes refuse with Loading. 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.

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.

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

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.

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.

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

  • Instant crafts are never active. Seconds = 0 completes inside the craft call: activeCraft never shows it, it cannot cause Busy, and it cannot be cancelled. A fast instant recipe is limited only by its inputs and the 5 per second intent rate.
  • OnComplete runs on its own thread, after outputs grant. An error in it cannot corrupt kit state, and if it yields, Craft.Completed can 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.ProfileLoaded subscriber. Its Craft.Completed can 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, WallClock and SkipLoop with manual _sweep() calls are how the specs run ten-minute crafts in microseconds.

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