Skip to content

Tutorial 1: Game Flow

Lio edited this page Jan 31, 2025 · 3 revisions

Introduction

Important

This tutorial starts at the workshop-tutorial-0 branch and the complete solution can be found at workshop-tutorial-1 branch.

This tutorial focuses on the implementation of the game flow and the Camera class.

Note

This tutorial will implement/update the following modules:

Game Flow

Before everything, we need to set up the flow of the game. The game flow contatins the setup, the main loop, and the cleanup of the game. We are going to implement these in game.main and train.main.

Gameplay Mode

Before doing anything, let's first import the pygame module.

import argparse
from pathlib import Path
from typing import List, Tuple

import pygame  # NEW

We are first going to implement the main_scene function, which is called from the main function and given the arguments args, which we will use to setup the window. Take a look at the args.resolution and args.fullscreen arguments.

Solution Code
def main_scene(args: argparse.Namespace):
    """
    Main scene for training the AI

    :param args: The arguments
    :return: None
    """

    #vvv NEW vvv#

    # Initialize pygame by calling `init`
    pygame.init()

    # Set the window title with `set_caption`
    pygame.display.set_caption("Simple RL Driver - Game Play")

    # Create a clock object to help control the frame rate
    clock = pygame.time.Clock()

    # Create a screen with `set_mode`
    #
    # `args.resolution` is a tuple[int, int] in the form of (width, height)
    #
    # `args.fullscreen` is a boolean indicating whether to run in fullscreen
    # mode
    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #^^^ NEW ^^^#

Then, create the main loop, where we will check the pygame.event and update the game. We will quit the game if there is a pygame.QUIT event or if the user pressed Ctrl + Q.

    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                _____ = _____
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    _____ = _____

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        # Update the display with `update`
        pygame.display.update()

        # Tick the clock to control the frame rate
        clock.tick(_____)

    #^^^ NEW ^^^#
Solution Code
    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                running = False
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    running = False

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        # Update the display with `update`
        pygame.display.update()

        # Tick the clock to control the frame rate
        clock.tick(60)

    #^^^ NEW ^^^#

Lastly, after the loop finishes which indicates the game has quitted, we are going to do a cleanup. We can call the pygame.quit function to quit the game.

Solution Code
        # ...

        # Tick the clock to control the frame rate
        clock.tick(60)

    #vvv NEW vvv#

    # Quit pygame
    pygame.quit()

    #^^^ NEW ^^^#

Now if we run python main.py game -t demo0, we should see a white screen that can be closed by pressing the close button or Ctrl + Q.

Training Mode

Training mode is similar to gameplay mode, but we are going to add a few features, specifically the skip frames and the frame rate limit so that when training we can skip unnecessary frames to speed up the process.

Same as before, let's first import the pygame module.

import argparse
import math
from pathlib import Path
from typing import List, Optional, Tuple

import pygame  # NEW

from engine.activations import activation_funcs
from engine.entity.ai_car import AICar

We are going to first setup the main_scene similar to that in the gameplay mode.

Solution Code
def main_scene(args: argparse.Namespace):
    """
    Main scene for training the AI

    :param args: The arguments
    :return: None
    """

    #vvv NEW vvv#

    # Initialize pygame by calling `init`
    pygame.init()

    # Set the window title with `set_caption`
    pygame.display.set_caption("Simple RL Driver - Training")

    # Create a clock object to help control the frame rate
    clock = pygame.time.Clock()

    # Create a screen with `set_mode`
    #
    # `args.resolution` is a tuple[int, int] in the form of (width, height)
    #
    # `args.fullscreen` is a boolean indicating whether to run in fullscreen
    # mode
    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #^^^ NEW ^^^#

Then, we are going to add the main loop, where it on top of the implementation of gameplay mode, we are also going to skip frames based on args.skip_frames and args.limit_fps arguments.

    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    skip_frame_counter = 0
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                _____ = _____
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    _____ = _____

        # Skip frames
        #
        # if counter is currently less than `args.skip_frames`, we increment
        # the counter and just skip this loop, otherwise we reset the counter
        _____ += 1
        if _____ < args.skip_frames:
            _____
        _____ = 0

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        # Update the display with `update`
        pygame.display.update()

        # Tick the clock to control the frame rate
        if args.limit_fps and args.skip_frames == 0:
            clock.tick(60)

    #^^^ NEW ^^^#
