circuitpython_clue-simple-rpsgame-showingwin.jpg
The Simple version of the rock, paper, scissors game on a CLUE. This player has just won with rock-blunts-scissors indicated by the inverted colours on rock and cyan cursor indicating the opponent's losing choice, scissors.

This is a two-player version of rock, paper, scissors game which uses Bluetooth Low Energy (BLE) advertising packets to exchange the players' choices and presents them on a graphical screen. It uses text to keep the program relatively short and simple.

Example Video

The video below shows the game being played on a pair of devices side-by-side. The two players would normally hide their choices. Bluetooth facilitates this as the game works well up to 4m (13ft) apart. The boards have been placed next to each other in this demonstration to ease the video recording.

The sequence of events in the video:

  • 00:03 The program has already started before the video. The choices are selected on the two boards with the left button.
  • 00:06 The right buttons are pressed starting the exchange of choices over BLE.
  • 00:12 The appearance of the cyan cursor on the right indicates the opponent's choice has been received. The winning choice is evaluated and flashes (inverts foreground/background) on screen. In this case, rock blunts scissors.
  • 00:23 Start of second round.
  • 00:29 Paper wraps rock, paper flashes.
  • 00:39 Start of third round.
  • 00:46 Cyan cursor without any flashing indicates both players have chosen paper - a draw (tie).

Installing Project Code

To use with CircuitPython, you need to first install a few libraries, into the lib folder on your CIRCUITPY drive. Then you need to update code.py with the example script.

Thankfully, we can do this in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and the code.py file in a zip file. Extract the contents of the zip file, open the directory CLUE_Rock_Paper_Scissors/simple/ and then click on the directory that matches the version of CircuitPython you're using and copy the contents of that directory to your CIRCUITPY drive.

Your CIRCUITPY drive should now look similar to the following image:

CIRCUITPY
# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries
#
# SPDX-License-Identifier: MIT

# clue-simple-rpsgame v1.3
# CircuitPython rock paper scissors game over Bluetooth LE

# Tested with CLUE and Circuit Playground Bluefruit Alpha with TFT Gizmo
# and CircuitPython and 5.3.0

# copy this file to CLUE/CPB board as code.py

# MIT License

# Copyright (c) 2020 Kevin J. Walters

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import time
import os
import struct
import sys

import board
from displayio import Group
import terminalio
import digitalio

from adafruit_ble import BLERadio
from adafruit_ble.advertising import Advertisement, LazyObjectField
from adafruit_ble.advertising.standard import ManufacturerData, ManufacturerDataField

from adafruit_display_text.label import Label


debug = 3

def d_print(level, *args, **kwargs):
    """A simple conditional print for debugging based on global debug level."""
    if not isinstance(level, int):
        print(level, *args, **kwargs)
    elif debug >= level:
        print(*args, **kwargs)


def tftGizmoPresent():
    """Determine if the TFT Gizmo is attached.
       The TFT's Gizmo circuitry for backlight features a 10k pull-down resistor.
       This attempts to verify the presence of the pull-down to determine
       if TFT Gizmo is present.
       Only use this on Circuit Playground Express (CPX)
       or Circuit Playground Bluefruit (CPB) boards."""
    present = True
    try:
        with digitalio.DigitalInOut(board.A3) as backlight_pin:
            backlight_pin.pull = digitalio.Pull.UP
            present = not backlight_pin.value
    except ValueError:
        # The Gizmo is already initialised, i.e. showing console output
        pass

    return present


# Assuming CLUE if it's not a Circuit Playround (Bluefruit)
clue_less = "Circuit Playground" in os.uname().machine

