In this guide, you will learn how to use OpenAI's "chat" API to generate an endless text adventure game on the PyPortal family of devices; the PyPortal Titano is recommended for its larger screen.

By providing a suitable prompt to OpenAI, it will offer a fictional adventure where the next choice can be made on the PyPortal touchscreen. This choice, along with some of the previous text, is fed back to the API to get the result of the action. Because of the random factor in the text ChatGPT generates, and the branching choices, it's unlikely that two games would ever be the same.

Ultimately the "adventure" is not pre-planned and frequently makes unjustifiable creative leaps, but the author has found it fun to play with anyway.

If the "Zorque Mansion" scenario doesn't interest you, create a scenario of your choice by writing a sentence or two in natural human language describing it; no complicated coding is needed to tell a sci-fi story instead, or even to play in French instead of English!

Photos in this guide show the PyPortal Titano with the 3D printed "retro case". Check out the full guide for that 3D printed project.

Parts

Hand holding PyPortal Titano development board with SAMD51, ESP32 Wifi, and 3.5" touchscreen TFT display.
The PyPortal Titano is the big sister to our popular PyPortal now with twice as many pixels! The PyPortal...
$59.95
In Stock
Angled shot of coiled pink and purple USB cable with USB A and USB C connectors.
This cable is not only super-fashionable, with a woven pink and purple Blinka-like pattern, it's also made for USB C for our modernized breakout boards, Feathers, and...
$2.95
In Stock
Front view of a Adafruit PyPortal - CircuitPython Powered Internet Display with a pyportal logo image on the display.
PyPortal, our easy-to-use IoT device that allows you to create all the things for the “Internet of Things” in minutes. Make custom touch screen interface...
$54.95
In Stock
Hand holding PyPortal Titano development board with SAMD51, ESP32 Wifi, and 2.4" touchscreen TFT display.Adafruit PyPortal Pynt
The PyPortal Pynt is the little sister to our popular PyPortal - zapped with a shrink ray to take the design...
$49.95
In Stock
Fully Reversible Pink/Purple USB A to micro B Cable
This cable is not only super-fashionable, with a woven pink and purple Blinka-like pattern, it's also fully reversible! That's right, you will save seconds a day by...
$3.95
In Stock
1 x OpenAI Account & API Key
A $2.00 budget suffices for multiple hours of play.

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY "flash" drive to iterate.

The following instructions will show you how to install CircuitPython. If you've already installed CircuitPython but are looking to update it or reinstall it, the same steps work for that as well!

Set up CircuitPython Quick Start!

Follow this quick step-by-step for super-fast Python power :)

Click the link above to download the latest version of CircuitPython for the PyPortal Titano.

Download and save it to your desktop (or wherever is handy).

Plug your PyPortal into your computer using a known-good USB cable.

A lot of people end up using charge-only USB cables and it is very frustrating! So make sure you have a USB cable you know is good for data sync.

Double-click the Reset button on the top in the middle (magenta arrow) on your board, and you will see the NeoPixel RGB LED (green arrow) turn green. If it turns red, check the USB cable, try another USB port, etc. Note: The little red LED next to the USB connector will pulse red. That's ok!

If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!

You will see a new disk drive appear called PORTALBOOT.

Drag the adafruit-circuitpython-pyportal-etc.uf2 file to PORTALBOOT.

The LED will flash. Then, the PORTALBOOT drive will disappear and a new disk drive called CIRCUITPY will appear.

If you haven't added any code to your board, the only file that will be present is boot_out.txt. This is absolutely normal! It's time for you to add your code.py and get started!

That's it, you're done! :)

PyPortal Titano Default Files

Click below to download a zip of the files that shipped on the PyPortal Titano.

The OpenAI platform is managed by OpenAI and changes at their discretion, and so the details may be slightly different from what is documented here.

In your web browser, visit https://platform.openai.com/

Click the "sign up" link. Then, you can use your e-mail to sign up, or an existing Google or Microsoft account.

OpenAI may require additional steps such as e-mail or phone verification before you can log in to your account.

Once you have completed the verification process and logged in, you will next create an API key. Use the menu in the far upper right corner (probably labeled "Personal") and then select "View API Keys".

