Czy AI umie zaprogramować prosty gameplay?


Dyskusja na temat AI, w tym etyczności stosowania zawartości generowanej przez AI, trwa. I chyba jest tylko jedno pole eksploatacji, w którym osoby “zagrożone” przez sztuczną inteligencję przyjmują to zagrożenie bardziej z ekscytacją niż strachem – programowanie. Zobaczmy jak to działa na dziś dzień.

O ile artyści, muzycy i pisarze aktywnie walczą z zastępowaniem ich pracy przez generowany content, programiści wręcz chcieliby, żeby przynajmniej te najbardziej mechaniczne aspekty ich pracy były zautomatyzowane.

Ostatnimi czasy modne jest porównywanie jakości kodu pisanego przez AI na różnej maści benchmarkach, ale brakuje dobrych przykładów jeśli chodzi o programowanie gier. W tym artykule postaram się przedstawić taki przykład i porównać jakość kodu produkowanego przez różne modele na konkretnym (choć minimalnym) problemie w silniku Unity.

Tekst skierowany jest raczej do “technicznych” odbiorców. W szczególności, zakładam znajomość Unity, C# i Zenjecta. Ale bez obaw, przedstawiony kod będzie dostatecznie prosty, żeby był zrozumiały również dla programistów znających inne silniki.

Problem do rozwiązania

Na początek zadajmy sobie pytanie: w jaki sposób powinna być zorganizowana współpraca programisty z modelem językowym, żeby miało to największy sens? Wiemy, że LLMy nie są geniuszami jeśli chodzi o architekturę kodu i mają tendencję do powielania słabych rozwiązań jedynie dlatego, że zbyt łatwo powielają konwencjonalną mądrość znalezioną w repozytoriach na Githubie. Zakładam zatem, że naszym pożądanym sposobem pracy jest posadzenie seniora do wymyślenia architektury i stworzenie szablonowego kodu, a następnie zostawienie LLMowi “klepańska”. 

Ponieważ będę testował wiele różnych modeli, dla zwięzłości proponuję najmniejszy problem, jaki przyszedł mi do głowy: implementacja poruszania się postacią po płaszczyźnie. Zadaniem naszych modeli będzie przeanalizowanie wymagań zostawionych przez ludzkiego programistę i wygenerowanie kodu, który a) będzie się kompilował b) będzie działał poprawnie bez dalszej ingerencji człowieka.

Testowane modele

Przetestujemy sześć różnych modeli:

  1. claude-3-7-sonnet od Anthropic,
  2. o1 od OpenAI,
  3. llama3.3-70b za pośrednictwem API chatllama.com,
  4. gpt-neo-20b za pośrednictwem API goose.ai,
  5. DeepSeek-R1,
  6. gemini-2.0-flash od Google.

Pewnie od razu zauważyliście, że llama i gpt-neo trochę odstają od towarzystwa, ale myślę, że warto przetestować również prostsze modele – a nuż zadziałają.

Projekt testowy

Przyszedł czas omówić pokrótce nasze środowisko testowe. Jest to projekt w silniku Unity, w którym chcemy zrealizować funkcjonalność poruszania się postacią po płaszczyźnie, na podstawie inputu przekazywanego przez nowy Input System.

W projekcie znajdują się cztery pliki z kodem, zawierające napisany przeze mnie szkic architektury rozwiązania oraz scenę ze wszystkimi obiektami i komponentami niezbędnymi do tego, żeby gra uruchomiła się i działała poprawnie po tym, jak nasi robotyczni juniorzy wstawią swój kod.

Kod

Zacznijmy od prezentacji kodu.

PlayerInputHandler

Komponent odpowiedzialny za przetwarzanie inputu dostarczanego przez wbudowany system Unity. Zakłada istnienie interfejsu ICharacterMovement, do którego może przekazać stosowne dane.


//WSTĘP

using UnityEngine;
using Zenject;

public class PlayerInputHandler : MonoBehaviour
{
    [Inject] private ICharacterMovement characterMovement = null;
        
    //TODO: this class should contain methods to be called directly by Unity's input component (new input system) and set property values on character movement
    //Assume that input settings contain Move and Look actions, each accepting two-dimensional input. The input component will be set to "Send Messages"
}

ICharacterMovement

Interfejs przyjmujący input w postaci kierunku ruchu oraz pożądanej rotacji.


using UnityEngine;

public interface ICharacterMovement
{
    Vector2 MovementInput { get; set; }
    float RotationInput { get; set; }
}

PlayerCharacterMovement