# Note: difference in pull-up and pull-down
#       and not use for buttons
if clue_less:
    # CPB with TFT Gizmo (240x240)

    # Outputs
    if tftGizmoPresent():
        from adafruit_gizmo import tft_gizmo
        display = tft_gizmo.TFT_Gizmo()
    else:
        display = None

    # Inputs
    # buttons reversed if it is used upside-down with Gizmo
    _button_a = digitalio.DigitalInOut(board.BUTTON_A)
    _button_a.switch_to_input(pull=digitalio.Pull.DOWN)
    _button_b = digitalio.DigitalInOut(board.BUTTON_B)
    _button_b.switch_to_input(pull=digitalio.Pull.DOWN)
    if display is None:
        def button_left():
            return _button_a.value
        def button_right():
            return _button_b.value
    else:
        def button_left():
            return _button_b.value
        def button_right():
            return _button_a.value

else:
    # CLUE with builtin screen (240x240)

    # Outputs
    display = board.DISPLAY

    # Inputs
    _button_a = digitalio.DigitalInOut(board.BUTTON_A)
    _button_a.switch_to_input(pull=digitalio.Pull.UP)
    _button_b = digitalio.DigitalInOut(board.BUTTON_B)
    _button_b.switch_to_input(pull=digitalio.Pull.UP)
    def button_left():
        return not _button_a.value
    def button_right():
        return not _button_b.value

if display is None:
    print("FATAL:", "This version of program only works with a display")
    sys.exit(1)

choices = ("rock", "paper", "scissors")
my_choice_idx = 0

# Top y position of first choice and pixel separate between choices
top_y_pos = 60
choice_sep = 60

DIM_TXT_COL_FG = 0x505050
DEFAULT_TXT_COL_FG = 0xa0a0a0
DEFAULT_TXT_COL_BG = 0x000000
CURSOR_COL_FG = 0xc0c000
OPP_CURSOR_COL_FG = 0x00c0c0


def setCursor(c_idx, who, visibility=None):
    """Set the position of the cursor on-screen to indicate the player's selection."""
    char = None

    if visibility == "show":
        char = ">"
    elif visibility == "hide":
        char = " "

    if 0 <= c_idx < len(choices):
        dob = cursor_dob if who == "mine" else opp_cursor_dob
        dob.y = top_y_pos + choice_sep * c_idx
        if char is not None:
            dob.text = char


def flashWinner(c_idx, who):
    """Invert foreground/background colour a few times
       to indicate the winning choice."""

    if who == "mine":
        sg_idx = rps_dob_idx[0] + c_idx
    elif who == "opp":
        sg_idx = rps_dob_idx[1] + c_idx
    else:
        raise ValueError("who is mine or opp")

    # An even number will leave colours on original values
    for _ in range(5 * 2):
        tmp_col = screen_group[sg_idx].color
        screen_group[sg_idx].color = screen_group[sg_idx].background_color
        screen_group[sg_idx].background_color = tmp_col
        time.sleep(0.5)


# The 6x14 terminalio classic font
FONT_WIDTH, FONT_HEIGHT = terminalio.FONT.get_bounding_box()
screen_group = Group()

# The position of the two players RPS Label objects inside screen_group
rps_dob_idx = []

# Create the simple arrow cursors
left_col = 20
right_col = display.width // 2 + left_col
for x_pos in (left_col, right_col):
    y_pos = top_y_pos
    rps_dob_idx.append(len(screen_group))
    for label_text in choices:
        rps_dob = Label(terminalio.FONT,
                        text=label_text,
                        scale=2,
                        color=DEFAULT_TXT_COL_FG,
                        background_color=DEFAULT_TXT_COL_BG)
        rps_dob.x = x_pos
        rps_dob.y = y_pos
        y_pos += 60
        screen_group.append(rps_dob)

cursor_dob = Label(terminalio.FONT,
                   text=">",
                   scale=3,
                   color=CURSOR_COL_FG)
cursor_dob.x = left_col - 20
setCursor(my_choice_idx, "mine")
cursor_dob.y = top_y_pos
screen_group.append(cursor_dob)

# Initially set to a space to not show it
opp_cursor_dob = Label(terminalio.FONT,
                       text=" ",
                       scale=3,
                       color=OPP_CURSOR_COL_FG,
                       background_color=DEFAULT_TXT_COL_BG)
