Skip to content

InteractionKit

InteractionKit lets players interact with the world through ProximityPrompts created from CollectionService tags. The defining decision: a prompt trigger is client input. ProximityPrompt.Triggered fires because the client said so — exploit tooling fires prompts from across the map — so the kit treats every trigger as a claim and re-validates distance, line of sight, and cooldown server-side through the fail-closed Intent.Interact hook chain before any game code runs.

Tag a part in Studio, define the interaction in config, and the prompt appears, follows the tag, and can’t be exploited past its rules.

Prompts are server-created and tag-driven. At start(), for each interaction the kit creates a ProximityPrompt on every instance carrying the Tag, then watches GetInstanceAddedSignal/GetInstanceRemovedSignal — tag a part at runtime and it gets a prompt; untag it and the prompt is destroyed. Because prompts are parented to their parts, they stream in and out with them under instance streaming with zero extra work (streaming notes).

The prompt itself is presentation: ActionText, ObjectText, HoldDuration, and MaxActivationDistance shape the client UX. RequiresLineOfSight is forced off — that flag is evaluated client-side and worth nothing; the kit raycasts on the server instead.

Every trigger runs the chain. Triggered_attemptHooks:fire("Intent.Interact", { Session, Player, Id, Instance, Definition }):

  • Pass → the cooldown stamps, Interact.Triggered publishes, and OnInteract runs in its own thread (task.spawn, so a throwing handler can’t break the prompt’s connection).
  • FailInteract.Rejected publishes. Nothing else happens — rejections don’t consume the cooldown, so a denied attempt can’t lock an honest player out of their next one.

The kit gate sits at priority 50 so game validators prepend below it. It rejects when:

Check Rule
Cooldown Cooldown set and the last accepted trigger for this interaction id is newer than it (per player, os.clock).
Distance No measurable distance (no character, no resolvable position — fail-closed), or distance > MaxDistance × 1.25. The 1.25 slack exists because prompt activation distance is evaluated on the client: a player at the edge who triggers legitimately is measured by the server a latency-step later, possibly further out. The slack absorbs that without letting cross-map triggers through.
Line of sight LineOfSight = true and a server raycast from the character’s root to the target hits anything (the character and the target instance itself are excluded from the ray).

Distance resolves against a position derived per instance: a BasePart’s Position, a Model’s pivot, otherwise the nearest ancestor that has one. Both distance and line-of-sight functions are injectable (GetDistance, HasLineOfSight) for specs and non-standard worlds.

local ServerScriptService = game:GetService("ServerScriptService")
local InteractionKit = require(ServerScriptService.ChloeKernelServer.Kits.InteractionKit)
return function(kernel: any)
kernel:registerService(InteractionKit.service({
Interactions = {
OpenChest = {
Tag = "Chest", -- tag parts in Studio; prompts appear (and follow the tag) automatically
ActionText = "Open",
HoldDuration = 0.5,
MaxDistance = 10,
Cooldown = 3,
LineOfSight = true,
OnInteract = function(session, chest)
session.Data.Coins = (session.Data.Coins or 0) + 25
end,
},
ReadSign = {
Tag = "Sign",
ActionText = "Read",
MaxDistance = 6,
},
},
}))
end

Game rules prepend below the kit’s gate at lower priority numbers — no forking:

kernel.Hooks:on("Intent.Interact", function(context)
if context.Id == "OpenChest" then
return context.Session.Data.HasKey == true -- no key, no loot
end
end, 10)

The context’s Definition field is the interaction’s own config table, so one generic validator can read context.Definition instead of switching on ids.

Chests that roll loot link naturally into LootKit; prompts that grant items go through InventoryKit.

CraftingKit recipes can require a station (Station = "Anvil"), and its client intent passes a claimed station id — which, like all client input, needs proximity verified server-side. InteractionKit is the natural front door. Two shapes:

Direct craft on prompt — the simplest honest version. The interaction is the proximity proof, because the kit already validated distance before OnInteract ran:

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local InventoryKit = require(Root.Kits.InventoryKit)
local CraftingKit = require(Root.Kits.CraftingKit)
local Inventory = InventoryKit.attach(kernel, {
Items = { IronOre = { Stack = 20 }, IronSword = {} },
})
local Crafting = CraftingKit.attach(kernel, {
Inventory = Inventory,
Recipes = {
IronSword = { Inputs = { IronOre = 3 }, Output = { IronSword = 1 }, Seconds = 2, Station = "Anvil" },
},
})
kernel:registerService(InteractionKit.service({
Interactions = {
ForgeSword = {
Tag = "Anvil",
ActionText = "Forge Sword",
HoldDuration = 1,
MaxDistance = 8,
OnInteract = function(session)
Crafting:craft(session, "IronSword", "Anvil")
end,
},
},
}))

Station UI — the prompt opens a menu and the client sends CR_Craft picks. Record the validated station visit and verify it (with a freshness window) in CraftingKit’s Craft.CanCraft hook:

kernel:registerService(InteractionKit.service({
Interactions = {
UseAnvil = {
Tag = "Anvil",
ActionText = "Craft",
MaxDistance = 8,
OnInteract = function(session, anvil)
session.Data.AtStation = { Id = "Anvil", Instance = anvil, At = os.clock() }
-- push a state to open the client's crafting menu here
end,
},
},
}))
kernel.Hooks:on("Craft.CanCraft", function(context)
local Visit = context.Session.Data.AtStation
if context.Station and (not Visit or Visit.Id ~= context.Station or os.clock() - Visit.At > 30) then
return false -- claimed a station they haven't validly stood at recently
end
end)

Either way the proximity check happens exactly once, server-side, in this kit.

InteractionKit.service(config)KitConfig

Section titled “InteractionKit.service(config) — KitConfig”
Field Type Default Description
Interactions { [string]: InteractionConfig } required Interaction id → definition. The id is what hooks and bus events carry.
GetDistance ((player, instance) -> number?)? character-root to instance-position magnitude Injectable distance measure. Return nil to reject (no measurable distance).
HasLineOfSight ((player, instance) -> boolean)? server raycast excluding character + target Injectable sight check.
Field Type Default Description
Tag string required CollectionService tag receiving a prompt.
ActionText string? the interaction id Prompt action label.
ObjectText string? "" Prompt object label.
HoldDuration number? 0 Client hold-to-trigger seconds (UX only — not security).
MaxDistance number? 10 Prompt activation distance; the server re-checks at MaxDistance × 1.25.
Cooldown number? Seconds per player per interaction id. Stamped on acceptance only.
LineOfSight boolean? false Require a clear server raycast on every trigger.
OnInteract ((session, instance) -> ())? Runs after the full chain passes, in its own thread.
Member Description
InteractionKit.service(config: KitConfig) Returns a service definition for kernel:registerService.
service:init(kernel) Registers the priority-50 Intent.Interact gate. Called by the kernel.
service:start() Creates prompts on tagged instances and begins following tag add/remove. Called by the kernel.
service:stop() Disconnects the gate and every tag/prompt connection, destroys every kit-created prompt.

_attempt(player, id, instance) is internal — specs call it to simulate triggers without a real prompt.

Hook point Context Mode Notes
Intent.Interact { Session, Player, Id, Instance, Definition } fail-closed Every prompt trigger. Kit gate (cooldown/distance/sight) at priority 50; prepend game rules at lower numbers. A crashing validator rejects.
Topic Args When
Interact.Triggered session, id, instance A trigger passed the full chain (before OnInteract runs).
Interact.Rejected player, id Any validator refused. Note the asymmetry: Triggered carries the session, Rejected the player.

Interact.Triggered is a clean QuestKit objective source — “open 5 chests” is { Topic = "Interact.Triggered", Count = 5, Filter = function(session, _, s, id) return s == session and id == "OpenChest" end }.