Then, create a fresh API key by clicking "Create new secret key".

Save this secret key in the file settings.toml on the CIRCUITPY drive in a line that looks like

OPENAI_API_KEY="sk-b6...kP5"

This file also requires your WiFI credentials, see the next page of the guide for the details.

At the time of writing, OpenAI provides a free credit with new accounts. After the free credit is used or expires, you'll need to enter a credit card in your billing information to keep using the service.

Using the project tends to cost a few cents per session at most, and it's easy to limit your monthly bill to a pre-set amount such as $8.00.

To set a hard usage limit per month, visit the "Usage Limits" section of the OpenAI website.

This graph shows the author's usage costs while developing and playtesting an app, a total of $1.27 in API calls.

This project depends on you adding your WiFi settings and OpenAI API key in order to generate the text adventure.

Plug your CircuitPython board into your computer via a known good data + power USB cable. Your board should show up as a thumb drive in your File Explorer / Finder (depending on your operating system) named CIRCUITPY.

Create a file with the name settings.toml in the root directory of the CIRCUITPY drive.

Edit it to contain the keys WIFI_SSID, WIFI_PASSWORD, and OPENAI_API_KEY. (It's also OK for it to contain other keys)

Your file should look similar to the one shown below:

OPENAI_API_KEY="sk-b6...kP5"
WIFI_SSID="GuestAP"
WIFI_PASSWORD="i trust u"

Text Editor

Adafruit recommends using the Mu editor for editing your CircuitPython code. You can get more info in this guide.

Alternatively, you can use any text editor that saves simple text files.

Download the Project Bundle

Your project will use a specific set of CircuitPython libraries and the code.py file. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.

Hook your PyPortal Titano to your computer via a known good USB data+power cable. It should show up as a thumb drive named CIRCUITPY.

Using File Explorer/Finder (depending on your Operating System), drag the contents of the uncompressed bundle directory onto your board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.

Once the game restarts, it will connect to WiFi and start using OpenAI to generate the game text. When presented with options, just touch the screen in one of the 4 quadrants to make your choice. To restart the game, just use the reset button to start fresh.

Head on to the next page for explanation of key parts of the code and hints on how to modify it to create your own adventure.

folder
# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries
# SPDX-License-Identifier: MIT
import os
import traceback

import adafruit_esp32spi.adafruit_esp32spi_socket as socket
import adafruit_requests as requests
import adafruit_touchscreen
from adafruit_ticks import ticks_ms, ticks_add, ticks_less
from adafruit_bitmap_font.bitmap_font import load_font
from adafruit_display_text.bitmap_label import Label
from adafruit_display_text import wrap_text_to_pixels
import board
import displayio
import supervisor
import terminalio
from adafruit_esp32spi import adafruit_esp32spi
from digitalio import DigitalInOut

# Use
# https://github.com/adafruit/Adafruit_CircuitPython_Touchscreen/blob/main/examples/touchscreen_calibrator_built_in.py
# to calibrate your touchscreen
touchscreen_calibration=((6616, 60374), (8537, 57269))


# set to True to use openapi, False for quicker testing of the rest of the game
# logic
use_openai = True

# Place the key in your settings.toml file
openai_api_key = os.getenv("OPENAI_API_KEY")

# Select a 14-point font for the PyPortal titano, 10-point for original & Pynt
if board.DISPLAY.width > 320:
    nice_font = load_font("helvR14.pcf")
else:
    nice_font = load_font("helvR10.pcf")
line_spacing = 0.75

# Customize this prompt as you see fit to create a different experience
base_prompt = """
You are an AI helping the player play an endless text adventure game. You will stay in character as the GM.

The goal of the game is to save the Zorque mansion from being demolished. The \
game starts outside the abandoned Zorque mansion.

As GM, never let the player die; they always survive a situation, no matter how \
harrowing.

At each step:
    * Offer a short description of my surroundings (1 paragraph)
    * List the items I am carrying, if any
    * Offer me 4 terse numbered action choices (1 or 2 words each)

In any case, be relatively terse and keep word counts small.

In case the player wins (or loses) start a fresh game.
"""

clear='\033[2J'

def set_up_wifi():
    print(end=clear)
    if openai_api_key is None:
        print(
            "please set OPENAPI_API_KEY in settings.toml"
        )
        raise SystemExit

    wifi_ssid = os.getenv('WIFI_SSID')
    wifi_password = os.getenv('WIFI_PASSWORD')
    if wifi_ssid is None:
        print(
            "please set WIFI_SSID and WIFI_PASSWORD in settings.toml"
        )
        raise SystemExit

    esp_cs = DigitalInOut(board.ESP_CS)
    esp_ready = DigitalInOut(board.ESP_BUSY)
    esp_reset = DigitalInOut(board.ESP_RESET)

    spi = board.SPI()
    esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp_cs, esp_ready, esp_reset)
    requests.set_socket(socket, esp)

    while not esp.is_connected:
        print("Connecting to AP...")
        try:
            esp.connect_AP(wifi_ssid, wifi_password)
        except Exception as e: # pylint: disable=broad-except
            print("could not connect to AP, retrying: ", e)
            for ap in esp.scan_networks():
                print("%-24s RSSI: %d" % (str(ap["ssid"], "utf-8"), ap["rssi"]))
            continue
    print("Connected to WiFi")

