Overview
Hello, I am a software engineer with 6 years of experience. I’ve been developing a MMORPG-style game for the last 1 year. In this tutorial, I am going to go over how I would write a Party system for a multiplayer action RPG. This Party System will implement the minimal set of methods that my game needs. Keep in mind that different games might need different things from the party system. A Party System is a complex state management problem, so I thought it would be useful to write a tutorial.
What is a Party System?
A party system is a way for players to group themselves together temporarily or for the length of the play session. It’s often used in MMORPGs. Instead of trying to broadly define all party systems, I will instead define the requirements I need for my game, Path of Magic.
Party System Requirements
Knowing what features you want to support is really important when designing a system. In my case, I want three things to be true:
- Players in the same party cannot damage each other in PVP
- Players in the same party share a little bit of XP each player earns
- Players can invite each other to their party
Note that in this tutorial, I will not be going over those specific use cases, but rather those use cases will inform the implementation of the Party System for Path of Magic.
The Interface
The first thing I do when creating a new system is enable strict typechecking mode by putting --!strict at the top of my script.
The second thing I do is create the type for the module I’m writing. Note this assumes an OOP approach to doing things. For my server functionality, I generally create Managers which are responsible for all of the logic and data flow of a particular feature on the server.
Assumptions
It’s important to note two things about this type.
_invitesis atablewhich can be used to associate a player with all of the invites they have sent out to other players._partiesis atablewhich can be used to associate a player with a list of other players in the party with them._players[player.UserId]only exists ifplayeris the party leader. Otherwise, the player will be a member of another player’s party.- This design assumes a small number of total players per server. For my case, I will only allow 10 players per server. If we had a very large amount of potential parties, we would probably need to implement a reverse lookup table so we can find which player a party is in without looping through all parties. For most Roblox games, this assumption is fine to make.
export type PartyManager = {
_invites: { [number]: { number } }, -- inviting player userID to list containing which players they have invited
_parties: { [number]: { number } }, -- party leader user ID to list of user IDs in the party
-- Used when determining if a player's magic should damage another player
ArePlayersInPartyTogether: (self: PartyManager, player1: Player?, player2: Player?) -> boolean,
InvitePlayerToParty: (self: PartyManager, invitingPlayer: Player, invited: Player) -> (),
AcceptPartyInvitation: (self: PartyManager, invited: Player, invitingPlayer: Player, isDeny: boolean?) -> (),
-- Used for sharing XP amongst the entire party
GetPlayersInPartyWithPlayer: (self: PartyManager, player: Player) -> { Player },
-- Used to put a player in their own empty party when they join the game
OnPlayerAdded: (self: PartyManager, player: Player) -> (),
-- Used to remove the player from any existing party when they leave the game
OnPlayerRemoving: (self: PartyManager, player: Player) -> (),
RemovePlayerFromParty: (self: PartyManager, player: Player) -> (),
RemovePlayerAsLeader: (self: PartyManager, player: Player, party: { number }) -> (),
Start: (self: PartyManager) -> (),
}
The third thing I do is write the boilerplate code for the module I’m creating. Let’s write the constructor boilerplate.
local PartyManager = {}
PartyManager.__index = PartyManager
function PartyManager.new()
local self = setmetatable({}, PartyManager) :: PartyManager
self._invites = {}
self._parties = {}
return self
end
return PartyManager
Client Communication
It’s important to consider how your server component will communicate with the client. In my case, I will use two RemoteEvents:
PlayerInvited: used to send or cancel an invite from one player to another.
– Argument 1: The UserID of the player inviting the target player
– Argument 2: an optional boolean indicating if this event is to cancel the previous invite sent from the player in Argument 1.PartyStructureChanged: Used to tell a given player information about what party they’re currently in.
–Argument 1: the UserId of the party leader
–Argument 2: a list of UserIds of everyone in the party (besides the leader)
Why did I choose these two events and those arguments? I did so because it will make state management on the client extremely easy.
- When a player receives a
Partyinviteevent, they can simply store all of the user IDs that have invited them previously. If thePartyInviteevent contains theisCancelArgument 2, then we mark it as though we never received an invite from the player in Argument 1. - When a player receives a
PartyStructureChangedevent, they get the exact information they need to render the current state of things: the party leader and the other party members. The client doesn’t need to do any complex logic to figure out the state of things, the server will do that for them.
The Implementation
OnPlayerAdded
When the player joins the game, we need to initialize their state. I made the decision that players are always in a party. When a player first joins the server, they will simply be in a party by themselves as the leader.
To do this, we create an empty list (an empty party) and set the player as the leader of their own empty party. We also need to “tell” the player’s client that they are in their own party via sending the PartyStructureChanged event.
function PartyManager:OnPlayerAdded(player: Player)
self = self :: PartyManager
self._parties[player.UserId] = {} -- put player in their own party where they are Leader
-- remember our data layout: we send the player.UserId to say "this is the party leader", and then a list containing all the party members, which is empty when a player first joins.
PartyStructureChangedEvent:FireClient(player, player.UserId, {})
-- initialize the player's outgoing invites, they have none when they first join.
self._invites[player.UserId] = {}
end
OnPlayerRemoving
When a player leaves the game, we need to clean up all data associated with them. This prevents memory leaks. Also, if they’re in a party with any other player, we need to tell all players in the party with them that this player has left.
Let’s go over all the cases we need to cover when a player leaves:
- We need to remove any of the leaving player’s outgoing invites.
- We need to remove any invites that were sent to the leaving player by other players.
- We need to remove the player from any party they’re in. If they’re a party leader, we need to elect a new leader for that party.
function PartyManager:OnPlayerRemoving(player: Player)
self = self :: PartyManager
-- Clear the leaving player's outgoing invites (prevents memory leaks)
for _, invited in self._invites[player.UserId] do
local invitedPlayer = Players:GetPlayerByUserId(invited)
if not invitedPlayer then
continue
end
PartyInviteEvent:FireClient(invitedPlayer, player.UserId, true) -- true indicates a party invite cancel
end
self._invites[player.UserId] = nil -- prevent a memory leak
-- clear other player's invites that were sent to this leaving player (also prevents memory leaks)
for _, inviteList in self._invites do
local idx = table.find(inviteList, player.UserId)
if idx then
table.remove(inviteList, idx)
end
end
local party = self._parties[player.UserId]
if not party then
-- player is in a party with other players, we have to remove them from the current party
self:RemovePlayerFromParty(player)
return
end
-- If we found a party keyed by this player's userId, that means they were the leader.
-- We need to gracefully transfer ownership of the party to someone else.
self:RemovePlayerAsLeader(player, party)
end
RemovePlayerFromParty
We call this function when we need to remove a non-leader from whichever party they are currently in.
It’s crucial that we alert other players to this change so that their UI reflects that the player left.
function PartyManager:RemovePlayerFromParty(player: Player)
self = self :: PartyManager
local party: { number }
local leader: number
-- first, we need to find which party the player is a part of
for partyLeader, playerParty in self._parties do
if table.find(playerParty, player.UserId) then
party = playerParty
leader = partyLeader
break
end
end
if not party or not leader then
return
end
-- then, we copy all OTHER players to a new party
local newParty = {}
for _, member in party do
if member == player.UserId then
continue
end
table.insert(newParty, member)
end
-- make sure to point the leader to this new party which does not contain the player we're removing
self._parties[leader] = newParty
-- notify all party members of the change
for _, member in newParty do
local memberPlayer = Players:GetPlayerByUserId(member)
if not memberPlayer then
continue
end
PartyStructureChangedEvent:FireClient(memberPlayer, leader, newParty)
end
-- edge case: also, we need to notify the party leader of the change.
local leaderPlayer = Players:GetPlayerByUserId(leader)
if not leaderPlayer then
return
end
PartyStructureChangedEvent:FireClient(leaderPlayer, leader, newParty)
end
RemovePlayerAsLeader
If the player is a Leader of a party, it’s a little bit more tricky. We need to determine if they have any players in the party with them: if they do, then we need to make one of the other players the new leader. We also need to notify everyone in the party of the change,
function PartyManager:RemovePlayerAsLeader(player: Player, party: { number })
self = self :: PartyManager
if #party < 1 then
-- the player was in a party alone, there are no players to notify or move to a new party
self._parties[player.UserId] = nil
return
end
-- player was the party leader + other players were in the party: we need to elect a new leader.
local newLeader
for i, userId in party do
newLeader = userId
table.remove(party, i)
break
end
-- copy old party to new party so we can nil out the old entry
local newParty = {}
for _, userId in party do
table.insert(newParty, userId)
end
self._parties[player.UserId] = nil
if newLeader then
self._parties[newLeader] = newParty
for _, userId in newParty do
local member = Players:GetPlayerByUserId(userId)
if not member then
continue
end
-- partyLeaderUserId, {partyMemberUserId}
PartyStructureChangedEvent:FireClient(member, newLeader, newParty)
end
else
-- this condition should be impossible to reach, as we guarded on the party being empty. so there has to be a new leader.
end
end
InvitePlayerToParty
This method is simple. We add the pending invite to our _invites table for the inviting player and we sent a RemoteEvent to the player that was invited.
function PartyManager:InvitePlayerToParty(invitingPlayer: Player, invited: Player)
self = self :: PartyManager
local invites = self._invites[invitingPlayer.UserId]
if table.find(invites, invited.UserId) then
return -- already invited
end
table.insert(invites, invited.UserId)
PartyInviteEvent:FireClient(invited, invitingPlayer.UserId)
end
AcceptPartyInvitation
This method is how we allow players to accept invites from other players.
function PartyManager:AcceptPartyInvitation(invited: Player, invitingPlayer: Player, isDeny: boolean?)
self = self :: PartyManager
local invites = self._invites[invitingPlayer.UserId]
local found = false
-- my invites for the old party i was in are no longer valid
for id, userId in invites do
if userId == invited.UserId then
found = true
table.remove(invites, id)
PartyInviteEvent:FireClient(invited, invitingPlayer.UserId, true) -- true means to cancel the old invite
break
end
end
if isDeny then
return -- we've deleted the invite, not much else to do.
end
if not found then
return -- player was not actually invited
end
-- we need to figure out which party the player who accepted the invite is joining
local currentParty: { number }
local currentLeader: number
if self._parties[invitingPlayer.UserId] then
-- player owns the party
currentParty = self._parties[invitingPlayer.UserId]
currentLeader = invitingPlayer.UserId
else
-- player does not own the party. do we allow non-leaders to invite? I think yes.
-- we need to find which party it is if the player does not own it.
for leader, party in self._parties do
if table.find(party, invitingPlayer.UserId) then
currentParty = party
currentLeader = leader
break
end
end
end
if not currentParty then
return
end
-- invited player's old invites should expire as they join a new party
for _, userId in self._invites[invited.UserId] do
local player = Players:GetPlayerByUserId(userId)
if not player then
continue
end
PartyInviteEvent:FireClient(player, invited.UserId, true)
end
-- edge case: player also has invites from other members of the current party, those should be removed
-- to prevent double joining
for inviterId, playerInvites in self._invites do
local partyIndex = table.find(currentParty, inviterId)
local inviteIndex = table.find(playerInvites, invited.UserId)
if partyIndex and inviteIndex then
table.remove(playerInvites, inviteIndex)
PartyInviteEvent:FireClient(invited, inviterId, true)
end
end
-- edge case part 2: we also need to check if the leader invited the joining player and remove that invite
local leaderInvites = self._invites[currentLeader]
if leaderInvites then
local index = table.find(leaderInvites, invited.UserId)
if index then
table.remove(leaderInvites, index)
PartyInviteEvent:FireClient(invited, currentLeader, true)
end
end
-- we need to remove the player from the party they're leaving in order to join this new party.
if self._parties[invited.UserId] then
self:RemovePlayerAsLeader(invited, self._parties[invited.UserId])
else
self:RemovePlayerFromParty(invited)
end
table.insert(currentParty, invited.UserId)
-- Notify every player in the party that the new player joined
for _, userId in currentParty do
local player = Players:GetPlayerByUserId(userId)
if not player then
continue
end
PartyStructureChangedEvent:FireClient(player, currentLeader, currentParty)
end
local leaderPlayer = Players:GetPlayerByUserId(currentLeader)
if not leaderPlayer then
return
end
PartyStructureChangedEvent:FireClient(leaderPlayer, currentLeader, currentParty)
end
ArePlayersInPartyTogether
This function will be useful for one of our use cases. When our Combat System is calculating how much damage a spell applies to another player, we need to first check if they’re in a party with each other using this method. If they are, the calculation would result in 0.
function PartyManager:ArePlayersInPartyTogether(player1: Player?, player2: Player?): boolean
self = self :: PartyManager
if not player1 or not player2 then
return false
end
local party1 = self._parties[player1.UserId]
local party2 = self._parties[player2.UserId]
-- case 1: player 1 is a party leader
if party1 then
if table.find(party1, player2.UserId) then
return true
end
end
-- case 2: player 2 is a party leader
if party2 then
if table.find(party2, player1.UserId) then
return true
end
end
-- case 3: both are in a party together, but neither are the leader
for _, party in self._parties do
local found1 = false
local found2 = false
for _, userId in party do
if userId == player1.UserId then
found1 = true
end
if userId == player2.UserId then
found2 = true
end
if found1 and found2 then
return true
end
end
end
return false
end
GetPlayersInPartyWithPlayer
This method will be useful for the use case of sharing XP amongst players. When any given player gains XP, we want to reward all of the other players in the same Party with some fraction of that XP.
function PartyManager:GetPlayersInPartyWithPlayer(player: Player): { Player }
self = self :: PartyManager
-- Case 1: player is party leader
if self._parties[player.UserId] then
local players = {}
for _, userId in self._parties[player.UserId] do
local memberPlayer = Players:GetPlayerByUserId(userId)
if not memberPlayer then
continue
end
table.insert(players, memberPlayer)
end
return players
end
-- Case 2: player is in a party but not the leader
for leader, party in self._parties do
if table.find(party, player.UserId) then
local players = {}
for _, member in party do
if member == player.UserId then
continue
end
local memberPlayer = Players:GetPlayerByUserId(member)
if not memberPlayer then
continue
end
table.insert(players, memberPlayer)
end
local leaderPlayer = Players:GetPlayerByUserId(leader)
if leaderPlayer then
table.insert(players, leaderPlayer)
end
return players
end
end
-- This case should be unreachable, the player either owns a party or is in a party. Players cannot be in neither state.
return {}
end
Start
The Start method is what we will use to tell the PartyManager to begin listening for RemoteEvents originating from the client. It’s where we hook up our remote event listeners.
function PartyManager:Start()
self = self :: PartyManager
local reference = self
PartyInviteEvent.OnServerEvent:Connect(function(invitingPlayer: Player, invitedUserId: number)
-- always verify the validity of the data you're receiving from a remote event
if typeof(invitedUserId) ~= "number" then
return
end
local invitedPlayer = Players:GetPlayerByUserId(invitedUserId)
if not invitedPlayer then
return
end
reference:InvitePlayerToParty(invitingPlayer, invitedPlayer)
end)
PartyInviteAcceptEvent.OnServerEvent:Connect(
function(invitedPlayer: Player, invitingUserId: number, isDeny: boolean?)
if typeof(invitingUserId) ~= "number" then
return
end
if isDeny ~= nil and typeof(isDeny) ~= "boolean" then
return
end
local invitingPlayer = Players:GetPlayerByUserId(invitingUserId)
if not invitingPlayer then
return
end
reference:AcceptPartyInvitation(invitedPlayer, invitingPlayer, isDeny)
end
)
end
Conclusion
Note we would need some sort of Script in ServerScriptService which calls PartyManager.new() and then calls Start on the resulting object. We would also need to wire up the PartyManager object’s OnPlayerRemoving and OnPlayerAdded functions to game.Players.PlayerRemoving and game.Players.PlayerAdded.
I hope you enjoyed this tutorial, and please let me know if you have any questions.