Aus Linux-Magazin 07/2025

Mit LLMs Spielfiguren steuern oder komplette Spielinhalte erzeugen

© diter / 123rf.com

Mit aktueller KI-Technik lässt sich ein Murder-Mystery-Game entwerfen, in dem der Spieler der Detektiv ist und ChatGPT die Rolle aller Verdächtigen übernimmt. Selbst einen Kriminalfall generierte das Large Language Model (LLM). Python und Streamlit machen daraus ein Webspiel.

Das klassische Schema eines Krimis: Ein Mord passiert. Fünf oder sechs Verdächtige treten auf. Eine Detektivin oder ein Detektiv ermittelt, bis das Verbrechen aufgeklärt ist. Wer das in ein spannendes Computerspiel gießen möchte, steht vor allem vor dem Herausforderung, möglichst interessante Verhöre zu kreieren. Warum diese Aufgabe nicht einem LLM wie ChatGPT übertragen, dessen Kernfunktionalität die Kommunikation mit dem Anwender ist?

Sobald ein Kriminalfall gelöst ist, wäre es außerdem schön, wenn das Spiel direkt den nächsten generiert. Auch das erledigt ChatGPT. Der Spieler gibt einen Schauplatz vor und das Chatmodell erzeugt sämtliche Figuren, alle Schauplätze und den kompletten Ablauf.

Für die Umsetzung kommt für diesen Artikel Python zusammen mit der Oberflächenbibliothek Streamlit [1] zum Einsatz, die aus einem Python-Programm eine Webapp macht. Das Beispielprogramm nutzt die API für ChatGPT von OpenAI zur Kommunikation mit dem Chatmodell.

Das Grundsätzliche zum Einsatz von Chatmodellen in einem Spiel zusammen mit Python und Streamlit zeigt der Artikel “Künstliche Fantasie” [2] aus Ausgabe 04/2025 des Linux-Magazins. Nun konzentrieren wir uns auf das Steuern der Spielcharaktere und das Generieren neuer Inhalte.

Spielaufbau

Das Murder-Mystery-Spiel besteht aus vier Programm-Teilen:

  • »app.py«: Das eigentliche Spiel und Hauptprogramm.
  • »app_lib.py«: Darin liegen alle Funktionen für den Spielablauf und die Bildschirmdarstellung.
  • »common.py«: Dieses Modul enthält sämtliche Funktionen, die man durchaus bei anderen Programmen mit LLMs wiederverwenden kann.
  • »prompts.py«: Die Texte zu Eingabeaufforderungen für die Text- und Bildgenerierung befinden sich in diesem Modul.

Im Fokus des Artikels stehen die Funktionen »conversate« für die Befragung des Detektivs der Verdächtigen und die Funktion »generate« zur Generierung aller Spielinhalte.

Personalunion

Damit ein Chatmodell weiß, was Sie von ihm erwarten – im Murder-Mystery-Spiel die Non-Player-Charaktere (NPCs) in einem Spiel zu steuern – besteht der erste Schritt darin, ihm zu erklären, was es zu tun hat und welche Rolle es genau einnehmen soll: “Du bist Emily White, ein NPC in einem Murder Mystery Game. Der Spieler ist der Detektiv.”

Damit das LLM eine möglichst passende Antwort liefern kann, fehlen zusätzliche Informationen über die zu spielende Person und die aktuelle Situation, in der sie sich im Spiel befindet. Je treffender die Beschreibung ausfällt, desto interessanter wird der Dialog (Abbildung 1). Diese Informationen bekommt das Chatmodell nur beim ersten Kontakt mit dem Spieler. Bei allen weiteren Gesprächen sind sie bereits bekannt.

Abbildung 1: Für jeden Verdächtigen gibt es eine detaillierte Beschreibung, die ChatGPT als Rollenvorlage erhält.

Abbildung 1: Für jeden Verdächtigen gibt es eine detaillierte Beschreibung, die ChatGPT als Rollenvorlage erhält.

Bei der technischen Umsetzung in Python gibt es eine Liste »npcs« mit sämtlichen NPCs im Spiel und den zugehörigen Informationen: »st.session_state.npcs«. Die Liste füllt das Programm zu Beginn aus der JSON-Datei »npcs.json« – dazu später mehr. Da die Liste »npcs« in der zentralen Datenstruktur »st.session_state« von Streamlit abgelegt ist, können unterschiedliche Funktionen damit arbeiten.