Solution Code
    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    skip_frame_counter = 0
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                running = False
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    running = False

        # Skip frames
        #
        # if counter is currently less than `args.skip_frames`, we increment
        # the counter and just skip this loop, otherwise we reset the counter
        skip_frame_counter += 1
        if skip_frame_counter < args.skip_frames:
            continue
        skip_frame_counter = 0

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        # Update the display with `update`
        pygame.display.update()

        # Tick the clock to control the frame rate
        if args.limit_fps and args.skip_frames == 0:
            clock.tick(60)

    #^^^ NEW ^^^#

Lastly, we are also going to cleanup the same way as the gamplay mode.

Solution Code
        # ...

        # Tick the clock to control the frame rate
        if args.limit_fps and args.skip_frames == 0:
            clock.tick(60)

    #vvv NEW vvv#

    # Quit pygame
    pygame.quit()

    #^^^ NEW ^^^#

Now if we run python main.py train -t demo0 -n demo, we should see the same white screen as in gameplay mode that can be closed by pressing the close button or Ctrl + Q.

Entity

We are now going to implement the Transformable and Camera.

Transformable

The transformable class will act as the base class for all entities that can be transformed, i.e. have a position and rotation. It will be used for the camera and the car. The implementation is very simple and straightforward.

We are first going to implement the __init__ where the Transformable.pos and the Transformable.rot fields will be initialized.

Solution Code
    def __init__(
        self,
        pos: npt.NDArray[np.float32] = vec(0, 0),
        rot: float = 0,
    ):
        """
        Initialize the Transformable object.

        :param pos: The position of the object
        :param rot: The rotation of the object
        """

        #vvv NEW vvv#

        self.pos = pos
        self.rot = rot

        #^^^ NEW ^^^#

Next, we are going to implement the Transformable.translate where the position will be moved by delta amount.

Solution Code
    def translate(self, delta: npt.NDArray[np.float32]):
        """
        Translate the object.

        :param delta: The translation vector
        :return: None
        """

        #vvv NEW vvv#

        self.pos += delta

        #^^^ NEW ^^^#

Note

We assume delta is always a 2D vector, i.e. npt.NDArray[np.float32] with shape (2,).

Then, let's implement the Transformable.rotate where the rotation will be rotated by rad amount. It is also good to wrap the rotation back into the $[0, 2\pi)$ range.

    def rotate(self, rad: float):
        """
        Rotate the object.

        :param rad: The rotation in radians
        :return: None
        """
        self.rot = _____
Solution Code
    def rotate(self, rad: float):
        """
        Rotate the object.

        :param rad: The rotation in radians
        :return: None
        """
        self.rot = (self.rot + rad) % (2 * math.pi)

Remember to import the math module so we can use the math.pi constant.

import math  # NEW

import numpy as np
import numpy.typing as npt

While the above 2 functions are sufficient for our use, it is often much more convenient to define a function that moves the position towards the position the object is facing. We are going to implement the Transformable.translate_forward function.

Solution Code
    def translate_forward(self, dist: float):
        """
        Translate the object forward.

        :param dist: The distance to translate
        :return: None
        """

        #vvv NEW vvv#

        self.translate(dir(self.rot) * dist)

        #^^^ NEW ^^^#

Also import the dir function.

import math

import numpy as np
import numpy.typing as npt

from engine.utils import dir, vec  # UPDATED

Camera

Now that we have the transformable class, we can implement the Camera class.

We are first going to implement the __init__ where we will initialize the Camera.follow and the Camera.screen fields.

Solution Code
    def __init__(
        self, screen: pygame.Surface, follow: Optional[Transformable] = None
    ):
        super().__init__()

        #vvv NEW vvv#

        self.follow = follow
        self.screen = screen

        #^^^ NEW ^^^#

Note

We need to use super().__init__() to also call the Transformable.__init__ method for initializing the base class.

Next, we are going to implement the Camera.update where the camera will follow the Camera.follow transformable. Furthermore, we want to allow it to also follow a new transformable if it is passed as the argument, since later when we are training cars, we want to be able to keep following the AI car that is the fastest.

Solution Code
    def update(self, dt: float, follow: Optional[Transformable] = None):
        """
        Update the camera.

        :param dt: The time delta
        :param follow: The new object to follow
        :return: None
        """

        #vvv NEW vvv#

        # If there is a new object to follow, update the follow field
        if follow:
            self.follow = follow

        # Then update the position and rotation of the camera to match the
        # object being followed
        self.pos = self.follow.pos
        self.rot = self.follow.rot

        #^^^ NEW ^^^#

Lastly, one of the most essential function of a camera is to let all other object to be displayed from the camera point of view, so we need to implement the Camera.get_coord.

