Let’s see what we can figure out about the Arcade camera. I think we’ll look at an example, and maybe bash it a bit to see what it does.

The simplest example I’ve looked at so far is a very simple side-scroller game that uses two cameras. One displays the score, and one contains the game world and player. The game scrolls sideways as the player moves. It keeps the player in the center of the play field, unless doing so would scroll outside the game’s boundaries. When the player moves, the camera eases back to center. Let me try to take a little movie.

You can see how the camera kind of eases back and forth to center the player in mid view. It feels very natural, pretty much what we’re all used to in such games. Let’s look at the source for this little game. I’ll show the whole thing and then call out things I notice after that.

"""
Platformer Template

If Python and Arcade are installed, this example can be run from the command line with:
python -m arcade.examples.template_platformer
"""
import arcade
from arcade.types import Color

# --- Constants
WINDOW_TITLE = "Platformer"
WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 720

# Constants used to scale our sprites from their original size
CHARACTER_SCALING = 1
TILE_SCALING = 0.5
COIN_SCALING = 0.5
SPRITE_PIXEL_SIZE = 128
GRID_PIXEL_SIZE = SPRITE_PIXEL_SIZE * TILE_SCALING

# Movement speed of player, in pixels per frame
PLAYER_MOVEMENT_SPEED = 10
GRAVITY = 1
PLAYER_JUMP_SPEED = 20

# Camera constants
FOLLOW_DECAY_CONST = 0.3  # get within 1% of the target position within 2 seconds


