Skip to content

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.

The kit keeps one map: session → { Id, Npc }. Three paths mutate it:

  • summon — gate through the Companion.CanSummon hook, dismiss whatever is out (one per session, a new summon replaces the old), spawn the archetype beside the owner, apply the aura, publish Companion.Summoned.
  • dismiss — clear the slot, remove the aura, destroy the npc, publish Companion.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 publishes Companion.Died; otherwise distance to the owner decides — beyond TeleportDistance the model pivots beside the owner, beyond FollowDistance it walks via npc:moveTowards (which rides Pathfinding when the NPCKit has a grid), inside FollowDistance it 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.ChloeKernelServer
local 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)
end
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).
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.

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.

Every TickSeconds, per companion, in this order:

  1. Npc.Alive == false → queue the death cleanup (slot, aura, Companion.Died).
  2. No owner root → skip (owner between characters; the companion waits).
  3. distance > TeleportDistanceModel:PivotTo beside the owner at owner + (3, 2, 3). No pathfinding, no traversal — the leash exists so a companion can never be lost to geometry.
  4. distance > FollowDistancenpc:moveTowards(owner) — routed movement when the NPCKit has Pathfinding/Grid, straight-line otherwise.
  5. Inside FollowDistance → idle. No movement call at all, so your own loop is free to make the companion do tricks.

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:

-- CLIENT
local 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).

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.

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