Teleporters
This is the twelfth 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 Showing Best Scores and adds teleporters to the game.
This tutorial uses Godot 4.5, the regular version, but you could also use the .NET version.
Interactive Objects
This time we're going to add teleporters to our game that displace objects across the map. We could just have teleporters change the object's position to displace them, but to facilitate object-specific behavior we'll add a displace() function to our objects instead. We don't name it teleport because displacement is more generic, which could also apply to objects being moved by something like a conveyor belt.
We'll support displacement of both movable objects and the player character. To make this easy we'll introduce a super class for both. Create a interactive_object.gd
script, give it the InteractiveObject class name and make it extend CharacterBody2D. This is where we put the displace() function. It has an offset parameter, which it adds to its own position.
class_name InteractiveObject
extends CharacterBody2D
func displace(offset: Vector2) -> void:
position += offset
Make movable_object.gd
extend this class instead of directly extending CharacterBody2D so it inherits the displace() function.
class_name MovableObject
extends InteractiveObject
Do the same for player_character.gd
.
class_name PlayerCharacter
extends InteractiveObject
Now both object types inherit from InteractiveObject can be displaced. But if we were to displace the player character then its displacement would add to its travel distance. As teleportation provides a spatial shortcut the displacement should not add to the travel distance. The same would be true for hitching a ride on a conveyor belt, as the player won't be moving themselves in that case.
To ignore the displacement we have to set the last position to the position directly after displacement. We can add this functionality to displace() by redefining it in player_character.gd
, overriding the function that it inherited. Inside the function we can call the original by calling super(), then we update the last position.
func displace(offset: Vector2) -> void:
super(offset)
last_position = position
Teleporter Design
We will implement teleportation by introducing specific source and destination areas, each with their own sprite. We'll make them 16×16 pixels, matching the tile size, but we make them separate sprites so they don't need to align to the tile grid. Import these images into the project. Let's put all teleporter assets in a teleporters
folder.
I created simple symbolic sprites, representing inward-pointing arrows for the source area and outward-pointing arrows for the destination area, both with a border. The solid parts of the sprites are white, which we'll modulate in the game. The rest of the pixels are semitransparent black. I made them like that because we'll put the sprites on top of everything else, so they act like a roof for the areas.
Every teleporter source will be linked to a single destination. It is allowed for multiple sources to link to the same destination. Whatever interactive objects ends up in the source area will be displaced to the destination area, keeping its relative position inside the area. Teleportation should only trigger if the object is fully inside the source area, so it will also end up fully inside the destination area. If multiple objects are in the source area at the same time they will teleport together. Finally, teleportation will only happen if the destination area is empty, otherwise the teleportation will be blocked, which should be made visible via a color change. That way we won't get overlapping objects at the destination, and it also allows for gameplay revolving around unblocking or purposefully blocking teleporters.
Teleporter Destination
Let's begin by creating a scene for the teleporter destination area, because it's simpler than the source area. Give the scene an Area2D root node and save it as teleporter_destination.tscn
in the teleporters
folder. Give it a CollisionShape2D child node with a RectangleShape2D shape with its Size set to 16×16.
Add a Sprite2D node that is a duplicate of the Sprite2D node from player_character.tscn
, because it uses the same settings. Just copy the node from that scene and paste it in the new scene. Then change its texture to the teleporter destination sprite. Let's also halve the A channel of the Visibility › Modulate color, from 255 down to 128, so we can see what's below it when editing maps.
We'll also add a particle effect when teleportation happens. We do this by copying the GPUParticles2D node from the player to this scene. To make it fit the square area open its Process Material and set its Spawn › Position › Emission Shape to Box. Set its Emission Box Extends to (6.0, 6.0, 0.0) so the particles can poke a little bit out of the area. Because the particles should only appear during teleportation turn on Time › One Shot, which should turn off Emitting. Also reduce Lifetime to 0.25. Finally, reset Process Material › Display › Color Curves, both Color and Alpha Curve.
The teleporter destination is mostly passive, we'll control it via the source. To make that possible add a teleporter_destination.gd
script to its root node. Give it the TeleporterDestination class name, have it extend Area2D, and add export variables for its sprite and particles, which should be hooked up via the inspector.
class_name TeleporterDestination
extends Area2D
@export var sprite: Sprite2D
@export var particles: GPUParticles2D
Besides that, to communicate whether the destination is in a valid state we add a validity_changed signal to it, based on its object count, just like we did in the detector.gd
script.
signal validity_changed(valid: bool)
@export var sprite: Sprite2D
@export var particles: GPUParticles2D
var object_count := 0
Connect the body-entered and body-exited signals from the area, adjusting the object count and emitting signals just as the detector, just without changing its color.
func _on_body_entered(_body: Node2D) -> void:
if object_count == 0:
validity_changed.emit(false)
object_count += 1
func _on_body_exited(_body: Node2D) -> void:
object_count -= 1
if object_count == 0:
validity_changed.emit(true)
This is all that the teleporter destination needs to do. The rest is up to the source.
Teleporter Source
We create the teleporter source scene by duplicating teleporter_destination.tscn
and renaming it to teleporter_source.tscn
. Replace its sprite texture with the source sprite. Then disconnect the area signals and remove the script from its root node. We give it a new teleporter_source.gd
script instead. We again extend Area2D and let's give it the TeleporterSource class name for consistency, even though we won't need to hold a reference to it anywhere.
Besides export variables for the sprite and particles we also need to keep track of its collision shape and its destination. We give it valid and invalid colors as well, using cyan and magenta for their defaults. Hook up the sprite, collision shape, and partices via the inspector. The destination remains unset in its own scene.
class_name TeleporterSource
extends Area2D
## Color for when the teleporter is free.
@export var valid_color := Color.CYAN
## Color for when the teleporter is occupied.
@export var invalid_color := Color.MAGENTA
@export var sprite: Sprite2D
@export var colissionShape: CollisionShape2D
@export var particles: GPUParticles2D
@export var destination: TeleporterDestination
Let's halve the opacity of the default colors, to make the sprites semitransparent. The easiest way to adjust the default colors is to construct a new Color by passing the desired color to its constructor function along with a new alpha value, for which we use 0.5.
## Color for when the teleporter is free.
@export var valid_color := Color(Color.CYAN, 0.5)
## Color for when the teleporter is occupied.
@export var invalid_color := Color(Color.MAGENTA, 0.5)
We have a few things to set up when the teleporter source is ready. First, if it does not have a destination then it should be deactivated and its invalid color should be used. We keep track of whether the teleporter is active via a variable, which is true by default.
var is_active := true
func _ready() -> void:
if not destination:
is_active = false
sprite.modulate = invalid_color
If there is a destination then we use the valid color for the sprite and particles, for both itself and its destination. Note that this means that the destination takes on the color of the source. If there are multiple sources linked to the same destination then the last one to be ready sets the destination color. It makes most sense to give those sources the same colors, but this is not mandatory.
func _ready() -> void:
if not destination:
…
else:
sprite.modulate = valid_color
destination.sprite.modulate = valid_color
particles.modulate = valid_color
destination.particles.modulate = valid_color
The last thing to set up is the connection to the destination's signal, for which we introduce an _on_detector_validity_changed() function. Wet set the source's activate state based on the destination's validity. We also adjust the colors of the sprites accordingly. We do not adjust the particle color as they only appear when teleportation happens, so we always use the valid color for them.
func _ready() -> void:
if not destination:
…
else:
destination.validity_changed.connect(_on_detector_validity_changed)
…
func _on_detector_validity_changed(valid: bool) -> void:
is_active = valid
var color := valid_color if valid else invalid_color
sprite.modulate = color
destination.sprite.modulate = color
Interactive objects will remain in the source area while it is inactive and also while they aren't fully inside the area. So we need to keep track of all objects that have entered the area. We do so by storing references to them in an array variable. We use an array of InteractiveObject references, which type is Array[InteractiveObject], initialized to an empty array.
var is_active := true
var objects: Array[InteractiveObject] = []
Connect to the body-entered signal of the area. In its function we get the body as an InteractiveObject to be sure that we have something of the correct type. If so we add it to the end of the array by calling push_back() on it.
func _on_body_entered(body: Node2D) -> void:
var object := body as InteractiveObject
if object:
objects.push_back(object)
Also connect to the body-exited signal of the area. In this case we have to find the index to remove, by calling find() on the array, passing it the supposed interactive object. If it's the wrong kind of object it will fail to find it. In case of failure the found index will be −1. So only if the index is at least zero we remove the element at the given index, by calling remove_at() on the array.
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)
Now we have to check all objects in _physics_process(). But if the teleporter is not active or if the objects array is empty then there is no work to do. So let's immediately return from the function when that is the case. We can check if the array is empty by calling is_empty() on it.
func _physics_process(_delta: float) -> void:
if not is_active or objects.is_empty():
return
If there is work to do then we have to check whether the objects are fully inside the area. For this we need to compare the rectangles of the object and the teleporter's own rectangle. Begin by getting its own rect by calling get_rect() on the .shape of its collision shape. This gives us a Rect2 rectangle relative to the teleporter itself, but we need it relative to the map, in the global space. As we neither scale nor rotate the teleporter all we have to do is add its position to the rectangle's position.
if not is_active or objects.is_empty():
return
var own_rect := colissionShape.shape.get_rect()
own_rect.position += position
We get the rectangles of the interactive objects in the same way. Let's add an export variable for the collision shape and a get_current_rect() function for this to interactive_object.gd
. We then need to hook up the collider via the inspector in both movable_object.tscn
and player_character.tscn
.
@export var colission_shape: CollisionShape2D
func get_current_rect() -> Rect2:
var rect := colission_shape.shape.get_rect()
rect.position += position
return rect
Back to teleporter_source.gd
, we can now loop through all objects and teleport them if possible. We don't need to manipulate the array elements, so we can create a simple array loop via for object in objects, with object being a variable for the current array element inside the loop. We then check if the teleporter's own rectangle encloses the current rectangle of the object, by calling encloses() on the former and passing it the latter. If so we call displace() on the object with an offset going from source to destination. We also set .emitting on the particle systems to true, which triggers a one-shot emission for both, after which they'll turn off again automatically.
own_rect.position += position
for object in objects:
if own_rect.encloses(object.get_current_rect()):
object.displace(destination.position - position)
particles.emitting = true
destination.particles.emitting = true
Don't we have to remove the object from the array?
During the next physics process step we'll get notified that the displaced object exited the area. That's when we remove it.
Now we can design a map with functional teleporters. I created a third map, with its Map Name set to Map 3: Teleporters
and put some teleporters and more detectors and movable objects on it. To make the teleporters draw on top of everything below them they must be on bottom of the scene hierarchy tree. I kept the default colors for some teleporters and set the valid and invalid color of some others to yellow and red.
Sleeping Teleporters
The teleporter won't do anything most of the time, so it's a bit of a waste that their _physics_process() function will get called all the time. We can reduce this by calling set_physics_process(false) to disable physics processing for it, which effectively puts them to sleep. This is a slight optimization. The most obvious place to do this is when we detect that there is no destination, because then the teleporter is permanently inactive.
func _ready() -> void:
if not destination:
is_active = false
sprite.modulate = invalid_color
set_physics_process(false)
else:
…
We can also turn it off when we detect that there is nothing to do in _physics_process().
func _physics_process(_delta: float) -> void:
if not is_active or objects.is_empty():
set_physics_process(false)
return
…
Now we must also turn it back on when appropiate, by calling set_physics_process(true). This needs to happen when an InteractiveObject entered the area.
func _on_body_entered(body: Node2D) -> void:
var object := body as InteractiveObject
if object:
objects.push_back(object)
set_physics_process(true)
And also when the validity changed. In this case whether we should activate it depends on the validity.
func _on_detector_validity_changed(valid: bool) -> void:
is_active = valid
set_physics_process(valid)
…
Connection Lines
It can be hard to determine which teleporter destination belongs to which source, especially when there are multiple of the same color. So let's visualize their connection by drawing a line between them. We do this by adding a Line2D node to teleporter_source.tscn
.
We make this line three pixels wide, by setting its Width to 3. We'll make it a dotted line, for which we need a repeating texture, so set its Fill › Texture Mode to Tile.
We want these lines to draw on top of everything else, so let's set the line's Ordering › Z Index to 1000. That should be enough to make them draw last.
We won't use a regular texture for the line, we'll instead create it procedurally in a shader. Set its Material › Material to a new ShaderMaterial. Then set that material's Shader to a new shader, named teleporter_line.gdshader
. This should be an unshaded canvas item shader, so in the Shader Editor declare its shader_type as canvas_item and also declare its render_mode as unshaded.
shader_type canvas_item;
render_mode unshaded;
void fragment() {
}
We'll only change the alpha channel by scaling it based on the texture UV coordinate's X component, which increases along the line's length. As we made the line three pixels wide the 0–1 range of the X component also covers three pixels and it keeps increasing beyond that. Let's scale its progressing down so 0–1 is stretched across 12 pixels instead.
void fragment() {
COLOR.a *= UV.x * 0.25;
}
The coordinate keeps increasing along the line. We make it repeat the 0–1 range by only taking its fractional part, by passing it to the fract() function.
COLOR.a *= fract(UV.x * 0.25);
We turn this into a dotted line by making it binary. From 0–0.75 it will be zero and above that 1, so we get 3×3 pixel blocks with gaps of nine pixels between them. This is done by calling step() with 0.75 as the threshold and what we have so far as the value to check.
COLOR.a *= step(0.75, fract(UV.x * 0.25));
Finally, we make the dots move from source to destination by subtracting TIME from the coordinate. We halve the time so a dot arrives at the destination every two seconds.
COLOR.a *= step(0.75, fract(UV.x * 0.25 - TIME * 0.5));
Now we must make the line go from source to destination. Add an export variable for it to teleporter_destination.gd
and hook it up via the inspector.
@export var sprite: Sprite2D
@export var line: Line2D
When the teleporter is ready and there is a destination, set the line's color and then add two points to it by calling add_point() on it twice. These points are relative to the teleporter, so the first point is Vector2.ZERO and the second point is the offset to the destination, the same vector used for displacement.
func _ready() -> void:
if not destination:
…
else:
…
line.modulate = valid_color
line.add_point(Vector2.ZERO)
line.add_point(destination.position - position)
We should also adjust the line's color when the validity changes.
func _on_detector_validity_changed(valid: bool) -> void:
…
line.modulate = color
Our game now has functional teleporters and clearly shows how sources and destinations are connected. In the future we'll add more ways to move objects around.