The code for the robot is divided into two parts. The first part is the main code that is automatically run with CircuitPython. The second part is a custom library for the robot that allows neatly wrapping everything up in a package.

Code and Libraries

After you've finished setting up your CLUE with CircuitPython, you can download all of the code and necessary libraries in the Project Bundle.

To do this, click on the Download Project Bundle button in the window below. It will download as a zipped folder. Inside the folder there should be different versions which correspond to the version of CircuitPython you are running.

# SPDX-FileCopyrightText: 2022 Melissa LeBlanc-Williams for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import board
import neopixel
from robot import Robot

# Ring:bit Pins
UNDERLIGHT_PIXELS = board.D0
LEFT_MOTOR = board.D1
RIGHT_MOTOR = board.D2

# Set up the hardware
underlight_neopixels = neopixel.NeoPixel(UNDERLIGHT_PIXELS, 2)
robot = Robot(LEFT_MOTOR, RIGHT_MOTOR, underlight_neopixels)

while True:
    robot.wait_for_connection()
    while robot.is_connected():
        robot.check_for_packets()
# SPDX-FileCopyrightText: 2022 Melissa LeBlanc-Williams for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import time
import pwmio
import board
import digitalio
import displayio
import vectorio
import terminalio

import neopixel
import adafruit_motor.servo
from adafruit_ble import BLERadio
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.services.nordic import UARTService
from adafruit_bluefruit_connect.packet import Packet
from adafruit_bluefruit_connect.color_packet import ColorPacket
from adafruit_bluefruit_connect.button_packet import ButtonPacket
from adafruit_display_text.label import Label

# Throttle Directions and Speeds
FWD = 1.0
REV = -1.0
STOP = 0

# Custom Colors
RED = (200, 0, 0)
GREEN = (0, 200, 0)
BLUE = (0, 0, 200)
PURPLE = (120, 0, 160)
YELLOW = (100, 100, 0)
AQUA = (0, 100, 100)

