Skip to content

PartyKit

Runs same-server player parties with expiring invites, lazy creation, leader inheritance, Roblox platform party grouping and six fail-closed intents.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Kits.PartyKit
Since
v0.5.0
Source
Kits/PartyKit.luau

PartyKit runs same-server player parties: invites with an expiry, one party per player, a leader who kicks and promotes, and roster pushes so party UI only renders what the server sends. Players who arrive together in a Roblox platform party are grouped on join. Parties are social structure and never touch the combat gates; TeamKit owns friendly fire. PartyKit exposes sameParty(a, b) for games that want party rules of their own and members(player) as the roster for Matchmaking group queues.

The kit keeps three maps and a counter:

  • Parties: { [id]: Party }, where a party is { Id, Leader, Members }. Members is in join order, which makes leader inheritance deterministic. Members[1] is not necessarily the leader.
  • ByPlayer: { [Player]: Party }, the one-party-per-player rule and the lookup everything else uses.
  • Invites: { [target]: { [fromPlayer]: expiresAt } }, pending invites per target.
  • NextId, an increasing party id. Party.Disbanded publishes the id, not the table.

Creation is lazy. An invite stores an entry and nothing else. The party forms on the first accept, with the inviter as leader, so a declined or expired invite leaves no one-member party behind.

Invites expire lazily. Each invite stores expiresAt = Clock() + InviteTtlSeconds. A target’s expired entries are dropped on that target’s next invite, accept or decline, and accept refuses an expired invite because it was just dropped. No timer runs. TickSeconds and SkipLoop are accepted and ignored.

Leadership has no gaps. The leader kicks and promotes. A leader who leaves hands leadership to Members[1], the longest-standing member. The last member out disbands the party. A kick never disbands, so a party of one can remain and keep inviting.

Session end cleans up everything for the leaver: they leave their party (with inheritance or disband) and their pending invites clear in both directions, sent and received.

Roblox platform parties (friends who join together from the app) set Player.PartyId. With PlatformParties on (the default), each session start, and every later change of the player’s PartyId, runs this for a partyless player:

  1. Read Player.PartyId. An empty value stops here.
  2. SocialService:GetPlayersByPartyId(PartyId) lists the connected members. A member already in a kit party is the host; otherwise the first connected member is.
  3. Stop silently if the host’s party (or the host alone) is at MaxSize, or a Party.CanInvite handler returns false for { From = host, To = player, Platform = true }.
  4. Create the party with the host as leader if needed, append the player, publish Party.Joined and push PT_Sync.

No invite is stored and Party.Invited does not publish. A refusal is silent and the player can still be invited normally. PlatformParties = false turns this off.

PartyKit is an attach kit. Attach it from the owning service’s init:

src/Server/Game/Services/PartyService.luau
--!strict
-- PartyService: parties and the invite rules.
local ServerScriptService = game:GetService("ServerScriptService")
local PartyKit = require(ServerScriptService.ChloeKernelServer.Kits.PartyKit)
local PartyService = {
Name = "PartyService",
Dependencies = { "DataService" },
}
function PartyService.init(self: any, kernel: any)
self.Kernel = kernel
self.Parties = PartyKit.attach(kernel, {
MaxSize = 4,
InviteTtlSeconds = 60,
})
end
function PartyService.start(self: any)
-- Blocklists, level requirements; platform joins arrive with Platform = true
self.Unhook = self.Kernel.Hooks:on("Party.CanInvite", function(context: any): boolean?
local Session = self.Kernel:getSession(context.To)
local Profile = Session and Session.Profile
local Blocked = Profile and Profile.Data.Blocked
if Blocked and Blocked[tostring(context.From.UserId)] then
return false
end
return nil
end)
end
function PartyService.stop(self: any)
if self.Unhook then
self.Unhook()
end
if self.Parties then
self.Parties:destroy()
end
end
return PartyService

Queue a whole party as one Matchmaking item so it lands in one match. A partyless player is a party of one, so this needs no special case:

Matchmaker:enqueueGroup(Parties:members(player))
Option Default Description
MaxSize 4 Members per party.
InviteTtlSeconds 60 Invite lifetime, in seconds of Clock.
Clock os.clock Time source for invite expiry.
Intents true Define the six PT_* intents. Needs a kernel with networking. The PT_Sync and PT_Invited state channels are defined whenever the kernel has networking, with or without the intents. Before 0.9.1 they came only with the intents, so with Intents = false the first invite or roster change errored.
PlatformParties true Group Roblox platform party members on join and on PartyId change.
Social SocialService Test injection point for GetPlayersByPartyId.
TickSeconds, SkipLoop none Ignored since 0.9.0. There is no sweep loop.

invite(from, target) -> (ok, reason?) refuses, in this 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 drops the target’s expired entries, stores the new one, pushes PT_Invited to the target and publishes Party.Invited(from, target). Inviting someone in a different party is allowed; accepting moves them.

