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 28, 2024.

This page (Code Walkthrough) was last updated on Mar 08, 2024.

Text editor powered by tinymce.