Conveyors

Fun with conveyors.

This is the thirteenth tutorial in a series that covers the creation of a simple true top-down 2D game with pixel graphics, using the Godot Engine, version 4. It follows Teleporters and adds conveyors to the game.

This tutorial uses Godot 4.5, the regular version, but you could also use the .NET version.

Conveyor Design

Last time we added teleporters to our game and this time we'll add conveyors. A conveyor is an area that moves interactive objects inside it in one direction at a given speed. The typical example is a conveyor belt carrying objects, but it could also be another kind of conveyor system that doesn't use a belt, so we'll just call it a conveyor.

Create a new conveyor.tscn in a new conveyor folder with an Area2D root note, just like the teleporters. Give it a CollisionShape2D child node with a RectangleShape2D shape. Also give it a Line2D child node with two points set to zero and its width set to 12. Enable Repeat on its texture.

We'll use this sprite, which represents a simple 12×12 monochrome conveyor belt with an arrow on it. Assign it to the Line2D › Fill › Texture property of the line.

Conveyor sprite.

Conveyor Length

The conveyor's length should be configurable. Add a new script to the root node and name it conveyor.gdscript. Give it an exported variable for its length, with a minimum of 12 and a maximum of something like 400, and a default of 32, which covers two tiles. Also add export variables for the collision shape and line and hook them up via the inspector.

extends Area2D

@export_range(12, 400) var length := 32

@export var colissionShape: CollisionShape2D
@export var line: Line2D

To make designing easy we'll immediately configure the scene so the conveyor is of the desired length. We do this by adding a setter function to the length variable. This function gets called whenever something is assigned to the variable, except inside the setter itself. Initially we just replace the current with the new length.

The setter function is defined by adding a colon to the variable declaration and defining set() on the next line, indented one step. The function needs a parameter for the new value. We don't have to define the type of this parameter because it matches the variable's.

@export_range(12, 400) var length := 32 :
	set(new_length):
		length = new_length

We'll set the line's second point so it matches the given length, if needed. The line should exist and have two points, but let's verify that before adjusting it, to avoid potential errors. The conveyor goes to the right by default.

@export_range(12, 400) var length := 32 :
	set(new_length):
		length = new_length
		if line and line.points.size() == 2 and line.points[1].x != length:
			line.points[1] = Vector2(length, 0.0)

We'll also configure the collision shape here. Let's assume that if the line is set and valid the collision shape is as well. We position it a the halfway point of the belt and we grab its shape as a RectangleShape2D to set its size.

		if line and line.points.size() == 2 and line.points[1].x != length:
			line.points[1] = Vector2(length, 0.0)
			colissionShape.position = Vector2(length * 0.5, 0.0)
			var rect := colissionShape.shape as RectangleShape2D
			rect.size = Vector2(length, 12.0)

This currently doesn't work when we change the length via the inspector, because it bypasses the setter function. To make the Godot editor honor the setter we have to turn our script into a tool script, which does function in the editor. We do this by giving it the @tool annotation.

@tool
extends Area2D

Now the setter also gets called when we change the length via the inspector and everything gets set up correctly. However, when the scene gets loaded when we open it in the inspector the setter will be called immediately when the data is loaded, before the scene is ready. One of the consequences of this is that the variables linking to the collision shape and line have not been set up yet and will be null. Because we check whether the line exists this won't result in an error, but this means that the line and collision shape will not be configured on scene load. This isn't a problem, because when change the length everything does get set up correctly and saving the scene will preserve that.

Conveyor Speed

To make the conveyor visibly move give the line a new CanvasItemMaterial with a custom conveyor.gdshader. The shader will simply sample the texture while sliding it to the right with a configurable speed. To allow coloration via the canvas item modulation color, pass the vertex color to the fragment function separately and multiply it with the texture color there.

shader_type canvas_item;

uniform float speed = 1.0;

varying vec4 vertex_color;

void vertex() {
	vertex_color = COLOR;
}

void fragment() {
	COLOR = texture(TEXTURE, vec2(UV.x - TIME * speed, UV.y));
	COLOR *= vertex_color;
}

To make the speed configurable add an export variable for it to conveyor.gd, with a 0–60 range and a default of 30 pixels per second. Give it a setter that sets the line's material property, if the line exists. To set our custom shader's property via code we have to call set("shader_parameter/speed", speed) on the material.

## Conveyor speed, in pixels per second.
@export_range(0, 60) var speed := 30 :
	set(new_speed):
		speed = new_speed
		if line:
			line.material.set("shader_parameter/speed", speed)

However, we also have to divide the speed by the width of the line's texture to get the correct animation speed.

			line.material.set(
					"shader_parameter/speed",
					speed / line.texture.get_size().x
			)

Local Scene Resources

We can now add conveyor instances to maps, but if we adjust their length or speed strange things will happen. Changing one conveyor will affect all others, including the original scene. This happens because we are adjusting some values that are stored in resources, which are shared between scenes by default.

The first case is the RectangleShape2D used by the collision shape. Each instance of the conveyor should use its own rectangle, so we have make it unique per scene. We could create a new shape resource in the setter, but that is not needed. Instead all we have to do is enable Resource › Local To Scene for it. That will make Godot duplicate the resource automatically for each instance of the scene that we use.

Rectangle shape resource made local to scene.

Do the same for the line's material.

The conveyor instances should now function independently. I created a fourth map scene and placed some conveyors on it, with different lengths, speeds, orientations, and colors. Because they are supposed to be underneath all other objects they have to be placed directly below the tile map in the scene hierarchy, with everything else below them.

Map with conveyors.

Pushing Objects

To push objects along the conveyor we'll have to keep track of all interactive objects that are in their area. We'll use the same approach as for the teleporters, detecting on-body-enter and on-body-exit signals and storing the objects in an array, also enabling physics processing when an object gets added. Connect the appropriate signals to functions and copy the relevant code from teleporter_source.gd to conveyor.gd.

var objects: Array[InteractiveObject] = []


func _on_body_entered(body: Node2D) -> void:
	var object := body as InteractiveObject
	if object:
		objects.push_back(object)
		set_physics_process(true)


func _on_body_exited(body: Node2D) -> void:
	var index_to_remove := objects.find(body as InteractiveObject)
	if index_to_remove >= 0:
		objects.remove_at(index_to_remove)

This time we cannot simply displace objects, because we cannot control where they end up. Multiple objects can be sliding along a conveyor at the same time, partially overlapping it and colliding with other objects along the way. To make this work we'll add a push() function to interactive_object.gd that will take care of this.

The simplest way to implement pushing is to apply a given push velocity, by making it override or counteract the object's own velocity when appropriate. Let's first simplify this problem to only a single dimension. In that case we have a current speed and apply a push speed. Let's start with a _push() function that takes these two are parameters and returns both added together.

func _push(speed: float, push_speed: float) -> float:
	return speed + push_speed

This makes sense when the speeds are opposed, as they would cancel each other out. The push slows the object down or even reverses its direction. However, when both are in the same direction we should only pick the highest speed, because either the push makes no difference or speeds up the object. Otherwise continuous pushing would keep accelerating the object way past the conveyor's own speed. We can do this by using the max() function if both speeds are at least zero and min() if both are negative.

func _push(speed: float, push_speed: float) -> float:
	if push_speed >= 0.0:
		if speed >= 0.0:
			# Both positive, pick strongest.
			return max(speed, push_speed)
	elif speed < 0.0:
		# Both negative, pick strongest.
		return min(speed, push_speed)
	# Opposite directions, add.
	return speed + push_speed

Now we can implement our push() function by simply calling _push() for both dimensions independently.

func push(push_velocity: Vector2) -> void:
	velocity.x = _push(velocity.x, push_velocity.x)
	velocity.y = _push(velocity.y, push_velocity.y)

To apply the push add a _physics_process() function to conveyor.dg that disables itself if there are no objects to push and otherwise pushes all objects on it. We find its push velocity by multiplying the transform.x axis vector with the speed.

func _physics_process(_delta: float) -> void:
	if objects.is_empty():
		set_physics_process(false)
		return
	
	var push_velocity = transform.x * speed
	for object in objects:
		object.push(push_velocity)
Doesn't _physics_process() always get called in the editor now?

Yes, because our script is an editor tool it will get called all the time by default. However, beyond that physics is not operational and no signals will be sent due to overlapping objects, so our conveyors will immediately disable their physics processing in the editor.

Now movable objects get pushed by the conveyors and they behave correctly, even if they would collide with something or touch multiple conveyors at the same time. However, they end up moving at bit slower than the conveyor that they are on. This is because we apply drag before moving. To solve this we'll from now on apply drag after moving in movable_object.gd.

func _physics_process(delta: float) -> void:
	if velocity.length_squared() > 1.0:
		#velocity *= 1.0 - drag * delta
		if move_and_slide():
			resolve_collisions()
		velocity *= 1.0 - drag * delta
	else:
		position = round(position)
		velocity = Vector2.ZERO

Also, the player is not yet affected by the conveyors, because we override its velocity with the player's input. We solve this by instead adding the player input to the current velocity, then setting it to zero after moving. This lets conveyors to push in between and allows the player to run along the conveyor, adding its own speed to it to go extra fast.

func _physics_process(_delta: float) -> void:
	get_player_input()
	if move_and_slide():
		resolve_collisions()
	velocity = Vector2.ZERO
	travel_distance += last_position.distance_to(position)
	last_position = position


…


func get_player_input() -> void:
	var vector := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	velocity += vector * speed
Conveyors in action.

Our game is getting more interesting! We'll add more gameplay elements in the future.