class GameView(arcade.View):
    """
    Main application class.
    """

    def __init__(self):
        super().__init__()

        # A Camera that can be used for scrolling the screen
        self.camera_sprites = arcade.Camera2D()

        # A rectangle that is used to constrain the camera's position.
        # we update it when we load the tilemap
        self.camera_bounds = self.window.rect

        # A non-scrolling camera that can be used to draw GUI elements
        self.camera_gui = arcade.Camera2D()

        # The scene which helps draw multiple spritelists in order.
        self.scene = self.create_scene()

        # Set up the player, specifically placing it at these coordinates.
        self.player_sprite = arcade.Sprite(
            ":resources:images/animated_characters/female_adventurer/femaleAdventurer_idle.png",
            scale=CHARACTER_SCALING,
        )

        # Our physics engine
        self.physics_engine = arcade.PhysicsEnginePlatformer(
            self.player_sprite, gravity_constant=GRAVITY, walls=self.scene["Platforms"]
        )

        # Keep track of the score
        self.score = 0

        # What key is pressed down?
        self.left_key_down = False
        self.right_key_down = False

        # Text object to display the score
        self.score_display = arcade.Text(
            "Score: 0",
            x=10,
            y=10,
            color=arcade.csscolor.WHITE,
            font_size=18,
        )

    def create_scene(self) -> arcade.Scene:
        """Load the tilemap and create the scene object."""
        # Our TileMap Object
        # Layer specific options are defined based on Layer names in a dictionary
        # Doing this will make the SpriteList for the platforms layer
        # use spatial hashing for collision detection.
        layer_options = {
            "Platforms": {
                "use_spatial_hash": True,
            },
        }
        tile_map = arcade.load_tilemap(
            ":resources:tiled_maps/map.json",
            scaling=TILE_SCALING,
            layer_options=layer_options,
        )

        # Set the window background color to the same as the map if it has one
        if tile_map.background_color:
            self.window.background_color = Color.from_iterable(tile_map.background_color)

        # Use the tilemap's size to correctly set the camera's bounds.
        # Because how how shallow the map is we don't offset the bounds height
        self.camera_bounds = arcade.LRBT(
            self.window.width/2.0,
            tile_map.width * GRID_PIXEL_SIZE - self.window.width/2.0,
            self.window.height/2.0,
            tile_map.height * GRID_PIXEL_SIZE
        )


        # Our Scene Object
        # Initialize Scene with our TileMap, this will automatically add all layers
        # from the map as SpriteLists in the scene in the proper order.
        return arcade.Scene.from_tilemap(tile_map)

    def reset(self):
        """Reset the game to the initial state."""
        self.score = 0
        # Load a fresh scene to get the coins back
        self.scene = self.create_scene()

        # Move the player to start position
        self.player_sprite.position = (128, 128)
        # Add the player to the scene
        self.scene.add_sprite("Player", self.player_sprite)

    def on_draw(self):
        """Render the screen."""

        # Clear the screen to the background color
        self.clear()

        # Draw the map with the sprite camera
        with self.camera_sprites.activate():
            # Draw our Scene
            # Note, if you a want pixelated look, add pixelated=True to the parameters
            self.scene.draw()

        # Draw the score with the gui camera
        with self.camera_gui.activate():
            # Draw our score on the screen. The camera keeps it in place.
            self.score_display.text = f"Score: {self.score}"
            self.score_display.draw()

    def update_player_speed(self):
        # Calculate speed based on the keys pressed
        self.player_sprite.change_x = 0

        if self.left_key_down and not self.right_key_down:
            self.player_sprite.change_x = -PLAYER_MOVEMENT_SPEED
        elif self.right_key_down and not self.left_key_down:
            self.player_sprite.change_x = PLAYER_MOVEMENT_SPEED

    def on_key_press(self, key, modifiers):
        """Called whenever a key is pressed."""

        # Jump
        if key == arcade.key.UP or key == arcade.key.W:
            if self.physics_engine.can_jump():
                self.player_sprite.change_y = PLAYER_JUMP_SPEED

        # Left
        elif key == arcade.key.LEFT or key == arcade.key.A:
            self.left_key_down = True
            self.update_player_speed()

        # Right
        elif key == arcade.key.RIGHT or key == arcade.key.D:
            self.right_key_down = True
            self.update_player_speed()

    def on_key_release(self, key, modifiers):
        """Called when the user releases a key."""
        if key == arcade.key.LEFT or key == arcade.key.A:
            self.left_key_down = False
            self.update_player_speed()
        elif key == arcade.key.RIGHT or key == arcade.key.D:
            self.right_key_down = False
            self.update_player_speed()

    def center_camera_to_player(self):
        # Move the camera to center on the player
        self.camera_sprites.position = arcade.math.smerp_2d(
            self.camera_sprites.position,
            self.player_sprite.position,
            self.window.delta_time,
            FOLLOW_DECAY_CONST,
        )

        # Constrain the camera's position to the camera bounds.
        self.camera_sprites.view_data.position = arcade.camera.grips.constrain_xy(
            self.camera_sprites.view_data, self.camera_bounds
        )

    def on_update(self, delta_time: float):
        """Movement and game logic"""

        # Move the player with the physics engine
        self.physics_engine.update()

        # See if we hit any coins
        coin_hit_list = arcade.check_for_collision_with_list(
            self.player_sprite, self.scene["Coins"]
        )

        # Loop through each coin we hit (if any) and remove it
        for coin in coin_hit_list:
            # Remove the coin
            coin.remove_from_sprite_lists()
            # Add one to the score
            self.score += 1

        # Position the camera
        self.center_camera_to_player()

    def on_resize(self, width: int, height: int):
        """ Resize window """
        super().on_resize(width, height)
        # Update the cameras to match the new window size
        self.camera_sprites.match_window()
        # The position argument keeps `0, 0` in the bottom left corner.
        self.camera_gui.match_window(position=True)


def main():
    """Main function"""
    window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
    game = GameView()
    game.reset()

    window.show_view(game)
    arcade.run()


if __name__ == "__main__":
    main()

Pretty standard main, create the window and view, reset the game, show the view, run. I notice an on_resize that is probably interesting but in fact the game does not allow me to resize, so let’s ignore that for now. Let’s start with the init. We’ll focus on the camera bits, although there is plenty more to learn from this little program.

    def __init__(self):
        super().__init__()

        # A Camera that can be used for scrolling the screen
        self.camera_sprites = arcade.Camera2D()

        # A rectangle that is used to constrain the camera's position.
        # we update it when we load the tilemap
        self.camera_bounds = self.window.rect

        # A non-scrolling camera that can be used to draw GUI elements
        self.camera_gui = arcade.Camera2D()