def terminal_label(text, width_in_chars, palette, x, y):
    label = displayio.TileGrid(terminalio.FONT.bitmap, pixel_shader=palette,
        width=width_in_chars, height=1, tile_width=glyph_width,
        tile_height=glyph_height)
    label.x = x
    label.y = y
    term = terminalio.Terminal(label, terminalio.FONT)
    term.write(f"{text: ^{width_in_chars-1}}")
    return label

def terminal_palette(fg=0xffffff, bg=0):
    p = displayio.Palette(2)
    p[0] = bg
    p[1] = fg
    return p

class WrappedTextDisplay:
    def __init__(self):
        self.line_offset = 0
        self.lines = []

    def add_text(self, text):
        self.lines.extend(wrap_text_to_pixels(text, use_width, nice_font))

    def set_text(self, text):
        self.lines = wrap_text_to_pixels(text, use_width, nice_font)
        self.line_offset = 0

    def scroll_to_end(self):
        self.line_offset = self.max_offset()

    def scroll_next_line(self):
        max_offset = self.max_offset()
        if max_offset > 0:
            line_offset = self.line_offset + 1
            self.line_offset = line_offset % (max_offset + 1)

    def max_offset(self):
        return max(0, len(self.lines) - max_lines)

    def on_last_line(self):
        return self.line_offset == self.max_offset()

    def refresh(self):
        text = '\n'.join(self.lines[self.line_offset : self.line_offset + max_lines])
        # Work around https://github.com/adafruit/Adafruit_CircuitPython_Display_Text/issues/183
        while '\n\n' in text:
            text = text.replace('\n\n', '\n \n')
        terminal.text = text
        board.DISPLAY.refresh()
wrapped_text_display = WrappedTextDisplay()

def print_wrapped(text):
    wrapped_text_display.set_text(text)
    wrapped_text_display.refresh()

def make_full_prompt(action):
    return session + [{"role": "user", "content": f"PLAYER: {action}"}]

def record_game_step(action, response):
    session.extend([
        {"role": "user", "content": f"PLAYER: {action}"},
        {"role": "assistant", "content": response.strip()},
    ])
    # Keep a limited number of exchanges in the prompt
    del session[1:-5]