class Robot:
    def __init__(self, left_pin, right_pin, underlight_neopixel):
        self.left_motor = self._init_motor(left_pin)
        self.right_motor = self._init_motor(right_pin)
        self._init_display()
        self._init_ble()
        self.under_pixels = underlight_neopixel
        self.neopixel = neopixel.NeoPixel(board.NEOPIXEL, 1)
        self.direction = STOP
        self.release_color = None
        self.headlights = digitalio.DigitalInOut(board.WHITE_LEDS)
        self.headlights.switch_to_output()
        self.set_underglow(PURPLE)
        self.set_speed(STOP)

    @classmethod
    def _make_palette(cls, color):
        palette = displayio.Palette(1)
        palette[0] = color
        return palette

    @classmethod
    def _init_motor(cls, pin):
        pwm = pwmio.PWMOut(pin, frequency=50)
        return adafruit_motor.servo.ContinuousServo(pwm, min_pulse=600, max_pulse=2400)

    def _init_ble(self):
        self.ble = BLERadio()
        self.uart_service = UARTService()
        self.advertisement = ProvideServicesAdvertisement(self.uart_service)

    def _init_display(self):
        self.display = board.DISPLAY
        self.display_group = displayio.Group()
        self.display.show(self.display_group)
        self.shape_color = 0
        self.bg_color = 0xFFFF00
        rect = vectorio.Rectangle(
            pixel_shader=self._make_palette(0xFFFF00),
            x=0, y=0,
            width=self.display.width,
            height=self.display.height)
        self.display_group.append(rect)

    def wait_for_connection(self):
        self.set_status_led(BLUE)
        self.ble.start_advertising(self.advertisement)
        self._set_status_waiting()
        while not self.ble.connected:
            # Wait for a connection.
            pass
        self.ble.stop_advertising()
        self.set_status_led(GREEN)
        self.set_throttle(STOP)

    def is_connected(self):
        return self.ble.connected

    def check_for_packets(self):
        if self.uart_service.in_waiting:
            self._process_packet(Packet.from_stream(self.uart_service))

    def set_underglow(self, color, save_release_color = False):
        if save_release_color:
            self.release_color = self.get_underglow()
        for index, _ in enumerate(self.under_pixels):
            self.under_pixels[index] = color

    def get_underglow(self):
        # Set the 2 Neopixels on the underside fo the robot
        return self.under_pixels[0]

    def set_status_led(self, color):
        # Set the status NeoPixel on the CLUE
        self.neopixel[0] = color

    def toggle_headlights(self):
        self.headlights.value = not self.headlights.value

    def _set_left_throttle(self, speed):
        self.left_motor.throttle = speed

    def _set_right_throttle(self, speed):
        # Motor is rotated 180 degrees of the left, so we invert the throttle
        self.right_motor.throttle = -1 * speed

    def rotate_right(self):
        self.release_color = self.get_underglow()
        self.set_underglow(YELLOW, True)
        if self.direction == STOP:
            self._set_status_rotate_cw()
        else:
            self._set_status_right()
        speed = FWD if self.direction == STOP else self.direction
        self._set_left_throttle(speed)
        self._set_right_throttle(STOP if self.direction != STOP else -1 * speed)

    def rotate_left(self):
        self.release_color = self.get_underglow()
        self.set_underglow(YELLOW, True)
        if self.direction == STOP:
            self._set_status_rotate_ccw()
        else:
            self._set_status_left()
        speed = FWD if self.direction == STOP else self.direction
        self._set_left_throttle(STOP if self.direction != STOP else -1 * speed)
        self._set_right_throttle(speed)

    def set_throttle(self, speed):
        if speed == STOP:
            self._set_status_stop()
        elif speed > STOP:
            self._set_status_forward()
        elif speed < STOP:
            self._set_status_reverse()
        self.set_speed(speed)

    def set_speed(self, speed):
        self._set_left_throttle(speed)
        self._set_right_throttle(speed)
        self.direction = speed

    def stop(self):
        # Temporarily grab the current color
        color = self.get_underglow()
        self.set_underglow(RED)
        self.set_throttle(STOP)
        time.sleep(0.5)
        self.set_underglow(color)

    def _remove_shapes(self):
        while len(self.display_group) > 1:
            self.display_group.pop()

    def _add_centered_rect(self, width, height, x_offset=0, y_offset=0, color=None):
        if color is None:
            color = self.shape_color
        rectangle = vectorio.Rectangle(
            pixel_shader=self._make_palette(color),
            width=width,
            height=height,
            x=(self.display.width//2 - width//2) + x_offset - 1,
            y=(self.display.height//2 - height//2) + y_offset - 1
        )
        self.display_group.append(rectangle)

    def _add_centered_polygon(self, points, x_offset=0, y_offset=0, color=None):
        if color is None:
            color = self.shape_color
        # Figure out the shape dimensions by using min and max
        width = max(points, key=lambda item:item[0])[0] - min(points, key=lambda item:item[0])[0]
        height = max(points, key=lambda item:item[1])[1] - min(points, key=lambda item:item[1])[1]
        polygon = vectorio.Polygon(
            pixel_shader=self._make_palette(color),
            points=points,
            x=(self.display.width // 2 - width // 2) + x_offset - 1,
            y=(self.display.height // 2 - height // 2) + y_offset - 1
        )
        self.display_group.append(polygon)

    def _add_centered_circle(self, radius, x_offset=0, y_offset=0, color=None):
        if color is None:
            color = self.shape_color
        circle = vectorio.Circle(
            pixel_shader=self._make_palette(color),
            radius=radius,
            x=(self.display.width // 2) + x_offset - 1,
            y=(self.display.height // 2) + y_offset - 1
        )
        self.display_group.append(circle)

    def _set_status_waiting(self):
        self._remove_shapes()
        text_area = Label(
            terminalio.FONT,
            text="Waiting for\nconnection",
            color=self.shape_color,
            scale=3,
            anchor_point=(0.5, 0.5),
            anchored_position=(self.display.width // 2, self.display.height // 2)
        )
        self.display_group.append(text_area)

    def _set_status_reverse(self):
        self._remove_shapes()
        self._add_centered_polygon([(40, 0), (60, 0), (100, 100), (0, 100)], 0, 0)
        self._add_centered_polygon([(0, 40), (100, 40), (50, 0)], 0, -40)

    def _set_status_forward(self):
        self._remove_shapes()
        self._add_centered_polygon([(20, 0), (60, 0), (80, 100), (0, 100)])
        self._add_centered_polygon([(0, 0), (150, 0), (75, 50)], 0, 50)

    def _set_status_right(self):
        self._remove_shapes()
        self._add_centered_rect(100, 40)
        self._add_centered_polygon([(50, 0), (50, 100), (0, 50)], -50, 0)

    def _set_status_rotate_ccw(self):
        self._remove_shapes()
        self._add_centered_circle(80)
        self._add_centered_circle(50, 0, 0, self.bg_color)
        self._add_centered_rect(160, 60, 0, 0, self.bg_color)
        self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], 60, 10)
        self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], -60, -10)

    def _set_status_left(self):
        self._remove_shapes()
        self._add_centered_rect(100, 40)
        self._add_centered_polygon([(0, 0), (0, 100), (50, 50)], 50)

    def _set_status_rotate_cw(self):
        self._remove_shapes()
        self._add_centered_circle(80)
        self._add_centered_circle(50, 0, 0, self.bg_color)
        self._add_centered_rect(160, 60, 0, 0, self.bg_color)
        self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], -60, 10)
        self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], 60, -10)

    def _set_status_stop(self):
        self._remove_shapes()
        self._add_centered_rect(100, 100)

    def _process_packet(self, packet):
        if isinstance(packet, ColorPacket):
            self._handle_color_packet(packet)
        elif isinstance(packet, ButtonPacket) and packet.pressed:
            # do this when buttons are pressed
            self._handle_button_press_packet(packet)
        elif isinstance(packet, ButtonPacket) and not packet.pressed:
            # do this when some buttons are released
            self._handle_button_release_packet(packet)

    def _handle_color_packet(self, packet):
        # Change the color
        self.set_underglow(packet.color)

    def _handle_button_press_packet(self, packet):
        if packet.button == ButtonPacket.UP:  # UP button pressed
            self.set_throttle(FWD)
        elif packet.button == ButtonPacket.DOWN:  # DOWN button
            self.set_throttle(REV)
        elif packet.button == ButtonPacket.RIGHT:
            self.rotate_right()
        elif packet.button == ButtonPacket.LEFT:
            self.rotate_left()
        elif packet.button == ButtonPacket.BUTTON_1:
            self.stop()
        elif packet.button == ButtonPacket.BUTTON_2:
            self.set_underglow(GREEN)
        elif packet.button == ButtonPacket.BUTTON_3:
            self.set_underglow(BLUE)
        elif packet.button == ButtonPacket.BUTTON_4:
            self.toggle_headlights()

    def _handle_button_release_packet(self, packet):
        if self.release_color is not None:
            self.set_underglow(self.release_color)
            self.release_color = None
        if packet.button == ButtonPacket.RIGHT:
            self.set_throttle(self.direction)
        if packet.button == ButtonPacket.LEFT:
            self.set_throttle(self.direction)