Sobald der Detektiv in der Spieloberfläche auf eine verdächtige Person trifft, sucht das Programm (Listing 1) den entsprechenden Eintrag für die Figur in der Liste »npcs« und startet mit diesem die Funktion »conversate«. Die führt den Dialog (Abbildung 3) mit dem Spieler durch (Listing 2). Die Variable »npc« enthält den Eintrag der aktuellen Spielfigur aus der Liste »npcs«.

Listing 1

interact_npc prüft, auf welchen Verdächtigen der Spieler trifft

def interact_npc():
  for npc in st.session_state.npcs:
      if (st.session_state.player_position[0] == npc["x"] and
              st.session_state.player_position[1] == npc["y"]):
          conversate(npc)

Listing 2

conversation übernimmt die Kommunikation zwischen Spieler und LLM

@st.dialog("Talk to", width="large")
def conversate(npc):
  st.image(BASE_DIRECTORY_CASES + '/' + st.session_state.case_name +
'/assets/' + npc["image"], width=200)
  st.markdown(f"**{npc['name']}**: {npc['description']}")
  # -- erstes Gespräch mit Detektiv
  if "messages" not in npc:
    if npc["name"] == st.session_state.mmg['killer']:
        taeter_verdaechtig = "Du bist der Täter.
        Das darfst du den Spieler(Detektiv) auf keinen Fall verraten."
    else:
          taeter_verdaechtig = "Du bist zwar verdächtig,
          aber du hast nichts getan."
    timeline = ""
    for j in st.session_state.timeline[npc['name']]:
        timeline += f'Um {j["time"]} bis du im {j["location"]}\n'
        timeline += f'Deine Aktivität: {j["activity"]}\n'
    timeline += "\n"
    content = prompt.FIRST_TALK.format(
        name=npc['name'],
        description=npc["description"],
        story=st.session_state.mmg['story'],
        backstory=npc["backstory"],
        appearance=npc["appearance"],
        psychological_profile=npc["psychological_profile"],
        possible_motive=npc["possible_motive"],
        taeter_verdaechtig=taeter_verdaechtig,
        timeline=timeline
    )
    npc["messages"] = [
        {
            'role': 'system',
            'content': content
        }
    ]
  else: # -- alle weiteren Gespräche mit dem Detektiv
    content = f""" Aktuelle Spielsituation: Du sprachst bereits mit dem
    Spieler(Detektiv). Er verwickelt dich erneut in ein Gespräch."""
    npc['messages'].append(
        {
            'role': 'system',
            'content': content
        }
    )
  # -- Frage des Detektiv
  change_chatbot_style()
  if prompt := st.chat_input("Deine Frage"):
    npc['messages'].append(  # save prompt
        {
            'role': "user",
            'content': prompt
        }
    )
  for message in npc['messages']:  # Display the prior chat messages
    if message['role'] != "system":
        with st.chat_message(message['role']):
            st.write(message['content'])
  # -- Antwort des LLMs
  if npc['messages'][-1]['role'] != "assistant" and npc['messages'][-
1]['role'] != "system":
    with st.chat_message("assistant"):
        with st.spinner("Thinking..."):
            response = st.session_state.client.chat.completions.create(
                model=st.session_state.model,
                messages=npc['messages']
            )
            p = response.choices[0].message.content
            st.write(p)
            message = {
                'role': "assistant",
                'content': p
            }
            npc['messages'].append(message)  # Add response to message
            history
Abbildung 2: Das LLM spielt die Rolle der Verdächtigen im Dialog mit dem Spieler.

Abbildung 2: Das LLM spielt die Rolle der Verdächtigen im Dialog mit dem Spieler.

Sie erstellen einen modalen Dialog in Streamlit, in dem Sie die entsprechende Funktion im Programm mit dem Text »@st.dialog« (Python-Decorator, erste Zeile) versehen. Er umfasst auch die Beschriftung des Fensters »Talk to«. Sobald das Programm diese Funktion aufruft, erscheint der Dialog: »if “messages” not in npc:« (Zeile 7).

Die Funktion »conversate« überprüft zunächst, ob der aktuelle NPC einen Eintrag »messages« hat, in dem der gesamte Dialog mit dem Spieler gespeichert ist. Trifft das nicht zu, handelt es sich um den ersten Kontakt zwischen Detektiv und diesem Verdächtigen. Dann bekommt das Chatmodell in den Zeilen 16 und 17 eine ausführliche Beschreibung, wie bereits erläutert.

Die Konstante »FIRST_TALK« enthält den Text für die erste Anfrage, den die Funktion mit den aktuellen Bausteinen auffüllt, zum Beispiel Name und Beschreibung der Person (Zeilen 30 bis 35). Damit bekommt der NPC einen neuen Eintrag »messages« mit der ersten Nachricht mit dem zusammengestellten Eintrag »content«. Sie verfügt zusätzlich über das Feld »role«. Darin findet sich, von wem die Nachricht stammt.