opp_cursor_dob.x = right_col - 20
setCursor(my_choice_idx, "your")
opp_cursor_dob.y = top_y_pos
screen_group.append(opp_cursor_dob)

display.root_group = screen_group

# From adafruit_ble.advertising
MANUFACTURING_DATA_ADT = 0xFF
ADAFRUIT_COMPANY_ID = 0x0822

# pylint: disable=line-too-long
# According to https://github.com/adafruit/Adafruit_CircuitPython_BLE/blob/master/adafruit_ble/advertising/adafruit.py
# 0xf000 (to 0xffff) is for range for Adafruit customers
RPS_ACK_ID = 0xfe30
RPS_DATA_ID = 0xfe31


class RpsAdvertisement(Advertisement):
    """Broadcast an RPS message.
       This is not connectable and elicits no scan_response based on defaults
       in Advertisement parent class."""

    flags = None

    _PREFIX_FMT = "<BHBH"
    _DATA_FMT = "8s"  # this NUL pads if necessary

    # match_prefixes tuple replaces deprecated prefix
    # comma for 1 element is very important!
    match_prefixes = (
        struct.pack(
            _PREFIX_FMT,
            MANUFACTURING_DATA_ADT,
            ADAFRUIT_COMPANY_ID,
            struct.calcsize("<H" + _DATA_FMT),
            RPS_DATA_ID
        ),
    )
    manufacturer_data = LazyObjectField(
        ManufacturerData,
        "manufacturer_data",
        advertising_data_type=MANUFACTURING_DATA_ADT,
        company_id=ADAFRUIT_COMPANY_ID,
        key_encoding="<H",
    )

    test_string = ManufacturerDataField(RPS_DATA_ID, "<" + _DATA_FMT)
    """RPS choice."""


NS_IN_S = 1000 * 1000  * 1000
MIN_SEND_TIME_NS = 6 * NS_IN_S
MAX_SEND_TIME_S = 20
MAX_SEND_TIME_NS = MAX_SEND_TIME_S * NS_IN_S

# 20ms is the minimum delay between advertising packets
# in Bluetooth Low Energy
# extra 10us deals with API floating point rounding issues
MIN_AD_INTERVAL = 0.02001

ble = BLERadio()

opponent_choice = None

timeout = False
round_no = 1
wins = 0
losses = 0
draws = 0
voids = 0

TOTAL_ROUND = 5


def evaluate_game(mine, yours):
    """Determine who won the game based on the two strings mine and yours_lc.
       Returns three booleans (win, draw, void)."""
    # Return with void at True if any input is None
    try:
        mine_lc = mine.lower()
        yours_lc = yours.lower()
    except AttributeError:
        return (False, False, True)

    r_win = r_draw = r_void = False
    # pylint: disable=too-many-boolean-expressions
    if (mine_lc == "rock" and yours_lc == "rock"
            or mine_lc == "paper" and yours_lc == "paper"
            or mine_lc == "scissors" and yours_lc == "scissors"):
        r_draw = True
    elif (mine_lc == "rock" and yours_lc == "paper"):
        pass  # r_win default is False
    elif (mine_lc == "rock" and yours_lc == "scissors"):
        r_win = True
    elif (mine_lc == "paper" and yours_lc == "rock"):
        r_win = True
    elif (mine_lc == "paper" and yours_lc == "scissors"):
        pass  # r_win default is False
    elif (mine_lc == "scissors" and yours_lc == "rock"):
        pass  # r_win default is False
    elif (mine_lc == "scissors" and yours_lc == "paper"):
        r_win = True
    else:
        r_void = True

    return (r_win, r_draw, r_void)


