TeamKit
TeamKit is one source of truth for team affiliation, wired into every combat gate — no kit needs to know teams exist. The defining decision is where friendly fire is enforced: not in your weapon code, not in a wrapper, but as vetoes registered on the hook points where damage actually happens — WeaponKit’s Weapon.CanDamage and MeleeKit’s Melee.CanHit. Any combat kit that fires those gates gets team policy for free, and npc handles resolve through their NPCKit Archetype.Faction, so faction npcs and same-named teams read as teammates with zero extra wiring.
Mental model
Section titled “Mental model”The kit keeps one assignment map (ByPlayer: subject → teamName) and derives everything else:
- Auto-balance — new sessions land on the team with the lowest load, where load is
memberCount / Capacitywhen the team declares a capacity, else the raw count. Capacity weighting is why a 2-cap duelist corner fills proportionally against 8-cap armies instead of soaking every other join. - Friendly fire policy — with
FriendlyFire = false(the default), one veto function registers on both combat gates at priority 40, running ahead of default-priority (100) game handlers. The veto resolves both entities throughteamOfand returnsfalseonly when attacker and victim resolve to the same, non-nil team; everything else abstains (nil), leaving the rest of the chain in charge. Unaffiliated is never vetoed — a team member can always damage a teamless target and vice versa. - Team spawns are map-authored — parts tagged
SpawnTag(default"TeamSpawn") with aTeamattribute naming the owner. On everyCharacterAdded, the character pivots to a random owned spawn with the anti-exploit pardoned for the jump (AntiExploit.Forgivepublishes first, so the teleport is not flagged as movement cheating). - Roblox mirroring is optional —
SyncRobloxcreates realTeaminstances (withAutoAssignableoff, so the engine never fights the kit’s assignment) purely for player-list colors.
teamOf is deliberately promiscuous about subject shapes: a Player (or anything assigned directly), a session (via .Player), a character Model (via GetPlayerFromCharacter), or an npc handle (via Archetype.Faction). nil means unaffiliated.
From the GunArena starter template — teams, colors, and friendly fire in one attach:
local ServerScriptService = game:GetService("ServerScriptService")
local Root = ServerScriptService.ChloeKernelServerlocal TeamKit = require(Root.Kits.TeamKit)
return function(kernel: any) -- Friendly fire vetoes register on Weapon.CanDamage and Melee.CanHit automatically local Teams = TeamKit.attach(kernel, { Teams = { Red = { Color = Color3.fromRGB(200, 60, 60), Capacity = 8 }, Blue = { Color = Color3.fromRGB(60, 60, 200), Capacity = 8 }, }, SyncRoblox = true, -- player-list colors via real Team instances })
kernel.Bus:subscribe("Team.Assigned", function(_, player, teamName, previous) print(`{player.Name}: {previous or "unassigned"} -> {teamName}`) end)endMap setup: tag spawn parts TeamSpawn and set the string attribute Team to "Red" or "Blue". That is the entire spawn system — no spawn scripts, no SpawnLocation juggling.
Attach options
Section titled “Attach options”| Option | Description |
|---|---|
Teams |
{ [teamName] = TeamConfig }. Required and non-empty — asserted at attach. |
AutoAssign |
Assign new sessions to the lowest-load team. Default true. |
FriendlyFire |
Default false — teammate damage is vetoed on both combat gates. true skips registering the vetoes entirely. |
SpawnTag |
CollectionService tag for spawn parts. Default "TeamSpawn". |
UseSpawns |
Pivot characters to owned spawns on CharacterAdded. Default true (only false disables). |
SyncRoblox |
Mirror teams into Teams service instances for player-list colors. Default false. |
Seed |
Seeds the spawn-picking Rng for deterministic spawn choice in specs. |
Team config
Section titled “Team config”| Field | Description |
|---|---|
Color |
Player-list color when SyncRoblox is on (applied via BrickColor.new(Color)). |
Capacity |
Load divisor for auto-balance. Teams without one balance by raw member count. |
Friendly fire on both gates
Section titled “Friendly fire on both gates”The veto is a single function registered twice:
local function veto(context) local AttackerTeam = Teams:teamOf(context.Attacker) local VictimTeam = Teams:teamOf(context.Victim) if AttackerTeam ~= nil and AttackerTeam == VictimTeam then return false -- same team: block the damage end return nil -- different teams or unaffiliated: abstainendBoth gates are fail-open hook points — abstaining handlers let damage through, and any handler returning false blocks it — so TeamKit composes cleanly with everything else on the chain: safe zones, duel pacts, and round-phase rules stack as additional handlers without knowing teams exist.
| Gate | Fired by | Context |
|---|---|---|
Weapon.CanDamage |
WeaponKit, before a validated shot deals damage | { Session, Attacker, Victim, WeaponId, Weapon } |
Melee.CanHit |
MeleeKit, per victim inside a swing’s hit frame | { Attacker, Victim, Attack } |
Because teamOf resolves every entity shape the combat kits pass — sessions, players, character models, npc handles — the same veto covers player-vs-player, player-vs-npc, and npc-vs-npc traffic through those gates.
Faction–npc parity
Section titled “Faction–npc parity”teamOf(npcHandle) returns Archetype.Faction. Give npcs the same faction string as a team name and the whole combat surface agrees on who is friendly:
Npcs:define("RaiderGrunt", { Model = ServerStorage.NPC.Raider, Faction = "Red", -- reads as team "Red" in every TeamKit check Targetable = true,})- A Red player shooting a
Faction = "Red"npc through WeaponKit: vetoed. - A Blue player shooting the same npc: passes.
- Inside NPCKit,
Factionalso filters target acquisition — but only npc-vs-npc: Red npcs never hunt each other. Keeping npcs from acquiring same-team players is roster policy (GetTargetson the NPCKit); the TeamKit veto still blocks the damage if one slips through a kit gate.
The parity is string equality, nothing more. There is no registration step — an npc faction that matches no team name simply never collides with a player’s team.
Assignment and balancing
Section titled “Assignment and balancing”assign(player, teamName) is the manual path: it refuses unknown teams (false), no-ops on reassignment to the same team (true, no event), updates the map, mirrors to the Roblox Team instance when syncing, and publishes Team.Assigned(player, teamName, previous?) — previous is nil on first assignment, which is how scoreboards distinguish joins from switches.
Auto-assignment runs once per session start (when AutoAssign is on) via emptiest(). There is no automatic rebalancing on leave — a session ending only clears the leaver’s map entry, so an uneven server stays uneven until new joins fill the gap or you intervene. Mid-round rebalancing is a game-rules decision the kit refuses to make; wire it yourself where your rules want it:
-- Example: rebalance the emptiest team on session end, between rounds onlykernel.Bus:subscribe("Kernel.SessionEnd", function() if RoundPhase ~= "Lobby" then return end for _, TeamPlayer in Teams:players(Teams:emptiest()) do -- inspect counts, move volunteers, etc. endend)Reassignment mid-session is just assign again — the next respawn uses the new team’s spawns.
Team spawns
Section titled “Team spawns”On CharacterAdded (with UseSpawns not disabled), the kit collects every part tagged SpawnTag whose Team attribute equals the player’s team, picks one at random, and pivots the character to spawn.CFrame + Vector3.new(0, 4, 0) on a deferred thread. AntiExploit.Forgive publishes for the player first so the movement monitor pardons the teleport. No owned spawns, or no team yet — no pivot; the character stays wherever Roblox spawned it.
API reference
Section titled “API reference”| Member | Description |
|---|---|
TeamKit.attach(kernel, options) → kit |
Construct, mirror Roblox teams, register friendly-fire vetoes, bind session auto-assign and spawn pivoting. |
kit:assign(player, teamName) → boolean |
Manual assignment. false on unknown team; publishes Team.Assigned on change. |
kit:teamOf(subject) → string? |
Resolve a Player, session, character Model, or npc handle. nil = unaffiliated. |
kit:sameTeam(a, b) → boolean |
Both resolve to the same non-nil team. |
kit:players(teamName) → { Player } |
Current roster of directly-assigned members. |
kit:emptiest() → string |
The lowest-load team (capacity-weighted). |
kit:destroy() |
Disconnect session wiring, remove vetoes, destroy mirrored Team instances, clear assignments. |
Hooks & bus topics
Section titled “Hooks & bus topics”TeamKit registers handlers on two existing fail-open gates rather than defining its own veto points:
| Hook point | Kit’s role | Priority |
|---|---|---|
Weapon.CanDamage |
Veto handler: same non-nil team → false, else abstain. |
40 |
Melee.CanHit |
The same veto function. | 40 |
| Topic | Args | When |
|---|---|---|
Team.Assigned |
player, teamName, previous? |
Every assignment change (previous nil on the first). |
AntiExploit.Forgive |
player |
Published before each spawn pivot so the teleport is pardoned. |