def get_one_completion(full_prompt):
    if not use_openai:
        return f"""\
This is a canned response in offline mode. The player's last choice was as follows:
    {full_prompt[-1]['content']}

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \
incididunt ut labore et dolore magna aliqua. Nulla aliquet enim tortor at \
auctor urna. Arcu ac tortor dignissim convallis aenean et tortor at. Dapibus \
ultrices in iaculis nunc sed augue. Enim nec dui nunc mattis enim ut tellus \
elementum sagittis. Sit amet mattis vulputate enim nulla. Ultrices in iaculis \
nunc sed augue lacus. Pulvinar neque laoreet suspendisse interdum consectetur \
libero id faucibus nisl. Aenean pharetra magna ac placerat vestibulum lectus \
mauris ultrices eros. Imperdiet nulla malesuada pellentesque elit eget. Tellus \
at urna condimentum mattis pellentesque id nibh tortor. Velit dignissim sodales \
ut eu sem integer vitae. Id ornare arcu odio ut sem nulla pharetra diam sit.

1: Stand in the place where you live
2: Now face West
3: Think about the place where you live
4: Wonder why you haven't before
    """.strip()
    try:
        response = requests.post(
            "https://api.openai.com/v1/chat/completions",
            json={"model": "gpt-3.5-turbo", "messages": full_prompt},
            headers={
                "Authorization": f"Bearer {openai_api_key}",
            },
        )
    except Exception as e: # pylint: disable=broad-except
        print("requests exception", e)
        return None
    if response.status_code != 200:
        print("requests status", response.status_code)
        return None
    j = response.json()
    result = j["choices"][0]["message"]["content"]
    return result.strip()

def get_touchscreen_choice():
    # Wait for screen to be released
    while ts.touch_point:
        pass

    # Wait for screen to be pressed
    touch_count = 0
    deadline = ticks_add(ticks_ms(), 1000)
    while True:
        t = ts.touch_point
        if t is not None:
            touch_count += 1
            if touch_count > 5:
                break
        else:
            touch_count = 0
            if wrapped_text_display.max_offset() > 0 and ticks_less(deadline, ticks_ms()):
                wrapped_text_display.scroll_next_line()
                wrapped_text_display.refresh()
                deadline = ticks_add(deadline,
                        5000 if wrapped_text_display.on_last_line() else 1000)

    # Depending on the quadrant of the screen, make a choice
    x, y, _ = t
    result = 1
    if x > board.DISPLAY.width / 2:
        result = result + 1
    if y > board.DISPLAY.height / 2:
        result = result + 2
    return result

def run_game_step(forced_choice=None):
    if forced_choice:
        choice = forced_choice
    else:
        choice = get_touchscreen_choice()
    wrapped_text_display.add_text(f"\nPLAYER: {choice}")
    wrapped_text_display.scroll_to_end()
    wrapped_text_display.refresh()

    prompt = make_full_prompt(choice)
    for _ in range(3):
        result = get_one_completion(prompt)
        if result is not None:
            break
    else:
        raise ValueError("Error getting completion from OpenAI")
    print(result)
    wrapped_text_display.set_text(result)
    wrapped_text_display.refresh()

    record_game_step(choice, result)

if use_openai:
    # Only set up wifi if using openai
    set_up_wifi()

# Set up the touchscreen
# These pins are used as both analog and digital! XL, XR and YU must be analog
# and digital capable. YD just need to be digital
ts = adafruit_touchscreen.Touchscreen(
    board.TOUCH_XL,
    board.TOUCH_XR,
    board.TOUCH_YD,
    board.TOUCH_YU,
    calibration=touchscreen_calibration,
    size=(board.DISPLAY.width, board.DISPLAY.height)
)

# Set up the 4 onscreen buttons & embedded terminal
main_group = displayio.Group()
main_group.x = 4
main_group.y = 4

# Determine the size of everything
glyph_width, glyph_height = terminalio.FONT.get_bounding_box()
use_height = board.DISPLAY.height - 4
use_width = board.DISPLAY.width - 4

# Game text is displayed on this wdget
terminal = Label(
    font=nice_font,
    color=0xFFFFFF,
    background_color=0,
    line_spacing=line_spacing,
    anchor_point=(0, 0),
    anchored_position=(0, glyph_height + 1),
)
max_lines = (use_height - 2 * glyph_height) // int(
    nice_font.get_bounding_box()[1] * terminal.line_spacing
)
main_group.append(terminal)

# Indicate what each quadrant of the screen does when tapped
label_width = use_width // (glyph_width * 2)
main_group.append(terminal_label('1', label_width, terminal_palette(0, 0xffff00), 0, 0))
main_group.append(terminal_label('2', label_width, terminal_palette(0, 0x00ffff),
    use_width - label_width*glyph_width, 0))
main_group.append(terminal_label('3', label_width, terminal_palette(0, 0xff00ff),
    0, use_height-glyph_height))
main_group.append(terminal_label('4', label_width, terminal_palette(0, 0x00ff00),
    use_width - label_width*glyph_width, use_height-glyph_height))

# Show our stuff on the screen
board.DISPLAY.auto_refresh = False
board.DISPLAY.root_group = main_group
board.DISPLAY.refresh()

# Track the game so far. ALways start with the base prompt.
session = [
        {"role": "system", "content": base_prompt.strip()},
]

try:
    run_game_step("New game")
    while True:
        run_game_step()
except Exception as e: # pylint: disable=broad-except
    traceback.print_exception(e) # pylint: disable=no-value-for-parameter
    print_wrapped("An error occurred (more details on REPL).\nTouch the screen to re-load")
    board.DISPLAY.refresh()
    get_touchscreen_choice()
    supervisor.reload()

There's a substantial amount of code in this project. This page focuses on just a few key parts.

Customization

# Use
# https://github.com/adafruit/Adafruit_CircuitPython_Touchscreen/blob/main/examples/touchscreen_calibrator_built_in.py
# to calibrate your touchscreen
touchscreen_calibration=((6616, 60374), (8537, 57269))

# set to True to use openapi, False for quicker testing of the rest of the game
# logic
use_openai = True

# Place the key in your settings.toml file
openai_api_key = os.getenv("OPENAI_API_KEY")

# Select a 14-point font for the PyPortal titano, 10-point for original & Pynt  
if board.DISPLAY.width > 320:                                                   
    nice_font = load_font("helvR14.pcf")                                        
else:                                                                           
    nice_font = load_font("helvR10.pcf")                                        
line_spacing = 0.75

After the required imports (not shown), the first customization section includes touchscreen calibration. You can also temporarily disable use of OpenAI, which is useful when testing changes to the program's UI (it's quicker and doesn't make API calls that can cost you money).

The API key is retrieved from the settings.toml file on the CIRCUITPY drive.

A 10-point or 14-point font is selected depending on the display resolution (14-point for the Titano, 10-point for the original and Pynt). This particular font needs a reduced line spacing. You can substitute another font of your choice, but you may also need to change the line_spacing at the same time.

Creating the game scenario through the base prompt

# Customize this prompt as you see fit to create a different experience
base_prompt = """
You are an AI helping the player play an endless text adventure game. You will stay in character as the GM.

The goal of the game is to save the Zorque mansion from being demolished. The game starts outside the abandoned Zorque mansion.
        
As GM, never let the player die; they always survive a situation, no matter how harrowing.

At each step:         
    * Offer a short description of my surroundings (1 paragraph)
    * List the items I am carrying, if any
    * Offer me 4 terse numbered action choices (1 or 2 words each)

In any case, be relatively terse and keep word counts small.

In case the player wins (or loses) start a fresh game.
"""

This block of text, which is sent to OpenAI with each completion request, sets the ground rules of the game. I crafted it by trial and error, especially when it came to the problem of how to keep the API from producing too much text to fit on the PyPortal screen. Of course, since things aren't clear cut like programming a computer in a traditional language, it's impossible to set a precise character limit; even "1 paragraph" and "be relatively terse" are treated as guidelines at best.

Want to play a different kind of game? You don't have to code in a computer language, you just write it here within the base prompt in plain English.

Feel like a sci-fi game? Try changing out part of the prompt like so:

The game is set on an unexplored alien planet. The goal is to repair the ship's Zorque Drive and escape.

The planet is dangerous and the player's survival is not assured! However, a fresh body is always available from the ship's cloning vat.

Or why not some light fantasy?

The player is a Princess and her task is to save the kingdom from a dragon that has taken up residence on a nearby hillside. (not necessarily by force, the Princess is more creative than violent)

You can even translate the whole game into another language by simply translating the prompt, done here by Google Translate:

Vous êtes une IA aidant le joueur à jouer à un jeu d'aventure textuel sans fin. Vous resterez dans votre personnage en tant que MJ.

Le but du jeu est de sauver le manoir Zorque de la démolition. Le jeu commence à l'extérieur du manoir abandonné de Zorque.
        
