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.
Mental model
Section titled “Mental model”The kit keeps three maps and a counter:
Parties: { [id]: Party }, where a party is{ Id, Leader, Members }.Membersis 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.Disbandedpublishes 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.
Platform parties
Section titled “Platform parties”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:
- Read
Player.PartyId. An empty value stops here. SocialService:GetPlayersByPartyId(PartyId)lists the connected members. A member already in a kit party is the host; otherwise the first connected member is.- Stop silently if the host’s party (or the host alone) is at
MaxSize, or aParty.CanInvitehandler returnsfalsefor{ From = host, To = player, Platform = true }. - Create the party with the host as leader if needed, append the player, publish
Party.Joinedand pushPT_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:
--!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() endend
return PartyServiceQueue 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))Attach options
Section titled “Attach options”| 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. |
The flows
Section titled “The flows”Invite
Section titled “Invite”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
Section titled “Accept”accept(player, from) -> (ok, reason?). NoInvite covers a missing invite, an expired one and an inviter who left.
A valid accept:
- Consumes the invite. A second accept of the same invite refuses.
- Refuses
PartyFullif the inviter’s party filled since the invite was sent. The invite is already consumed. - Leaves the accepter’s current party, with inheritance, disband and syncs as needed.
- Creates the party if the inviter is still partyless, publishing
Party.Created(party). - Appends the accepter, publishes
Party.Joined(party, player)and pushesPT_Syncto every member.
Leave, kick, promote, decline
Section titled “Leave, kick, promote, decline”leave(player)removes the player, publishesParty.Left(party, player)and pushes anilPT_Syncto the leaver (the partyless signal). An empty party disbands withParty.Disbanded(partyId). Otherwise a departing leader hands over toMembers[1]andParty.Promoted(party, newLeader)publishes before the roster sync.kick(leader, member) -> (ok, reason?)is leader-only.NotLeaderalso covers kicking yourself, andNotMembera target outside the party. It publishesParty.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 publishesParty.Promoted(party, member). Promoting yourself isNotMember.decline(player, from) -> booleandiscards a pending invite without telling the inviter.
Party.Promoted fires for explicit promotion and for inheritance, so one subscriber sees every leadership change.
Client intents
Section titled “Client intents”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.
State pushes
Section titled “State pushes”| 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.
--!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() endend
return PartyControllerQueries 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 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. |
API reference
Section titled “API reference”| 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. |
Hooks and Bus topics
Section titled “Hooks and Bus topics”| 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. |