Komponent, którego odpowiedzialnością jest przyjęcie inputu i wysterowanie kontrolera postaci. Kontroler postaci jest typu CharacterController (klasa wbudowana w Unity). Jak widać, oczekuję że asystent sam sobie poradzi z implementacją interfejsu ICharacterMovement.


using UnityEngine;
using Zenject;

//TODO: this class should implement ICharacterMovement
public class PlayerCharacterMovement : MonoBehaviour
{
    [Inject] private CharacterController characterController = null;
    
    private void Update()
    {
        //TODO: set proper values in character controller so that it moves and rotates properly based on the input properties.
    }

PlayerCharacterInstaller

Instalator frameworku Zenject odpowiedzialny za rejestrację naszych klas do kontenera DI. Jeśli nie jest Ci znany Zenject (lub w ogóle Dependency Injection), zerknij koniecznie tutaj.


using UnityEngine;
using Zenject;

[CreateAssetMenu(menuName = "Zenject Installers/Player Character Installer")]
public class PlayerCharacterInstaller : ScriptableObjectInstaller
{
    public override void InstallBindings()
    {
        //TODO: create bindings for all necessary components assume that all of them are placed directly on the game object
    }
}

Scena

Nasza scena testowa składa się z płaszczyzny, światła, kamery, no i oczywiście naszego głównego bohatera – postaci gracza.

Postać gracza ma dodane wszystkie tworzone przez nas komponenty, podpięty installer oraz skonfigurowany wbudowany komponent PlayerInput, będący częścią Input Systemu silnika Unity.

Generator kodu

Dla zachowania zwięzłości i aby uniknąć nadmiernego odbiegnięcia od tematu, pomijam szczegóły implementacyjne napisanego przeze mnie programu konstruującego zapytania do modeli językowych i aplikującego rezultaty ich działania do kodu gry. Wymienię jednak jego kluczowe cechy:

  1. Utworzony w języku C#, przy użyciu biblioteki SemanticKernel.
  2. Wysyła input i odbiera rezultat w formie obiektu json, zawierającego informacje o nazwach plików oraz ich zawartości.
  3. Mierzy czas między wysłaniem zapytania do modelu a otrzymaniem odpowiedzi.
  4. Aplikuje rezultat bezpośrednio do projektu Unity, nie wymaga ludzkiej interwencji.
  5. Umożliwia łatwe przełączanie się między modelami.

Prompt

Zastosowałem następujący prompt:

You’ll be given C# code from a Unity project. The input will be provided in a json format, 

containing a list of file_name:file_contents pairs. You should go through the contents and implement the logic in those files, according to TODOs left in the code by a senior programmer. Don’t modify interfaces, unless you really have to in order to make the program work. Your response should be in the same format as the received input.

Input json:

{{$input}}

Jako input podstawiany jest obiekt typu CodeData, zawierający pary <nazwa pliku, zawartość pliku>, zserializowany w formacie json. Poniżej zamieszczam kod klasy Code Data:


public class CodeData
{
    public List Files { get; set; } = [];
}

public class FileData
{
    public string FileName { get; set; } = "";
    public string FileContents { get; set; } = "";
}

Zastosowanie ustrukturyzowanej postaci wejścia/wyjścia ma dwie zalety:

  1. Umożliwia modelowi lepsze połapanie się w strukturze kodu oraz ogranicza bredzenie.
  2. Umożliwia łatwiejsze przetworzenie wyników na postać nadającą się do bezpośredniego wklejenia do projektu.

Wyniki

Skoro opowiedzieliśmy już sobie o całym setupie eksperymentu, przyszedł czas na prezentację wyników. Dla każdego modelu zmierzyłem czas potrzebny na wykonanie operacji, sprawdziłem czy kod działa bez potrzeby ludzkiej ingerencji oraz subiektywnie oceniłem jego jakość.

claude-3-7-sonnet

Czas odpowiedzi: 18.5s

Kod skompilował się oraz działa poprawnie. Model wystawił do inspektora niezbędne parametry do ustawienia przez designera. Co prawda trochę za dużo jest komentarzy, które opisują znaczenie dość oczywistych operacji, ale ogólnie kod jest dobrej jakości. Jedyną wadą jest dodanie klasy PlayerInputHandler do kontenera, mimo iż nie ma takiej potrzeby – żadna inna klasa nie zależy od niej bezpośrednio. Poniżej wklejam kod wygenerowany przez Claude’a oraz wideo z testów na scenie.


//CLAUDE
using UnityEngine;
using UnityEngine.InputSystem;
using Zenject;

public class PlayerInputHandler : MonoBehaviour
{
    [Inject] private ICharacterMovement characterMovement = null;
    