En tant que MJ, ne laissez jamais le joueur mourir; il survivent toujours à une situation, aussi pénible soit-elle.

A chaque étape :
     * Offrir une brève description de mon environnement (1 paragraphe)
     * Énumérez les articles que je transporte, le cas échéant
     * Offrez-moi 4 choix d'action numérotés succincts (1 ou 2 mots chacun)

Dans tous les cas, soyez relativement concis et gardez un petit nombre de mots.
    
Si le joueur gagne (ou perd), recommencez une nouvelle partie.

Interacting with the OpenAI "chat completion" API

# Track the game so far. Always start with the base prompt.
session = [
        {"role": "system", "content": base_prompt.strip()},
]

def make_full_prompt(action):
    return session + [{"role": "user", "content": f"PLAYER: {action}"}]

def record_game_step(action, response):
    session.extend([
        {"role": "user", "content": f"PLAYER: {action}"},
        {"role": "assistant", "content": response.strip()},
    ])
    # Keep a limited number of exchanges in the prompt
    del session[1:-5]

The list session keeps track of the "state of the game". It consists of:

  • One 'system' message, containing the base prompt
  • A limited number of the most recent exchanges between the "user" and the "assistant"

The "slice deletion" del session[1:-5] removes items from the middle of the list, keeping the initial item (item 0) and up to 5 items at the end of the list. This means that anything that happened more than 3 "moves" ago is forgotten by the game (because each "move" is two items, a "user" item and an "assistant" item; the 5 preserved items will be 3 "assistant" responses with two "user" actions). This is necessary both because there's an absolute upper limit to the size of a prompt, and because calling the completion API is more costly the longer the prompt is.

To create a full prompt, the user's choice is added to a copy of the session.

def get_one_completion(full_prompt):
    if not use_openai:
        return f"""This is a canned response in offline mode…""".strip()
    try:
        response = requests.post(    
            "https://api.openai.com/v1/chat/completions",
            json={"model": "gpt-3.5-turbo", "messages": full_prompt},
            headers={
                "Authorization": f"Bearer {openai_api_key}",
            },
        )
    except Exception as e: # pylint: disable=broad-except
        print("requests exception", e)
        return None
    if response.status_code != 200:
        print("requests status", response.status_code)
        return None
    j = response.json()
    result = j["choices"][0]["message"]["content"]
    return result.strip()

This function takes a "full prompt" which the Python list returned by make_full_prompt above.

If use of OpenAI is disabled, a canned response is returned instead of querying the API.

Otherwise, the request is sent to the API endpoint using the adafruit_requests library. The API key is included for authorization.

Various kinds of errors are handled; in case of almost any error, None is returned. Elsewhere in the code, the completion will be attempted 3 times before giving up.

When the request is successful, the text response is at j["choices"][0]["message"]["content"], and any whitespace at the start or end is removed from the response.

For more about the Chat Completion API, OpenAI provides their own guide and API reference. There are many other APIs provided by OpenAI, and they can be accessed by changing the post URL and post data appropriately.

def run_game_step(forced_choice=None):
    if forced_choice:
        choice = forced_choice
    else:
        choice = get_touchscreen_choice()
    print_wrapped(f"\n\nPLAYER: {choice}")
    prompt = make_full_prompt(choice)
    for _ in range(3):
        result = get_one_completion(prompt)
        if result is not None:
            break
    else:
        raise ValueError("Error getting completion from OpenAI")
    print(result)
    terminal.write(clear)
    print_wrapped(result)

    record_game_step(choice, result)

This function performs one step within the game. It can optionally take a 'forced choice' which is used to make the player request a 'new game' at the start of every session. Otherwise, the function get_touchscreen_choice returns a number from 1 to 4 depending on where the PyPortal's screen is touched. (this function, not shown, also handles vertically scrolling the screen in case it doesn't all fit at once) The OpenAI API call is retried up to 3 times in the case of network problems.

run_game_step("New game")
while True:
    run_game_step()

And playing the game consists of simply doing a step of the game forever! (together with some more error handling code, not shown)

This guide was first published on Mar 10, 2023. It was last updated on Mar 24, 2023.