Upload the Code and Libraries to the CLUE

After downloading the Project Bundle, plug your CLUE into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the CLUE's CIRCUITPY drive. 

  • lib folder
  • code.py
  • robot.py

Your CLUE CIRCUITPY drive should look like this after copying the lib folder and the code.py file.

CIRCUITPY

Code Walkthrough

It starts off by assigning the expected outputs to the board GPIO numbers. For instance, with the UNDERLIGHT_PIXELS, it is assigned to D0, which is the same as GPIO 0. Then the libraries are initialized. First the NeoPixel library, the the custom robot library which will be covered below.

# Ring:bit Pins
UNDERLIGHT_PIXELS = board.D0
LEFT_MOTOR = board.D1
RIGHT_MOTOR = board.D2

# Set up the hardware
underlight_neopixels = neopixel.NeoPixel(UNDERLIGHT_PIXELS, 2)
robot = Robot(LEFT_MOTOR, RIGHT_MOTOR, underlight_neopixels)

The main loop here waits for a connection, then continues to check packets while it is connected. If there is a disconnect, it goes back to waiting for a connection.

while True:
    robot.wait_for_connection()
    while robot.is_connected():
        robot.check_for_packets()

Robot Custom Library

The robot library is organized so similar functions are grouped together. Near the top, after the imports are done, are a few constants. These are just used to define the directions and speeds. There are also some custom colors set up a tuples arranged in red, green, and blue with values between 0-255.