    // Called by Unity's Input System Move action
    public void OnMove(InputValue value)
    {
        // Get the Vector2 value from the input
        Vector2 inputVector = value.Get();
        
        // Set the movement input on the character movement
        characterMovement.MovementInput = inputVector;
    }
    
    // Called by Unity's Input System Look action
    public void OnLook(InputValue value)
    {
        // Get the Vector2 value from the input
        Vector2 lookValue = value.Get();
        
        // Use the x component for rotation (horizontal looking)
        characterMovement.RotationInput = lookValue.x;
    }
}



using UnityEngine;
using Zenject;

[CreateAssetMenu(menuName = "Zenject Installers/Player Character Installer")]
public class PlayerCharacterInstaller : ScriptableObjectInstaller
{
    public override void InstallBindings()
    {
        // Bind the character movement interface to its implementation
        Container.Bind().To().FromComponentOnRoot().AsSingle();
        
        // Bind other necessary components
        Container.Bind().FromComponentOnRoot().AsSingle();
        Container.Bind().FromComponentOnRoot().AsSingle();
    }
}



using UnityEngine;
using Zenject;

public class PlayerCharacterMovement : MonoBehaviour, ICharacterMovement
{
    [Inject] private CharacterController characterController = null;
    
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float rotationSpeed = 120f;
    
    public Vector2 MovementInput { get; set; }
    public float RotationInput { get; set; }
    
    private void Update()
    {
        // Handle movement
        Vector3 moveDirection = new Vector3(MovementInput.x, 0, MovementInput.y);
        moveDirection = transform.TransformDirection(moveDirection);
        moveDirection *= moveSpeed * Time.deltaTime;
        
        // Apply movement to character controller
        characterController.Move(moveDirection);
        
        // Handle rotation
        float rotationAmount = RotationInput * rotationSpeed * Time.deltaTime;
        transform.Rotate(0, rotationAmount, 0);
    }
}
								

o1

Czas odpowiedzi: 36.2 s

Wygenerowany kod się kompiluje, natomiast nie działa poprawnie, ze względu na błędną implementację PlayerInputHandler. Sygnatury funkcji OnMove i OnLook są niewłaściwe – zakładają argumenty innego typu niż oczekiwany przez InputSystem.


//O1
using UnityEngine;
using Zenject;

public class PlayerInputHandler : MonoBehaviour
{
    [Inject]
    private ICharacterMovement characterMovement = null;

    // These methods will be called via Unity's new input system set to "Send Messages".
    public void OnMove(Vector2 input)
    {
        characterMovement.MovementInput = input;
    }

    public void OnLook(Vector2 input)
    {
        // Only horizontal rotation is considered in this example
        characterMovement.RotationInput = input.x;
    }
}

llama3.3-70b

Czas odpowiedzi:  9.5s

Kod nie skompilował się i było w nim tyle błędów, że nie będę nawet marnował miejsca na jego zamieszczenie. 

gpt-neo-20b

Czas odpowiedzi: 93.2s

Model zwrócił rezultat w niepoprawnym formacie – znajdowały się w nim fragmenty kodu C#, oraz jakieś uwagi w języku naturalnym, natomiast nic, co mogłoby się przydać.

DeepSeek-R1

Czas odpowiedzi: 156.3s

Podobnie jak w przypadku Claude’a – wszystko działa z marszu, postać porusza się, nie ma konieczności wprowadzania ręcznych poprawek. Rozwiązanie jest natomiast trochę gorsze od Claude’a, ponieważ DeepSeek nie wystawił konfigurowalnych parametrów do inspectora (prędkość ruchu i rotacji).


//DEEPSEEK
using UnityEngine;
using Zenject;
using UnityEngine.InputSystem;

public class PlayerInputHandler : MonoBehaviour
{
    [Inject] private ICharacterMovement characterMovement = null;

    public void OnMove(InputValue value)
    {
        characterMovement.MovementInput = value.Get();
    }

    public void OnLook(InputValue value)
    {
        Vector2 lookInput = value.Get();
        characterMovement.RotationInput = lookInput.x;
    }
}




using UnityEngine;
using Zenject;

[CreateAssetMenu(menuName = "Zenject Installers/Player Character Installer")]
public class PlayerCharacterInstaller : ScriptableObjectInstaller
{
    public override void InstallBindings()
    {
        Container.Bind().To().FromComponentOnRoot();
        Container.Bind().FromComponentOnRoot();
        Container.Bind().FromComponentOnRoot();
    }
}




using UnityEngine;
using Zenject;

public class PlayerCharacterMovement : MonoBehaviour, ICharacterMovement
{
    [Inject] private CharacterController characterController = null;
    
    public Vector2 MovementInput { get; set; }
    public float RotationInput { get; set; }