The caller will pass in a pos argument which is a 2D vector, then the position should be transformed to the camera's point of view. We will first translate the object, then do a rotation, lastly we also need to center the object to the screen by translating it by the size of the camera divided by 2.

    def get_coord(
        self, pos: npt.NDArray[np.float32]
    ) -> npt.NDArray[np.float32]:
        """
        Get the coordinate from the camera's perspective.

        :param pos: The position to transform
        :return: The transformed coordinate
        """

        #vvv NEW vvv#

        # Get the position from the camera's perspective
        local_pos = _____ - _____

        # Rotate the position about the camera position
        rot_pos = np.dot(rot_mat(-_____), local_pos)

        # Since the screen's origin is at the top left while we want the camera
        # position to be at the center of the screen, we need to add the center
        # of the screen to the rotated position
        center = vec(*self.screen.get_rect().center)

        return center + rot_pos

        #^^^ NEW ^^^#
Solution Code
    def get_coord(
        self, pos: npt.NDArray[np.float32]
    ) -> npt.NDArray[np.float32]:
        """
        Get the coordinate from the camera's perspective.

        :param pos: The position to transform
        :return: The transformed coordinate
        """

        #vvv NEW vvv#

        # Get the position from the camera's perspective
        local_pos = pos - self.pos

        # Rotate the position about the camera position
        rot_pos = np.dot(rot_mat(-self.rot), local_pos)

        # Since the screen's origin is at the top left while we want the camera
        # position to be at the center of the screen, we need to add the center
        # of the screen to the rotated position
        center = vec(*self.screen.get_rect().center)

        return center + rot_pos

        #^^^ NEW ^^^#

Let's also import the rot_mat which was used to get a rotation matrix from the radians.

from typing import Optional

import numpy as np
import numpy.typing as npt
import pygame

from engine.entity.transformable import Transformable
from engine.utils import rot_mat, vec  # UPDATED

Add Entity to Gameplay Mode

Next, we are going to add some entities into the game.main We are going to add the Camera and the Track.

First, let's import everything. We are also going to import the PlayerCar, because we need something for the camera to follow, and player car which is not yet implemented but it is already able to be used.

import argparse
from pathlib import Path
from typing import List, Tuple

import pygame

#vvv NEW vvv#

from engine.entity.camera import Camera
from engine.entity.player_car import PlayerCar
from engine.entity.track import Track

#^^^ NEW ^^^

Next let's create the track and camera. We are going to use the Track.load and the Camera.__init__ functions to create them. We are going to load the args.track argument into the track.

Solution Code
    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Setup the track by loading it
    #
    # `args.track` is a string representing the name of the track to play in
    track = Track.load(args.track)

    # Create a camera object
    camera = Camera(screen, PlayerCar())

    #^^^ NEW ^^^#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032

    # ...

Then we also need to call the Camera.update and call the Track.draw function.

    # ...

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                running = False
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    running = False

        #vvv NEW vvv#

        # Update the camera
        camera.update(_____)

        #^^^ NEW ^^^#

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        #vvv NEW vvv#

        # Draw the track and the cars on the screen
        track.draw(_____, _____, 5)

        #^^^ NEW ^^^#

        # Update the display with `update`
        pygame.display.update()
Solution Code
    # ...

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                running = False
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    running = False

        #vvv NEW vvv#

        # Update the camera
        camera.update(fixed_dt)

        #^^^ NEW ^^^#

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        #vvv NEW vvv#

        # Draw the track and the cars on the screen
        track.draw(screen, camera, 5)

        #^^^ NEW ^^^#

        # Update the display with `update`
        pygame.display.update()

Note

We passed in the camera object to the Track.draw function.

It is where the Camera.get_coord is used to correctly display the track.

Now if we run python main.py game -t demo0, we should see the track.

Tip

We can try to move the camera to see the track being updated in real time with respect to the camera's perspective!

Let's modify the Camera.update function so that it uses pygame.key.get_pressed to move the camera.

    def update(self, dt: float, follow: Optional[Transformable] = None):
        """
        Update the camera.

        :param dt: The time delta
        :param follow: The new object to follow
        :return: None
        """

        #vvv UPDATED vvv#

        # # If there is a new object to follow, update the follow field
        # if follow:
        #     self.follow = follow

        # # Then update the position and rotation of the camera to match the
        # # object being followed
        # self.pos = self.follow.pos
        # self.rot = self.follow.rot

        if pygame.key.get_pressed()[pygame.K_w]:
            self.translate_forward(100 * dt)

        if pygame.key.get_pressed()[pygame.K_s]:
            self.translate_forward(-100 * dt)

        if pygame.key.get_pressed()[pygame.K_a]:
            self.rotate(1.0 * dt)

        if pygame.key.get_pressed()[pygame.K_d]:
            self.rotate(-1.0 * dt)

        #^^^ UPDATED ^^^#

