Skip to content

TeamKit

Holds team assignment for the server, with capacity-weighted auto-balance, map-authored team spawns and friendly-fire vetoes on both combat gates.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Kits.TeamKit
Since
v0.4.0
Source
Kits/TeamKit.luau

TeamKit is the one place the server records who is on which team. Friendly fire is enforced as vetoes on the hook points where damage happens: WeaponKit’s Weapon.CanDamage and MeleeKit’s Melee.CanHit. Any combat path that fires those gates follows team policy without knowing teams exist. NPC handles resolve through their NPCKit Archetype.Faction, so an NPC whose faction matches a team name counts as that team’s member. PartyKit handles social groups and never touches the combat gates.

The kit keeps one map, ByPlayer: subject -> teamName, and derives the rest from it:

  • Auto-balance. Each new session joins the team with the lowest load: member count divided by Capacity when the team sets one, the plain count otherwise. Capacity weighting lets a 2-slot team fill in proportion to 8-slot teams instead of taking every other join.
  • Friendly fire. With FriendlyFire = false (the default), one veto function is registered on both combat gates at priority 40, ahead of game handlers at the default priority 100. It returns false only when attacker and victim resolve to the same non-nil team, and nil otherwise. An unaffiliated subject is never vetoed.
  • Team spawns. Parts tagged SpawnTag (default "TeamSpawn") with a Team attribute naming the owning team. Only parts in the Workspace count; a tagged spawn in a stored map is ignored. See team spawns.
  • Roblox teams. SyncRoblox = true creates real Team instances with AutoAssignable off and one distinct TeamColor each, for the player list and for engine spawning.

teamOf accepts several subject shapes: a Player (or anything assigned directly), a session (through .Player), a character Model (through GetPlayerFromCharacter) or an NPC handle (through Archetype.Faction). nil means unaffiliated.

TeamKit is an attach kit. Attach it from the owning service’s init with the team table from Game/Config:

src/Server/Game/Config/Teams.luau
--!strict
-- Teams: the arena's sides.
return {
Red = { Color = Color3.fromRGB(200, 60, 60), Capacity = 8 },
Blue = { Color = Color3.fromRGB(60, 60, 200), Capacity = 8 },
}
src/Server/Game/Services/ArenaTeamService.luau
--!strict
-- ArenaTeamService: sides, spawns and friendly fire.
local ServerScriptService = game:GetService("ServerScriptService")
local TeamKit = require(ServerScriptService.ChloeKernelServer.Kits.TeamKit)
local Teams = require(script.Parent.Parent.Config.Teams)
local ArenaTeamService = {
Name = "ArenaTeamService",
}
function ArenaTeamService.init(self: any, kernel: any)
self.Kernel = kernel
-- Friendly-fire vetoes register on Weapon.CanDamage and Melee.CanHit here
self.Teams = TeamKit.attach(kernel, {
Teams = Teams,
SyncRoblox = true, -- real Team instances: player list colors and engine spawning
})
end
function ArenaTeamService.start(self: any)
self.Connection = self.Kernel.Bus:subscribe("Team.Assigned", function(_topic, player, teamName, previous)
self.Kernel.Bus:publish("Arena.TeamChanged", player, teamName, previous)
end)
end
function ArenaTeamService.stop(self: any)
if self.Connection then
self.Connection:disconnect()
end
if self.Teams then
self.Teams:destroy()
end
end
return ArenaTeamService

Map setup: in the Workspace, tag each spawn part TeamSpawn and set its string attribute Team to "Red" or "Blue". Use SpawnLocation parts to let the engine place characters (see team spawns).

Option Default Description
Teams required { [teamName]: TeamConfig }, non-empty. Asserted at attach.
AutoAssign true Assign each new session to the lowest-load team.
FriendlyFire false false vetoes teammate damage on both gates. true registers no vetoes.
SpawnTag "TeamSpawn" CollectionService tag for spawn parts.
UseSpawns true Place characters at owned spawns. Only false turns it off.
SyncRoblox false Mirror teams into Teams service instances with distinct TeamColors. assign and unassign then set player.Team and player.Neutral.
Seed random Seeds the spawn pick, for deterministic specs.
Field Description
Color The team’s color. With SyncRoblox, converted with BrickColor.new(Color) for the TeamColor.
Capacity Load divisor for auto-balance. A team without one balances by plain member count.

The veto is one 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: no opinion
end

Both gates are fail-open hook points: handlers that return nil let damage through and any false blocks it. Safe zones, duel pacts and round rules stack as more handlers on the same chain.

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 }

