PartyKit
PartyKit is same-server player parties: invites with expiry, one party per player, a full leader flow, and roster pushes that make party UI a pure render problem. The defining decision is that parties are social structure, not combat structure — PartyKit never touches the combat gates. TeamKit owns friendly fire; PartyKit exposes sameParty(a, b) for games that want party friendly-fire rules and members(player) as the group feed for Matchmaking queues, and otherwise stays out of damage entirely.
Mental model
Section titled “Mental model”Three maps and a counter:
Parties: { [id] = Party }where aPartyis{ Id, Leader, Members }—Membersin join order, which is what makes leader inheritance deterministic.ByPlayer: { [Player] = Party }— the one-party-per-player invariant, and the O(1) lookup everything else uses.Invites: { [target] = { [fromPlayer] = expiresAt } }— pending invites keyed both ways, so cleanup can clear a leaver’s invites in both directions.NextId— monotonically increasing party ids;Party.Disbandedpublishes the id, not the table.
Creation is lazy. Inviting stores an invite; no party exists yet. The party forms on the first accept, with the inviter as leader. A declined or expired invite therefore costs nothing — no empty one-member parties litter the server.
Invites expire twice. Each invite stamps expiresAt = now + InviteTtlSeconds; accept checks the stamp directly, and a background sweep (every TickSeconds at Priority.Background) prunes stale entries so the maps never grow unbounded. The stamp check means correctness never depends on sweep timing.
Leadership flows without gaps. The leader kicks and promotes. A leader who leaves promotes the longest-standing member (Members[1] — join order, not roster luck). The last member out disbands the party. A party of one persists — it can keep inviting.
Session end is total cleanup. The kit binds to session teardown: the leaver leaves their party (triggering inheritance or disband) and their pending invites clear in both directions — invites they sent and invites they received.
local ServerScriptService = game:GetService("ServerScriptService")
local Root = ServerScriptService.ChloeKernelServerlocal PartyKit = require(Root.Kits.PartyKit)
return function(kernel: any) local Parties = PartyKit.attach(kernel, { MaxSize = 4, InviteTtlSeconds = 60, })
-- Gate invites: blocklists, level requirements, cross-team rules kernel.Hooks:on("Party.CanInvite", function(context) return not IsBlocked(context.To, context.From) end)
kernel.Bus:subscribe("Party.Joined", function(_, party, player) print(`{player.Name} joined party {party.Id} (leader {party.Leader.Name})`) end)endFeeding a group queue — the party is the matchmaking unit:
-- A partyless player is a party of one, so this needs no special caseMatchmaker:enqueue("Raid", Parties:members(somePlayer))Attach options
Section titled “Attach options”| Option | Description |
|---|---|
MaxSize |
Members per party. Default 4. |
InviteTtlSeconds |
Invite lifetime. Default 60. |
TickSeconds |
Invite-expiry sweep cadence. Default 5. |
Clock |
Injectable time source. Default os.clock. |
Intents |
Wire the PT_* intents (needs a kernel net driver). Default true. |
SkipLoop |
Skip the sweep loop (specs call _sweep manually). |
The flows, exactly
Section titled “The flows, exactly”Invite
Section titled “Invite”invite(from, target) → (ok, reason?). Refusals, in check order:
| Reason | Condition |
|---|---|
Self |
from == target. |
NoTarget |
The target has left the game. |
PartyFull |
The inviter’s party is at MaxSize. |
AlreadyMember |
The target is already in the inviter’s party. |
Vetoed |
A Party.CanInvite handler returned false. |
A successful invite stamps the TTL, pushes PT_Invited to the target ({ From = userId, FromName, ExpiresIn }), and publishes Party.Invited(from, target). Inviting someone who is in a different party is allowed — accepting moves them.
Accept
Section titled “Accept”accept(player, from) → (ok, reason?). NoInvite covers a missing invite, an expired stamp, and an inviter who has left; PartyFull re-checks at accept time, because the party may have filled since the invite was sent. A valid accept:
- Consumes the invite (one accept per invite — a second accept refuses).
- Leaves the accepter’s current party, with everything that implies (inheritance, disband, syncs).
- Creates the party if the inviter is still partyless —
Party.Created(party)— with the inviter as leader. - Appends the accepter to
Members, publishesParty.Joined(party, player), and pushesPT_Syncto every member.
Leave, kick, promote
Section titled “Leave, kick, promote”leave(player)— removes the player, publishesParty.Left(party, player), pushes anilPT_Syncto the leaver (the partyless signal). An empty party disbands (Party.Disbanded(partyId)); otherwise a departing leader hands leadership toMembers[1]andParty.Promoted(party, newLeader)publishes before the roster sync.kick(leader, member) → (ok, reason?)— leader-only;NotLeaderalso covers self-kick,NotMembercovers targets outside the party. PublishesParty.Kicked(party, member), syncs the kicked player partyless and the party’s new roster.promote(leader, member) → (ok, reason?)— leader-only leadership transfer to a current member. PublishesParty.Promoted(party, member).decline(player, from) → boolean— discards a pending invite silently.
Party.Promoted fires for both explicit promotion and leader inheritance — subscribe once, get every leadership change.
Client intents
Section titled “Client intents”With Intents on, six fail-closed intents wire through the NetDriver pipeline — session check, token bucket (rate limiting), then the Intent.* hook chain with the kit validator at priority 50. Players are addressed by UserId as NumberF64 on the wire; the server resolves ids through Players:GetPlayerByUserId with NaN rejected.
| Intent | Schema (args) | Rate limit | Kit validator (priority 50) |
|---|---|---|---|
PT_Invite |
{ NumberF64 } — targetUserId |
5 | Target resolves to a present player and is not the sender. |
PT_Accept |
{ NumberF64 } — inviterUserId |
5 | A pending invite from that inviter exists for the sender. |
PT_Decline |
{ NumberF64 } — inviterUserId |
5 | A pending invite from that inviter exists for the sender. |
PT_Leave |
{} |
5 | The sender is in a party. |
PT_Kick |
{ NumberF64 } — memberUserId |
5 | Sender leads a party the target is a member of. |
PT_Promote |
{ NumberF64 } — memberUserId |
5 | Sender leads a party the target is a member of. |
Validators prove shape and standing; the handlers re-run the full refusal logic (invite/accept/… return reasons server-side), so a race between validation and execution degrades to a clean refusal, never a corrupt roster.
State pushes
Section titled “State pushes”| Channel | Payload | When |
|---|---|---|
PT_Sync |
{ Id, Leader = userId, Members = { { UserId, Name } } } — or nil for partyless |
To every member on any roster change; to a player who becomes partyless. |
PT_Invited |
{ From = userId, FromName, ExpiresIn } |
To the invite target on a successful invite. |
The client renders PT_Sync and fires intents — no client-side party state machine:
-- CLIENTlocal NetClient = require(game:GetService("ReplicatedStorage").ChloeKernel.Net.Client)
local Net = NetClient.new()local Invite = Net:intent("PT_Invite", { "NumberF64" })local Accept = Net:intent("PT_Accept", { "NumberF64" })
Net:onState("PT_Sync", { "Any" }, function(roster) if roster == nil then PartyFrame.Visible = false return end RenderRoster(roster.Members, roster.Leader)end)
Net:onState("PT_Invited", { "Any" }, function(invite) ShowInviteToast(invite.FromName, invite.ExpiresIn, function() Accept:Fire(invite.From) end)end)
Invite:Fire(targetPlayer.UserId)Queries for other systems
Section titled “Queries for other systems”| Query | Contract |
|---|---|
partyOf(player) → Party? |
The live party table (Id, Leader, Members in join order). Treat as read-only. |
sameParty(a, b) → boolean |
Both in the same party. Two partyless players are not same-party. The hook for game-wired party friendly-fire — e.g. a veto on Weapon.CanDamage in your own code. |
members(player) → { Player } |
A copy of the roster, or { player } when partyless — a partyless player is a party of one, so group-queue code needs no special case. This is the Matchmaking group feed. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
PartyKit.attach(kernel, options?) → kit |
Construct, define the Party.CanInvite hook point, start the sweep, wire intents, bind session cleanup. |
kit:invite(from, target) → (boolean, reason?) |
Store a TTL invite. Reasons: Self, NoTarget, PartyFull, AlreadyMember, Vetoed. |
kit:accept(player, from) → (boolean, reason?) |
Consume the invite, move the player. Reasons: NoInvite, PartyFull. |
kit:decline(player, from) → boolean |
Discard a pending invite. |
kit:leave(player) → boolean |
Leave; inheritance/disband as needed. |
kit:kick(leader, member) → (boolean, reason?) |
Leader-only removal. Reasons: NotLeader, NotMember. |
kit:promote(leader, member) → (boolean, reason?) |
Leader-only transfer. Reasons: NotLeader, NotMember. |
kit:partyOf(player) → Party? |
Membership lookup. |
kit:sameParty(a, b) → boolean |
Same-party check. |
kit:members(player) → { Player } |
Roster copy; { player } when partyless. |
kit:destroy() |
Cancel the sweep and clear every map. |
Hooks & bus topics
Section titled “Hooks & bus topics”| Hook point | Mode | Context | Fired |
|---|---|---|---|
Party.CanInvite |
fail-open | { From, To } |
Before an invite is stored; false refuses with Vetoed. |
| Topic | Args | When |
|---|---|---|
Party.Created |
party |
The first accept formed the party. |
Party.Disbanded |
partyId |
The last member left. |
Party.Joined |
party, player |
An accept added a member. |
Party.Left |
party, player |
A member left (including session end). |
Party.Kicked |
party, player |
The leader removed a member. |
Party.Invited |
fromPlayer, toPlayer |
An invite was stored. |
Party.Promoted |
party, player |
Leadership changed — explicit promote or inheritance. |