Movable Objects
This is the third 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 Player Character and adds movable objects to it.
This tutorial uses Godot 4.3, the regular version, but you could also use the .NET version.
Movable Object
This time we give our player character something to interact with. We'll create simple objects that can be pushed around. We'll refer to these simply as movable objects.
Nodes
Create a new scene with a RigidBody2D
root node. Save it as movable_object.tscn
.
Add a CollisionShape2D
child node to make collisions possible. Set its Shape to a new RectangleShape2D
. Set the rectangle's Size to 8×8, so four would fit in a single tile.
Add a Sprite2D
node to visualize the object. Create a new GradientTexture2D
for its Texture. Set the gradient's Fill type to Square, keeping the default black-to-white color transition, so we get a square with a darker interior. Sets its size to 8×8. Also, like with the player character, set the sprite's CanvasItem › Texture › Filter mode to Nearest.
Global Physics
Add a few movable objects to the map scene, then run it. Any movable object that is not positioned directly on top of a solid tile will plummet. This happens because our project uses the default 2D gravity, which is set up for a side-view game. Because our game is top-down we have to disable gravity. In the project settings, set General › Physics › 2D › Default Gravity to zero. With gravity disabled our movable objects will remain stationary until pushed.
Pushing
Right now the player character is unable to push the movable objects. It is as if they are glued to the ground. That's because CharacterBody2D
doesn't exert any force when it collides with something, it only stops and slides. We have to add code to player_character.gd
to make the player push the objects.
Detecting Collisions
The first step of pushing is detecting that something needs to be pushed, which is when the player collides with a movable object. We move the player in _physics_process()
by calling move_and_slide()
. That function returns whether we hit something while moving. If so, let's call a new resolve_collisions()
function.
The new function returns nothing and will start by simply printing an indication that something was hit, using the code print("Bonk!")
.
func _physics_process(_delta: float) -> void:
get_player_input()
if move_and_slide():
resolve_collisions()
func resolve_collisions() -> void:
print("Bonk!")
Run the scene and make the player character collider with things. A stream of Bonk!
lines will show up in the editor's console output. We get a single line for each frame during which we hit something. However, it's possible that multiple things were hit at once, for example both a wall tile and a movable object.
We can retrieve the data for a specific collision by calling get_slide_collision()
, passing it a collision index. As we only call our function when there are collisions there is guaranteed to be at least one, with index 0. Retrieve it and store it in a variable, then print it instead of Bonk!
.
func resolve_collisions() -> void:
var collision := get_slide_collision(0)
print(collision)
To print all collisions we have to loop over all of them. We can find out how many collisions there are by calling get_slide_collision_count()
. Then use a for in
scope to loop over all indices. Specifally, we write for i in get_slide_collision_count()
. This defines a loop iterator variable named i
, which is shorthand for index. It starts at zero and keeps increasing until the indicated amount of indices have been processed in a loop. For example, if there are three collisions then the loop code will execute three times, with i
being 0, 1, and 2.
func resolve_collisions() -> void:
for i in get_slide_collision_count():
var collision := get_slide_collision(i)
print(collision)
Filtering Collisions
When we collide with things the console will now print the collisions, showing us their type, which is KinematicCollision2D
, follow by an object identifier. To know what kind of thing we hit we can retrieve the collider of the collision, by calling its get_collider()
function. This gives us the body of whatever we touched. Assign it to a variable and print it.
var collision := get_slide_collision(i)
var body := collision.get_collider()
print(body)
It turns out that we hit either a TileMapLayer
or a RigidBody2D
. The former are collisions with the map while the latter are collisions with movable objects. We're only interested in movable objects, so let's cast the body's type to RigidBody2D
via the as
operator. If that fails the body will be null
. We can then check if we have a valid non-null body and only if so print it.
var body := collision.get_collider() as RigidBody2D
if body:
print(body)
Now we only get notifications for collisions with movable objects.
Pushing
Before pushing the movable object we need to know in which direction to push it. We can figure this out by calling get_normal()
on the collision. This gives us the 2D normal vector of the collision, which is a unit-length vector that contains the direction of the detected hit. Specifically, it gives us the impact direction from the point of view of what we hit. So it points from the movable object to the player character. We can verify this by printing the vector.
print(collision.get_normal())
As we want to push things away when we hit them, we have to apply a force in the opposite direction. And we want to apply a decently strong force, so let's multiply the direction with −100 to try things out. To apply the force call apply_force()
on the body, passing it the force that we calculated.
#print(collision.get_normal())
body.apply_force(-100.0 * collision.get_normal())
This is a crude way to push things which isn't very realistic, but we're not going for realism, preferring simplicity instead. In fact, we don't even want the movable objects to rotate. We can prevent that by turning on RigidBody2D › Deactivation › Lock Rotation on the root node in movable_object.tscn
.
At this point we have movable objects that are straightforward to manipulate. However, there is an issue with collisions between them and the map. It's possible for movable objects to get stuck while sliding along a wall, if they've been pushed firmly against it.
This happens because each tile has its own separate collider rectangle. The rigid bodies can actually penetrate into colliders a little due to physics precision limitations. This isn't an issue when dealing with a single collider, but if the movable object then slides to an adjacent tile it can get stuck on the edge of the next collider.
There is no quick way to fix this problem, as it is an inherent limitation of 2D physics and tile maps. We could solve it by manually creating large colliders instead of defining them per tile, but that would make editing maps a nightmare. We could also rely on scripts to automatically generate such colliders, but that is fragile and problematic when supporting dynamically-changing maps. We're going to do something unconventional instead.
Objects as Characters
Our movable objects have an issue with collisions with the tile map, but our player character doesn't. The player character uses move_and_slide()
to move around, which uses different logic. So let's use that same logic for movable objects as well. We're not going for realstic physics anyway, so let's treat our movable objects as special cases of character bodies.
Movable Object Script
Change the type of the root note of movable_object.tscn
to CharacterBody2D
, via its Change Type.. context menu option, and set its Motion Mode to Floating, just like for the player character. Then attach a script to it and save it as movable_object.gd
.
Just like for the player character, the script extends CharacterBody2D
and calls move_and_slide()
in its _physics_process()
function, but it starts out not doing anything else.
extends CharacterBody2D
func _physics_process(_delta: float) -> void:
move_and_slide()
If we play the game at this point the player character can still push the movable objects, but only very slowly. This happens because the movable objects only avoid overlapping with the player character's collider and do nothing else.
Applying Impact
To properly apply the effect of an impact to our movable object we have to provide an alternative to add_force()
. Let's create our own apply_impact()
function with a 2D impact velocity parameter. We simply make it set the object's own velocity to match the given velocity. This isn't realistic at all, but it works and is easy to understand as a player.
func apply_impact(impact_velocity: Vector2) -> void:
velocity = impact_velocity
To make it possible for the player to call this function when appropriate, give our script the MovableObject
class name.
class_name MovableObject
extends CharacterBody2D
Then player_character.gd
can filter for it in resolve_collisions()
and call apply_impact()
, passing it its own velocity.
func resolve_collisions() -> void:
for i in get_slide_collision_count():
var collision := get_slide_collision(i)
var body := collision.get_collider() as MovableObject
if body:
body.apply_impact(velocity)
Our movable object now no longer get stuck when sliding along walls. They also get pushed at high speed, matching the player's, but we'll tune that shortly.
A nice feature of this simple approach is that glancing collisions still transfer the full velocity of the player character to the movable object. This makes it possible to drag objects sideways, as if the player character is sticky or grabs the movable objects. The only thing that the player can't do is pull.
Velocity Management
Right now movable objects always keep the velocity that the player character transferred to them. It as if they're player characters that always want to move in the same direction. This produces unexpected behavior, like sliding around corners.
To resolve this we have to adjust the velocity in movable_object.gd
when colliding with something. So let's also give it a resolve_collisions()
function and call it when appropriate. In this case we use the collision to adjust the velocity, eliminating the part of it that is blocked. That's done by calling project()
on the velocity, passing it the collision normal. This gives us the part of the velocity vector that aligns with the collision direction. Then subtract that from the velocity.
func _physics_process(_delta: float) -> void:
if move_and_slide():
resolve_collisions()
func resolve_collisions() -> void:
for i in get_slide_collision_count():
var collision := get_slide_collision(i)
velocity -= velocity.project(collision.get_normal())
Objects Pushing Objects
Currently objects block each other, only slightly pushing each other, just like the player character initially only slightly pushed movable objects.
We solve this by also applying impact when a movable object hits another one. Only when hitting something else do we adjust the object's own velocity.
func resolve_collisions() -> void:
for i in get_slide_collision_count():
var collision := get_slide_collision(i)
var body := collision.get_collider() as MovableObject
if body:
body.apply_impact(velocity)
else:
velocity -= velocity.project(collision.get_normal())
Drag
Once set in motion, our movable objects will keep going until they hit something, potentially crossing the entire map. Let's limit that by adding a configurable drag factor to them. Give it a range of 0.0–10.0, set to 5.0 by default.
@export_range(0.0, 10.0) var drag := 5.0
This is a nonrealistic drag coefficient, which slows down the object by multiplying the velocity with one minus the drag scaled by the time delta. Do this at the start of _physics_process()
. As we're using a fixed time delta of 60 frames per second, a drag of 1 would multiply the velocity with 59/60 each physics update.
func _physics_process(delta: float) -> void:
velocity *= 1.0 - drag * delta
if move_and_slide():
resolve_collisions()
Let's also entirely skip moving the object if its velocity is very low. So check whether the square length of the velocity vector exceeds 1, by calling length_squared()
on it, and only then do anything.
if velocity.length_squared() > 1.0:
velocity *= 1.0 - drag * delta
if move_and_slide():
resolve_collisions()
Why use length_squared()
instead of length()
?
Checking the squared length is a standard optimization, as it avoids having to calculate a square root. This requires comparing with the squared threshold instead of the regular one, but they're the same in the case of 1.
Impact Response
Let's also reduce how much velocity gets transferred during an impact. Realistically this is caused by friction and inertia, but we'll simply introduce a configurable impact response factor, with a range of 0.0–1.0 and a default of 0.5.
@export_range(0.0, 1.0) var impact_response := 0.5
Now instead of replacing the velocity in apply_impact()
we instead subtract the velocity from the impact velocity to get the velocity difference, relative to the object being impacted. Then we scale that by the impact response and add it to the velocity.
func apply_impact(impact_velocity: Vector2) -> void:
velocity += (impact_velocity - velocity) * impact_response
Besides making collisions slow things down, adding a sense of weight to the objects, this also makes it easier for the player character to manipulate movable objects that aren't pressed to a wall.
Head-on Collisions
When the player character collides with a movable object it always applies its velocity to the object, but the player itself is not pushed around by the object. However, when two movable objects collide they should both impact each other. This is currently not the case, but it's hard to get two objects to move in opposite directions to test this out. So let's add a configurable initial velocity 2D vector, set to zero by default.
@export var initial_velocity := Vector2.ZERO
Add a _ready()
function that sets the velocity to the initial velocity.
func _ready() -> void:
velocity = initial_velocity
Pick two movable objects that are near to each other and give them initial velocities with the same speed aimed at each other, so that they will collide head-on. For example set one to (60,0) and the other to (−60,0). Also set their drag to zero to we can see how the collision will play out without coming to a stop.
It turns out that one of the objects wins. This happens because the first one to get moved will apply its velocity to the other, drastically reducing the other's velocity before that one gets a chance to move.
To make the collision fair we have to resolve it symmetrically. This means that we have to apply the impact both ways at once. To do this properly we have to remember the current velocity of the object before resolving all its collisions. Then for each collision apply the impact toward the object itself first and then toward the other, using the current velocity. This isn't perfect and becomes even less correct for many-body collisions, but is good enough in practice.
func resolve_collisions() -> void:
var current_velocity := velocity
for i in get_slide_collision_count():
var collision := get_slide_collision(i)
var body := collision.get_collider() as MovableObject
if body:
apply_impact(body.velocity)
body.apply_impact(current_velocity)
else:
velocity -= velocity.project(collision.get_normal())
Collisions now also gets correctly resolved when opposite velocities are different, making the fastest object overpower the slower one, regardless which gets evaluated first.
We've given the players character movable objects to interact with, and these objects can also interact with each other in a believable and predictable way. In the next tutorial we'll make things look more interesting by adding lighting. It will be released in the future.