Tutorial: Create a Party System with me!

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.

  • _invites is a table which can be used to associate a player with all of the invites they have sent out to other players.
  • _parties is a table which can be used to associate a player with a list of other players in the party with them. _players[player.UserId] only exists if player is 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 Partyinvite event, they can simply store all of the user IDs that have invited them previously. If the PartyInvite event contains the isCancel Argument 2, then we mark it as though we never received an invite from the player in Argument 1.
  • When a player receives a PartyStructureChanged event, 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.

1 Like

Hi,
Sound interesting, Thanks for sharing it.

Do you have an .rbxl to check this out?

How are you doing the UI and GUI , to show party invites , accepts, who is in your party, player kick outs? List of players in game , that you can invite.

Also player invite spamming prevention.