Okay, after some blood, sweat and tears, got something that generally works (it of course needs a lot more refining, but the general idea of multi-level jumping in a completely 2d world is doable for a small number of surfaces). Here’s a short video
tl;dr for those who don’t want to read, its most likely overly complicated and has some edge cases that need ironing out, but generally works for what I wanted to do.
For example jumping into the left hand wall while on the kitchen surface, will land you on a jumpable
area and you fall down. Also its kinda tricky to jump up onto the fridge top (elevation 2) but possible.
If anyone else is wondering about it, here what it contains:
- 3 physics layers to walk around on:
floor
, elevation 1
, elevation 2
- 3 physics layers to handle falling:
jumpable
, always wall
and ignorable wall
- Character2D with individual
Area2D
’ for each physics layer to detect
- Tilemaps use all physics layers
- Limitation: Assumes that the “higher” level is higher than previous in bitmask terms
Welcome any improvements!
Thinking about it, ignorable wall
can most likely be collapsed into jumpable
and save you a physics layer. Will try it later.
Layer’s bitmask is defined in a global autoload and has 2 helper functions to deal with
const LAYER_NONE: int = 0
const LAYER_FLOOR: int = 1
const LAYER_JUMPABLE: int = 2
const LAYER_ALWAYS_WALL: int = 4
const LAYER_IGNORABLE_WALL: int = 8
const LAYER_ELEVATED_1: int = 16
const LAYER_ELEVATED_2: int = 32
func add_collision_layer(collision_mask: int, layer_bitmask: int) -> int:
return collision_mask | layer_bitmask
func remove_collision_layer(collision_mask: int, layer_bitmask: int) -> int:
return collision_mask & ~layer_bitmask
Ended up creating multiple collision areas under the base collision shape, each interacting with an individual layer

Then in the character script got a bunch of code to deal with setting and unsetting a stack of areas with which the areas are colliding.
func _on_layer_floor_area_body_entered(_body):
if _body.name == self.name:
return
if Globals.LAYER_FLOOR not in _current_layers_stack:
_current_layers_stack.append(Globals.LAYER_FLOOR)
func _on_layer_floor_area_body_exited(_body):
if _body.name == self.name:
return
_current_layers_stack.erase(Globals.LAYER_FLOOR)
func _on_layer_elevated_1_area_body_entered(_body):
if _body.name == self.name:
return
if Globals.LAYER_ELEVATED_1 not in _current_layers_stack:
_current_layers_stack.append(Globals.LAYER_ELEVATED_1)
func _on_layer_elevated_1_area_body_exited(_body):
if _body.name == self.name:
return
_current_layers_stack.erase(Globals.LAYER_ELEVATED_1)
func _on_layer_elevated_2_area_body_entered(_body):
if _body.name == self.name:
return
if Globals.LAYER_ELEVATED_2 not in _current_layers_stack:
_current_layers_stack.append(Globals.LAYER_ELEVATED_2)
func _on_layer_elevated_2_area_body_exited(_body):
if _body.name == self.name:
return
_current_layers_stack.erase(Globals.LAYER_ELEVATED_2)
func _on_layer_jumpable_area_body_entered(_body):
if _body.name == self.name:
return
if Globals.LAYER_JUMPABLE not in _current_layers_stack:
_current_layers_stack.append(Globals.LAYER_JUMPABLE)
func _on_layer_jumpable_area_body_exited(_body):
if _body.name == self.name:
return
_current_layers_stack.erase(Globals.LAYER_JUMPABLE)
func _on_layer_ignorable_wall_area_body_entered(_body):
if _body.name == self.name:
return
if Globals.LAYER_IGNORABLE_WALL not in _current_layers_stack:
_current_layers_stack.append(Globals.LAYER_IGNORABLE_WALL)
func _on_layer_ignorable_wall_area_body_exited(_body):
if _body.name == self.name:
return
_current_layers_stack.erase(Globals.LAYER_IGNORABLE_WALL)
This produces debug like
*** Area: jumpable - enter, stack: [1, 16, 2]
*** Area: elevated 1 - exit, stack: [1, 2]
Some helper functions to decide if the character is falling and setting of collision masks based on which walkable level character is on and setup of the layer stack.
var _current_layers_stack: Array[int] = [Globals.LAYER_FLOOR]
func _is_falling() -> bool:
if _current_layers_stack.has(Globals.LAYER_JUMPABLE) or _current_layers_stack.size() == 0:
return true
if (
_current_layers_stack.has(Globals.LAYER_IGNORABLE_WALL)
and not _current_layers_stack.has(Globals.LAYER_FLOOR)
and not _current_layers_stack.has(Globals.LAYER_ELEVATED_1)
and not _current_layers_stack.has(Globals.LAYER_ELEVATED_2)
):
return true
return false
func _set_collision_masks() -> void:
match _current_layer:
Globals.LAYER_FLOOR:
collision_mask = Globals.add_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
Globals.LAYER_ELEVATED_1:
collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
Globals.LAYER_ELEVATED_2:
collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
_:
printt("Unexpected layer: ", _current_layer, collision_shape.global_position)
func _get_current_elevation() -> int:
var _highest_layer: int = 0
if _current_layers_stack.size() > 0:
_highest_layer = _current_layers_stack.max()
return _highest_layer
I am using a state machine to decide what character is doing, but basically when you enter the jump state
STATE.JUMP:
collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_JUMPABLE)
collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
_animate_jump()
is_jumping = true
And when you leave the jumping state
STATE.JUMP:
collision_mask = Globals.add_collision_layer(collision_mask, Globals.LAYER_JUMPABLE)
collision_mask = Globals.add_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
is_jumping = false
velocity = Vector2.ZERO
_set_collision_masks()
Then during the jumping process itself
STATE.JUMP:
if velocity != Vector2.ZERO:
velocity = last_move_direction * speed * (dash_multiplier/3)
var collision = move_and_collide(velocity * delta)
if collision:
_set_state(STATE.FALL)
return
# Jumping in place
if !animation_player.is_playing():
_set_state(STATE.IDLE)
Tilemap setup involves setting multiple areas to the different physics layers. for example kitchen counter inner area is elevation 1
and the black border around it is jumpable
Same for the walls, some parts are jumpable and the “top” of the walls is always wall