CompanionKit
CompanionKit gives sessions an owned NPC follower — the witchcraft familiar, the lobby pet — built entirely from NPCKit archetypes. The defining decision is that the kit owns ownership, not behavior: a companion is a plain npc handle spawned from an archetype you defined in NPCKit, plus a slot on the owner’s session, a follow loop, and an optional Effects aura held on the owner while the companion is out. Everything the npc is — model, health, moveset, difficulty — lives in the archetype; anything smarter than following is your game loop’s job, same as any other npc.
Mental model
Section titled “Mental model”The kit keeps one map: session → { Id, Npc }. Three paths mutate it:
summon— gate through theCompanion.CanSummonhook, dismiss whatever is out (one per session, a new summon replaces the old), spawn the archetype beside the owner, apply the aura, publishCompanion.Summoned.dismiss— clear the slot, remove the aura, destroy the npc, publishCompanion.Dismissed.- The follow loop — a scheduler task every
TickSeconds(default 0.25,Priority.Low). Per active companion: a dead npc clears the slot, removes the aura, and publishesCompanion.Died; otherwise distance to the owner decides — beyondTeleportDistancethe model pivots beside the owner, beyondFollowDistanceit walks vianpc:moveTowards(which rides Pathfinding when the NPCKit has a grid), insideFollowDistanceit idles.
An owner between characters (no PrimaryPart) is skipped, not dismissed — the companion waits where it stands until the owner respawns.
local ServerScriptService = game:GetService("ServerScriptService")local ServerStorage = game:GetService("ServerStorage")
local Root = ServerScriptService.ChloeKernelServerlocal CompanionKit = require(Root.Kits.CompanionKit)local Effects = require(Root.Effects)local NPCKit = require(Root.Kits.NPCKit)
return function(kernel: any) local Buffs = Effects.attach(kernel) Buffs:define("CatLuck", { Stats = { Luck = 1.25 } }) -- no Duration: held until removed
local Npcs = NPCKit.attach(kernel) Npcs:define("Familiar", { Model = ServerStorage.NPC.BlackCat, WalkSpeed = 18, Difficulty = "Medium", })
local Companions = CompanionKit.attach(kernel, { Npcs = Npcs, Effects = Buffs, Companions = { BlackCat = { Archetype = "Familiar", FollowDistance = 8, TeleportDistance = 60, Aura = "CatLuck", }, }, })
-- Gate ownership: only sessions that unlocked the companion may summon it kernel.Hooks:on("Companion.CanSummon", function(context) local Owned = context.Session.Profile.Data.Companions return Owned ~= nil and Owned[context.CompanionId] == true end)
-- Server-side summon (clients ride CP_Summon instead) kernel:onSession(function(session) Companions:summon(session, "BlackCat") end)endAttach options
Section titled “Attach options”| Option | Description |
|---|---|
Npcs |
The NPCKit instance companions spawn from. Required — asserted at attach. |
Effects |
An Effects instance. Required when any companion declares an Aura — asserted per companion at attach, so a missing wiring fails at boot, not at first summon. |
Companions |
{ [companionId] = Companion } — the definition table. Required. |
TickSeconds |
Follow-loop cadence. Default 0.25. |
Intents |
Wire CP_Summon/CP_Dismiss client intents (needs a kernel net driver). Default true. |
SkipLoop |
Skip the follow loop (specs drive _step manually). |
Companion definition
Section titled “Companion definition”| Field | Description |
|---|---|
Archetype |
NPCKit archetype name to spawn. |
FollowDistance |
Studs from the owner before the companion walks. Default 8. |
TeleportDistance |
Studs before it pivots to the owner instead — the leash. Default 60. |
Aura |
Effects name applied to the owner while summoned. |
OnSummon |
(session, npc) — spawned in a fresh thread after the summon completes. |
OnDismiss |
(session) — spawned in a fresh thread on explicit dismiss. |
The rules, exactly
Section titled “The rules, exactly”One per session; replacement, not refusal. summon on a session with a live companion dismisses the old one first — full dismiss path, including Companion.Dismissed and the old aura’s removal — then summons the new. Summoning the same id is a fresh respawn beside the owner.
Summon placement. The npc spawns at the owner’s root position plus (3, 0, 3), or Vector3.zero when the owner has no character. Unknown companion ids return nil without side effects.
The Companion.CanSummon gate is a fail-open hook point fired with { Session, CompanionId } before anything happens. Any handler returning false vetoes and summon returns nil — the current companion is not dismissed on a vetoed summon. Ownership checks, zone restrictions (“no pets in ranked”), and cooldowns all live here.
Death is its own path. The follow loop notices Npc.Alive == false (the npc died and NPCKit destroyed it, or something destroyed it directly), clears the slot, removes the aura, and publishes Companion.Died(session, companionId). OnDismiss does not run and Companion.Dismissed does not publish on death — subscribe to Companion.Died for death VFX and resummon timers.
The aura lives on the owner. Effects:apply(session, Aura) with no Duration — held until removed — on summon; Effects:remove(session, Aura) on dismiss and death. The buff is the owner’s reward for keeping the companion alive, which is why it is not applied to the npc. Define the effect in Effects before attach; apply errors on unknown names.
The follow loop, step by step
Section titled “The follow loop, step by step”Every TickSeconds, per companion, in this order:
Npc.Alive == false→ queue the death cleanup (slot, aura,Companion.Died).- No owner root → skip (owner between characters; the companion waits).
distance > TeleportDistance→Model:PivotTobeside the owner atowner + (3, 2, 3). No pathfinding, no traversal — the leash exists so a companion can never be lost to geometry.distance > FollowDistance→npc:moveTowards(owner)— routed movement when the NPCKit hasPathfinding/Grid, straight-line otherwise.- Inside
FollowDistance→ idle. No movement call at all, so your own loop is free to make the companion do tricks.
Client intents
Section titled “Client intents”With Intents left on (and a net driver attached), two fail-closed intents wire through the NetDriver pipeline — session check, token bucket, then the Intent.* hook chain where the kit installs its validator at priority 50:
| Intent | Schema | Rate limit | Kit validator (priority 50) |
|---|---|---|---|
CP_Summon |
{ String } — companionId |
RateLimit = 5 |
The companion id exists in Companions. |
CP_Dismiss |
{} |
RateLimit = 5 |
The session has an active companion. |
The validator only proves the payload is well-formed; your Companion.CanSummon handler is the ownership gate — without one, any client can summon any defined companion. Client side:
-- CLIENTlocal NetClient = require(game:GetService("ReplicatedStorage").ChloeKernel.Net.Client)
local Net = NetClient.new()local Summon = Net:intent("CP_Summon", { "String" })local Dismiss = Net:intent("CP_Dismiss", {})
Summon:Fire("BlackCat")Dismiss:Fire()Intent wiring also binds a session-end cleanup: a leaving owner’s companion is destroyed and the slot cleared silently (no bus events — the session is already gone).
Binding to an inventory item
Section titled “Binding to an inventory item”The canonical wiring is an InventoryKit OnUse handler — a summon charm in a bag slot:
local InventoryKit = require(Root.Kits.InventoryKit)
local Inventory = InventoryKit.attach(kernel, { Items = { CatCharm = { Name = "Cat Charm", Stack = 1, OnUse = function(session, item, kit) local Npc = Companions:summon(session, "BlackCat") return false -- a focus, not a consumable: never burn the charm end, }, OwlTreat = { Name = "Owl Treat", Stack = 10, OnUse = function(session, item, kit) -- Consumed only when the summon actually happened (gate not vetoed) return Companions:summon(session, "Owl") ~= nil end, }, },})OnUse returning true consumes one from the stack, so returning summon(...) ~= nil makes the item burn only on a successful summon — a vetoed or unknown summon costs nothing.
API reference
Section titled “API reference”| Member | Description |
|---|---|
CompanionKit.attach(kernel, options) → kit |
Construct, assert wiring, define the Companion.CanSummon hook point, start the follow loop, wire intents. |
kit:summon(session, companionId) → npc? |
Gate, replace, spawn, aura, publish. nil on unknown id or veto. |
kit:dismiss(session) → boolean |
Clear slot, remove aura, destroy npc, publish. false when nothing was out. |
kit:companionOf(session) → (npc?, id?) |
The live companion handle and its definition id. |
kit:destroy() |
Cancel the loop and dismiss every active companion. |
Hooks & bus topics
Section titled “Hooks & bus topics”| Hook point | Mode | Context | Fired |
|---|---|---|---|
Companion.CanSummon |
fail-open | { Session, CompanionId } |
Before every summon; false vetoes. |
| Topic | Args | When |
|---|---|---|
Companion.Summoned |
session, companionId, npc |
After a successful summon. |
Companion.Dismissed |
session, companionId |
Explicit dismiss (including replacement by re-summon). |
Companion.Died |
session, companionId |
The follow loop found the npc dead. Not accompanied by Companion.Dismissed. |
The spawned npc is an ordinary NPCKit citizen, so the whole Npc.* topic surface (NPCKit) fires for companions too — Npc.Spawned, Npc.Died, Npc.Path while following, and the rest.