# Throttle Directions and Speeds
FWD = 1.0
REV = -1.0
STOP = 0

# Custom Colors
RED = (200, 0, 0)
GREEN = (0, 200, 0)
BLUE = (0, 0, 200)
PURPLE = (120, 0, 160)
YELLOW = (100, 100, 0)
AQUA = (0, 100, 100)

The rest of the code is inside of the Robot class itself. The first part initializes the robot hardware by calling several initialization functions that will be covered below. Initial variable values are set, and the car is put into a STOP state with the underglow set to the value of PURPLE.

def __init__(self, left_pin, right_pin, underlight_neopixel):
    self.left_motor = self._init_motor(left_pin)
    self.right_motor = self._init_motor(right_pin)
    self._init_display()
    self._init_ble()
    self.under_pixels = underlight_neopixel
    self.neopixel = neopixel.NeoPixel(board.NEOPIXEL, 1)
    self.direction = STOP
    self.release_color = None
    self.headlights = digitalio.DigitalInOut(board.WHITE_LEDS)
    self.headlights.switch_to_output()
    self.set_underglow(PURPLE)
    self.set_speed(STOP)

In the next section, there are a couple of functions with the @classmethod decorator. This allows the functions to be part of the class without actually making use of the self variable.

The first function just wraps takes the given color and assigns it to a displayio Palette object and returns that object.

The other three functions just initialize the given motor, BLE (or Bluetooth Low Energy), and the Display.

Note that any functions that start with the underscore character "_" are considered private functions by Python and not intended to be called from outside of the class.

@classmethod
def _make_palette(cls, color):
    palette = displayio.Palette(1)
    palette[0] = color
    return palette

@classmethod
def _init_motor(cls, pin):
    pwm = pwmio.PWMOut(pin, frequency=50)
    return adafruit_motor.servo.ContinuousServo(pwm, min_pulse=600, max_pulse=2400)

def _init_ble(self):
    self.ble = BLERadio()
    self.uart_service = UARTService()
    self.advertisement = ProvideServicesAdvertisement(self.uart_service)

def _init_display(self):
    self.display = board.DISPLAY
    self.display_group = displayio.Group()
    self.display.show(self.display_group)
    self.shape_color = 0
    self.bg_color = 0xFFFF00
    rect = vectorio.Rectangle(
        pixel_shader=self._make_palette(0xFFFF00),
        x=0, y=0,
        width=self.display.width,
        height=self.display.height)
    self.display_group.append(rect)

Next are a few functions that are used in the main program loop to manage the BLE connection properly as well as check if any new packets have been sent from the Bluefruit Connect app and send all of those to the _process_packet() function.

def wait_for_connection(self):
    self.set_status_led(BLUE)
    self.ble.start_advertising(self.advertisement)
    self._set_status_waiting()
    while not self.ble.connected:
        # Wait for a connection.
        pass
    self.ble.stop_advertising()
    self.set_status_led(GREEN)
    self.set_throttle(STOP)

def is_connected(self):
    return self.ble.connected

def check_for_packets(self):
    if self.uart_service.in_waiting:
        self._process_packet(Packet.from_stream(self.uart_service))

These function are for controlling the NeoPixels and the LED on the CLUE. The get_underglow() function saves a little memory by making use of the values that are stored in the NeoPixel library to get the current color.

def set_underglow(self, color, save_release_color = False):
    if save_release_color:
        self.release_color = self.get_underglow()
    for index, _ in enumerate(self.under_pixels):
        self.under_pixels[index] = color

def get_underglow(self):
    # Set the 2 Neopixels on the underside fo the robot
    return self.under_pixels[0]

def set_status_led(self, color):
    # Set the status NeoPixel on the CLUE
    self.neopixel[0] = color

def toggle_headlights(self):
    self.headlights.value = not self.headlights.value

The next set of functions are for controlling the movement of the robot. Servos spin in the same direction, but because they are mounted at 180 degrees to each other, one wheel wants to go forward and the other backwards. It starts out with the left and right throttle functions which solves this.

The rotate functions will behave a little different depending on whether the robot is currently stopped or currently moving in a direction. If it is stopped, the robot will rotate in place, whereas either the left or right wheel will act as a pivot if the robot is moving forward or backward.

The set_throttle() and set_speed() functions are similar except that the set_throttle will handle updating the display and underglow in addition to setting the speed.

The stop() function will also set the underglow to the value of RED for half a second after stopping to simulate brake lights before returning to the previously set color.

def _set_left_throttle(self, speed):
    self.left_motor.throttle = speed

def _set_right_throttle(self, speed):
    # Motor is rotated 180 degrees of the left, so we invert the throttle
    self.right_motor.throttle = -1 * speed

def rotate_right(self):
    self.release_color = self.get_underglow()
    self.set_underglow(YELLOW, True)
    if self.direction == STOP:
        self._set_status_rotate_cw()
    else:
        self._set_status_right()
    speed = FWD if self.direction == STOP else self.direction
    self._set_left_throttle(speed)
    self._set_right_throttle(STOP if self.direction != STOP else -1 * speed)

def rotate_left(self):
    self.release_color = self.get_underglow()
    self.set_underglow(YELLOW, True)
    if self.direction == STOP:
        self._set_status_rotate_ccw()
    else:
        self._set_status_left()
    speed = FWD if self.direction == STOP else self.direction
    self._set_left_throttle(STOP if self.direction != STOP else -1 * speed)
    self._set_right_throttle(speed)

def set_throttle(self, speed):
    if speed == STOP:
        self._set_status_stop()
    elif speed > STOP:
        self._set_status_forward()
    elif speed < STOP:
        self._set_status_reverse()
    self.set_speed(speed)

def set_speed(self, speed):
    self._set_left_throttle(speed)
    self._set_right_throttle(speed)
    self.direction = speed

def stop(self):
    # Temporarily grab the current color
    color = self.get_underglow()
    self.set_underglow(RED)
    self.set_throttle(STOP)
    time.sleep(0.5)
    self.set_underglow(color)

Next are the drawing helpers. The first one removes everything from the main displayio group and acts much like clear() function.

The remain three function will draw at the center of the display and offset from there if offsets are provided. The _add_centered_polygon() function makes use of a lambda to return only the x and y values, so it can figure out the largest and smallest points on the respective axis to get the overall geometry of the shape for easy centering.

Once the absolute coordinates are determined, everything is passed to the vectorio shape drawing functions.

def _remove_shapes(self):
    while len(self.display_group) > 1:
        self.display_group.pop()