accept(player, from) -> (ok, reason?). NoInvite covers a missing invite, an expired one and an inviter who left. A valid accept:

  1. Consumes the invite. A second accept of the same invite refuses.
  2. Refuses PartyFull if the inviter’s party filled since the invite was sent. The invite is already consumed.
  3. Leaves the accepter’s current party, with inheritance, disband and syncs as needed.
  4. Creates the party if the inviter is still partyless, publishing Party.Created(party).
  5. Appends the accepter, publishes Party.Joined(party, player) and pushes PT_Sync to every member.
  • leave(player) removes the player, publishes Party.Left(party, player) and pushes a nil PT_Sync to the leaver (the partyless signal). An empty party disbands with Party.Disbanded(partyId). Otherwise a departing leader hands over to Members[1] and Party.Promoted(party, newLeader) publishes before the roster sync.
  • kick(leader, member) -> (ok, reason?) is leader-only. NotLeader also covers kicking yourself, and NotMember a target outside the party. It publishes Party.Kicked(party, member), syncs the kicked player as partyless and pushes the new roster.
  • promote(leader, member) -> (ok, reason?) hands leadership to a current member and publishes Party.Promoted(party, member). Promoting yourself is NotMember.
  • decline(player, from) -> boolean discards a pending invite without telling the inviter.

Party.Promoted fires for explicit promotion and for inheritance, so one subscriber sees every leadership change.

With Intents on, six fail-closed intents run through the NetDriver pipeline: session check, rate limit, then the Intent.* hook chain with the kit’s validator at priority 50. Players are addressed by UserId as NumberF64 on the wire. The server passes an id to Players:GetPlayerByUserId only if it is a whole number below 2^53 in magnitude; NaN, infinities and fractions never name a player, because the engine call takes an int64.

Intent Schema Rate limit Kit validator (priority 50)
PT_Invite { "NumberF64" } (target UserId) 5/s The id resolves to a player in the server who is not the sender.
PT_Accept { "NumberF64" } (inviter UserId) 5/s The sender has a pending invite from that player.
PT_Decline { "NumberF64" } (inviter UserId) 5/s The sender has a pending invite from that player.
PT_Leave {} 5/s The sender is in a party.
PT_Kick { "NumberF64" } (member UserId) 5/s The sender leads a party the target is in.
PT_Promote { "NumberF64" } (member UserId) 5/s The sender leads a party the target is in.

The validators check shape and standing. The handlers run the full refusal logic again, so a race between validation and execution ends in a clean refusal.

Channel Payload When
PT_Sync { Id, Leader = userId, Members = { { UserId, Name, DisplayName } } }, or nil when partyless To every member on any roster change, and to a player who becomes partyless.
PT_Invited { From = userId, FromName, FromDisplayName, ExpiresIn } To the target of a successful invite.

Name is the username. Show DisplayName.

src/Client/Game/Controllers/PartyController.luau
--!strict
-- PartyController: the party roster, invite toasts and party requests.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local PartyController = {
Name = "PartyController",
Roster = nil :: any,
Invites = {} :: { [number]: { Name: string, ExpiresAt: number } },
}
function PartyController.init(self: any, _kernel: any)
self.KitNet = NetClient.new()
self.Invite = self.KitNet:intent("PT_Invite", { "NumberF64" })
self.Accept = self.KitNet:intent("PT_Accept", { "NumberF64" })
self.Leave = self.KitNet:intent("PT_Leave", {})
end
function PartyController.start(self: any)
self.SyncConnection = self.KitNet:onState("PT_Sync", { "Any" }, function(roster)
self.Roster = roster -- nil when partyless
end)
self.InvitedConnection = self.KitNet:onState("PT_Invited", { "Any" }, function(invite)
-- Time the toast out locally: an expired invite fails closed on the server with no reply
self.Invites[invite.From] = { Name = invite.FromDisplayName, ExpiresAt = os.clock() + invite.ExpiresIn }
end)
end
function PartyController.invite(self: any, target: Player)
self.Invite.fire(target.UserId)
end
function PartyController.accept(self: any, fromUserId: number)
self.Invites[fromUserId] = nil
self.Accept.fire(fromUserId)
end
function PartyController.stop(self: any)
if self.SyncConnection then
self.SyncConnection:Disconnect()
end
if self.InvitedConnection then
self.InvitedConnection:Disconnect()
end
end
return PartyController
Query Contract
partyOf(player) -> Party? The live party table (Id, Leader, Members in join order). Treat it as read-only.
sameParty(a, b) -> boolean Both players are in the same party. Two partyless players are not. Use it for party rules of your own, such as a veto on Weapon.CanDamage.
members(player) -> { Player } A copy of the roster, or { player } when partyless. This is the input for Matchmaking:enqueueGroup.
Member Description
PartyKit.attach(kernel, options?) -> kit Defines the Party.CanInvite hook point and, on a networked kernel, the PT_Sync and PT_Invited state channels, wires the intents unless Intents = false, and binds session join and leave handling.
kit:invite(from, target) -> (boolean, string?) Stores an expiring invite. Reasons: Self, NoTarget, PartyFull, AlreadyMember, Vetoed.
kit:accept(player, from) -> (boolean, string?) Consumes the invite and moves the player. Reasons: NoInvite, PartyFull.
kit:decline(player, from) -> boolean Discards a pending invite.
kit:leave(player) -> boolean Leaves, with inheritance or disband. false when partyless.
kit:kick(leader, member) -> (boolean, string?) Leader-only removal. Reasons: NotLeader, NotMember.
kit:promote(leader, member) -> (boolean, string?) 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, or { player } when partyless.
kit:destroy() Unhooks the intent validators (the handlers go inert), disconnects the session hook and clears every party and invite. Sends no PT_Sync and publishes nothing.
Hook point Mode Context Fired
Party.CanInvite fail-open { From, To, Platform? } Before an invite is stored, and before a platform party join (Platform = true). false refuses.
Topic Payload When
Party.Created party An accept or a platform join formed the party.
Party.Disbanded partyId The last member left.
Party.Joined party, player An accept or a platform join added a member.
Party.Left party, player A member left, including at 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, by promotion or inheritance.

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