Skip to content

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.

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.

A trigger resolves the session and fires Intent.Interact with { Session, Player, Id, Instance, Prompt, Definition }:

  • Pass: the cooldown is stamped, Interact.Triggered publishes and OnInteract runs on its own thread, so a throwing handler cannot break the shared connection.
  • Fail: Interact.Rejected publishes 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:

src/Server/Game/Config/Interactions.luau
--!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
},
},
}
src/Server/Game/Registry.luau
--!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 loot
self.Unhook = self.Kernel.Hooks:on("Intent.Interact", function(context): boolean?
if context.Id == "OpenChest" then
return context.Session.Data.HasKey == true
end
return nil
end, 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 rejected
end

Chests that roll loot pair with LootKit; prompts that grant items go through InventoryKit.

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

Either way the proximity check happens once, on the server, in this kit.

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

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

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