2025/10/21

Type
Learning Resource
Format
Study Guide
Version
Godot 4.x
  • Downloadable Demo
  • FAQ/Troubleshooting
Code
Assets
All else
Copyright 2016-2025, GDQuest
Created
2025/10/07
Updated
2025/10/21

A dive into split-screen co-ops with a shared world

Split-screen co-op games let multiple players enjoy a game together on the same screen, each with their own view of the game world.

This video was made with Godot 3. The guide below has been rewritten for Godot 4.

In split-screen games, we use multiple cameras to render different views of the same game world. Each camera follows a player and renders its view to a separate viewport. These viewports are then arranged on the screen to create the split-screen effect.

In this study guide, you'll learn:

What changed in Godot 4

This section is a quick reference for the video above, which was made with Godot 3. Here's what changed in Godot 4:

  • Viewport is now an abstract class: Use SubViewport instead to create viewports that render to textures.
  • Cameras work differently: There's no Current property on Camera2D anymore. Instead, you use the Enabled property in combination with Camera2D.make_current() from code. The same applies to Camera3D.
  • You can now edit viewport children directly in the editor. In Godot 3, you couldn't transform child nodes of a viewport in the editor, which was a major pain point.

Also, the ViewportContainer node was renamed to SubViewportContainer.

For this demo specifically, we also made these two improvements:

  1. We use an array instead of a dictionary to store player nodes. This is cleaner since we only have two players.
  2. We enabled the Stretch property on the SubViewportContainer nodes so the viewports always fill the available space.

The rest of this guide uses Godot 4 syntax and conventions.

Set up split-screen co-op

Setting up split-screen co-op in Godot is surprisingly simple. You add a SubViewportContainer for each player, and inside each container, you place a SubViewport and a Camera2D.

Then you attach the cameras to the player nodes, set up input mappings for each player, and make sure the game world is shared between the viewports. We'll go over these steps in the next sections.

NOTE:
Head to the download section to download the open-source demo and explore the code.

Splitting the screen

In this demo, we split the screen evenly using a HBoxContainer. Each player gets a SubViewportContainer that fills half the screen.

We also added a ColorRect in between as a visual divider.

Screenshot of the main scene tree with the SubViewport layout

Notice how we added the Level2D scene as a child of one SubViewport. In the 2D editor, you can see the game world rendered in one of the viewports only.

To make the two viewports share the same game world, we assign the LeftSubViewport's world_2d property to the RightSubViewport's world_2d property from code.

Finally, we assign a Camera2D to each player by making them children of their respective SubViewport nodes.

You'll see how we share the world between the SubViewport nodes and attach the cameras to the players in the code section coming up.

Mapping player input

First, you need to define the input map for each player. You can use any input scheme or controllers you prefer, but in this demo, we went with the following:

Screenshot of the Input Map assignments

Next, we need to connect these input actions to each player node. To do this, we create a custom Resource class called PlayerControls. This class stores the input action names for a player:

class_name PlayerControls extends Resource

@export var player_index := 0
@export var move_left := "p1_move_left"
@export var move_right := "p1_move_right"
@export var jump := "p1_jump"

Now we create the actual PlayerControls resources. We make two of them, one for each player, and save them as res://character/player_1_controls.tres and res://character/player_2_controls.tres.

The default values are for player 1. For player 2, we change the player_index to 1 and update the input action names in the Inspector.

Screenshot of the export variables for player 2 in the Inspector with the Controls property expanded

Putting everything together in code

Let's put everything together with some code in the main scene's script.

We start by defining an array of dictionaries to hold references to each player's SubViewport, Camera2D, and player node:

extends Control

# We need to:
# 1. Share the world of the first viewport with the second viewport.
# 2. Create a remote transform attached to each player that pushes their position to the camera.
# This data structure helps us to do that conveniently. See the _ready() function below.
@onready var players: Array[Dictionary] = [
	{
		sub_viewport = %LeftSubViewport,
		camera = %LeftCamera2D,
		player = %Level2D/Player2D1,
	},
	{
		sub_viewport = %RightSubViewport,
		camera = %RightCamera2D,
		player = %Level2D/Player2D2,
	},
]

This makes our _ready() function clean and straightforward. We loop through each player's info to set up the shared world and attach the cameras to their players using remote transforms:

func _ready() -> void:
	# The `world_2d` object of the Viewport class contains information about
	# what to render. Here, it's our game level. We need to pass it
	# from the first to the second SubViewport for both of them to render
	# the same level.
	players[1].sub_viewport.world_2d = players[0].sub_viewport.world_2d

	# For each player, we create a remote transform that pushes the character's
	# position to the corresponding camera.
	for info in players:
		var remote_transform := RemoteTransform2D.new()
		remote_transform.remote_path = info.camera.get_path()
		info.player.add_child(remote_transform)

Now let's see how we use our custom PlayerControls resource.

These are the relevant parts of the res://character/player_2d.gd script:

extends CharacterBody2D

extends CharacterBody2D
# ...
# ...
@export var controls: PlayerControls = null
@export var controls: PlayerControls = null

func _ready() -> void:
	# Disable the player if we don't assign a `PlayerControls` resource to
	# the `controls` variable to prevent a crash.
	if controls == null:
		set_physics_process(false)


func _physics_process(delta: float) -> void:
	var horizontal_direction := Input.get_axis(
		controls.move_left, controls.move_right
	)
	velocity.x = horizontal_direction * speed
	velocity.y += gravity * delta

	var is_jumping := Input.is_action_just_pressed(controls.jump)
	var is_jump_cancelled := (
		Input.is_action_just_released(controls.jump) and velocity.y < 0.0
	)

	# ...

By using the controls variable instead of hard-coding input action names, our player script can work with any player's control scheme.

Questions and troubleshooting

Why isn't my SubViewport filling up the entire SubViewportContainer?

Make sure you enabled the SubViewportContainer's Stretch property in the Inspector. This makes the SubViewport child node always fill the available space in its container.

Why not add RemoteTransform2D nodes in the editor instead of code?

We could have added the RemoteTransform2D nodes directly in the editor, but since we created the level as a separate scene, it's cleaner to add them through code.

This prevents us from expanding the Level2D node by using the Editable Children feature in the Scene dock as in the following image:

Screenshot of the main scene tree with the Level2D node expanded to show its children

As a general rule, I recommend against using Editable Children. It can cause you to lose changes on the expanded children if you update the parent scene later on.

Download files

Import the zip file in Godot 4.5 or later to explore the code and see how everything works together.

Updates / Code patches

Become an Indie Gamedev with GDQuest!

Don't stop here. Step-by-step tutorials are fun but they only take you so far.

Try one of our proven study programs to become an independent Gamedev truly capable of realizing the games you’ve always wanted to make.

Nathan

Founder and teacher at GDQuest
  • Starter Kit
  • Learn Gamedev from Zero
Check out GDSchool

You're welcome in our little community

Get help from peers and pros on GDQuest's Discord server!

20,000 membersJoin Server

Contribute to GDQuest's Free Library

There are multiple ways you can join our effort to create free and open source gamedev resources that are accessible to everyone!