Then, let's try it with python main.py game -t demo0.

Add Entity to Training Mode

Lastly, let's also add the camera and the track to the training mode. Since in training mode, there can be multiple tracks in args.tracks argument, so we can use random.choice to randomly choose a track to train on.

Let's first import everything.

import argparse
import math
import random  # NEW
from pathlib import Path
from typing import List, Optional, Tuple

import pygame

from engine.activations import activation_funcs
from engine.entity.ai_car import AICar

#vvv NEW vvv#

from engine.entity.camera import Camera
from engine.entity.player_car import PlayerCar
from engine.entity.track import Track

#^^^ NEW ^^^#

Then, let's create the track and camera the same way we did for the gameplay mode.

    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Setup the tracks by loading them
    #
    # `args.tracks` is a list of strings representing the names of the tracks
    tracks = [Track.load(t) for t in args.tracks]
    track = _____._____(tracks)

    # Create a camera object
    camera = Camera(screen)

    #^^^ NEW ^^^#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    skip_frame_counter = 0

    # ...
Solution Code
    # ...

    screen = pygame.display.set_mode(
        args.resolution,
        (pygame.FULLSCREEN if args.fullscreen else 0) | pygame.RESIZABLE,
    )

    #vvv NEW vvv#

    # Setup the tracks by loading them
    #
    # `args.tracks` is a list of strings representing the names of the tracks
    tracks = [Track.load(t) for t in args.tracks]
    track = random.choice(tracks)

    # Create a camera object
    camera = Camera(screen)

    #^^^ NEW ^^^#

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    skip_frame_counter = 0

    # ...

Note

We don't need to pass in the player car to the camera because we are going to keep the followed car updated in the Camera.update function instead.

There is other way such that in gameplay mode we also don't need to pass in anything such that the camera always stay in place.

However, during my development, I didn't take that into consideration, thus this is the end result. You can try to fix it on your own if you want!

Next, let's update the camera and draw the track.

    # ...

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    skip_frame_counter = 0
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                running = False
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    running = False

        #vvv NEW vvv#

        # Update the camera
        camera.update(_____, _____)

        #^^^ NEW ^^^#

        # Skip frames
        #
        # if counter is currently less than `args.skip_frames`, we increment
        # the counter and just skip this loop, otherwise we reset the counter
        skip_frame_counter += 1
        if skip_frame_counter < args.skip_frames:
            continue
        skip_frame_counter = 0

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        #vvv NEW vvv#

        # Draw the track and the cars on the screen
        track.draw(_____, _____, 5)

        #^^^ NEW ^^^#

        # Update the display with `update`
        pygame.display.update()

        # Tick the clock to control the frame rate
        if args.limit_fps and args.skip_frames == 0:
            clock.tick(60)

    # ...
Solution Code
    # ...

    # Main loop forever while `running` is True
    running = True
    fixed_dt = 0.032
    skip_frame_counter = 0
    while running:
        # Handle events from `pygame.event.get()`
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # If it is a quit event, we stop the loop
                running = False
            elif event.type == pygame.KEYDOWN:
                if (
                    pygame.key.get_mods() & pygame.KMOD_CTRL
                    and event.key == pygame.K_q
                ):
                    # If control + q is pressed, we quit the program
                    running = False

        #vvv NEW vvv#

        # Update the camera
        camera.update(fixed_dt, PlayerCar())

        #^^^ NEW ^^^#

        # Skip frames
        #
        # if counter is currently less than `args.skip_frames`, we increment
        # the counter and just skip this loop, otherwise we reset the counter
        skip_frame_counter += 1
        if skip_frame_counter < args.skip_frames:
            continue
        skip_frame_counter = 0

        # Clear the screen with white color by using `fill` method on the
        # screen object
        screen.fill(pygame.Color(255, 255, 255))

        #vvv NEW vvv#

        # Draw the track and the cars on the screen
        track.draw(screen, camera, 5)

        #^^^ NEW ^^^#

        # Update the display with `update`
        pygame.display.update()

        # Tick the clock to control the frame rate
        if args.limit_fps and args.skip_frames == 0:
            clock.tick(60)

    # ...

Now if we run python main.py train -t demo0 -n demo, we should see the track.

Tip

We can do the same thing that we did in gameplay mode to try to control the camera with keyboard and see if the track updates correctly with respect to the camera's perspective.

Clone this wiki locally