OK a camera for sprites … that’ll be the big game view, and a camera for the GUI, which is the white score that appears down at the bottom of the game window. We’ll have to wait and see what we do with self.camera_bounds.

Then init says:

        # The scene which helps draw multiple spritelists in order.
        self.scene = self.create_scene()

    def create_scene(self) -> arcade.Scene:
        """Load the tilemap and create the scene object."""
        # Our TileMap Object
        # Layer specific options are defined based on Layer names in a dictionary
        # Doing this will make the SpriteList for the platforms layer
        # use spatial hashing for collision detection.
        layer_options = {
            "Platforms": {
                "use_spatial_hash": True,
            },
        }
        tile_map = arcade.load_tilemap(
            ":resources:tiled_maps/map.json",
            scaling=TILE_SCALING,
            layer_options=layer_options,
        )

        # Set the window background color to the same as the map if it has one
        if tile_map.background_color:
            self.window.background_color = Color.from_iterable(tile_map.background_color)

        # Use the tilemap's size to correctly set the camera's bounds.
        # Because how how shallow the map is we don't offset the bounds height
        self.camera_bounds = arcade.LRBT(
            self.window.width/2.0,
            tile_map.width * GRID_PIXEL_SIZE - self.window.width/2.0,
            self.window.height/2.0,
            tile_map.height * GRID_PIXEL_SIZE
        )

        # Our Scene Object
        # Initialize Scene with our TileMap, this will automatically add all layers
        # from the map as SpriteLists in the scene in the proper order.
        return arcade.Scene.from_tilemap(tile_map)

