Multiple Players

Introduction

This walkthrough looks at the FlatRedBall Multiplayer Platformer project and explains the important details of creating a (local) multiplayer game similar to games like Contra III.

The sample project can be downloaded from Github: https://github.com/vchelaru/FlatRedBall/tree/NetStandard/Samples/Platformer/MultiplayerPlatformerDemo

We will be referring to the MultiplayerPlatformerDemo as this demo and the demo throughout this walkthrough.

Selecting and Storing Join Status

Local multiplayer games provide a variety of ways to join. Older games on the Super Nintendo assumed that if you selected two players, you would use the first and second controller without checking that the controllers were actually connected. More modern games, especially on the PC, check the connected status of controllers and allow players to join and leave. Joining and leaving can be performed on a dedicated page, or can be performed dynamically allowing players to join and leave mid-game. For simplicity, this demo only allows players to join and leave in a dedicated screen. Conceptually, the process for joining has the following steps:

  1. A page displays UI to tell the user which controllers are connected. Players can connect controllers and press buttons to join.

  2. The values for which players have joined must be stored somewhere which is accessible by both the GameScreen and the screen for joining/leaving. These values cannot be instance values on a screen.

  3. The GameScreen must inspect these values and create Player instances (using a Factory) for every joined player. The correct gamepad must be assigned.

We will take a deep dive into these concepts throughout this walkthrough.

CharacterJoiningScreen

The demo includes a Screen called CharacterJoiningScreen. This screen does not inherit from the GameScreen - it is not a level. Rather, it is a screen which contains only Gum UI and logic.

It is marked as the startup screen to give players a chance to join/leave the game before starting Level1.

The CharacterJoiningScreen uses Gum and the MVVM pattern to make the UI update according to the join state stored in the ViewModel. The UI does not participate in the logic of the game - it only displays the values stored in the underlying view model objects. The state is stored in the CharacterJoiningScreen's ViewModel object.

public partial class CharacterJoiningScreen
{
    CharacterJoiningScreenViewModel ViewModel => CharacterJoiningScreenGum.BindingContext as
        CharacterJoiningScreenViewModel;

The code modifies this ViewModel in a few different places.

Initialization

The ViewModel object is instantiated and assigned as the BidingContext to the Gum screen in CustomInitialize. Notice that we instantiate the ViewModel and assign it initially, but thereafter we use the ViewModel property. Once the ViewModel is instantiated and assigned as the Gum UI BindingContext, we can initialize it based on the connected state of the controllers and whether the player had already joined.

for(int i = 0; i < ViewModel.IndividualJoinViewModels.Length; i++)
{
    if(InputManager.Xbox360GamePads[i].IsConnected)
    {
        if(GameScreen.PlayerJoinStates[i] == GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.Joined)
        {
            ViewModel.IndividualJoinViewModels[i].JoinState = GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.Joined;
        }
        else
        {
            ViewModel.IndividualJoinViewModels[i].JoinState = GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.PluggedInNotJoined;
        }
    }
}

Notice that if a controller is connected, we check the GameScreen.PlayerJoinStates to see if the player should be fully joined, or plugged in but not yet joined. This demo uses a static object in the GameScreen to store the joined status. Since the values are static, they are not reset when moving between screens. Larger games may store this information in a dedicated object such as a singleton which also stores profile information like inventory and experience points.

Controller (GamePad) Connected/Disconnected

This screen needs to respond to Xbox360GamePads being connected and disconnected. The InputManager provides a ControllerConnectionEvent event which we can subscribe to in CustomInitialize:

InputManager.ControllerConnectionEvent += HandleControllerConnectionEvent;

The HandleControllerConnectionEvent method checks if the Xbox360GamePad was connected or disconnected and sets the ViewModel values accordingly.

private void HandleControllerConnectionEvent(object sender, InputManager.ControllerConnectionEventArgs e)
{
    var individualVm = ViewModel.IndividualJoinViewModels[e.PlayerIndex];
    if (e.Connected)
    {
        if(individualVm.JoinState == GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.NotPluggedIn)
        {
            individualVm.JoinState = GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.PluggedInNotJoined;
        }
    }
    else // disconnected
    {
        individualVm.JoinState = GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.NotPluggedIn;
    }
}

As mentioned earlier, Gum is bound to the view model, so changing these values automatically updates the Gum visuals. We won't discuss this in much depth in this guide.

GamePad Activity

The demo checks each gamepad for button presses. The following buttons are considered:

  • A button - joins if the player isn't already joined

  • B button - unjoins if already joined

  • Start button - moves to Level1 if the Xbox360GamePad represents a player who has already joined

Buttons cannot be pushed on an Xbox360GamePad so we don't need to check the connected status when looping through the InputManager list.

var gamepads = InputManager.Xbox360GamePads;

for(int i = 0; i < gamepads.Length; i++)
{
    var gamePad = gamepads[i];
    var viewModel = ViewModel.IndividualJoinViewModels[i];
    if (gamePad.ButtonPushed(Xbox360GamePad.Button.A))
    {
        if(viewModel.JoinState == GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.PluggedInNotJoined)
        {
            viewModel.JoinState = GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.Joined;
        }
    }
    if(gamePad.ButtonPushed(Xbox360GamePad.Button.B))
    {
        if (viewModel.JoinState == GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.Joined)
        {
            viewModel.JoinState = GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.PluggedInNotJoined;
        }
    }
    if(gamePad.ButtonPushed(Xbox360GamePad.Button.Start))
    {
        if(viewModel.JoinState == GumRuntimes.IndividualJoinComponentRuntime.JoinCategory.Joined)
        {
            StartLevel();
        }
    }
}

Storing Joined Values

As mentioned earlier, the values must be stored in variables which are not instance variables in any of the screens (either CharacterJoiningScreen or GameScreen). Instead, these values are stored as static values in the GameScreen.

public static IndividualJoinComponentRuntime.JoinCategory[] PlayerJoinStates
{ 
    get; 
    private set; 
} = new IndividualJoinComponentRuntime.JoinCategory[4];

These values are used to instantiate players in the GameScreen's CustomInitialize:

for(int i = 0; i < PlayerJoinStates.Length; i++)
{
    if(PlayerJoinStates[i] == IndividualJoinComponentRuntime.JoinCategory.Joined)
    {
        var player = Factories.PlayerFactory.CreateNew(160 + 16 * i, -260);
        player.SetIndex(i);
        player.InitializePlatformerInput(InputManager.Xbox360GamePads[i]);
    }
}

This for loop in CustomInitialize is solely responsible for creating Players. Notice that the GameScreen does not automatically create any Player instances through Glue.

Therefore, if the PlayerJoinStates are not assigned, then the game will begin without any Players. The CharacterJoiningScreen is responsible for assigning these values, and it does so right before moving into a level.

private void StartLevel()
{