InteractionKit
Creates ProximityPrompts on tagged Workspace instances and re-validates every trigger on the server for enabled state, distance, line of sight and cooldown.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .InteractionKit - Since
- v0.3.0
- Source
- Kits/InteractionKit.luau
InteractionKit gives players world interactions through ProximityPrompts created from CollectionService tags. A prompt
trigger is client input: exploit tooling can fire any prompt from anywhere on the map. The kit treats every trigger as a
claim and re-checks the prompt’s Enabled flag, distance, line of sight and cooldown on the server through the
Intent.Interact hook chain before any game code runs.
Tag an instance in Studio, define the interaction in config, and the prompt appears on it while it is in the Workspace.
Mental model
Section titled “Mental model”Prompts are created by the server from tags. At start(), each definition watches its Tag through
ChloeKernel.TagWatch, which reports only instances inside the Workspace. A tagged instance that enters the
Workspace gets a prompt; one that leaves it or loses the tag has its prompt destroyed. A tagged prefab in
ServerStorage or ReplicatedStorage gets nothing until it moves into the world. Prompts are parented to their instances,
so they stream in and out with them (streaming notes).
A prompt works only on a BasePart, an Attachment or a Model with a PrimaryPart. Any other tagged instance logs a
warning with its full name and gets no prompt.
Each definition owns its own prompt per instance, so two definitions may share a tag: the instance carries two prompts,
and both are destroyed when the tag goes. Prompts are created with Archivable = false, so cloning a tagged instance
never copies a prompt with no trigger route; the clone gets its own prompt once it is tagged and in the Workspace.
One ProximityPromptService.PromptTriggered connection serves every prompt the kit made. It looks the prompt up in the
kit’s table and ignores prompts the kit did not create.
The prompt itself is presentation. ActionText, ObjectText, HoldDuration, MaxActivationDistance and the optional
presentation fields shape the client UI. RequiresLineOfSight mirrors LineOfSight: the engine’s client check (from
the camera) only hides an occluded prompt, and the server raycast stays the gate.
Every trigger runs the chain
Section titled “Every trigger runs the chain”A trigger resolves the session and fires Intent.Interact with
{ Session, Player, Id, Instance, Prompt, Definition }:
- Pass: the cooldown is stamped,
Interact.Triggeredpublishes andOnInteractruns on its own thread, so a throwing handler cannot break the shared connection. - Fail:
Interact.Rejectedpublishes and nothing else happens. A rejection does not stamp the cooldown, so a denied attempt never locks an honest player out of the next one.
The kit’s handler runs at priority 50, so game rules at lower numbers run first. It rejects when:
| Check | Rule |
|---|---|
| Enabled | The prompt’s Enabled is false. A disabled prompt is hidden on the client, so a trigger for it is not a real press. |
| Cooldown | Cooldown is set and the player’s last accepted trigger of this interaction id is more recent (os.clock). |
| Distance | No measurable distance (no character root or no position: fail-closed), or the distance is over MaxDistance * 1.25. Prompt activation is evaluated on the client and the server measures a moment later, so the 1.25 slack absorbs latency without admitting cross-map triggers. |
| Line of sight | LineOfSight = true and a server raycast from the character’s PrimaryPart to the target hits something. The ray excludes the triggering character and the target instance. |
The target position is a BasePart’s Position, an Attachment’s WorldPosition, a Model’s pivot, or else the
nearest ancestor that has one. Distance and sight are injectable (GetDistance, HasLineOfSight) for specs and custom
worlds.
InteractionKit is a service kit. Add it to the server Registry.luau with its definitions from Game/Config:
--!strict-- Interactions: every ProximityPrompt interaction, keyed by id.
return { Interactions = { OpenChest = { Tag = "Chest", ActionText = "Open", HoldDuration = 0.5, MaxDistance = 10, Cooldown = 3, LineOfSight = true, KeyboardKeyCode = Enum.KeyCode.F, OnInteract = function(session, _chest) local Profile = session.Profile if Profile then Profile.Data.Coins = (Profile.Data.Coins or 0) + 25 end end, }, ReadSign = { Tag = "Sign", ActionText = "Read", MaxDistance = 6, Style = Enum.ProximityPromptStyle.Default, MaxIndicatorDistance = 20, -- a small hint visible beyond MaxDistance }, },}--!strict-- Every server service, one require per module in Services. ServiceManager-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local InteractionKit = require(ServerScriptService.ChloeKernelServer.Kits.InteractionKit)
return { require(script.Parent.Services.ComponentService), InteractionKit.service(require(script.Parent.Config.Interactions)), require(script.Parent.Services.DungeonService),} :: { any }Game rules are extra handlers on the chain at a lower priority number:
-- In DungeonService.start(): no key, no lootself.Unhook = self.Kernel.Hooks:on("Intent.Interact", function(context): boolean? if context.Id == "OpenChest" then return context.Session.Data.HasKey == true end return nilend, 10)context.Definition is the interaction’s own config table, so one generic handler can read it instead of switching on
ids. To turn a prompt off for a while (a chest already looted), look it up with promptFor and set Enabled:
local Interactions = self.Kernel:getService("InteractionKit")local Prompt = Interactions:promptFor("OpenChest", chest)if Prompt then Prompt.Enabled = false -- hidden on clients; triggers for it are rejectedendChests that roll loot pair with 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 a crafting request names the
station it claims. Like all client input, that claim needs a proximity check on the server, and InteractionKit already
does one. Two shapes:
Craft directly from the prompt. The interaction is the proximity proof, because the kit checked distance before
OnInteract ran:
ForgeSword = { Tag = "Anvil", ActionText = "Forge Sword", HoldDuration = 1, MaxDistance = 8, OnInteract = function(session, _anvil) Crafting:craft(session, "IronSword", "Anvil") end,},Open a station menu from the prompt and let the client send crafting picks. Record the validated visit in
OnInteract and check it, with a freshness window, in CraftingKit’s Craft.CanCraft hook:
UseAnvil = { Tag = "Anvil", ActionText = "Craft", MaxDistance = 8, OnInteract = function(session, anvil) session.Data.AtStation = { Id = "Anvil", Instance = anvil, At = os.clock() } end,},self.Unhook = self.Kernel.Hooks:on("Craft.CanCraft", function(context): boolean? 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 have not stood at recently end return nilend)Either way the proximity check happens once, on the server, in this kit.
Configuration
Section titled “Configuration”InteractionKit.service(config)
Section titled “InteractionKit.service(config)”| Field | Type | Default | Description |
|---|---|---|---|
Interactions |
{ [string]: InteractionConfig } |
required | Interaction id to definition. Hooks and Bus topics carry the id. |
GetDistance |
((player, instance) -> number?)? |
character PrimaryPart to target position |
Distance measure. nil rejects. |
HasLineOfSight |
((player, instance) -> boolean)? |
server raycast excluding the character and the target | Sight check. |
InteractionConfig
Section titled “InteractionConfig”| Field | Type | Default | Description |
|---|---|---|---|
Tag |
string |
required | CollectionService tag that receives a prompt. Several definitions may share one. |
ActionText |
string? |
the interaction id | Prompt action label. |
ObjectText |
string? |
"" |
Prompt object label. |
HoldDuration |
number? |
0 |
Client hold time. Presentation only. |
MaxDistance |
number? |
10 |
Prompt activation distance. The server re-checks at MaxDistance * 1.25. |
Cooldown |
number? |
nil |
Seconds per player per interaction id, stamped on acceptance only. |
LineOfSight |
boolean? |
false |
Require a clear server raycast on every trigger. Also sets the prompt’s RequiresLineOfSight. |
OnInteract |
((session, instance) -> ())? |
nil |
Runs on its own thread after the chain passes. |
KeyboardKeyCode |
Enum.KeyCode? |
engine default | Prompt key on keyboard. |
GamepadKeyCode |
Enum.KeyCode? |
engine default | Prompt button on gamepad. |
Exclusivity |
Enum.ProximityPromptExclusivity? |
engine default | Which prompts can show at once. |
ClickablePrompt |
boolean? |
engine default | Whether the prompt can be clicked or tapped. |
Style |
Enum.ProximityPromptStyle? |
engine default | Default or Custom (for your own prompt UI). |
UIOffset |
Vector2? |
engine default | Pixel offset of the prompt UI. |
MaxIndicatorDistance |
number? |
engine default (0, off) | Distance at which a small indicator shows beyond MaxDistance. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
InteractionKit.service(config) -> service |
Returns a service definition named InteractionKit. |
service:init(kernel) |
Registers the priority-50 Intent.Interact handler. The kernel calls it. |
service:start() |
Connects the one PromptTriggered handler and starts a Workspace tag watch per definition. The kernel calls it. |
service:promptFor(id, instance) -> ProximityPrompt? |
The prompt the kit made for that interaction on that instance, for games that toggle Enabled. |
service:stop() |
Removes the hook handler, stops every tag watch, disconnects PromptTriggered and destroys every prompt the kit made. |
_attempt(player, id, instance, prompt?) is internal. Specs call it to simulate a trigger without a real prompt.
Hooks and Bus topics
Section titled “Hooks and Bus topics”| Hook point | Context | Notes |
|---|---|---|
Intent.Interact |
{ Session, Player, Id, Instance, Prompt, Definition } |
Every trigger of a kit prompt. The kit’s handler (priority 50) checks enabled state, cooldown, distance and sight. Any false rejects, and a handler that errors rejects too. |
| Topic | Payload | When |
|---|---|---|
Interact.Triggered |
session, id, instance |
A trigger passed the chain, before OnInteract runs. |
Interact.Rejected |
player, id |
A handler refused. Triggered carries the session and Rejected the Player. |
Interact.Triggered works as a QuestKit objective: “open 5 chests” is
{ Topic = "Interact.Triggered", Count = 5, ActorArg = 1, Match = "OpenChest" }.