Easy, visual, camera bounds in Godot
Godot comes with a powerful and easy 2D camera system. The way it works is that you can add a Camera2D node to a scene, and the view will then follow the parent of the Camera2D node as you move around. There's more work to be done if you require things like multiple cameras in your game, but this built in camera already handles the most common requirements such as being able to set bounds on the camera movement, add a "drag" area so that small movements in the middle of the screen don't cause view jitter and movement smoothing.
There are a couple of downside that will come up in even the simplest games though.
Firstly, the camera boundaries are set on the Camera2D node, but normally that node is part of your 'player' scene - which doesn't know what the bounds of the current level are. It's also hard to set the correct values as they need to be given in global coordinates (which makes sense for something that has to move the current view, if you think about it) and they are not shown visually in the editor, even if you are able to somehow get the node into the level scene.
Secondly, the camera boundaries don't prevent the parent node from moving beyond them. Which is reasonable, because sometimes you will want the player to be able to move off screen - but normally it isn't what you want in one of your standard game levels.
Let's look at a potential way to work around these issues and make level creation that much easier.
Signaling change
First off, I'm going to assume that you have some way of passing around a signal in your game. If you already have your own way of doing that, skip to the next section and tweak the code to use your system wherever I use SignalBus.
If you don't already have a signal passing strategy, feel free to steal mine. I'm using a global SignalBus for any signals that may have either multiple emitters or multiple subscribers. As multiple levels will need to signal that the camera boundaries need to change as they are loaded, I'm going to add a camera_bounds_changed signal to my bus for levels to emit as they load, and the Player node to listen to.
My real signal bus has other signals as well, but if you're following along at home the example will work with just this one:
class_name SignalBusClass extends Node @warning_ignore("unused_signal") signal camera_bounds_changed(left: int, top: int, right: int, bottom: int)
Save that into a new script file in your project, go to your project settings, click on the Globals section and create a new item in the Autoload list with the name SystemBus and a path pointing to the script file you just created. Make sure that Global Variable option is ticked to allow you to reference the bus anywhere in your code.
Adding the bounds to the level
Now let's add a new node type for defining the boundaries of a level. Add a new script file, and let's start building this up. I'll also post the complete file at the end of the post.
@tool class_name CameraBounds extends Node2D
We've defining something that we want to be able to add to a scene as a node, so we need to give the node a name (CameraBounds) and it has to extend from Node2D (or a child of Node2D). It is also going to run code in the editor view (drawing the camera boundaries on the scene) which we do not want to run in the game itself, so we add the @tool annotation.
@export var size := Vector2(1000, 1000): set(value): size = value queue_redraw()
We add a size property to the node that can be changed in the editor. This represents the size of the box that the camera can move around within, with the top left corner of the box being the position of the CameraBounds node. We add a set function so that we can trigger a redraw if the value is changed.
func _draw() -> void: if Engine.is_editor_hint(): draw_rect(Rect2(Vector2.ZERO, size), Color.AQUAMARINE, false, 2.0)
Talking of redrawing! We override the _draw function here. Editor.is_editor_hint returns true if the node is currently being drawn in the editor rather than during the game being run, so this code draws a rectangle onto the current scene with a nice aquamarine border but only in the editor.
Finally, need to calculate the actual level boundaries.
func _ready() -> void: if Engine.is_editor_hint(): queue_redraw() else: var global_bottom_right = to_global(size) SignalBus.camera_bounds_changed.emit( global_position.x, global_position.y, global_bottom_right.x, global_bottom_right.y ) var world_boundary = StaticBody2D.new() create_wall(world_boundary, 0, Vector2.RIGHT) create_wall(world_boundary, -size.x, Vector2.LEFT) create_wall(world_boundary, 0, Vector2.DOWN) create_wall(world_boundary, -size.y, Vector2.UP) add_child(world_boundary)
As the level is loaded and this node is added to the tree, the _ready function will be called. In the editor, we just make sure our rectangle is drawn. In the game, things get more fun.
First we work out where the bottom right corner of our "boundaries" rectangle is in the global coordinate space. Fortunately, Godot gives us a built in method for this; to_global will take into account the position of our CameraBounds node and any scaling that has been applied to it, so we can just give it the position of the bottom right corner in local coordinates.
We then emit a signal to say that the camera bounds need to be updated. The camera should never show anything further left than the position of the node, higher than the position of the node, further right than the bottom right corner of our "box", or lower than the bottom right corner. Although - side note - if your screen is larger than your boundary box Godot will show the full size of the screen even if that does go outside the boundaries.
Then to make sure that the player can't move off camera, we set up a world boundary. This is a StaticBody2D which we then add four WorldBoundaryShape2D collision shapes to using create_wall (see below), one for each side of the box. Finally, we add the world boundary we just created as a child of this node; this allows us to define the boundaries in terms of local coordinates and know that they will match the box we drew visually in the editor.
func create_wall(world_boundary: StaticBody2D, distance: float, normal: Vector2) -> void: var wall = CollisionShape2D.new() var shape = WorldBoundaryShape2D.new() shape.distance = distance shape.normal = normal wall.shape = shape world_boundary.add_child(wall)
That only leaves us to define the create_wall function we call above. WorldBoundaryShape2D creates a collision "line" that extends infinitely. The normal is the direction that it 'applies force towards' - i.e. a flat, horizontal "floor" (both in game and in real life) has a normal of "straight up" because when gravity pushes you down, the floor resists with an equal and opposite upwards force. The distance is how far away the boundary is in the "normal" direction, which is why in the code above we are normally passing in a negative number. For example, the right boundary of the level needs a normal of Vector2.LEFT (it pushes you back into the level) and a distance of -size.x because it is to the right of the nodes position (and the direction of distance is the direction assigned to normal).
Now we have our new node! Go to one of your level scenes, and you should be able to add a child node of type CameraBounds and reposition the boundaries by moving the node and changing the size property. Top tip: if you're using a tile map, you can set the values in size by typing the number of tiles times the number of pixels per tile and the editor will calculate the total for you (i.e. typing in 20 * 32 for a level that is 20 tiles wide with each tile being 32 pixels across).
Updating the camera with the new boundaries
Now we only have one small piece of the puzzle left. Over in the player node, we need to make sure that we update the properties of the camera when a change of bounds signal is sent out. Hop on over to the script for your player node.
Let's assign the camera node to a variable when it is created so we can access it efficiently later.
@onready var camera: Camera2D = $Camera2D # <- if you renamed the camera node, this will be your name
We need to connect to the signal in _ready, so that we receive it when it is fired.
func _ready() -> void: # You will probably have other code here already SignalBus.camera_bounds_changed.connect(camera_bounds_changed)
And we need to create the camera_bounds_changed method we just connected up:
func camera_bounds_changed(left: int, top: int, right: int, bottom: int): camera.limit_left = left camera.limit_top = top camera.limit_right = right camera.limit_bottom = bottom
That's it. All the calculations have already been done for us, we just plug them in.
The full listing
For your cutting and pasting convenience, here's the full listing of the script file defining the CameraBounds node.
@tool class_name CameraBounds extends Node2D @export var size := Vector2(1000, 1000): set(value): size = value queue_redraw() func _ready() -> void: if Engine.is_editor_hint(): queue_redraw() else: var global_bottom_right = to_global(size) SignalBus.camera_bounds_changed.emit( global_position.x, global_position.y, global_bottom_right.x, global_bottom_right.y ) var world_boundary = StaticBody2D.new() create_wall(world_boundary, 0, Vector2.RIGHT) create_wall(world_boundary, -size.x, Vector2.LEFT) create_wall(world_boundary, 0, Vector2.DOWN) create_wall(world_boundary, -size.y, Vector2.UP) add_child(world_boundary) func _draw() -> void: if Engine.is_editor_hint(): draw_rect(Rect2(Vector2.ZERO, size), Color.AQUAMARINE, false, 2.0) func create_wall(world_boundary: StaticBody2D, distance: float, normal: Vector2) -> void: var wall = CollisionShape2D.new() var shape = WorldBoundaryShape2D.new() shape.distance = distance shape.normal = normal wall.shape = shape world_boundary.add_child(wall)
That's a wrap for today. Let me know if this helps, if you've found a better way of doing it, or if you have any other comments. Comment on this post or drop me an email.