# 1D Chomper Tabletop Arcade Game

## Overview

https://www.youtube.com/watch?v=C8OBw5CkFmY

This project is a `displayio` based CircuitPython port of Paku Paku, a 1D Pacman-style web game found here: [https://abagames.github.io/crisp-game-lib-11-games/?pakupaku](https://abagames.github.io/crisp-game-lib-11-games/?pakupaku).

![gaming_inside_v0.png](https://cdn-learn.adafruit.com/assets/assets/000/127/195/medium640/gaming_inside_v0.png?1706055250)

It's designed to run on the [Adafruit Qualia ESP32-S3 for TTL RGB-666 Displays](https://www.adafruit.com/product/5800) with a display that is at least 960px wide, but could be adapted to different display sizes or driver boards with modifications.

The input from the player comes from a single irresistibly press-able big red button. When you press the button, the chomper character turns around and starts moving in the opposite direction. You'll need to press it frequently to stay away from the dreaded ghost. However, when you chomp a big circular pellet, the ghost becomes vulnerable to being eaten and starts running away from you to try to avoid becoming dinner.

Everything mounts nicely into a recycled cardboard box. If you'd like to go the extra mile, you can fancy it out with decals, glue and paper, markers, or whatever other craft supplies you've got around.

## Parts
Featured
### Adafruit Qualia ESP32-S3 for TTL RGB-666 Displays

[Adafruit Qualia ESP32-S3 for TTL RGB-666 Displays](https://www.adafruit.com/product/5800)
There's a few things everyone loves: ice cream, kittens, and honkin' large TFT screens. We're no strangers to small TFT's - [from our itsy 1.14" color display](https://www.adafruit.com/search?q=1.14+tft) that graces many-a-TFT-Feather to <a...></a...>

In Stock
[Buy Now](https://www.adafruit.com/product/5800)
[Related Guides to the Product](https://learn.adafruit.com/products/5800/guides)
![Overhead shot of TFT driver board connected to a round TFT display, which says, "Hello world!"](https://cdn-shop.adafruit.com/640x480/5800-06.jpg)

Featured
### Rectangle Bar RGB TTL TFT Display - 3.7" 240x960

[Rectangle Bar RGB TTL TFT Display - 3.7" 240x960](https://www.adafruit.com/product/5799)
This is a screen for advanced hackers who like the look of a loooooooong rectangular bar TFT screen with tons of pixels. The 3.7" rectangular&nbsp;display has 240x960 18-bit full-color pixels and is an&nbsp; **IPS** &nbsp;display, so the color looks great up to 80 degrees...

In Stock
[Buy Now](https://www.adafruit.com/product/5799)
[Related Guides to the Product](https://learn.adafruit.com/products/5799/guides)
![3.7" TFT 240x960 RGB](https://cdn-shop.adafruit.com/640x480/5799-00.jpg)

Or

Featured
### Rectangle Bar RGB TTL TFT Display - 4.58" 320x960 No Touchscreen

[Rectangle Bar RGB TTL TFT Display - 4.58" 320x960 No Touchscreen](https://www.adafruit.com/product/5805)
This is a screen for advanced hackers who like the look of a loooooooong rectangular bar TFT screen with tons of pixels. The 4.58" rectangular&nbsp;display has 320x960 16-bit full-color pixels and is an&nbsp; **IPS** &nbsp;display, so the color looks great up to 80 degrees...

In Stock
[Buy Now](https://www.adafruit.com/product/5805)
[Related Guides to the Product](https://learn.adafruit.com/products/5805/guides)
![Angled shot of 4.6" TFT display.](https://cdn-shop.adafruit.com/640x480/5805-00.jpg)

Featured
### Arcade Button - 30mm Translucent Red

[Arcade Button - 30mm Translucent Red](https://www.adafruit.com/product/473)
A button is a button, and a switch is a switch, but these translucent arcade buttons are in a class of their own. They're the same size as common arcade controls (often referred to as 30mm diameter) but have some nice things going for them that justify the extra dollar.  
  
First,...

In Stock
[Buy Now](https://www.adafruit.com/product/473)
[Related Guides to the Product](https://learn.adafruit.com/products/473/guides)
![Angled shot of a translucent red round 30mm arcade button. ](https://cdn-shop.adafruit.com/640x480/473-00.jpg)

Featured
### JST PH 2mm 3-pin Plug to Color Coded Alligator Clips Cable

[JST PH 2mm 3-pin Plug to Color Coded Alligator Clips Cable](https://www.adafruit.com/product/4030)
This cable will let you turn a JST PH 3-pin cable port into 3 individual wires with grippy mini alligator clips. We're carrying these to match up with any of our boards or breakouts with 3-pin 'STEMMA' connectors on them such as the&nbsp;<a...></a...>

In Stock
[Buy Now](https://www.adafruit.com/product/4030)
[Related Guides to the Product](https://learn.adafruit.com/products/4030/guides)
![Angled shot of JST PH 3-pin Plug to Color Coded Alligator Clips Cable.](https://cdn-shop.adafruit.com/640x480/4030-04.jpg)

Featured
### USB Type A to Type C Cable - approx 1 meter / 3 ft long

[USB Type A to Type C Cable - approx 1 meter / 3 ft long](https://www.adafruit.com/product/4474)
As technology changes and adapts, so does Adafruit. This&nbsp;&nbsp; **USB Type A to Type C** cable will help you with the transition to USB C, even if you're still totin' around a USB Type A hub, computer or laptop.

USB C is the latest industry-standard connector for...

In Stock
[Buy Now](https://www.adafruit.com/product/4474)
[Related Guides to the Product](https://learn.adafruit.com/products/4474/guides)
![Angled shot of a coiled black, USB-C to USB-A cable.](https://cdn-shop.adafruit.com/640x480/4474-02.jpg)

# 1D Chomper Tabletop Arcade Game

## Project Setup

Are you new to using CircuitPython? No worries,&nbsp;[there is a full getting-started guide here](https://learn.adafruit.com/welcome-to-circuitpython "Welcome to CircuitPython").

Plug the Qualia ESP32-S3 into your computer with a known good USB cable (not a charge-only cable). The Qualia ESP32-S3 will appear to your computer in File Explorer or Finder (depending on your operating system) as a flash drive named&nbsp; **CIRCUITPY**. If the drive does not appear, you can&nbsp;[install CircuitPython](https://learn.adafruit.com/welcome-to-circuitpython/installing-circuitpython)&nbsp;on your Qualia ESP32-S3 and then return here.

Download the project files with the Download Project Bundle button below.&nbsp;Unzip the file and copy/paste the&nbsp; **code.py** &nbsp;and other project files to your&nbsp; **CIRCUITPY** &nbsp;drive using File Explorer or Finder (depending on your operating system).

## Drive Structure

After copying the files, your drive should look like the listing below. It can contain other files as well, but must contain these at a minimum:

![code.py,](https://github.com/adafruit/Adafruit_Learning_System_Guides/blob/folder-images/Qualia_Qualia_S3_1D_Chomper_Game.png?raw=true )

## Code

The **code.py** file for the project is shown below:

https://github.com/adafruit/Adafruit_Learning_System_Guides/blob/main/Qualia/Qualia_S3_1D_Chomper_Game/code.py

# 1D Chomper Tabletop Arcade Game

## TileGrid Animations

## Prerequisite: Displayio.TileGrid

All of the graphics in this game are based on the `displayio` core CircuitPython API which is largely centered around the `TileGrid` class. In our game, the map world is a `TileGrid`, the Player and Ghost are also their own individual 1x1 sized TileGrids. As such, it will be easier to understand how the code within the game and its helper classes work if you've already read through the basics of [TileGrids over in the main Displayio Learn Guide](https://learn.adafruit.com/circuitpython-display-support-using-displayio/tilegrid-and-group). It's not a super-long read, so if you haven't already, head there and give it a read over, then come back to here.

## Entering the 4th Dimension

While our game map is only a single dimension, the animations we want to use within it are most dependent on the 4th dimension: Time. To achieve animations we simply show a frame of our animation, then wait a little bit of time and show the next frame, repeating the process until there are no more frames to show, then start over at the beginning. I like to think of it like a flip book made with a stack of paper with slightly different drawings on each page. As the pages are flipped our eyes and brains work together to create the visual appearance of relatively smooth motion out of the different frames visible on each page.

&nbsp;

![](https://cdn-learn.adafruit.com/assets/assets/000/127/216/medium800thumb/gaming_chomper_animation_1.jpg?1706203031)

The animations used in 1D Chomper are very basic, just 2 frames each. However the same technique can be used on animations with more frames just as easily.

## Changing Image Sprites within a TileGrid

In this game the Player and the Ghost are the two things that have animations. The player chomps it's mouth up and down, the Ghost flickers between blue and white to warn the user when the effects of the ghost subduing large pellet is about to wear off.

Once a TileGrid is initialized with a sprite sheet, changing the image that is showing is as simple as modifying the index at an x,y location within the TileGrid. Our Player and Ghost TileGrid's are 1x1 sized so the only x,y coordinate pair they contain is `0,0` the indexes represent the index within the spritesheet. Illustration shows the indexes labeled next to an enlarged copy of the spritesheet:

![](https://cdn-learn.adafruit.com/assets/assets/000/127/212/medium800/gaming_tile_indexes.png?1706134947)

## Player and Ghost Animations

For the player chomping animation, the sprites we use depends on the direction the Player is moving. When moving right we cycle between sprites 0 and 5. When the moving left instead we cycle between sprites 2 and 7.

The Ghost animation blinks between the blue and white versions of the ghost which are sprites 18 and 19.

## Example Code

This code shows a minimal self-contained example of creating and animating sprites with TileGrid:

```python
"""
Tilegrid Animation Example
"""
import time
from adafruit_qualia import Qualia
from adafruit_qualia.graphics import Displays
import displayio
import adafruit_imageload

ANIMATION_DELAY = 0.15  # seconds
last_animation_time = 0

# Create the Qualia object for the 240x960 display PID 5799
qualia = Qualia(Displays.BAR240X960)

# OR create the Qualia object for the 320x960 display PID 5805
# qualia = Qualia(Displays.BAR320X960)

display = qualia.display

# Landscape orientation, mounted with ribbon cable coming off the left side of display.
display.rotation = 270

# scaled group to put our tilegrid in for showing on the display
main_group = displayio.Group(scale=5)
main_group.x = 100
main_group.y = 50

spritesheet_bmp, spritesheet_palette = adafruit_imageload.load("1d_chomper_spritesheet.bmp")
spritesheet_palette.make_transparent(0)
animation_example_tg = displayio.TileGrid(bitmap=spritesheet_bmp, pixel_shader=spritesheet_palette,
                                          width=1, height=1, tile_width=15, tile_height=15, default_tile=0)
current_sprite = 0
main_group.append(animation_example_tg)

display.root_group = main_group

# main loop
while True:
    now = time.monotonic()

    # if it's been long enough since previous frame
    if now > last_animation_time + ANIMATION_DELAY:
        last_animation_time = now
        # alternate sprites
        if current_sprite == 0:
            current_sprite = 5
        else:
            current_sprite = 0

        # update the sprite index in the tilegrid
        animation_example_tg[0, 0] = current_sprite
```

In the 1D Chomper game code this animation functionality is encapsulated within helper classes described on the next page.

# 1D Chomper Tabletop Arcade Game

## Helper Classes

The project is broken into 4 helper classes which are utilized by the **code.py** script rather than having all of the game logic exist directly inside of **code.py**. This helps make the code more understandable and allows for more easy re-use if you'd like to make your own version of the game with different features of functionality.

### Entity

The Entity class is a super class that gets extended by both the Player and Ghost classes. You can think of Entity like a TileGrid that comes along with a few extra properties and functions that make it convenient for use in a game. In addition to having a TileGrid, it tracks the direction it's moving in, and holds a list of sprites with a `next_sprite()` function for iterating through them to create animations. The other helpful one is the `is_colliding()` function which allows you to test whether a given instance of Entity is colliding with a different instance of Entity. We'll use that to determine when the Player and Ghost touch each other.

```python
class Entity:
    """
    Entity helper class holds a TileGrid and keeps track of direction and a set of current sprites.
    Includes functions for iterating through the sprites to make animations and
    for checking on collissions with other Entities. 
    """

    # Direction Constants
    DIRECTION_UP = 0
    DIRECTION_RIGHT = 1
    DIRECTION_LEFT = 2
    DIRECTION_DOWN = 3
    DIRECTION_NONE = 4

    def __init__(
            self,
            bitmap: displayio.Bitmap,
            pixel_shader: displayio.Palette,
            width: int = 1,
            height: int = 1,
            tile_width: int = 15,
            tile_height: int = 15,
            default_tile: int = 0
    ):
        # default direction is NONE subclasses can override it
        self._direction = self.DIRECTION_NONE

        # initialize the tilegrid for showing sprite(s)
        self._tilegrid = displayio.TileGrid(
            bitmap,
            pixel_shader=pixel_shader,
            width=width,
            height=height,
            tile_width=tile_width,
            tile_height=tile_height,
            default_tile=default_tile,
        )

        # keep property variables for size
        self.width = tile_width * width
        self.height = tile_height * height

        # sprites for the animation (None by default, subclasses or game activity can change them)
        self.current_sprites = []
        self.sprite_index = 0

    def next_sprite(self):
        """
        Advance to the next sprite in the sequence for animation.
        :return: None
        """
        self.sprite_index += 1
        if self.sprite_index >= len(self.current_sprites):
            self.sprite_index = 0
        self._tilegrid[0, 0] = self.current_sprites[self.sprite_index]

    @property
    def x(self):
        """
        X pixel position of the tilegrid on the display
        :return: int x location
        """
        return self._tilegrid.x

    @property
    def y(self):
        """
        Y pixel position of the tilegrid on the display
        :return: int y location
        """
        return self._tilegrid.y

    @x.setter
    def x(self, new_x):
        self._tilegrid.x = new_x

    @y.setter
    def y(self, new_y):
        # print("y setter")
        self._tilegrid.y = new_y

    @property
    def tilegrid(self):
        """
        The tilegrid used to show sprite(s)
        :return: TileGrid
        """
        return self._tilegrid

    def is_colliding(self, _other_entity):
        """
        Check if this entity is colliding with another entity.
        :param _other_entity: another entity to check ourself against
        :return: True if this instance is colidding with the _other_entity
        """
        _colliding_x = False
        _colliding_y = False
        length_x = abs(self.x - _other_entity.x)
        half_width_self = self.width / 2
        half_width_other = _other_entity.width / 2

        gap_between = length_x - half_width_self - half_width_other
        if (gap_between > 0):
            pass
        elif (gap_between == 0):
            pass
        elif (gap_between < 0):
            _colliding_x = True

        length_y = abs(self.y - _other_entity.y)
        half_height_self = self.height / 2
        half_height_other = _other_entity.height / 2

        gap_between = length_y - half_height_self - half_height_other
        if (gap_between > 0):
            pass
        elif (gap_between == 0):
            pass
        elif (gap_between < 0):
            _colliding_y = True

        # print("colliding x: {} - y: {}".format(_colliding_x, _colliding_y))

        return _colliding_x and _colliding_y

    def game_tick(self, game_obj):
        """
        Subclasses override this to add behavior to the entity.
        :return:
        """
```

### Player

The Player class extends Entity, so it gets all of the behavior, properties and functions from Entity and adds a few of it's own on top of those. Player handles changing to the appropriate sprites when the player changes directions. It also has a `game_tick()` which will get called on each iteration of the main game loop. Inside of `game_tick()` Player will handle moving in the current direction by one step. It will also call the `next_sprite()` function inherited from Entity when enough time has elapsed since the previous animation frame was drawn. Lastly, there is logic to teleport the player from one edge to the other once they reach the far left or right of the display.

```python
class Player(Entity):
    """
    Player helper class manages behavior and graphics for the player character.
    """

    ANIMATION_DELAY = 200  # ms between chomp animation
    MOVE_DELAY = 6  # ms between movement steps

    RIGHT_SPRITES = (0, 5)
    LEFT_SPRITES = (2, 7)
    UP_SPRITES = (1, 6)
    DOWN_SPRITES = (3, 8)

    def __init__(self, bitmap: displayio.Bitmap, pixel_shader: displayio.Palette, *args, **kwargs):
        super().__init__(bitmap, pixel_shader, *args, **kwargs)
        # now = ticks_ms()
        now = time.monotonic_ns() // 1000000

        # initialize the movement and animation timestamps
        self.last_animation_time = now
        self.last_move_time = now

    def game_tick(self, game_obj):
        """
        Main behavior function for Player movement
        :param game_obj: ChomperGame object for accessing variables and changing things
        :return: True if the tick results in the display needing to be refreshed, otherwise False
        """

        # now = ticks_ms()
        now = time.monotonic_ns() // 1000000

        # if it's been long enough since the last movement
        if now > self.last_move_time + Player.MOVE_DELAY:
            # update the last moved timestamp
            self.last_move_time = now

            # if it's been long enough since the last aninmation sprite frame
            if self.last_animation_time + Player.ANIMATION_DELAY < now:
                # change sprites
                self.next_sprite()
                # update animation timestmp
                self.last_animation_time = now

            if self.direction == Entity.DIRECTION_RIGHT:
                # we're moving right, x location increases
                self.x += 1

            if self.direction == Entity.DIRECTION_LEFT:
                # we're moving left, x location decreases
                self.x -= 1

            # if we made it to an edge, teleport to the other side
            if self.x < 0 - self.width:
                self.x = game_obj.display_size[0] // 3
            elif self.x > game_obj.display_size[0] // 3:
                self.x = 0
            return True
        return False

    @property
    def direction(self):
        """
        whether the player is facing left or right
        :return: either Entity.DIRECTION_LEFT or Entity.DIRECTION_RIGHT
        """
        return self._direction

    @direction.setter
    def direction(self, new_direction):
        """
        Update the direction of the player
        :param new_direction: Entity.DIRECTION_LEFT or Entity.DIRECTION_Right
        :return: None
        """

        # update the sprites for chomp animation based on new direction
        if new_direction == self.DIRECTION_RIGHT:
            self.current_sprites = self.RIGHT_SPRITES
        if new_direction == self.DIRECTION_LEFT:
            self.current_sprites = self.LEFT_SPRITES

        # update direction variable
        self._direction = new_direction
```

### Ghost

The Ghost class also extends Entity. It's structured the same as Player with a `game_tick()` function called each iteration of the main loop. The logic inside of Ghost's `game_tick()` function enables the following behaviors:

- Move the Ghost one step in the current direction.
- Keep track of whether the Ghost is currently edible for the Player or not.
- Turn to face the player so we chase them.
- If we are edible due to the Player eating a large pellet, then face away from them instead so that we run away.
- When the time comes, start the blinking animation to warn the player that the vulnerability window is nearly closed.
- Calling `next_sprite()` to advance the animation frames at the appropriate intervals.
- Change the Ghost back to the normal, more dangerous, mode when the vulnerability window runs out.
- When the Ghost has been eaten by the player change the sprite to its eyes only and fly them off of the screen

```python
class Ghost(Entity):
    """
    Ghost helper class manages behavior and graphics for the enemy ghost.
    """

    # Time based variables
    ANIMATION_DELAY = 100  # ms
    MOVE_DELAY = 8  # ms
    EDIBLE_DURATION = 2000  # ms
    EDIBLE_WARNING_DURATION = 1000  # ms

    # state machine state variables:

    # normal ghost eats pacman and results in gameover
    STATE_NORMAL = 0

    # edible ghost can be eaten by pacman for points
    STATE_EDIBLE = 1

    # edible warning state blinks the ghost to warn player
    STATE_EDIBLE_WARNING = 2

    # after the ghost is eaten by pacman its eyes fly off the screen
    STATE_DESPAWN_FLYOFF = 3

    def __init__(self, bitmap: displayio.Bitmap, pixel_shader: displayio.Palette, *args, **kwargs):
        """
        :param bitmap: sprite Bitmap object
        :param pixel_shader: sprite palette
        :param args: any other positional arguments to pass to parent class
        :param kwargs: any other keyword arguments to pass to parent class
        """
        super().__init__(bitmap, pixel_shader, *args, **kwargs)

        # last time we moved
        self.last_move_time = time.monotonic()

        # whether we are edible
        self._edible = False

        # when we turned edible
        self.turned_edible_at = None

        # when we need to start blinking
        self.edible_warn_time = None

        # last time we blinked
        self.last_animation_time = 0

        # state machine current state
        self.current_state = Ghost.STATE_NORMAL

    @property
    def edible(self):
        """
        Whether the ghost is edible
        :return: True if ghost is edible otherwise False
        """
        return self._edible

    @edible.setter
    def edible(self, new_val):
        # if ghost is becoming edible
        if new_val:
            # set the sprite to blue ghost
            self.tilegrid[0, 0] = 18

            # current timestamp
            # now = ticks_ms()
            now = time.monotonic_ns() // 1000000

            # save timestampe we turned edible
            self.turned_edible_at = now

            # set the time we need to start blinking
            self.edible_warn_time = now + (Ghost.EDIBLE_DURATION - Ghost.EDIBLE_WARNING_DURATION)

        # if ghost is becoming non-edible
        else:
            # set the sprite to normal red ghost
            self.tilegrid[0, 0] = 12

            # clear out the turned edible at timestamp
            self.turned_edible_at = None

        # update the _edible boolean
        self._edible = new_val

    def game_tick(self, game_obj):
        """
        Main behavior action function for the Ghost. Gets called by ChomperGame.game_tick().
        Checks the current state and acts accordingly.
        :param game_obj: The ChomperGame object to access variables and update things
        :return: True if the action resulted in display needing to be refreshed, otherwise False
        """
        # now = ticks_ms()
        now = time.monotonic_ns() // 1000000
        need_refresh = False

        # Top level if statements to check current state within the state machine

        # process movement for normal, edible, and blinking states
        if self.current_state in (Ghost.STATE_NORMAL, Ghost.STATE_EDIBLE, Ghost.STATE_EDIBLE_WARNING):

            # if it's been long enough since the last movement
            if now > self.last_move_time + Ghost.MOVE_DELAY:
                # update the last moved timestamp
                self.last_move_time = now

                # Check direction and prevent Ghost from moving thru the edges
                if self.x < (game_obj.display_size[0] // 3) - 16 and self.direction == Entity.DIRECTION_RIGHT:
                    # we're moving right, increase x position
                    self.x += 1
                if self.x > 0 and self.direction == Entity.DIRECTION_LEFT:
                    # we're moving left, decrease x position
                    self.x -= 1

                need_refresh = True

        # normal state ghost faces toward the player to chase them
        if self.current_state == Ghost.STATE_NORMAL:
            if self.x < game_obj.player_entity.x:
                self.direction = Entity.DIRECTION_RIGHT
            elif self.x > game_obj.player_entity.x:
                self.direction = Entity.DIRECTION_LEFT

        # edible and blinking faces away from the player to run away from them
        elif self.current_state in (Ghost.STATE_EDIBLE, Ghost.STATE_EDIBLE_WARNING):
            if self.x < game_obj.player_entity.x:
                self.direction = Entity.DIRECTION_LEFT
            else:
                self.direction = Entity.DIRECTION_RIGHT

        # edible state needs to determine when to blink
        if self.current_state == Ghost.STATE_EDIBLE:
            # if it's time to start warning blinks
            if self.edible_warn_time and now > self.edible_warn_time:
                # set sprites to blue and white ghosts for blinking
                self.current_sprites = (19, 18)
                # update the state machine current state variable
                self.current_state = Ghost.STATE_EDIBLE_WARNING

        # blinking state needs to animate sprites and determine
        # when to go back to normal state
        if self.current_state == Ghost.STATE_EDIBLE_WARNING:
            # if it's time to change sprites for the animation
            if now > self.last_animation_time + Ghost.ANIMATION_DELAY:
                # change to next sprite in the animation
                self.next_sprite()

            # if it's time to go back to normal
            if self.turned_edible_at and now > self.turned_edible_at + Ghost.EDIBLE_DURATION:
                # update the sprite to normal red ghost
                self.tilegrid[0, 0] = 12

                # set edible property and state machine variable
                self.edible = False
                self.current_state = Ghost.STATE_NORMAL

        # despawn flyoff state needs to fly the eyes off the screen
        # and determine when to respawn the new ghost
        if self.current_state == Ghost.STATE_DESPAWN_FLYOFF:
            # if it's time to move the eyes
            if now > self.last_move_time + Ghost.MOVE_DELAY:
                self.last_move_time = now

                # check direction and move. Stopping at the edge
                if self.x < game_obj.display_size[0] // 3 and self.direction == Entity.DIRECTION_RIGHT:
                    self.x += 2
                if self.x > 0 and self.direction == Entity.DIRECTION_LEFT:
                    self.x -= 2

                # if we hit the edge, it's time to respawn a new Ghost
                if self.x <= 0 or self.x >= game_obj.display_size[0] // 3:
                    # set the state to normal
                    self.current_state = Ghost.STATE_NORMAL

                    # clear out the warning timestamp
                    self.edible_warn_time = None

                    # set the sprite to normal red ghost
                    self.tilegrid[0, 0] = 12

                    # set edible property false for new ghost
                    self.edible = False

                    # spawn the new ghost at the edge furthest away from the player
                    spawn_left = game_obj.player_entity.x >= (game_obj.display_size[0] // 2) // 3
                    if spawn_left:
                        self.x = 0
                    else:
                        self.x = 21 * 15

        return need_refresh
```

### ChomperGame

If Player and Ghost are the puppets, then ChomperGame is the puppeteer. It makes use of the other helper classes and manages the entire game. ChomperGame has a `game_tick()` function too, within which it calls `game_tick()` on the Player and Ghost respectively as well as handling a few other aspects of the game.

ChomperGame extends displayio.Group, so it's ready to be set as the `root_group` of a display or added to another Group as needed. Because it's a Group it also supports the `scale` property, by default this game uses a scale of 3 so that the sprites can be relatively small on disk and in RAM, but then be drawn visibly larger on the display.

The ChomperGame class is responsible for the following:

- Initialize the background and map TileGrids, as well as the Player and Ghost objects.
- `spawn_pellets()` function which fills the board with pellets. One big pellet at random and the rest small.
- Initialize the text Labels for showing the score, multiplier, high score, and game over messages.
- Keep track of score and multiplier values, and update their text in the UI.
- Read the high score from NVM, show it in the UI and update when the player beats it.
- When the player moves onto a tile with a pellet, change the tile to empty and add to the score.
- When the player eats a big pellet, set the Ghost to edible.
- When all of the pellets in play have been eaten spawn a new batch of them and increase the multiplier.
- Check for collisions between the Player and Ghost and act according to whether the Ghost is currently edible.
- Show the Game Over text and pause all other activity when the Player touches the non-edible Ghost.

```python
class ChomperGame(displayio.Group):
    """
    Game helper class manages behavior and graphics for the entire game.
    Extends displayio.Group so it can be shown on a display directly, or
    added to another Group to be shown. Also supports Group scaling which
    is used by default at scale=3.
    """

    EMPTY_MAP_TILE = 17
    PLAYER_TILE = 0

    EASY_DIFFICULTY_THRESHOLD = 5
    MEDIUM_DIFFICULTY_THRESHOLD = 10
    HARD_DIFFICULTY_THRESHOLD = 20
    VERYHARD_DIFFICULTY_THRESHOLD = 40
    INSANE_DIFFICULTY_THRESHOLD = 80

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

        # variables for score and multiplier
        self.score = 0
        self.multiplier = 1

        # display_size tuple referenced to know where the edges of the map are
        self.display_size = display_size

        # boolean for game over state
        self.game_over = False

        # Load the sprite sheet (bitmap)
        self._sprite_sheet, self._palette = adafruit_imageload.load("1d_chomper_spritesheet.bmp")

        # "green screen" transparent color
        self._palette.make_transparent(0)

        # sprites are 15px square
        self._tile_width = 15
        self._tile_height = 15

        # Create the background TileGrid. It will be flat black
        # with everything else drawn on top.
        self._background_tilegrid = displayio.TileGrid(self._sprite_sheet, pixel_shader=self._palette,
                                                       width=21,
                                                       height=3,
                                                       tile_width=self._tile_width,
                                                       tile_height=self._tile_height,
                                                       default_tile=ChomperGame.EMPTY_MAP_TILE)

        # Create the map TileGrid. It will contain the blue line walls, pellets, and empty tiles
        self._map_tilegrid = displayio.TileGrid(self._sprite_sheet, pixel_shader=self._palette,
                                                width=21,
                                                height=3,
                                                tile_width=self._tile_width,
                                                tile_height=self._tile_height,
                                                default_tile=ChomperGame.EMPTY_MAP_TILE)

        self._map_tilegrid.x = 2
        self._map_tilegrid.y = 12

        # spawn the top and bottom wall tiles
        for i in range(21):
            self._map_tilegrid[i, 0] = 13
            self._map_tilegrid[i, 2] = 13

        # create the pellets and keep a variable to count them
        self.spawn_pellets()
        self.pellets_in_play = 21

        # create player object
        self._player_entity = Player(
            self._sprite_sheet, pixel_shader=self._palette,
            width=1,
            height=1,
            tile_width=self._tile_width,
            tile_height=self._tile_height,
            default_tile=ChomperGame.PLAYER_TILE
        )
        self._player_entity.y = 12 + 15

        # add tilegrids to self Group instance
        self.append(self._background_tilegrid)
        self.append(self._map_tilegrid)
        self.append(self._player_entity.tilegrid)

        # create the ghost object
        ghost = Ghost(self._sprite_sheet, self._palette, default_tile=12)
        ghost.direction = Entity.DIRECTION_LEFT
        ghost.x = 21 * 15
        ghost.y = 12 + 15
        self.append(ghost.tilegrid)
        self.ghost = ghost

        # create labels for score, multiplier, highscore and gameover

        # Score label, top left
        self.score_lbl = Label(terminalio.FONT, text="Score:")
        self.score_lbl.anchor_point = (0, 0)
        self.score_lbl.anchored_position = (1, 1)
        self.append(self.score_lbl)

        # Score Value, top left
        self.score_value_lbl = Label(terminalio.FONT, text="0")
        self.score_value_lbl.anchor_point = (0, 0)
        self.score_value_lbl.anchored_position = (40, 1)
        self.append(self.score_value_lbl)

        # Multiplier label, top middle
        self.multiplier_lbl = Label(terminalio.FONT, text="x")
        self.multiplier_lbl.anchor_point = (0, 0)
        self.multiplier_lbl.anchored_position = (110, 1)
        self.append(self.multiplier_lbl)

        # Multiplier value, top middle
        self.multiplier_value_lbl = Label(terminalio.FONT, text="0")
        self.multiplier_value_lbl.anchor_point = (0, 0)
        self.multiplier_value_lbl.anchored_position = (118, 1)
        self.append(self.multiplier_value_lbl)

        # Gameover, bottom middle
        self.gameover_lbl = Label(terminalio.FONT, text="Game Over")
        self.gameover_lbl.anchor_point = (0.5, 1.0)
        self.gameover_lbl.anchored_position = ((self.display_size[0] // 2) // 3, self.display_size[1] // 3 - 5)

        # Highscore, top right.
        # Doesn't need separate label and value because it doesn't update during the game loop
        self.highscore_value_lbl = Label(terminalio.FONT, text="HI: 100")
        self.highscore_value_lbl.anchor_point = (1.0, 0)
        self.highscore_value_lbl.anchored_position = ((self.display_size[0] // 3) - 4, 1)
        self.append(self.highscore_value_lbl)
        self.highscore = 0

        try:
            # read data from NVM storage
            read_data = nvm_helper.read_data()

            # if we found data check if it's a highscore value
            if type(read_data) == list and read_data[0] == "1dc_hs":
                # it is a highscore so populate the label with its value
                self.highscore_value_lbl.text = f"HI: {read_data[1]}"
                self.highscore = read_data[1]

        except EOFError:
            # No saved Highscore
            pass

    def update_score(self):
        """
        Update the score and multiplier value labels with teir current values
        :return: None
        """
        self.score_value_lbl.text = str(self.score)
        self.multiplier_value_lbl.text = str(self.multiplier)

    def change_map_tile(self, tile_coords, new_tile_index):
        """
        Change a tile on the map to a different type
        :param tile_coords: Tuple (x, y) of coordinates in the map tilegrid to change
        :param new_tile_index: int new tile index value to set at the specified location
        :return: None
        """
        self._map_tilegrid[tile_coords[0], tile_coords[1]] = new_tile_index

    @property
    def player_entity(self):
        """
        The player entity helper object
        :return: Player object
        """
        return self._player_entity

    def spawn_pellets(self):
        """
        Fill the center row of the map with pellets. One is randomly
        chosen to be a big pellet instead of small.
        :return: None
        """
        # random choice for big pellet
        big_pellet_loc = random.randint(0, 20)

        # loop over tiles
        for i in range(21):
            if big_pellet_loc == i:
                # set the big pellet in its chosen location
                self._map_tilegrid[i, 1] = 9
            else:
                # set small pellets everywhere else
                self._map_tilegrid[i, 1] = 4

        # reset the pellet counter variable
        self.pellets_in_play = 21

    def game_tick(self):
        """
        Main "heartbeat" function of the game. This will get called over and over from the
        main code.py file. game_tick() is responsible for carrying out all game logic and
        updating visible components.
        :return: None
        """
        # if the game is over just return so nothing else happens.
        # game is effectively paused during game over, waiting on
        # the player to start a new game.
        if self.game_over:
            return
        # print(ticks_ms())
        # player takes action
        self.player_entity.game_tick(self)

        # player location in tile coordinate space
        if self.player_entity.direction == Entity.DIRECTION_LEFT:
            current_coords = (self.player_entity.x // 15, 1)
        elif self.player_entity.direction == Entity.DIRECTION_RIGHT:
            current_coords = ((self.player_entity.x + 1) // 15, 1)

        try:
            # check the tile type at the player's location
            current_tile_type = self._map_tilegrid[current_coords]
        except IndexError:
            # player is teleporting between sides right now
            current_tile_type = ChomperGame.EMPTY_MAP_TILE

        # if it's a pellet
        if current_tile_type in (4, 9):  # pellet, big pellet
            # remove the pellet, set the tile to empty
            self.change_map_tile(current_coords, ChomperGame.EMPTY_MAP_TILE)

            # add points to score
            self.score += 1 * self.multiplier

            # subtract a pellet from the count variable
            self.pellets_in_play -= 1

            # if the last pellet was eaten
            if self.pellets_in_play == 0:
                # make new pellets
                self.spawn_pellets()

                # add 1 to the multiplier
                self.multiplier += 1

                # increase difficulty if we reached a threshold
                if self.multiplier == ChomperGame.EASY_DIFFICULTY_THRESHOLD:
                    self.ghost.MOVE_DELAY -= 1
                if self.multiplier == ChomperGame.MEDIUM_DIFFICULTY_THRESHOLD:
                    self.ghost.MOVE_DELAY -= 1
                if self.multiplier == ChomperGame.HARD_DIFFICULTY_THRESHOLD:
                    self.ghost.MOVE_DELAY -= 1
                    self.player_entity.MOVE_DELAY -= 1
                if self.multiplier == ChomperGame.VERYHARD_DIFFICULTY_THRESHOLD:
                    self.ghost.MOVE_DELAY -= 1
                if self.multiplier == ChomperGame.INSANE_DIFFICULTY_THRESHOLD:
                    self.ghost.MOVE_DELAY -= 1

            # update the score labels in the UI
            self.update_score()

            # if it was a big pellet
            if current_tile_type == 9:
                # if the ghost is currently normal state, it becomes edible
                if self.ghost.current_state in (Ghost.STATE_NORMAL, Ghost.STATE_EDIBLE, Ghost.STATE_EDIBLE_WARNING):
                    # set the state and edible property
                    self.ghost.current_state = Ghost.STATE_EDIBLE
                    self.ghost.edible = True

        # ghost takes action
        self.ghost.game_tick(self)

        # if the player is touching the ghost and the ghost isn't currently flying off to despawn
        if self.player_entity.is_colliding(self.ghost) \
                and not self.ghost.current_state == Ghost.STATE_DESPAWN_FLYOFF:

            # if the ghost is in normal state, non-edible
            if not self.ghost.edible:
                # set the gameover boolean
                self.game_over = True

                # show the gameover text at bottom middle of display
                self.append(self.gameover_lbl)

                # if the score is greater than the previous highscore
                if self.score > self.highscore:
                    # save new high score value to NVM storage
                    nvm_helper.save_data(("1dc_hs", self.score), test_run=False)

                    # update the highscore variable for comparison after the next game
                    self.highscore = self.score

                    # update the highscore text in the UI
                    self.highscore_value_lbl.text = f"HI: {self.highscore}"

                # sleep a bit to ignore any btn presses that were intended for turning,
                # not starting next game.
                time.sleep(0.5)

            else:  # ghost is edible, nomnomnom

                # set the ghost state to despawn flyoff
                self.ghost.current_state = Ghost.STATE_DESPAWN_FLYOFF

                # change ghost's direction
                self.ghost.direction = Entity.DIRECTION_LEFT if self.ghost.direction == Entity.DIRECTION_RIGHT else Entity.DIRECTION_RIGHT

                # set the ghost sprite to eyes only
                self.ghost.tilegrid[0, 0] = 23

                # add points to player's score
                self.score += 2 * self.multiplier

                # if there are no big pellets on the map
                if not self.big_pellet_exists():
                    # convert one small pellet to a big one
                    self.convert_pellet_to_big()

    def convert_pellet_to_big(self):
        """
        Convert a random existing small pellet to a big one
        :return: None
        """

        # check which pellets are currently up
        pellet_locs = self.find_pellets()

        # choose a random one of them
        chosen_pellet = random.choice(pellet_locs)

        # update it's sprite to big pellet
        self._map_tilegrid[chosen_pellet, 1] = 9

    def big_pellet_exists(self):
        """
        Check if any big pellets exist
        :return: True if there is at least one big pellet on the map 
        """
        for i in range(21):
            if self._map_tilegrid[i, 1] == 9:
                return True

    def find_pellets(self):
        """
        Find all pellet locations
        :return: List of x location indexes containing small pellets
        """
        pellet_locs = []
        for i in range(21):
            if self._map_tilegrid[i, 1] == 4:
                pellet_locs.append(i)
        return pellet_locs

    def restart(self):
        """
        Reset everything and start a new game
        :return: None
        """
        # move player to the left side
        self.player_entity.x = 15

        # move ghost to the right side
        self.ghost.x = ((self.display_size[0] // 4) * 3) // 3

        # respawn pellets
        self.spawn_pellets()

        # set game_over to False
        self.game_over = False

        # reset score and multiplier
        self.score = 0
        self.multiplier = 1

        # update score and multiplier in the UI
        self.update_score()

        # remove the gameover text
        self.remove(self.gameover_lbl)
```

# 1D Chomper Tabletop Arcade Game

## Code Walkthrough

The main **code.py** for the project is fairly small and simple. The bulk of the game logic and functionality is handled in the [helper classes, which you can learn about over on their own page](https://learn.adafruit.com/1d-chomper-tabletop-arcade-game/helper-classes).

The **code.py** script initializes the ChomperGame helper class and Qualia display driver library, as well as the digitalio input on pin `A0`.

In the main loop of the code, it calls `game_tick()` on the ChomperGame process a tick of action within the game. Lastly, it polls the button digitalio input, and when pressed, accesses the Player object via the ChomperGame and changes its direction to the opposite of whichever way it was headed. If the Player had already been nabbed by a Ghost and the game was over, then the button press will start up a new round to play again.

https://github.com/adafruit/Adafruit_Learning_System_Guides/blob/main/Qualia/Qualia_S3_1D_Chomper_Game/code.py

# 1D Chomper Tabletop Arcade Game

## Box & Decorations

![](https://cdn-learn.adafruit.com/assets/assets/000/127/225/medium800/gaming_box.png?1706207807)

The cardboard box that I mounted everything into is an upside-down recycled retail box for frozen burritos. It's dimensions are 10" x 7.25" on the face that is used as the top, and 4.25" deep. There is plenty of room to spare inside and on the top face of the box, the project could certainly be fit into a different-sized box. You are encouraged to recycle a box you have lying around rather than try to match mine exactly.

## Fancy It Up (Optional)

To decorate my arcade box, I printed out decals to cover each of the 5 visible faces from full sheet sticker paper and a standard inkjet printer. Click the button below for a copy of the decal sheets I printed. If your box is similarly sized, you can print some for yourself to use.

If your box is a different size, or you don't have access to the sticker paper or printer, no problem. You can get creative and decorate using markers, paints, or just plain paper and glue.

[Decal Sheets PDF](https://cdn-learn.adafruit.com/assets/assets/000/127/223/original/box_art_final_print.pdf?1706206862)
## Cuts To Make

To cut the box, I used a sharp X-acto style craft knife. As always, please be careful when using cutting instruments!

There is slit that is just over 1 inch long and about 1/16 of an inch wide cut in the box to pass the display ribbon cable through.

![gaming_cable_slit.png](https://cdn-learn.adafruit.com/assets/assets/000/127/228/medium640/gaming_cable_slit.png?1706208383)

The other cut needed is a hole of about 30mm in diameter cut for the panel mount button to fit into.

![gaming_button_cutout_top.png](https://cdn-learn.adafruit.com/assets/assets/000/127/229/medium640/gaming_button_cutout_top.png?1706208588)

![](https://cdn-learn.adafruit.com/assets/assets/000/127/227/medium800/gaming_button_cutout.png?1706208308)

## Assembly

The display and microcontroller are mounted onto the box with pieces of adhesive Velcro "hook and loop" tape. I used two small pieces each for the display and the microcontroller, but neither are very heavy, one each probably would have been enough with the 1" or so sized pieces I used.

Stick the display ribbon cable through the slit first, and then stick the display down with its adhesive Velcro piece(s). Then flip the box over to access the inside. Carefully plug in the ribbon cable to the Qualia driver board, and then mount it into place with its piece(s) of adhesive Velcro. Doing the display first, and connecting the ribbon cable next allows you to find a good placement for the Qualia driver board that results in the ribbon cable being not too tight or too slack before you stick it down.

All that's left after that is to carefully press the arcade button into its hole and connect it with the JST 3-pin to Alligator connector.

![](https://cdn-learn.adafruit.com/assets/assets/000/127/230/medium800/gaming_inside_v0.png?1706208700)


## Guide Products

### Adafruit Qualia ESP32-S3 for TTL RGB-666 Displays

[Adafruit Qualia ESP32-S3 for TTL RGB-666 Displays](https://www.adafruit.com/product/5800)
There's a few things everyone loves: ice cream, kittens, and honkin' large TFT screens. We're no strangers to small TFT's - [from our itsy 1.14" color display](https://www.adafruit.com/search?q=1.14+tft) that graces many-a-TFT-Feather to <a...></a...>

In Stock
[Buy Now](https://www.adafruit.com/product/5800)
[Related Guides to the Product](https://learn.adafruit.com/products/5800/guides)
### Rectangle Bar RGB TTL TFT Display - 3.7" 240x960

[Rectangle Bar RGB TTL TFT Display - 3.7" 240x960](https://www.adafruit.com/product/5799)
This is a screen for advanced hackers who like the look of a loooooooong rectangular bar TFT screen with tons of pixels. The 3.7" rectangular&nbsp;display has 240x960 18-bit full-color pixels and is an&nbsp; **IPS** &nbsp;display, so the color looks great up to 80 degrees...

In Stock
[Buy Now](https://www.adafruit.com/product/5799)
[Related Guides to the Product](https://learn.adafruit.com/products/5799/guides)
### Rectangle Bar RGB TTL TFT Display - 4.58" 320x960 No Touchscreen

[Rectangle Bar RGB TTL TFT Display - 4.58" 320x960 No Touchscreen](https://www.adafruit.com/product/5805)
This is a screen for advanced hackers who like the look of a loooooooong rectangular bar TFT screen with tons of pixels. The 4.58" rectangular&nbsp;display has 320x960 16-bit full-color pixels and is an&nbsp; **IPS** &nbsp;display, so the color looks great up to 80 degrees...

In Stock
[Buy Now](https://www.adafruit.com/product/5805)
[Related Guides to the Product](https://learn.adafruit.com/products/5805/guides)
### Arcade Button - 30mm Translucent Red

[Arcade Button - 30mm Translucent Red](https://www.adafruit.com/product/473)
A button is a button, and a switch is a switch, but these translucent arcade buttons are in a class of their own. They're the same size as common arcade controls (often referred to as 30mm diameter) but have some nice things going for them that justify the extra dollar.  
  
First,...

In Stock
[Buy Now](https://www.adafruit.com/product/473)
[Related Guides to the Product](https://learn.adafruit.com/products/473/guides)
### JST PH 2mm 3-pin Plug to Color Coded Alligator Clips Cable

[JST PH 2mm 3-pin Plug to Color Coded Alligator Clips Cable](https://www.adafruit.com/product/4030)
This cable will let you turn a JST PH 3-pin cable port into 3 individual wires with grippy mini alligator clips. We're carrying these to match up with any of our boards or breakouts with 3-pin 'STEMMA' connectors on them such as the&nbsp;<a...></a...>

In Stock
[Buy Now](https://www.adafruit.com/product/4030)
[Related Guides to the Product](https://learn.adafruit.com/products/4030/guides)
### USB Type A to Type C Cable - approx 1 meter / 3 ft long

[USB Type A to Type C Cable - approx 1 meter / 3 ft long](https://www.adafruit.com/product/4474)
As technology changes and adapts, so does Adafruit. This&nbsp;&nbsp; **USB Type A to Type C** cable will help you with the transition to USB C, even if you're still totin' around a USB Type A hub, computer or laptop.

USB C is the latest industry-standard connector for...

In Stock
[Buy Now](https://www.adafruit.com/product/4474)
[Related Guides to the Product](https://learn.adafruit.com/products/4474/guides)

## Related Guides

- [Adafruit Qualia ESP32-S3 for RGB-666 Displays](https://learn.adafruit.com/adafruit-qualia-esp32-s3-for-rgb666-displays.md)
- [50 Cent CPI Tracker for MagTag](https://learn.adafruit.com/50-cent-cpi-tracker-for-magtag.md)
- [Tiny Wiki for CircuitPython](https://learn.adafruit.com/tiny-wiki-for-circuitpython.md)
- [NeoTrellis MIDI Feedback Controller](https://learn.adafruit.com/neotrellis-midi-feedback-controller.md)
- [Minesweeper on the Fruit Jam and Metro RP2350](https://learn.adafruit.com/minesweeper-on-metro-rp2350.md)
- [IBM PC Keyboard to USB HID with CircuitPython](https://learn.adafruit.com/ibm-pc-keyboard-to-usb-hid-with-circuitpython.md)
- [Clue And MagTag Pep Talk Generator](https://learn.adafruit.com/clue-and-magtag-pep-talk-generator.md)
- [Adafruit QT Py Activity Timer and Hydration Reminder](https://learn.adafruit.com/qt-py-activity-timer-and-hydration-reminder.md)
- [Simplifying Qualia CircuitPython Projects](https://learn.adafruit.com/simplifying-qualia-circuitpython-projects.md)
- [NeXT Computer Keyboard to USB HID with CircuitPython](https://learn.adafruit.com/next-computer-keyboard-to-usb-hid-with-circuitpython.md)
- [Funhouse Door Alert with Email Notification](https://learn.adafruit.com/funhouse-door-alert-email-notification.md)
- [Which CircuitPython Board is Right for You?](https://learn.adafruit.com/choose-your-circuitpython-board.md)
- [I2S Microphones with CircuitPython](https://learn.adafruit.com/i2s-microphones-with-circuitpython.md)
- [MagTag Showerthoughts and Quotes](https://learn.adafruit.com/magtag-showerthoughts.md)
- [Mushroom Lights with NeoPixels and WLED](https://learn.adafruit.com/mushroom-lights-with-neopixels-and-wled.md)
- [Gravity Falls Memory Gun](https://learn.adafruit.com/gravity-falls-memory-gun.md)
