Skip to content

CompanionKit

Gives each session one owned NPC follower built from an NPCKit archetype, with a teleport leash and an optional Effects aura held on the owner.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Kits.CompanionKit
Since
v0.5.0
Source
Kits/CompanionKit.luau

CompanionKit gives a session an owned NPC follower, such as a familiar or a lobby pet, spawned from an NPCKit archetype. The kit owns ownership and following, and nothing else. A companion is an ordinary NPC handle plus a slot on the owner’s session, a follow job and an optional Effects aura held on the owner while the companion is out. Model, health, moveset and difficulty live in the archetype. Anything beyond following, such as fighting or tricks, is game code driving the NPC like any other.

The kit keeps one map, session -> { Id, Npc }. Four paths change it:

  • summon fires the Companion.CanSummon hook, dismisses any companion already out, spawns the archetype beside the owner, applies the aura and publishes Companion.Summoned.
  • dismiss clears the slot, removes the aura, destroys the NPC and publishes Companion.Dismissed.
  • Death. The kit subscribes to Npc.Died and clears the slot the moment the companion dies: it removes the aura and publishes Companion.Died. The corpse stays for the archetype’s DespawnSeconds and NPCKit removes it.
  • Session end. A leaving owner’s companion is destroyed and the slot cleared without Bus topics.

The follow job is a Scheduler every(TickSeconds) at Priority.Low. It starts with the first summon and is cancelled when the last companion leaves by any of those paths, so a server with no companions out has no follow work at all. SkipLoop = true never starts it; specs call _step() by hand.

Every TickSeconds (default 0.25 s), for each companion, in this order:

  1. The NPC is dead (Alive == false or Dying): run the death cleanup. This catches an NPC destroyed by something other than a normal death.
  2. The owner has no Character.PrimaryPart: skip. The companion waits where it is until the owner respawns.
  3. Farther than TeleportDistance: teleport beside the owner (see below) and end any walk in progress.
  4. Farther than FollowDistance: walk toward the owner. How depends on the rig (see below).
  5. Otherwise: nothing. The kit issues no movement call, so game code is free to move the companion near its owner.

For a plain Humanoid rig (the NPCKit has no Pathfinding and Grid, and the archetype has no custom Move), the kit issues one Humanoid:MoveTo(root.Position, root) with the owner’s root part as the target. The engine keeps re-targeting that walk as the owner moves, so the kit does not call anything per tick. It re-issues the walk when:

  • the owner’s root part changes (a respawn),
  • 7 seconds have passed since the last MoveTo, because the engine abandons a MoveTo after 8 seconds,
  • MoveToFinished fires with false while the companion is still beyond FollowDistance.

When the walk finishes, the next tick starts a new one only if the owner has moved beyond FollowDistance again.

Every other rig (a routed NPCKit, an archetype Move, a headless NPC) calls npc:moveTowards(owner) on each tick while it is beyond FollowDistance. With a grid attached, that routes through Pathfinding.

The leash keeps a companion from being lost to geometry. The kit tries four spots around the owner’s root, in order: (3, 2, 3), (-3, 2, 3), (3, 2, -3), (-3, 2, -3). For each, it asks GetPartBoundsInBox whether the companion’s bounding box fits, counting colliding parts only (RespectCanCollide = true) and excluding the owner’s character and the companion. The model pivots to the first clear spot, turned to face the owner. When all four are blocked, the kit calls Model:MoveTo on the first spot, which lifts the rig clear of whatever is in the way, and then restores the facing.

CompanionKit is an attach kit. Attach it from the owning service’s init, after NPCKit and Effects.

src/Server/Game/Config/Companions.luau
--!strict
-- Companions: every summonable follower, keyed by id.
return {
BlackCat = {
Archetype = "Familiar",
FollowDistance = 8,
TeleportDistance = 60,
Aura = "CatLuck",
},
}
src/Server/Game/Services/CompanionService.luau
--!strict
-- CompanionService: familiars and the luck aura they grant.
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
local CompanionKit = require(ServerScriptService.ChloeKernelServer.Kits.CompanionKit)
local Companions = require(script.Parent.Parent.Config.Companions)
local Effects = require(ServerScriptService.ChloeKernelServer.Effects)
local NPCKit = require(ServerScriptService.ChloeKernelServer.Kits.NPCKit)
local CompanionService = {
Name = "CompanionService",
Dependencies = { "DataService" },
}
function CompanionService.init(self: any, kernel: any)
self.Kernel = kernel
self.Buffs = Effects.attach(kernel)
self.Buffs:define("CatLuck", { Stats = { Luck = 1.25 } }) -- no Duration: held until removed
self.Npcs = NPCKit.attach(kernel)
self.Npcs:define("Familiar", {
Model = ServerStorage.Npcs.BlackCat,
WalkSpeed = 18,
})
self.Companions = CompanionKit.attach(kernel, {
Npcs = self.Npcs,
Effects = self.Buffs,
Companions = Companions,
})
end
function CompanionService.start(self: any)
-- Without this gate any client can summon any defined companion through CP_Summon
self.Unhook = self.Kernel.Hooks:on("Companion.CanSummon", function(context: any): boolean?
local Profile = context.Session.Profile
local Owned = Profile and Profile.Data.Companions
return Owned ~= nil and Owned[context.CompanionId] == true
end)
end
function CompanionService.stop(self: any)
if self.Unhook then
self.Unhook()
end
if self.Companions then
self.Companions:destroy()
end
end
return CompanionService