# Advertise for 20 seconds maximum and if a packet is received
# for 5 seconds after that
while True:
    if round_no > TOTAL_ROUND:
        print("Summary: ",
              "wins {:d}, losses {:d}, draws {:d}, void {:d}".format(wins, losses, draws, voids))

        # Reset variables for another game
        round_no = 1
        wins = 0
        losses = 0
        draws = 0
        voids = 0
        round_no = 1

    if button_left():
        while button_left():
            pass
        my_choice_idx = (my_choice_idx + 1) % len(choices)
        setCursor(my_choice_idx, "mine")

    if button_right():
        tx_message = RpsAdvertisement()

        choice = choices[my_choice_idx]
        tx_message.test_string = choice
        d_print(2, "TXing RTA", choice)

        opponent_choice = None
        ble.start_advertising(tx_message, interval=MIN_AD_INTERVAL)
        sending_ns = time.monotonic_ns()

        # Timeout value is in seconds
        # RSSI -100 is probably minimum, -128 would be 8bit signed min
        # window and interval are 0.1 by default - same value means
        # continuous scanning (sending Advertisement will interrupt this)
        for adv in ble.start_scan(RpsAdvertisement,
                                  minimum_rssi=-90,
                                  timeout=MAX_SEND_TIME_S):
            received_ns = time.monotonic_ns()
            d_print(2, "RXed RTA",
                    adv.test_string)
            opponent_choice_bytes = adv.test_string

            # Trim trailing NUL chars from bytes
            idx = 0
            while idx < len(opponent_choice_bytes):
                if opponent_choice_bytes[idx] == 0:
                    break
                idx += 1
            opponent_choice = opponent_choice_bytes[0:idx].decode("utf-8")
            break

        # We have received one message or exceeded MAX_SEND_TIME_S
        ble.stop_scan()

        # Ensure we send our message for a minimum period of time
        # constrained by the ultimate duration cap
        if opponent_choice is not None:
            timeout = False
            remaining_ns = MAX_SEND_TIME_NS - (received_ns - sending_ns)
            extra_ad_time_ns = min(remaining_ns, MIN_SEND_TIME_NS)
            # Only sleep if we need to, the value here could be a small
            # negative one too so this caters for this
            if extra_ad_time_ns > 0:
                sleep_t  = extra_ad_time_ns / NS_IN_S
                d_print(2, "Additional {:f} seconds of advertising".format(sleep_t))
                time.sleep(sleep_t)
        else:
            timeout = True

        ble.stop_advertising()

        d_print(1, "ROUND", round_no,
                "MINE", choice,
                "| OPPONENT", opponent_choice)
        win, draw, void = evaluate_game(choice, opponent_choice)

        if void:
            voids += 1
        else:
            opp_choice_idx = choices.index(opponent_choice)
            setCursor(opp_choice_idx, "opp", visibility="show")
            if draw:
                time.sleep(4)
                draws += 1
            elif win:
                flashWinner(my_choice_idx, "mine")
                wins += 1
            else:
                flashWinner(opp_choice_idx, "opp")
                losses += 1
            setCursor(opp_choice_idx, "opp", visibility="hide")
        d_print(1, "wins {:d}, losses {:d}, draws {:d}, void {:d}".format(wins, losses, draws, voids))

        round_no += 1

Code Discussion

The main loop runs forever checking for three conditions.

while True:
    if round_no > TOTAL_ROUND:
        print("Summary: ",
              "wins {:d}, losses {:d}, draws {:d}, void {:d}".format(wins, losses, draws, voids))

        ### Reset variables for another game
        round_no = 1
        wins = 0
        losses = 0
        draws = 0
        voids = 0
        round_no = 1

    if button_left():
        while button_left():
            pass
        my_choice_idx = (my_choice_idx + 1) % len(choices)
        setCursor(my_choice_idx, "mine")

    if button_right():

The first if checks to see if the last round of the game has occurred in order to print a summary to the serial console and resets the per-round counters. The astute reader will spot an unintentional repetition of round_no = 1 - this is harmless but does waste a few bytes of memory.

The second if checks to see if the left button has been pressed. A function is used here to allow the button to be customised for the board being used. The short while loop is waiting for the finger to release the button and then the my_choice_idx is incremented. This wraps around the three choices, the value after 2 is 0. setCursor() updates the display, the second parameter is controlling whose selection needs updating. The success of waiting for button_left() not to be True (pressed) as a debounce mechanism is discussed further down.