    private void Update()
    {
        Vector3 movement = new Vector3(MovementInput.x, 0, MovementInput.y);
        characterController.Move(movement * Time.deltaTime);
        transform.Rotate(Vector3.up * RotationInput * Time.deltaTime);
    }
}

Gemini

Czas odpowiedzi: 5.7s

Kod nie skompilował się, natomiast okazało się, że błąd jest bardzo niewielki – brak jednej instrukcji using (using UnityEngine.InputSystem;). Po dodaniu brakującej linijki, kod działa bez zarzutu. Podobnie jak Claude, Gemini uwzględnił parametry ustawiane z poziomu inspectora. Na pochwałę zasługuje również fakt, że nie zarejestrował klasy PlayerInputHandler w kontenerze, w przeciwieństwie do Claude’a i DeepSeeka, a więc nie umieścił w kodzie zbędnej instrukcji..Wadą jest to, że nie sprzątnął pozostawionych przeze mnie komentarzy TODO, w przeciwieństwie do swoich kolegów po fachu.


//GEMINI
using UnityEngine;
using Zenject;

//TODO: this class should implement ICharacterMovement
public class PlayerCharacterMovement : MonoBehaviour, ICharacterMovement
{
    [Inject] private CharacterController characterController = null;

    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float rotationSpeed = 200f;

    public Vector2 MovementInput { get; set; }
    public float RotationInput { get; set; }

    private void Update()
    {
        //TODO: set proper values in character controller so that it moves and rotates properly based on the input properties.
        Vector3 moveDirection = new Vector3(MovementInput.x, 0, MovementInput.y).normalized;
        moveDirection = transform.TransformDirection(moveDirection);
        characterController.Move(moveDirection * moveSpeed * Time.deltaTime);

        transform.Rotate(Vector3.up, RotationInput * rotationSpeed * Time.deltaTime);
    }
}




using UnityEngine;
using Zenject;

[CreateAssetMenu(menuName = "Zenject Installers/Player Character Installer")]
public class PlayerCharacterInstaller : ScriptableObjectInstaller
{
    public override void InstallBindings()
    {
        Container.Bind().To().FromComponentInHierarchy().AsSingle();
        Container.Bind().FromComponentInHierarchy().AsSingle();
    }
}




using UnityEngine;
using Zenject;

public class PlayerInputHandler : MonoBehaviour
{
    [Inject] private ICharacterMovement characterMovement = null;
        
    //TODO: this class should contain methods to be called directly by Unity's input component (new input system) and set property values on character movement
    //Assume that input settings contain Move and Look actions, each accepting two-dimensional input. The input component will be set to "Send Messages"

    public void OnMove(InputValue value)
    {
        characterMovement.MovementInput = value.Get();
    }

    public void OnLook(InputValue value)
    {
        Vector2 lookValue = value.Get();
        characterMovement.RotationInput = lookValue.x;
    }
}

Podsumowanie

Dotarliśmy już do końca naszej przygody z generowaniem kodu gameplayowego przy pomocy LLMów i przyszedł czas na krótkie podsumowanie. Zdecydowanie największym zaskoczeniem jest fakt, że nawet zaawansowane modele (o1, Gemini) nie poradziły sobie z wyprodukowaniem kompilującego się kodu, szczególnie biorąc pod uwagę prostotę problemu do rozwiązania. Może taki stan rzeczy wynika z niedostatecznie dużej liczby publicznie dostępnych projektów wysokiej jakości. 

Jedynie Claude i DeepSeek poradziły sobie z wytworzeniem działającego kodu, choć jestem zdania, że Gemini można wybaczyć – jego błąd był bardzo niewielki, a czas odpowiedzi był znacznie krótszy od konkurencji. Należy również zauważyć, że propozycja DeepSeeka była gorszej jakości niż rozwiązanie dostarczone przez Claude’a i Gemini (jeśli wybaczymy mu ten jeden błąd).  

Podsumowując, jeśli miałbym wskazać zwycięzcę tego porównania, byłby to zdecydowanie Claude – dostarczył działający, czytelny kod i zrobił to w bardzo krótkim czasie. Natomiast musimy pamiętać, że to był tylko mały, zabawkowy przykład, który nie daje nam jeszcze odpowiedzi na pytanie, czy warto stosować programistów AI w prawdziwym procesie produkcyjnym. Postaram się odpowiedzieć na to pytanie w ramach kolejnych artykułów z serii. Spodziewajcie się bardziej złożonych testów z wykorzystaniem Claude’a, DeepSeeka i Gemini, pokazujących bardziej zaawansowany przypadek użycia. A może w międzyczasie Grok udostępni API w Europie? 

Kategorie