A client summons and dismisses through the kit’s intents:

src/Client/Game/Controllers/CompanionController.luau
--!strict
-- CompanionController: summon and dismiss requests.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local CompanionController = {
Name = "CompanionController",
}
function CompanionController.init(self: any, _kernel: any)
local KitNet = NetClient.new()
self.Summon = KitNet:intent("CP_Summon", { "String" })
self.Dismiss = KitNet:intent("CP_Dismiss", {})
end
function CompanionController.summon(self: any, companionId: string)
self.Summon.fire(companionId)
end
function CompanionController.dismiss(self: any)
self.Dismiss.fire()
end
return CompanionController
Option Default Description
Npcs required The NPCKit instance companions spawn from. Asserted at attach.
Effects nil An Effects instance. Asserted at attach when any companion has an Aura, so a missing wire fails at boot.
Companions required { [companionId]: Companion }.
TickSeconds 0.25 Follow step cadence while a companion is out.
Intents true Wire CP_Summon and CP_Dismiss. Needs a kernel with networking.
SkipLoop false Never start the follow job. Specs drive _step() directly.
World workspace The WorldRoot for teleport overlap queries. Specs inject a double.
Field Default Description
Archetype required NPCKit archetype name to spawn.
FollowDistance 8 Studs from the owner before the companion walks.
TeleportDistance 60 Studs from the owner before it teleports instead.
Aura nil Effects name applied to the owner while the companion is out.
OnSummon nil (session, npc), run on its own thread after a summon.
OnDismiss nil (session), run on its own thread after a dismiss, including a replacement and destroy(). Not run on death or session end.

One companion per session. summon on a session that already has one runs the full dismiss path first (aura removal, OnDismiss, Companion.Dismissed), then spawns the new one. Summoning the same id again respawns it beside the owner.

The NPC spawns at the owner’s root position plus (3, 0, 3), or at (3, 0, 3) when the owner has no character. The first follow step then teleports it if it is beyond TeleportDistance. An unknown companion id returns nil before the hook fires.

Companion.CanSummon is a fail-open hook point fired with { Session, CompanionId }. Any handler that returns false vetoes the summon: summon returns nil and the current companion stays out. Ownership, zone rules (“no pets in ranked”) and cooldowns belong here.

The aura lives on the owner. Summon calls Effects:apply(session, Aura) with no duration override, so an effect without a Duration is held until removed. Dismiss and death both call Effects:remove(session, Aura). Define the effect before the first summon; Effects:apply errors on an unknown name.

Death is its own path. Companion.Died publishes as soon as NPCKit publishes Npc.Died, and the aura goes with it. OnDismiss does not run and Companion.Dismissed does not publish. Before 0.9.0 both happened only after DespawnSeconds.

NPCKit pins spawned NPCs to server network ownership (unless the archetype sets ClientPhysics), so a nearby client cannot shove another player’s companion around.

Intent Schema Rate limit Kit validator (priority 50)
CP_Summon { "String" } (companionId) 5/s The id exists in Companions.
CP_Dismiss {} 5/s The session has a companion out.

The validators only prove the payload makes sense. The Companion.CanSummon hook is the ownership gate.

Session-end cleanup is registered with kernel:onSession whether or not intents are wired, so a kit attached with Intents = false still cleans up leavers. The kit keeps that connection and disconnects it in destroy().

A summon charm in an InventoryKit slot is an OnUse handler:

local Inventory = InventoryKit.attach(kernel, {
Items = {
CatCharm = {
Name = "Cat Charm",
Stack = 1,
OnUse = function(session, _item, _kit)
Companions:summon(session, "BlackCat")
return false -- a focus, never consumed
end,
},
OwlTreat = {
Name = "Owl Treat",
Stack = 10,
OnUse = function(session, _item, _kit)
return Companions:summon(session, "Owl") ~= nil
end,
},
},
})

OnUse returning true consumes one from the stack, so summon(...) ~= nil burns the treat only when the summon happened. A vetoed or unknown summon costs nothing.

Member Description
CompanionKit.attach(kernel, options) -> kit Asserts the wiring, defines the Companion.CanSummon hook point, subscribes to Npc.Died, registers session cleanup and wires the intents.
kit:summon(session, companionId) -> npc? Gate, replace, spawn, apply the aura, publish. nil for an unknown id or a veto.
kit:dismiss(session) -> boolean Clear the slot, remove the aura, destroy the NPC, publish. false when nothing was out.
kit:companionOf(session) -> (npc?, id?) The live companion handle and its id.
kit:destroy() Unhooks the intent validators (the handlers go inert), cancels the follow job, disconnects the Npc.Died subscription and the session hook, and dismisses every companion.
Hook point Mode Context Fired
Companion.CanSummon fail-open { Session, CompanionId } Before every summon of a known id. false vetoes.
Topic Payload When
Companion.Summoned session, companionId, npc After a summon.
Companion.Dismissed session, companionId On dismiss, a replacing summon and destroy().
Companion.Died session, companionId When the companion dies (from Npc.Died), or the follow step finds it destroyed.
Npc.Died npc Consumed: matched against live companions.

The companion is an ordinary NPCKit NPC, so every Npc.* topic fires for it too.

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