def _add_centered_rect(self, width, height, x_offset=0, y_offset=0, color=None):
    if color is None:
        color = self.shape_color
    rectangle = vectorio.Rectangle(
        pixel_shader=self._make_palette(color),
        width=width,
        height=height,
        x=(self.display.width//2 - width//2) + x_offset - 1,
        y=(self.display.height//2 - height//2) + y_offset - 1
    )
    self.display_group.append(rectangle)

def _add_centered_polygon(self, points, x_offset=0, y_offset=0, color=None):
    if color is None:
        color = self.shape_color
    # Figure out the shape dimensions by using min and max
    width = max(points, key=lambda item:item[0])[0] - min(points, key=lambda item:item[0])[0]
    height = max(points, key=lambda item:item[1])[1] - min(points, key=lambda item:item[1])[1]
    polygon = vectorio.Polygon(
        pixel_shader=self._make_palette(color),
        points=points,
        x=(self.display.width // 2 - width // 2) + x_offset - 1,
        y=(self.display.height // 2 - height // 2) + y_offset - 1
    )
    self.display_group.append(polygon)

def _add_centered_circle(self, radius, x_offset=0, y_offset=0, color=None):
    if color is None:
        color = self.shape_color
    circle = vectorio.Circle(
        pixel_shader=self._make_palette(color),
        radius=radius,
        x=(self.display.width // 2) + x_offset - 1,
        y=(self.display.height // 2) + y_offset - 1
    )
    self.display_group.append(circle)

The next group of functions make use of the shape drawing functions to draw specific graphics. The only exception to this is the _set_status_waiting() function, which simply creates a label.

def _set_status_waiting(self):
    self._remove_shapes()
    text_area = Label(
        terminalio.FONT,
        text="Waiting for\nconnection",
        color=self.shape_color,
        scale=3,
        anchor_point=(0.5, 0.5),
        anchored_position=(self.display.width // 2, self.display.height // 2)
    )
    self.display_group.append(text_area)

def _set_status_reverse(self):
    self._remove_shapes()
    self._add_centered_polygon([(40, 0), (60, 0), (100, 100), (0, 100)], 0, 0)
    self._add_centered_polygon([(0, 40), (100, 40), (50, 0)], 0, -40)

def _set_status_forward(self):
    self._remove_shapes()
    self._add_centered_polygon([(20, 0), (60, 0), (80, 100), (0, 100)])
    self._add_centered_polygon([(0, 0), (150, 0), (75, 50)], 0, 50)

def _set_status_right(self):
    self._remove_shapes()
    self._add_centered_rect(100, 40)
    self._add_centered_polygon([(50, 0), (50, 100), (0, 50)], -50, 0)

def _set_status_rotate_ccw(self):
    self._remove_shapes()
    self._add_centered_circle(80)
    self._add_centered_circle(50, 0, 0, self.bg_color)
    self._add_centered_rect(160, 60, 0, 0, self.bg_color)
    self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], 60, 10)
    self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], -60, -10)

def _set_status_left(self):
    self._remove_shapes()
    self._add_centered_rect(100, 40)
    self._add_centered_polygon([(0, 0), (0, 100), (50, 50)], 50)

def _set_status_rotate_cw(self):
    self._remove_shapes()
    self._add_centered_circle(80)
    self._add_centered_circle(50, 0, 0, self.bg_color)
    self._add_centered_rect(160, 60, 0, 0, self.bg_color)
    self._add_centered_polygon([(40, 0), (80, 40), (0, 40)], -60, 10)
    self._add_centered_polygon([(40, 40), (80, 0), (0, 0)], 60, -10)

def _set_status_stop(self):
    self._remove_shapes()
    self._add_centered_rect(100, 100)

Finally, there are the functions that actually handle the packets. If you would like to modify what the buttons in the Bluefruit app do, this is where you would change that.

The first function will take the given packet and if it is one of the expected packet types, it will route it to the appropriate handler function, otherwise it will be ignored.

The handler functions then look at more specific details in the packet and call the function for whatever action should be performed.

def _process_packet(self, packet):
    if isinstance(packet, ColorPacket):
        self._handle_color_packet(packet)
    elif isinstance(packet, ButtonPacket) and packet.pressed:
        # do this when buttons are pressed
        self._handle_button_press_packet(packet)
    elif isinstance(packet, ButtonPacket) and not packet.pressed:
        # do this when some buttons are released
        self._handle_button_release_packet(packet)

def _handle_color_packet(self, packet):
    # Change the color
    self.set_underglow(packet.color)

def _handle_button_press_packet(self, packet):
    if packet.button == ButtonPacket.UP:  # UP button pressed
        self.set_throttle(FWD)
    elif packet.button == ButtonPacket.DOWN:  # DOWN button
        self.set_throttle(REV)
    elif packet.button == ButtonPacket.RIGHT:
        self.rotate_right()
    elif packet.button == ButtonPacket.LEFT:
        self.rotate_left()
    elif packet.button == ButtonPacket.BUTTON_1:
        self.stop()
    elif packet.button == ButtonPacket.BUTTON_2:
        self.set_underglow(GREEN)
    elif packet.button == ButtonPacket.BUTTON_3:
        self.set_underglow(BLUE)
    elif packet.button == ButtonPacket.BUTTON_4:
        self.toggle_headlights()

def _handle_button_release_packet(self, packet):
    if self.release_color is not None:
        self.set_underglow(self.release_color)
        self.release_color = None
    if packet.button == ButtonPacket.RIGHT:
        self.set_throttle(self.direction)
    if packet.button == ButtonPacket.LEFT:
        self.set_throttle(self.direction)

This guide was first published on Nov 02, 2022. It was last updated on Oct 17, 2022.

This page (Code the Robot) was last updated on Mar 10, 2023.

Text editor powered by tinymce.