The first part of the code inside the third if is shown below.

# Right button code excerpt 1/3
    if button_right():
        tx_message = RpsAdvertisement()

        choice = choices[my_choice_idx]
        tx_message.test_string = choice
        d_print(2, "TXing RTA", choice)

        opponent_choice = None
        ble.start_advertising(tx_message, interval=MIN_AD_INTERVAL)
        sending_ns = time.monotonic_ns()

        # Timeout value is in seconds
        # RSSI -100 is probably minimum, -128 would be 8bit signed min
        # window and interval are 0.1 by default - same value means
        # continuous scanning (sending Advertisement will interrupt this)
        for adv in ble.start_scan(RpsAdvertisement,
                                  minimum_rssi=-90,
                                  timeout=MAX_SEND_TIME_S):
            received_ns = time.monotonic_ns()
            d_print(2, "RXed RTA",
                    adv.test_string)
            opponent_choice_bytes = adv.test_string

            # Trim trailing NUL chars from bytes
            idx = 0
            while idx < len(opponent_choice_bytes):
                if opponent_choice_bytes[idx] == 0:
                    break
                idx += 1
            opponent_choice = opponent_choice_bytes[0:idx].decode("utf-8")
            break
            
        # We have received one message or exceeded MAX_SEND_TIME_S
        ble.stop_scan()

This is creating a message using the RpsAdvertisement class and setting test_string (a poor name leftover from a prototype!) to the lower-case text representation of the player's choice. start_advertising() then starts sending that data repeatedly by broadcasting an advertising packet - this occurs in the background until it's explicitly stopped. Advertisements are received with the use of start_scan() which is commonly used in a loop to iterate over each packet as it arrives. For a two-player game the code is only waiting for the opponent's choice. As soon as the first RpsAdvertisement packet is received it's complete in terms of receiving data, hence the break to terminate the for loop. A stop_scan() then terminates the scanning.

The data needs to be converted from the underlying fixed-size bytes type back to a text string and this involves removing any NUL padding. There's no guarantee this data is from a trusted source since it's an unauthenticated packet received over the air. A robust program would validate this data as soon as possible. A few other programming languages have a feature to tag risky data from less trusted sources which has not been validated, for example taint checking.

The code then continues to send advertising packets as it does not know if these have been received. This is shown in the next code excerpt below.

# Right button code excerpt 2/3
        # Ensure we send our message for a minimum period of time
        # constrained by the ultimate duration cap
        if opponent_choice is not None:
            timeout = False
            remaining_ns = MAX_SEND_TIME_NS - (received_ns - sending_ns)
            extra_ad_time_ns = min(remaining_ns, MIN_SEND_TIME_NS)
            # Only sleep if we need to, the value here could be a small
            # negative one too so this caters for this
            if extra_ad_time_ns > 0:
                sleep_t  = extra_ad_time_ns / NS_IN_S
                d_print(2, "Additional {:f} seconds of advertising".format(sleep_t))
                time.sleep(sleep_t)
        else:
            timeout = True

        ble.stop_advertising()

        d_print(1, "ROUND", round_no,
                "MINE", choice,
                "| OPPONENT", opponent_choice)

It does this for MIN_SEND_TIME_NS or less if that would exceed MAX_SEND_TIME_NS in total. NS stands for nanoseconds, billionths of a second. Nanosecond precision is not required here, it's simply the units returned by time.monotonic_ns() which is the most precise time function available. The advertising packets are sent in the background, the code only needs to sleep for the calculated duration and then run stop_advertising().

The final part of the code

  1. checks who has won,
  2. shows the opponent's choice on the display with setCursor(),
  3. indicates a win or lose with flashWinner() and
  4. then finally increments the integer variable round_no.
