Skip to content

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.

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 / Capacity when 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 through teamOf and returns false only 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 a Team attribute naming the owner. On every CharacterAdded, the character pivots to a random owned spawn with the anti-exploit pardoned for the jump (AntiExploit.Forgive publishes first, so the teleport is not flagged as movement cheating).
  • Roblox mirroring is optionalSyncRoblox creates real Team instances (with AutoAssignable off, 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.ChloeKernelServer
local 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)
end

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

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

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: abstain
end

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

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, Faction also filters target acquisition — but only npc-vs-npc: Red npcs never hunt each other. Keeping npcs from acquiring same-team players is roster policy (GetTargets on 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.

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 only
kernel.Bus:subscribe("Kernel.SessionEnd", function()
if RoundPhase ~= "Lobby" then
return
end
for _, TeamPlayer in Teams:players(Teams:emptiest()) do
-- inspect counts, move volunteers, etc.
end
end)

Reassignment mid-session is just assign again — the next respawn uses the new team’s 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.

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.

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.