Toggling Single Player and Co-op?

Godot Version

Godot 4.2

Question

I am working on a brick breaker style game. I have a paddle scene. In the world scene I instance two paddle scenes to have a paddle on the left and the right a la Pong.

I want to give players the ability to play in local co-op with one controlling each paddle. I also want to be able to allow a more advanced player to be able to split control of the paddles to individual joysticks or keys.

To handle this, I have created two_players and split_mode variables in an autoload. I then create local export versions of them in the paddle script. Basically, two_players and split_mode are both always true locally for one of the paddles, my script will determine if it gets set back to false for one player mode (or mouse control mode).

So my ready function does this:

func _ready() -> void:
	if !Globals.two_players:
		second_player = false
	if Globals.split_mode:
		second_player = false
	if !Globals.split_mode:
		split_mode = false

Then I’m using an input function and include the following there:

if !second_player && !split_mode:
		if Input.is_action_just_pressed("1p_change_color"):
			change_paddle_color()
		## Boost
		if Input.is_action_just_pressed("1p_boost_up"):
			boost(1)
		if Input.is_action_just_pressed('1p_boost_down'):
			boost(-1)
	if split_mode:
		if Input.is_action_just_pressed("1p_change_color_split"):
			change_paddle_color()
		## Boost
		if Input.is_action_just_pressed("1p_boost_up_split"):
			pass
		if Input.is_action_just_pressed('1p_boost_down_split'):
			pass
...

I also have the same for two players. I suspect there’s a better way to do this, but wanted to get advice on the right approach. This feels a bit brittle in the long run. It’s also something where I think I have to keep the local version of the two variables since both paddles are instances of a single scene.

State Machine? Custom Resources?
It felt like a separate scene didn’t make sense.
I’m guessing as far as assigning controls, my only option is something like: 1p_boost_up then 2p_boost_up?

This is a more conceptual response.

I learned recently how Steam Works api handles input configurations. The main idea as you transition to different game states (game, menus, mini games, etc.) You load a controller set during those states.

You can handle the Godot inputmap manually to store different controller sets for split mode and second player. And when the user inputs their intentions you update the input map to what the player is expecting. You could avoid complicated input logic and just listen for all input or disable input actions at a higher level by not associating a button to the action. Basically you can just get rid of the “split” logic all together here and apply what the core buttons are mapped too

https://partner.steamgames.com/doc/features/steam_controller

There is a video to watch to get the idea.

Thank you! I’ll definitely take a look at that. I intend to put this on Steam anyways, so it’ll be good to look through this for that too.

1 Like

They may already have a plugin for it so your effort may be minimal

Before concept/architecture answer, thought I’d show a slightly cleaner way to write exact same code (discard if you disagree, maybe it’s just cleaner for me)

	if split_mode:
		if Input.is_action_just_pressed("1p_change_color_split"):
			change_paddle_color()
		## Boost
		if Input.is_action_just_pressed("1p_boost_up_split"):
			pass
		if Input.is_action_just_pressed('1p_boost_down_split'):
			pass
	elif !second_player:
		if Input.is_action_just_pressed("1p_change_color"):
			change_paddle_color()
		## Boost
		if Input.is_action_just_pressed("1p_boost_up"):
			boost(1)
		if Input.is_action_just_pressed('1p_boost_down'):
			boost(-1)

But as far as new architecture/design, what will help you here is Command pattern. (Def look it up if not yet familiar.) Each command can have “player_index” property. The scene with the player doesn’t listen to any input, it just receives a command and applies it to proper paddle based on player_index.

What then listens to player input is the parent scene that either creates a splitscreen (has 2 child level scenes, half the size each) or creates a single full-size level scenes but sets it’s “players_count” property to 2.

So yes, the Level scene has no idea what a split screen even is, which is great, because that’s a higher-up decision.

So then this parent scene listens for input and creates command objects and adds them to array, passes that array to each Level scene, who it in turn passes that to each paddle. The paddle loops over the array and if player_index == paddle_index, it executes the command.

As to deciding which input goes to which player… easy:

var input_map: Dictionary

func _ready():
    two_players = Globals.two_players
    input_map = {
        "w_key": 0,
        "s_key": 0,
        "up_key": two_players,
        "down_key": two_players
    }

_process(delta):
    var commands: Array
    if Input.is_action_just_pressed("w_key"):
        var cmd = BoostCmd.new(1)
        cmd.player_index = input_map["w_key"]
        commands.append(cmd)
    if Input.is_action_just_pressed("s_key"):
        var cmd = BoostCmd.new(-1)
        cmd.player_index = input_map["s_key"]
        commands.append(cmd)
    if Input.is_action_just_pressed("up_key"):
        var cmd = BoostCmd.new(1)
        cmd.player_index = input_map["up_key"]
        commands.append(cmd)

    # blah-blah

    for level in levels: # this array would have 2 instances if this is split-screen
        level.on_paddle_commands(commands)

Then in level scene:

func on_paddle_commands(commands: Array):
    for paddle in paddles: # array has 1 or two paddles depending on if two-player or not
        for cmd in commands:
            paddle.execute(cmd)

and finally in Paddle scene:

func execute(cmd: Command):
    if !cmd:
        push_warning("meow message here meow") #should never happen but push warning msg to logs just in case
        return

    if cmd.player_index != self.player_index:
        return # ignore, this command is not for us

    if cmd is BoostCmd: #command is of BoostCmd class, which ofc inherits from Command class
        boost(cmd.value)
    elif cmd is ChangeColorCmd:
        change_color(cmd.value)

and ofc Command class can be:

extends RefCounted
class_name Command

var player_index: 0
var value

func _init(Value):
    value = Value

And finally for specific types of commands you just need to create the class, in this exactly they don’t have any additional properties:

extends Command
class_name BoostCmd

and

extends Command
class_name ChangeColorCmd

The only side-effect is that if when 1-player the player presses both W and UP keys at the same time, the Boost command will be sent to 1st player twice. But that’s fine, your code should already ignore 2nd command if it’s invalid. For ex, if player just began jumping, it shouldn’t jump again, etc. And if paddle color changes to same color twice, that’s no problem. Again, that’s only if when in single-player the player attempts to ‘break’ the game by pushing buttons for both 1st and 2nd player simultaneously.

Haven’t implemented this myself, so… spitballing here and do excuse me for any typons/errors. Hope it’s helpful, cheers!