Skip to content

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.

Three maps and a counter:

  • Parties: { [id] = Party } where a Party is { Id, Leader, Members }Members in 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.Disbanded publishes 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.ChloeKernelServer
local 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)
end

Feeding a group queue — the party is the matchmaking unit:

-- A partyless player is a party of one, so this needs no special case
Matchmaker:enqueue("Raid", Parties:members(somePlayer))
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).

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(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:

  1. Consumes the invite (one accept per invite — a second accept refuses).
  2. Leaves the accepter’s current party, with everything that implies (inheritance, disband, syncs).
  3. Creates the party if the inviter is still partyless — Party.Created(party) — with the inviter as leader.
  4. Appends the accepter to Members, publishes Party.Joined(party, player), and pushes PT_Sync to every member.
  • leave(player) — removes the player, publishes Party.Left(party, player), pushes a nil PT_Sync to the leaver (the partyless signal). An empty party disbands (Party.Disbanded(partyId)); otherwise a departing leader hands leadership to Members[1] and Party.Promoted(party, newLeader) publishes before the roster sync.
  • kick(leader, member) → (ok, reason?) — leader-only; NotLeader also covers self-kick, NotMember covers targets outside the party. Publishes Party.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. Publishes Party.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.

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.

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:

-- CLIENT
local 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)
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.
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.
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.