It’s tempting to explore just how load_tilemap works and such, but let’s focus on the camera bounds here:

        # Use the tilemap's size to correctly set the camera's bounds.
        # Because how how shallow the map is we don't offset the bounds height
        self.camera_bounds = arcade.LRBT(
            self.window.width/2.0,
            tile_map.width * GRID_PIXEL_SIZE - self.window.width/2.0,
            self.window.height/2.0,
            tile_map.height * GRID_PIXEL_SIZE

I think that LRBT creates a rectangle providing left right bottom top coordinates, but I’ll check that. Arcade has a very nice search ability and it comes back with, among other things:

arcade.LRBT(
    left: float | int, 
    right: float | int, 
    bottom: float | int, 
    top: float | int)→ Rect

Creates a new Rect from left, right, bottom, and top parameters.

So that’s just a rectangle. Its height is the full height of the window, which I believe means that it will not scroll vertically, and it is half the width of the window and it is all the way to the right side of the window, from width/2 out to the far side.

That’s all I want to take home from the scene definition, though of course before we’re any good at this platform we’ll want to understand scenes and tilemaps and what not.

Returning to the source, reset seems to make sense:

    def reset(self):
        """Reset the game to the initial state."""
        self.score = 0
        # Load a fresh scene to get the coins back
        self.scene = self.create_scene()

        # Move the player to start position
        self.player_sprite.position = (128, 128)
        # Add the player to the scene
        self.scene.add_sprite("Player", self.player_sprite)

If you were doing this, or you were here with me, we might be looking at different things in different order. I’m using my experience and intuition to spot chunks of code and decide quickly but tentatively, whether to explore them or not, keeping in mind that we’re here to learn a bit about the camera.

The on_draw has something that catches my eye, a couple of with statements that I don’t recall seeing before:

    def on_draw(self):
        """Render the screen."""

        # Clear the screen to the background color
        self.clear()

        # Draw the map with the sprite camera
        with self.camera_sprites.activate():
            # Draw our Scene
            # Note, if you a want pixelated look, add pixelated=True to the parameters
            self.scene.draw()

        # Draw the score with the gui camera
        with self.camera_gui.activate():
            # Draw our score on the screen. The camera keeps it in place.
            self.score_display.text = f"Score: {self.score}"
            self.score_display.draw()

So what is this activate? Something about drawing “through” the camera, I’m guessing. Note the comment on the score display “The camera keeps it in place”. At a guess, if we drew through the other camera, the score might slide around.

I decide to try that. Sure enough, if we draw the score with the sprites camera, it starts out there on the left but as the scene scrolls left, the score slides off screen.

So my tentative understanding is that whatever we draw with a camera activated is controlled by the scrolling (and doubtless zooming and maybe other things) associated with that camera.

We look further. I skip over the keystroke stuff, which is just moving the player by setting his x movement speed, usual sort of thing. Then we see center_camera_to_player. That’s going to be very interesting, but who calls it? Ah, it’s called at the end of on_update. I’ve learned that Arcade repeatedly calls on_update as well as on_draw. The idea is to move things on update and just draw them on draw, which probably provides a much smoother motion effect than if we were to compute and draw in the same method. Anyway, that’s what we’re supposed to.

So what does that center thing do?

    def center_camera_to_player(self):
        # Move the camera to center on the player
        self.camera_sprites.position = arcade.math.smerp_2d(
            self.camera_sprites.position,
            self.player_sprite.position,
            self.window.delta_time,
            FOLLOW_DECAY_CONST,
        )

        # Constrain the camera's position to the camera bounds.
        self.camera_sprites.view_data.position = arcade.camera.grips.constrain_xy(
            self.camera_sprites.view_data, self.camera_bounds
        )

A little searching helps me here. smerp_2d smoothly interpolates between camera_sprites.position and player_sprite.position. Details aside, my understanding is that this is going to smoothly move the camera position to the player position. I don’t know quite what camera_sprites.position is, and we see below that we set camera_sprites.view_data.position. It seems likely that the constrain_xy keeps us from scrolling off screen.

I decide to comment out the constraint lines to verify that theory. I expect to be able to get past the edges of the screen without those two lines. Sure enough, the game opens with the player centered on the screen.

player and platform offset up to right, player center screen

There’s another experiment we could do, which would be to just slam player_sprite.position into camera_sprites.position, which would make the scrolling happen immediately instead of with that smooth action.

And it does. You scarcely notice the difference but the smooth way is nicer.

I’m almost ready to try to explain what I have learned but I think I’d like to know more. Let’s add some text to the scene.

        # Draw the score with the gui camera
        with self.camera_gui.activate():
            # Draw our score on the screen. The camera keeps it in place.
            self.score_display.text = f"Pos:{self.player_sprite.position} {self.camera_sprites.position}"
            self.score_display.draw()

I take a few pictures, moving the player about, to see what values show up.

outside constraints, camera steady

just inside constraints, camera adjusted

up high

As a final experiment, I set the zoom to 0.5 and move to roughly the same position in the zoomed and normal view. As expected, the picture is smaller (zoomed out) and the coordinates we see are the same:

normal

normal view with player

zoomed out

zoomed out, same position same coordinates

The clipping isn’t right in the zoomed view, so it seems likely that we have something to learn about relating the constraints to the zoom factor.

Unfortunately, while there are lots of examples about the camera methods and properties, there isn’t much descriptive text, which leaves me guessing quite a bit at this point.

Enough for this session: I’m tired of cameras.

Summary

So here is what I think we have learned about the camera setting in the player part of the game:

  1. We can use more than one camera, allowing fixed or variable positioning for some items. Useful for things like score or GUI elements, I bet.
  2. The camera has bounds of half the width of the screen, and its full height.
  3. The camera.position, if unconstrained, would put the provided position at the center of the screen.
  4. (Tentative) The camera constraint, somehow, keeps the camera from sliding to undesirable positions. We have two points of information about that: setting the camera bounds y value to equal the window height kept it from scrolling vertically, and setting the x value to half the window size (and origin at bottom center of the window) allowed scrolling just exactly from the left to the right ends of the picture.

I’m not at all sure about that last item. I can sort of see a half-width full-height camera box inside the window frame, sliding left to right but unable to slide up or down.

More study and more experimentation is needed. I’ll see you next time!