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:
# 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
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
- checks who has won,
- shows the opponent's choice on the display with
setCursor()
, - indicates a win or lose with
flashWinner()
and - 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.
Text editor powered by tinymce.