Durch den Eintrag »system« weiß das Chatmodell, dass es sich um eine Steueranweisung, also eine Hintergrundanweisung, handelt. Bei »user« wäre es eine Anfrage des Anwenders und bei »assistant« die Antwort des LLMs.

Gespräch fortsetzen

Ist es nicht der erste Kontakt mit einem bestimmten Verdächtigen, geben Sie dem Chatmodell eine passende Steueranweisung, die die Funktion »conversate« wieder zur Liste »messages« hinzufügt. Danach gibt der Detektiv seine Frage (Zeile 47) ein, die ebenfalls zur Liste hinzukommt.

Wie bei Chatmodellen üblich, überträgt das Programm immer die gesamte Liste »messages« (ab Zeile 63), damit ChatGPT weiß, wie der Dialog bisher verlaufen ist. Da jede Spielfigur eine eigene Liste »messages« besitzt, spielt das Chatmodell jeden Verdächtigen unabhängig von den anderen.

Spielentwurf

Wenn ein Fall in einem Murder-Mystery-Spiel gelöst ist, braucht es einen neuen Fall. Alle Inhalte des Beispielprogramms haben LLMs erstellt. Der Spieler gibt lediglich Ort und Zeitpunkt des Mords vor. Alles andere, inklusive der Bilder, generiert die KI.

Sämtliche Daten des Spiels sind in JSON-Dateien abgelegt. Ein Fall besteht aus den Basisinformationen (»mmg.json«), den Verdächtigen (»npcs.json«), den Räumen (»rooms.json«), den Zeitabläufen (»timeline.json«) und noch ein paar Dateien mehr.

Sie müssen das Chatmodell also dazu zu bringen, genau diese Dateien zu erzeugen. Die Schwierigkeit dabei: Das LLM soll nicht irgendeinen Text zurück liefern, sondern eine JSON-Datei mit einer genau definierten Struktur. Das gelingt nicht immer. Doch die Anfrage lässt sich tunen, damit die Wahrscheinlichkeit steigt, die gewünschte JSON-Struktur zu erhalten.

Explizite Anweisungen spielen dabei die Hauptrolle – es ist entscheidend, möglichst klar zu formulieren, was man als Antwort von einem LLM erwartet. “Es ist ein Mord geschehen. Der Spieler ist der Detektiv. Der Schauplatz der Tat ist … Deine Antwort besteht nur aus der JSON-Datei mmg.json. Bitte stelle sicher, dass das JSON gültig und korrekt formatiert ist.”

Die Attribute, aus denen sich die JSON-Datei zusammensetzt, sind möglichst detailliert anzugeben (Listing 3). Zusätzlich ergibt es Sinn, ein Beispiel der gewünschten Struktur (Listing 4) in der Anfrage mitzugeben.

Listing 3

Inhalt der Attribute

title: Der Titel des Spiels.
description: Eine kurze Beschreibung des Spiels.
story: Die Hintergrundgeschichte des Mordfalls.
victim: Der Name des Mordopfers.
time: Der Zeitpunkt des Mordes. Format hh:mm
room: Der Raum, in dem der Mord stattfand.
killer: Der Name des Mörders.

Listing 4

Gewünschte Struktur

Erzeuge eine JSON-Struktur wie diese:
{
  "title": "dummy",
  "description": "dummy",
  "story": "dummy,
  "player": {
    "position": [0, 0]
  },
  "killer": "dummy"
}

Bei den Versuchen zu diesem Artikel war die Erfolgsquote ganz gut. Doch dass ein LLM in 100 Prozent der Fälle sofort exakt das tut, was Sie möchten, sollten Sie nicht erwarten.

Fall generieren

Für jede der JSON-Dateien, aus denen ein Fall im Murder-Mystery-Game besteht, gibt es eine Anfrage an das Chat-Modell. Beim Generieren der Personen ist das die Konstante »prompts.NPCS«. Sie enthält die beschriebene Anfrage (Listing 5, Zeile 23). Die Funktion »ask_llm« führt die Anfrage aus. Sie liefert als Ergebnisse den bisherigen Dialog mit dem LLM in der Variable »messages« und die generierte JSON-Struktur im Rückgabewert »npcs«.

Die Generierung neuer Spielinhalte hat einen eigenständigen Dialog (Variable »messages«), damit dem LLM bekannt ist, welche Inhalte es bereits erstellt hat, und es Zusammenhänge zwischen Personen, Räumen und Vorgängen herstellen kann (Zeile 24). Die Funktion »save_as_file« speichert die JSON-Struktur in einer Datei. Der ganze Ablauf wiederholt sich je benötigter JSON-Datei.