# Right button code excerpt 3/3
        win, draw, void = evaluate_game(choice, opponent_choice)

        if void:
            voids += 1
        else:
            opp_choice_idx = choices.index(opponent_choice)
            setCursor(opp_choice_idx, "opp", visibility="show")
            if draw:
                time.sleep(4)
                draws += 1
            elif win:
                flashWinner(my_choice_idx, "mine")
                wins += 1
            else:
                flashWinner(opp_choice_idx, "opp")
                losses += 1
            setCursor(opp_choice_idx, "opp", visibility="hide")
        d_print(1, "wins {:d}, losses {:d}, draws {:d}, void {:d}".format(wins, losses, draws, voids))

        round_no += 1

The tempting variable name round should be avoided as this clashes with CircuitPython's round() function. The code would be valid and would run but it would be likely to cause confusion, bugs or both.

The evaluate_game() function is a little different to the technique used in the Very Simple game for deciding the winner. This version makes no use of lookup tables and is far longer to the extent that pylint doesn't like it. The else is only reached if the inputs are invalid - this is indicated by the void variable in the returned tuple being set to True. C/C++ programmers would instinctively avoid the use of void as a variable name as it's a reserved word in those languages but Python does not use it.

def evaluate_game(mine, yours):
    """Determine who won the game based on the two strings mine and yours_lc.
       Returns three booleans (win, draw, void)."""
    # Return with void at True if any input is None
    try:
        mine_lc = mine.lower()
        yours_lc = yours.lower()
    except AttributeError:
        return (False, False, True)

    r_win = r_draw = r_void = False
    # pylint: disable=too-many-boolean-expressions
    if (mine_lc == "rock" and yours_lc == "rock"
            or mine_lc == "paper" and yours_lc == "paper"
            or mine_lc == "scissors" and yours_lc == "scissors"):
        r_draw = True
    elif (mine_lc == "rock" and yours_lc == "paper"):
        pass  # r_win default is False
    elif (mine_lc == "rock" and yours_lc == "scissors"):
        r_win = True
    elif (mine_lc == "paper" and yours_lc == "rock"):
        r_win = True
    elif (mine_lc == "paper" and yours_lc == "scissors"):
        pass  # r_win default is False
    elif (mine_lc == "scissors" and yours_lc == "rock"):
        pass  # r_win default is False
    elif (mine_lc == "scissors" and yours_lc == "paper"):
        r_win = True
    else:
        r_void = True

    return (r_win, r_draw, r_void)

The return type permits some "illegal" combinations of values. The first and second elements in the tuple could be True which means the player has both won and drawn. The current implementation is small enough to verify and will never return this combination. This would be more risky in a larger or more complex function.

A less typical form of assignment is used in evaluate_game().

r_win = r_draw = r_void = False

This is a compact way to assign the same value to several variables. It works because Python is like many other languages and has a right-associative = operator. This means r_void = False occurs first, and then the result of this is the value False which is assigned to r_draw and then r_win.

Current Issues

Debouncing

A user who plays the game for a while will notice that occasionally the selection advances by two choices rather than one. This happens infrequently and it would be easy to dismiss this as a mysterious glitch but in this case it is a straightforward case of switch bounce.

For this program the main while loop can iterate very rapidly as there's only a small amount of code to be run for the case of a left button press. If the display updates were immediate then this processing would probably take long enough for the button's contacts to stop bouncing but displayio updates are part of a background task - they happen soon after but not necessarily immediately.

This could be fixed with the help of the adafruit_debouncer library.

BLE - Advertising vs Connections

If a game is inherently a two-player game or there is no requirement or future possibility of a multi-player game then the more common connection-based approach is going to be superior in most cases.

Evolving the Game

The game's look is a bit dull and it doesn't make much use of the 240x240 graphical display. It also has a slow feel to the exchange of player's choices over BLE. The next version adds some graphics, sound and has a more sophisticated networking protocol for the exchange improving the responsiveness and facilitating more than two players.

This guide was first published on Sep 02, 2020. It was last updated on Sep 30, 2023.

This page (Simple Game) was last updated on Nov 29, 2023.

Text editor powered by tinymce.