teamOf resolves every subject shape the combat kits pass, so the same veto covers player against player, player against NPC and NPC against NPC.

teamOf(npcHandle) returns Archetype.Faction. Give NPCs the same faction string as a team name and every check agrees on who is friendly:

Npcs:define("RaiderGrunt", {
Model = ServerStorage.Npcs.Raider,
Faction = "Red", -- reads as team "Red" in every TeamKit check
Targetable = true,
})
  • A Red player shooting a Faction = "Red" NPC through WeaponKit is vetoed.
  • A Blue player shooting the same NPC passes.
  • Inside NPCKit, Faction also stops same-faction NPCs from targeting each other. Keeping NPCs from targeting players on their team is roster policy (GetTargets on the NPCKit); the TeamKit veto still blocks the damage if one gets through a gate.

The match is plain string equality with no registration step. A faction that matches no team name never collides with a player’s team.

assign(player, teamName) returns false for an unknown team and true without an event when the player is already on that team. Otherwise it updates the map, sets player.Team and player.Neutral = false when syncing, points the player’s next spawn at an owned SpawnLocation (see below) and publishes Team.Assigned(player, teamName, previous). previous is nil on a first assignment, which is how a scoreboard tells a join from a switch.

unassign(player) leaves the player unaffiliated, so they are never vetoed. It returns false when the player had no team. Otherwise it clears the entry, sets player.Team = nil and player.Neutral = true when syncing, clears the RespawnLocation the kit set and publishes Team.Unassigned(player, previous).

Auto-assignment runs once per session start when AutoAssign is on. A leave only removes the leaver’s entry and pending spawn move; the kit never rebalances. Mid-round rebalancing is a game rule, so wire it where your rules want it, for example on Kernel.SessionEnd between rounds using players() and emptiest(). Switching a player mid-session is another assign; the next spawn uses the new team’s spawns.

On every CharacterAdded for an assigned player (unless UseSpawns = false), the kit collects the Workspace parts tagged SpawnTag whose Team attribute is the player’s team. If there are none, or the player has no team, the character stays where the engine put it.

When some of those parts are SpawnLocations the engine accepts for the player (Neutral, or a TeamColor equal to the team’s), the kit sets one at random as player.RespawnLocation and the engine places the character itself. The kit sets it on assign and picks a new one on each spawn for the next life. It only clears a RespawnLocation it set.

Any other tagged part is a pivot target. CharacterAdded fires before the character is parented, and the engine moves the character to its spawn after parenting, so the kit waits until the character is in the Workspace, defers once more, checks it is still the player’s current character, publishes AntiExploit.Forgive so the movement monitor pardons the move, and pivots to spawn.CFrame + Vector3.new(0, 4, 0). Before 0.9.0 the pivot ran before the engine’s own spawn move, which could undo it.

The engine matches teams, spawns and the player list by TeamColor, so with SyncRoblox every team gets a different one. Teams with a Color use BrickColor.new(Color). If two colors round to the same BrickColor, attach errors and names both teams. Teams without a Color take the first unused of Red, Blue, Green, Yellow and Black, then the BrickColor palette, never White (a neutral player’s TeamColor reads white). All colors are resolved before any Team instance is created, so an error leaves nothing behind.

Member Description
TeamKit.attach(kernel, options) -> kit Resolves TeamColors and creates Roblox teams when syncing, registers the friendly-fire vetoes, and binds auto-assign and spawning to sessions.
kit:assign(player, teamName) -> boolean Manual assignment. false for an unknown team; publishes Team.Assigned on a change.
kit:unassign(player) -> boolean Leaves the player unaffiliated. false when not on a team; publishes Team.Unassigned.
kit:teamOf(subject) -> string? Resolves a Player, session, character Model or NPC handle. nil means unaffiliated.
kit:sameTeam(a, b) -> boolean Both resolve to the same non-nil team.
kit:players(teamName) -> { Player } Current directly assigned members.
kit:emptiest() -> string The lowest-load team, weighted by capacity.
kit:destroy() Disconnects the session wiring and pending spawn moves, removes the vetoes, destroys mirrored Team instances and clears assignments.

TeamKit registers handlers on two fail-open gates and defines no hook points of its own:

Hook point Kit’s role Priority
Weapon.CanDamage Veto: same non-nil team returns false, otherwise nil. 40
Melee.CanHit The same veto. 40
Topic Payload When
Team.Assigned player, teamName, previous? Every assignment change. previous is nil on the first.
Team.Unassigned player, previous unassign removed a player from a team. Not published when a session ends.
AntiExploit.Forgive player Published before each spawn pivot.

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