Listing 5

generate zur Erzeugung eines neuen Falls

def generate(location: str,
            time: datetime.time,
            base_directory_cases: str,
            llm_model: str,
            image_model: str,
            do_proof:bool = False) -> None:
  messages = [
    {
        "role": "system",
        "content": prompts.SYSTEM_GENERATE
    }
  ]
  # basis
  messages, mmg = ask_llm(messages, prompts.MMG.format(location=location,
time=time), llm_model, DEBUG)
  mmg_json = json.loads(to_json_string(mmg))
  case_path = os.path.join(base_directory_cases, mmg_json["title"])
  os.mkdir(case_path)
  data_path = os.path.join(case_path, "data")
  os.mkdir(data_path)
  save_as_file(mmg, os.path.join(data_path, "mmg.json"))
  # npcs
  messages, npcs = ask_llm(messages, prompts.NPCS, llm_model, DEBUG)
  save_as_file(npcs, os.path.join(data_path, "npcs.json"))
  # rooms
  messages, rooms = ask_llm(messages, prompts.ROOMS, llm_model, DEBUG)
  save_as_file(rooms, os.path.join(data_path, "rooms.json"))
  # timeline
  from datetime import datetime, timedelta
  dummy_date = datetime.combine(datetime.today(), time)
  new_time = dummy_date - timedelta(hours=2)
  messages, timeline = ask_llm(messages,
prompts.TIMELINE.format(start_time=new_time.strftime("%H:%M"),
end_time=time.strftime("%H:%M")),
                                llm_model, DEBUG)
  save_as_file(timeline, os.path.join(data_path, "timeline.json"))
  # hints
  messages, hints = ask_llm(messages, prompts.HINTS, llm_model, DEBUG)
  save_as_file(hints, os.path.join(data_path, "hints.json"))
  #proof
  if do_proof:
    proof(data_path)
  # images
  assets_path = os.path.join(case_path, "assets")
  os.mkdir(assets_path)
  generate_images(assets_path, data_path, messages, npcs, llm_model,
image_model)
  st.rerun()

JSON-Datei prüfen

Um sicher zu gehen, ob die vom LLM erzeugten JSON-Dateien wirklich dem gewünschten Schema entsprechen, sollten man deren Struktur und Inhalt überprüfen. Für diese Aufgabe gibt es in Python das Paket »jsonschema« (Listing 6).

Listing 6

Struktur und Inhalt der JSON-Dateien überprüfen

from jsonschema import validate
validate(instance=json_data, schema=schema)

Mit der Funktion »validate« prüft das Spiel die Struktur der JSON-Datei. Die Variable »json_data« enthält die Ausgabe des Chatmodells und die Variable »schema« eine Beschreibung der Struktur der JSON-Datei.

Die Herausforderung liegt in einer exakten Definition der Struktur nach den Vorgaben des Pakets »jsonschema«. Das wirkt etwas gewöhnungsbedürftig, aber tiefer darauf einzugehen würde diesen Artikel sprengen. Pragmatischerweise lassen Sie diese Beschreibung von ChatGPT formulieren. “Ein Python-Programm arbeitet mit dem Paket »jsonschema«. Erstelle bitte ein jsonschema für diese JSON-Datei …”

In den meisten Fällen funktionierte das erfolgreich. Bei einem nicht passenden Schema kommen Sie um ein Einarbeiten in die Dokumentation von »jsonschema« nicht herum.

Die Funktion »proof« (Listing 7) des Murder-Mystery-Games verwendet nicht die Funktion »validate« von »jsonschema«, sondern ein Objekt der Klasse »Draft7Validator« (Zeile 26), ebenfalls aus dem Python-Paket »jsonschema«. Das Objekt bringt den Vorteil mit, dass es alle Abweichungen in der Datei sucht und nicht schon bei der ersten aufhört.

Listing 7

proof prüft die JSON-Dateien

def proof(data_path: str):
  files = [
    {
        "name": "mmg.json",
        "proof": proof_data.MMG
    },
    {
        "name": "npcs.json",
        "proof": proof_data.NPCS
    },
    {
        "name": "rooms.json",
        "proof": proof_data.ROOMS
    },
    {
        "name": "timeline.json",
        "proof": proof_data.TIMELINE
    },
    {
        "name": "hints.json",
        "proof": proof_data.HINTS
    }
  ]
  for i in files:
    st.write(f'Datei {i["name"]}')
    validator = Draft7Validator(i["proof"])
    data = read_json(os.path.join(data_path, i["name"]))
    errors = sorted(validator.iter_errors(data),
                    key=lambda e: e.path)
    if errors:
        st.write("Validierungsfehler gefunden:")
        for error in errors:
            st.write(f"Fehler: {error.message}")
            if error.path:
                st.write(f"Pfad: {'/'.join(map(str, error.path))}")
            print()
    else:
        st.write("JSON-Daten sind valide.")

