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.
Mental model
Section titled “Mental model”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 → _attempt → Hooks:fire("Intent.Interact", { Session, Player, Id, Instance, Definition }):
- Pass → the cooldown stamps,
Interact.Triggeredpublishes, andOnInteractruns in its own thread (task.spawn, so a throwing handler can’t break the prompt’s connection). - Fail →
Interact.Rejectedpublishes. 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, }, }, }))endGame 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 endend, 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.
The crafting-station pattern
Section titled “The crafting-station pattern”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").ChloeKernelServerlocal 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 endend)Either way the proximity check happens exactly once, server-side, in this kit.
Configuration
Section titled “Configuration”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. |
InteractionConfig
Section titled “InteractionConfig”| 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. |
API reference
Section titled “API reference”| 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.
Hooks & bus topics
Section titled “Hooks & bus topics”| 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 }.