├── README.md ├── advent.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | # GPT Adventure Game Engine 2 | 3 | This is a text adventure game engine that uses an AI language model to 4 | generate all game elements (plot, locations, objects, etc). 5 | 6 | For example: 7 | 8 | ``` 9 | The Quest of the Lost Talisman 10 | 11 | You are a brave adventurer who has been called upon to save the 12 | kingdom from an evil sorcerer who has stolen the talisman that holds 13 | the key to immortality. You must journey through treacherous terrain 14 | and overcome many obstacles to retrieve the talisman and save the 15 | kingdom. 16 | 17 | Type your instructions using one or two words, for example: 18 | 19 | > look 20 | > take $object 21 | > look at $object 22 | > inventory 23 | > go north 24 | > drop $object 25 | > ? 26 | 27 | What do you want to do? 28 | ``` 29 | 30 | Every detail in the game is generated by a LLM (gpt-3.5-turbo by default). 31 | 32 | 33 | ## Playing 34 | 35 | The player will be prompted to interact with the game by typing 36 | commands. The game will then respond to the player's commands with text 37 | generated by OpenAI's language model. 38 | 39 | The game engine includes the following default actions: 40 | 41 | * `look`: Describes the current location. 42 | * `look `: Describes that object. 43 | * `take `: Take an object if it is in the same room. 44 | * `drop `: Drop an object if the player is carrying it. 45 | * `go `: Move to a different location in 46 | the game. 47 | * `help`: Displays a list of possible actions. 48 | 49 | For any other action, the game engine will delegate the response to the 50 | language model, which will act as a game master, providing a response 51 | and modifying the game state as necessary. (See function: `magic_action`) 52 | 53 | 54 | ## Game Structure 55 | 56 | The game structure object contains a title, plot and entities (player, 57 | locations, and objects). 58 | 59 | Each entity has a type, a position, a short description, and a long 60 | description. Locations also have `exits` ("north", "south", "east", 61 | "west"). 62 | 63 | 64 | ## Modifying 65 | 66 | The script can be extended by modifying the `GAME_TEMPLATE`, the prompts 67 | or by adding additional game actions. 68 | 69 | 70 | # AUTHOR 71 | 72 | Nelson Ferraz ([@nferraz](https://twitter.com/nferraz)) 73 | 74 | 75 | # DEVELOPMENT / SUPPORT 76 | 77 | This module is developed on 78 | [GitHub](https://github.com/nferraz/gpt-adventures). 79 | 80 | Send ideas, feedback, tasks, or bugs to 81 | [GitHub Issues](https://github.com/nferraz/gpt-adventures/issues). 82 | -------------------------------------------------------------------------------- /advent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import openai 5 | import os 6 | import pdb 7 | import re 8 | 9 | from random import randint 10 | from textwrap import dedent, fill 11 | 12 | GPT_MODEL = os.environ.get('GPT_MODEL', 'text-davinci-003') 13 | 14 | GAME_TEMPLATE = { 15 | '_title': '$game_title', 16 | '_genre': '$game_genre', 17 | '_objective': '$game_objective', 18 | '_plot': '$game_plot', 19 | 'entities': [ 20 | { 21 | 'type': 'location', 22 | 'exits': { 23 | 'north': '$location2_name', 24 | 'south': '$location3_name', 25 | }, 26 | 'short_description': 'a $short_description', 27 | 'long_description': 'You are in a $long_description', 28 | 'name': '$location1_name', 29 | 'adjective': '$single_word', 30 | }, 31 | { 32 | 'type': 'player', 33 | 'class': '$class', 34 | 'alive': True, 35 | 'location': '$location1_name', 36 | 'short_description': 'a $short_description', 37 | 'long_description': 'You are $long_description', 38 | }, 39 | { 40 | 'type': 'object', 41 | 'short_description': 'a $short_description', 42 | 'long_description': 'It\'s a $long_description', 43 | 'name': '$single_word', 44 | 'adjective': '$single_word', 45 | 'location': 'player', 46 | }, 47 | { 48 | 'type': 'object', 49 | 'short_description': 'a $short_description', 50 | 'long_description': 'It\'s a $long_description', 51 | 'name': '$single_word', 52 | 'adjective': '$single_word', 53 | 'location': '$location1_name', 54 | }, 55 | ], 56 | } 57 | 58 | 59 | def DEBUG(*msg): 60 | if os.environ.get('DEBUG'): 61 | print("\x1b[90m", *msg, "\x1b[0m") 62 | 63 | 64 | def DEBUG2(*msg): 65 | if int(os.environ.get('DEBUG', '0')) > 1: 66 | print("\x1b[90m", *msg, "\x1b[0m") 67 | 68 | 69 | def _get_entity_by_name(game, entity_name): 70 | for e in game['entities']: 71 | if e.get('name', '') == entity_name: 72 | return e 73 | return None 74 | 75 | 76 | def _get_entity_by_type(game, entity_type): 77 | for e in game['entities']: 78 | if e.get('type', '') == entity_type: 79 | return e 80 | return None 81 | 82 | 83 | ### AI text generation ### 84 | 85 | 86 | openai.api_key = os.environ.get('OPENAI_API_KEY') 87 | 88 | 89 | def _completion(prompt): 90 | if GPT_MODEL == 'text-davinci-003': 91 | res = openai.Completion.create( 92 | engine="text-davinci-003", 93 | prompt=prompt, 94 | max_tokens=2048, 95 | ) 96 | return res.choices[0].text 97 | elif GPT_MODEL == 'gpt-3.5-turbo': 98 | res = openai.ChatCompletion.create( 99 | model="gpt-3.5-turbo", 100 | messages=[{"role": "system", 101 | "content": 102 | "You are a software agent. You output will be strict JSON, with no indentation."}, 103 | {"role": "user", "content": prompt}],) 104 | return res.choices[0].message.content 105 | else: 106 | raise Exception('Invalid GPT model') 107 | 108 | 109 | def _generate_content(prompt, str_type): 110 | print(f"# Generating {str_type}...") 111 | prompt = dedent(prompt).lstrip() 112 | 113 | DEBUG2(prompt) 114 | 115 | json_str = None 116 | try: 117 | json_str = _completion(prompt) 118 | except BaseException: 119 | print(prompt) 120 | raise 121 | 122 | # fix malformed json 123 | json_str = re.sub(r',\s*}', '}', json_str) 124 | 125 | DEBUG2(json_str) 126 | 127 | try: 128 | data = json.loads(json_str) 129 | except BaseException: 130 | print('Error parsing JSON: ' + json_str) 131 | raise 132 | 133 | return data 134 | 135 | 136 | def generate_world(game): 137 | prompt = """ 138 | Given this json data structure that represents a text-adventure game, 139 | please replace all variables starting with a dollar sign (`$`) with 140 | rich descriptions. 141 | 142 | INPUT: 143 | 144 | $json 145 | 146 | OUTPUT (strict json): 147 | """ 148 | 149 | json_str = json.dumps(game) 150 | prompt = prompt.replace("$json", json_str) 151 | 152 | game = _generate_content(prompt, 'game') 153 | DEBUG(game) 154 | 155 | player = _get_entity_by_type(game, "player") 156 | 157 | # mark initial location as seen 158 | player_location = _get_entity_by_name(game, player['location']) 159 | player_location["seen"] = True 160 | 161 | # make sure all object names are lowercase 162 | for entity in game['entities']: 163 | if entity.get('type', '') == 'object': 164 | entity['name'] = entity['name'].lower() 165 | 166 | return game 167 | 168 | 169 | def generate_location(game, location): 170 | player = _get_entity_by_type(game, "player") 171 | 172 | prompt = ''' 173 | Given this json data structure that represents a text-adventure game, 174 | please create a new entity of type "location", named "{0}". 175 | 176 | Populate this new location with all necessary attributes, including 177 | rich new descriptions, following the same atmosphere of the previous 178 | locations. 179 | 180 | Most locations in the game should have at least two exits (usually 181 | "north", "south", "east" or "west"; sometimes "up" or "down"); and 182 | each exit should have a distinct name. One exit should go back to 183 | "{1}". 184 | 185 | Don't return the complete game JSON. Return the JSON for the data 186 | structure corresponding to the new entity. 187 | 188 | INPUT: 189 | 190 | {2} 191 | 192 | OUTPUT (strict json): 193 | '''.format( 194 | location, 195 | player['location'], 196 | json.dumps(game)) 197 | 198 | location = _generate_content(prompt, 'location') 199 | location['seen'] = False 200 | 201 | return location 202 | 203 | 204 | def create_object(game, location): 205 | prompt = ''' 206 | Given this json data structure that represents a text-adventure game, 207 | please create a new entity of type "object" in the location "{0}". 208 | 209 | Populate this new object with all necessary attributes, including a 210 | single-word name and rich descriptions, following the same atmosphere 211 | of the game. 212 | 213 | Make sure that the short description contains the object name. 214 | 215 | Don't return the complete game JSON. Return the JSON for the data 216 | structure corresponding to the new entity. 217 | 218 | INPUT: 219 | 220 | {1} 221 | 222 | OUTPUT (strict json): 223 | '''.format( 224 | location, 225 | json.dumps(game)) 226 | 227 | obj = _generate_content(prompt, 'object') 228 | 229 | return obj 230 | 231 | 232 | def magic_action(game, sentence): 233 | 234 | game['output'] = '$output' 235 | 236 | prompt = ''' 237 | Given this json data structure that represents a text-adventure game: 238 | 239 | {0} 240 | 241 | The user typed the following command: "{1}". 242 | 243 | Consider the player class and the game context to see if the action 244 | can be performed. 245 | 246 | Replace the "output" value with a description of the action result, and 247 | modify the data structure reflecting any changes. 248 | 249 | Some important points to consider: 250 | 251 | 1) Embrace creativity: Encourage your players to think outside the box 252 | and reward them for their creativity. 253 | 254 | 2) While it's important to be flexible, it's also important to ensure 255 | that the game world remains consistent. 256 | 257 | 3) Consider the consequences: Every action has consequences, both 258 | intended and unintended. 259 | 260 | If the action can be performed, modify the game properties as 261 | necessary to reflect the changes caused by this action. You may 262 | change player attributes, objects, and locations as necessary. 263 | 264 | No matter what, return the complete JSON data structure for the game 265 | including the "output" explaining what happened. 266 | 267 | OUTPUT (strict json): 268 | '''.format( 269 | json.dumps(game), 270 | sentence) 271 | 272 | game = _generate_content(prompt, 'action') 273 | 274 | if 'output' in game: 275 | print(fill(game['output'])) 276 | del game['output'] 277 | 278 | return game 279 | 280 | 281 | ### auxiliar functions ### 282 | 283 | 284 | def _clean_sentence(sentence): 285 | stopwords = ['the', 'a', 'an', 'at', 'of', 'to', 'in', 'on'] 286 | words = sentence.lower().split() 287 | clean_words = [word for word in words if word not in stopwords] 288 | return ' '.join(clean_words) 289 | 290 | 291 | def _list_exits_from(game, location): 292 | return sorted(location['exits'].keys()) 293 | 294 | 295 | def _list_objects_in(game, location): 296 | entities = game["entities"] 297 | 298 | objects_here = sorted([entity for entity in entities 299 | if entity["type"] == "object" and entity 300 | ["location"] == location["name"]]) 301 | 302 | return objects_here 303 | 304 | 305 | ### game actions ### 306 | 307 | 308 | def help(): 309 | print(dedent(''' 310 | Type your instructions using one or two words, for example: 311 | 312 | > look 313 | > take $object 314 | > look at $object 315 | > inventory 316 | > go north 317 | > drop $object 318 | > ? 319 | ''')) 320 | 321 | 322 | def take(game, entity): 323 | player = _get_entity_by_type(game, 'player') 324 | 325 | if entity.get('type') != 'object' or entity.get( 326 | 'location') != player['location']: 327 | print("You can't take that.") 328 | return 329 | 330 | entity['location'] = 'player' 331 | print("Taken!") 332 | 333 | 334 | def drop(game, entity): 335 | player = _get_entity_by_type(game, 'player') 336 | 337 | if entity.get('type') != 'object' or entity.get('location') != 'player': 338 | print("You can't drop that.") 339 | return 340 | 341 | entity['location'] = player['location'] 342 | print("Dropped!") 343 | 344 | 345 | def go(game, direction): 346 | player = _get_entity_by_type(game, 'player') 347 | player_location = _get_entity_by_name(game, player['location']) 348 | 349 | new_location_name = player_location['exits'].get(direction) 350 | 351 | if new_location_name is None: 352 | print("You can't go there.") 353 | return 354 | 355 | new_location = _get_entity_by_name(game, new_location_name) 356 | 357 | if new_location is None or len(new_location) == 0: 358 | new_location = generate_location(game, new_location_name) 359 | game['entities'].append(new_location) 360 | 361 | player['location'] = new_location_name 362 | print(fill(new_location['long_description'])) 363 | 364 | 365 | def _look_around(game): 366 | player = _get_entity_by_type(game, 'player') 367 | player_location = _get_entity_by_name(game, player['location']) 368 | 369 | print(fill(player_location['long_description'])) 370 | print("") 371 | print("I see here:") 372 | 373 | if not player_location["seen"]: 374 | # special case: this room was just created 375 | player_location["seen"] = True 376 | new_object = create_object(game, player_location['name']) 377 | if len(new_object): 378 | game['entities'].append(new_object) 379 | 380 | objects_here = _list_objects_in(game, player_location) 381 | 382 | if objects_here: 383 | print("; ".join(obj['short_description'] for obj in objects_here)) 384 | else: 385 | print("Nothing special.") 386 | 387 | print("") 388 | print("Exits: ", "; ".join(_list_exits_from(game, player_location))) 389 | 390 | 391 | def _look_object(game, obj): 392 | entities = game['entities'] 393 | player = _get_entity_by_type(game, 'player') 394 | 395 | for e in entities: 396 | if e.get('name') == obj['name'] and ( 397 | e['location'] == player['location'] or 398 | e['location'] == 'player'): 399 | print(obj['long_description']) 400 | return 401 | 402 | print("I can't see that.") 403 | 404 | 405 | def look(game, obj=None): 406 | if obj is None: 407 | _look_around(game) 408 | else: 409 | _look_object(game, obj) 410 | 411 | 412 | def inventory(game): 413 | objects = [e for e in game['entities'] if e['type'] 414 | == 'object' and e['location'] == 'player'] 415 | 416 | print("You are carrying:") 417 | 418 | if objects: 419 | print("; ".join(sorted([obj['short_description'] for obj in objects]))) 420 | else: 421 | print("Nothing special.") 422 | 423 | 424 | if __name__ == '__main__': 425 | game = generate_world(GAME_TEMPLATE) 426 | 427 | player = _get_entity_by_type(game, 'player') 428 | current_location = _get_entity_by_name(game, player['location']) 429 | 430 | print(game['_title']) 431 | print("") 432 | print(fill(game['_plot'])) 433 | 434 | help() 435 | 436 | # define a dictionary to map verbs to functions 437 | VERB_TO_FUNCTION = { 438 | 'quit': lambda game: exit(), 439 | 'look': lambda game, *objects: look(game, *objects), 440 | 'inventory': lambda game: inventory(game), 441 | 'go': lambda game, direction: go(game, direction), 442 | 'take': lambda game, obj_name: take(game, obj_name), 443 | 'drop': lambda game, obj_name: drop(game, obj_name), 444 | 'help': lambda game: help(), 445 | 'debug': lambda game: breakpoint(), 446 | '?': lambda game: print(game), 447 | } 448 | 449 | # main game loop 450 | while player['alive']: 451 | sentence = input("What do you want to do? ") 452 | verb, *object_names = _clean_sentence(sentence).split() 453 | print("") 454 | 455 | function = VERB_TO_FUNCTION.get(verb, None) 456 | 457 | if function is None or len(object_names) > 1: 458 | # LLM magic!!! 459 | game = magic_action(game, sentence) 460 | print("") 461 | continue 462 | 463 | entities = filter( 464 | None, 465 | [_get_entity_by_name(game, name) for name in object_names] 466 | ) 467 | 468 | # special case: go 469 | if object_names and object_names[0] in [ 470 | 'north', 'south', 'east', 'west']: 471 | entities = object_names 472 | 473 | try: 474 | function(game, *entities) 475 | except Exception as e: 476 | print(e) 477 | print(game) 478 | 479 | print("") 480 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | openai==0.27.2 2 | --------------------------------------------------------------------------------