KI erzeugt Bilder

Sämtliche Bilder des Spiels sind KI-generiert, konkret durch Dall-E von OpenAI. Deswegen war kein zusätzlicher Anbieter mit API-Key notwendig und als Schnittstelle kam ebenfalls die OpenAI-API zum Einsatz. Für jeden Raum im Spiel existiert ein eigenes Bild.

“Du bist Grafik-Designer in einer Spielefirma und arbeitest an einem Murder Mystery Spiel. Erstelle das Bild eines bestimmen Raumes in diesem Spiel in möglichst isometrischer Perspektive. Name: {name} Beschreibung: {description}”

Diese Anfrage erhielt Dall-E. Die Platzhalter name und description hat das Programm durch die passenden Inhalte aus der Datei »rooms.json« ersetzt (Listing 8, Zeile 28).

Listing 8

generate_images erstellt alle Bilder des Spiels

def generate_images(assets_path, data_path, messages, npcs, llm_model,
image_model):
  # title
  messages, title_json = ask_llm(messages, prompts.TITLE_IMAGE,
llm_model, DEBUG)
  title = json.loads(to_json_string(title_json))
get_image_and_save(prompts.CREATE_TITLE_IMAGE.format(description=title["des
cription"]),
                    assets_path, 'title_image.jpg', image_model, DEBUG)
  # npcs
  messages, image_json = ask_llm(messages, prompts.NPCS_IMAGE, llm_model,
DEBUG)
  images = json.loads(to_json_string(image_json))
  for j in json.loads(to_json_string(npcs)):
    name = j["name"]
    file_name = j["image"]
    description = ""
    for k in images:
        if k['name'] == name:
            description = k['description']
            break
    if len(description) == 0:
        description = j["description"] + " " + j["appearance"]
get_image_and_save(prompts.CREATE_NPCS_IMAGE.format(description=description
),
                        assets_path, file_name, image_model, DEBUG)
  # rooms
  with open(os.path.join(data_path, "rooms.json")) as f:
    d = json.load(f)
  for j in d:
    name = j["name"]
    file_name = j["image"]
    description = j["description"]
    get_image_and_save(prompts.CREATE_ROOMS_IMAGE.format(name=name,
description=description),
                        assets_path,
                        file_name, 'dall-e-3', DEBUG)

Zum Erzeugen eines einzelnen Bilds dient die Funktion »get_images_and_save« (Listing 9). Die Methode »client.images.generate« vom OpenAI bekommt die Anfrage in der Variablen »prompt« und liefert die Informationen über das generierte Bild zurück. Mit dem Code aus Zeile 12 und der URL lädt das Programm das Bild und speichert es.

Listing 9

get_image_and_save holt ein Bild von Dall-E und speichert es

def get_image_and_save(prompt: str, assets_path: str,
                    file_name: str, llm_model: str,
                    debug: bool = False) -> None:
  with st.spinner("Thinking..."):
    picture
 = st.session_state.client.images.generate(
        model=llm_model,
        prompt=prompt,
        size="1024x1024",
        quality="standard",
        n=1
    )
  image_url = picture.data[0].url
  if debug:
    st.write(prompt)
    st.image(image_url, width=360)
  download_image(image_url, os.path.join(assets_path, file_name))

Zusammenfassung

Neuere Versionen von ChatGPT und ähnlichen LLMs haben die Aufgabe des Spieldesigners und der Steuerung von Spielfiguren überraschend gut gemeistert. Bei älteren Chatmodellen fiel auf, dass sie bei der Kommunikation noch nicht die Stärke der aktuellen Versionen aufbrachten.

Selbstverständlich ist das Spielprinzip noch ausbaufähig: Unterschiedliche Charaktere könnten etwa miteinander sprechen und selbst Aktionen durchführen. Das komplette Spiel [3] mit einigen generierten Fällen und allen Codebeispielen finden Sie auf Github. (csi)

Infos

  1. Streamlit: https://streamlit.io
  2. LLM-Spiel (Teil 1): Gerhard Völkl, “Künstliche Fantasie”, LM 04/2025, S. 70, https://www.lm-online.de/51802
  3. Github-Repository zum Spiel: https://github.com/gkvoelkl/python-streamlit-murder-mystery-game
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben