Article

How to develop an AI game with Unity without building a rewrite

Building a Unity game with Cursor, Claude or Copilot works. The prototype arrives in days instead of weeks, and it plays. The question is not whether you can develop a game this way. It is whether what you have at the end is a product or a demo you cannot extend.

What follows is what I would put in place starting from an empty project, written as the process rather than the postmortem. None of it is about restraining the tools. It is about giving them a structure to work inside, which is the thing they are good at.

Put the architecture in before the AI arrives

The single decision that separates a recoverable AI-built codebase from an unrecoverable one is whether structure existed before generation started. AI tools are fast at filling in a structure and close to useless at deciding what it should be. If you have not decided, they will improvise one per feature, and the improvisations will not agree with each other.

Six decisions are worth making before you prompt anything.

A data and visual split. Game state in plain C# classes, kept away from the MonoBehaviours that draw it, so no gameplay logic ever touches a GameObject directly. This is the decision that makes most of the others enforceable. It is the difference between:

public class ScoreManager : MonoBehaviour
{
    public Text scoreLabel;
    private int _score;

    public void Add(int points)
    {
        _score += points;
        scoreLabel.text = _score.ToString();   // rules and rendering, fused
    }
}

and a rules type that knows nothing about Unity:

public sealed class Score
{
    public int Value { get; private set; }
    public event Action<int> Changed;

    public void Add(int points)
    {
        Value += points;
        Changed?.Invoke(Value);
    }
}

The second version can be unit tested without entering play mode, which matters more than it sounds: it is the only part of your project an AI tool can verify its own work against.

Events out of data, never calls into UI. State changes notify subscribers. Logic never reaches into the view. Once this holds, an agent asked to add a feature has nowhere to put a UI call inside your rules, because your rules cannot see the UI.

Assembly definitions. Carve the project into layers early, with an .asmdef per layer:

{
  "name": "Game.Rules",
  "references": [],
  "autoReferenced": false
}

An empty references array means this assembly can reference nothing but the C# standard library, so nothing in your rules layer can touch UnityEngine, your UI, or your networking code. Assembly boundaries are the only structural rule Unity enforces for you, and they turn an architecture decision into a compiler error: an agent that tries to reference across the boundary gets CS0246: The type or namespace name could not be found, sees the failure, and works within the constraint. Every other convention you write down is advisory. This one is not.

They also cut compile times noticeably, which you will care about by month three.

A decision on nulls. Whether that is an Option<T> type or a strict convention, make failure branches something the compiler asks about rather than something a caller forgets. AI-generated code is fluent at producing the null-guard that silently skips work, so the useful property is a type that will not let a caller ignore the empty case without saying so.

A decision on async. Pick your model, ban the alternatives explicitly, and say so in writing. async void deserves a specific ban, because its failure is not a style problem. This is a four-line demonstration, run on .NET 8:

static async void Fire()
{
    await Task.Yield();
    throw new InvalidOperationException("boom");
}

try { Fire(); }
catch (Exception e) { Console.WriteLine("CAUGHT: " + e.Message); }

CAUGHT never prints. An async void method has no Task for the caller to await, so the exception is raised on a thread pool thread after the try block has already exited. In a console app the process dies. In Unity you get an entry in the console, if you are watching the console, and the calling code carries on as though the operation succeeded. The caller wrote a try/catch and got no error handling at all.

Configuration validated at edit time. A misconfigured ScriptableObject should fail in the editor, not on a device in front of a publisher:

private void OnValidate()
{
    if (handSize < 1)
    {
        Debug.LogError($"{name}: handSize must be at least 1", this);
    }
}

OnValidate runs when the asset changes in the inspector, and the second argument makes the console entry select the offending asset when clicked. This is the cheapest safety net in Unity and almost nobody adds it, because nobody prompts for it.

None of this is exotic, and none of it is about AI. It is what you would do for a project you intended to keep. The reason it matters more here is speed: with AI generating features, a structural mistake propagates through twenty files before anyone reviews the first one.

Write the rules file first

Every AI coding tool reads project instructions from a file in the repository. Use that, and write it before you need it rather than after the third repeat of the same mistake.

What belongs in it is not general advice. It is the specific list of what your stack forbids and what to reach for instead. Write it as tendency and correction, because that is the form the tool applies most reliably:

Instead of Use
Task, async void UniTask / UniTaskVoid with .Forget()
== null checks Option<T>.Match() or .Exists()
GetComponent, FindObjectOfType Constructor injection through the service layer
Direct UI updates from game logic Raise an event, let the view subscribe
var everywhere Explicit types

Two things make this work better. Give a one-line reason for each rule, because a rule with a reason survives being applied to a case it did not anticipate. And keep it in the repository next to the code, so it is reviewed like code and updated in the same pull request as the pattern it describes.

Expect the file to grow, and expect it to feel disproportionate. On one project I architected carefully, the rules page ended up longer than most of the features it was protecting. That is the correct outcome. It is cheaper than correcting the same pattern on every feature for a year.

Keep the AI out of the scene

Unity serialises scenes and prefabs as YAML in most projects, and that helps far less than it sounds. The file is text, but it identifies objects by 19-digit fileID and scripts by GUID, so reading it tells an agent almost nothing about your hierarchy. When you ask one to wire a feature to something in your scene, it is inferring that hierarchy from names it saw in scripts. Sometimes the inference is right.

Two settings matter here, and both are one-time changes worth confirming on day one.

In Project Settings → Editor, check that Asset Serialization is Force Text and set Version Control to Visible Meta Files. Force Text is the default for new projects, but Mixed and Force Binary exist and older projects inherit whatever they were set to. Keeping it on Force Text means scene and prefab changes stay YAML, so they appear in a diff instead of arriving as an opaque blob. This is imperfect, because a scene diff is a wall of 19-digit fileIDs rather than something you read at a glance, but it is the difference between a change you can inspect with effort and one you cannot inspect at all.

Then add a merge driver for those files. Unity ships UnityYAMLMerge for exactly this, and without it two people editing the same scene produces a conflict git cannot resolve and neither can you.

The failure mode you are guarding against does not throw at compile time. It compiles, it runs, and the feature simply never triggers, usually because the generated code guarded the reference it guessed wrong.

Review for the failures that do not surface

A passing compile and a clean review are weaker signals here than on human-written code, because AI output is optimised to look correct. Four things are worth reading for specifically.

Mutation of shared state. This is the most common of the four and the most expensive, so it is worth seeing rather than describing. A scoring routine that reverses a hand before reading it:

static int ScoreInPlace(List<string> hand)
{
    hand.Reverse();          // returns void, mutates the caller's list
    return hand[0].Length;
}

Scoring the same unchanged hand four times returns 3, 1, 3, 1. Every other call is wrong, because List<T>.Reverse() reverses in place and the next call starts from the state the last one left behind. Taking a reversed copy instead returns 3, 3, 3, 3:

static int ScoreCopy(List<string> hand)
{
    var ordered = Enumerable.Reverse(hand).ToList();
    return ordered[0].Length;
}

Note what the fix is not. Writing hand.Reverse().ToList() does not compile, because C# resolves the List<T>.Reverse() instance method before the Enumerable.Reverse() extension, and you get CS0023: Operator '.' cannot be applied to operand of type 'void'. You have to reach past the instance method deliberately, with Enumerable.Reverse(hand) or hand.AsEnumerable().Reverse().

That is the whole problem in one method. It compiles, it never throws, it produces a plausible number, and it is wrong half the time. Playing the game will not reliably catch it, because a wrong score looks like a rules misunderstanding rather than a bug.

Input handling. Unity has three input systems, and an agent will reach for whichever it saw most, not the one you use. Changing a binding from Button to Value, or the reverse, compiles cleanly and stops input working with no error at all.

Duplicated logic. AI copy-pastes and adjusts rather than extracting a shared method. Look for the same operation appearing in two or three places, because they will drift, and then fixing one leaves the bug live in the others. Reward handling, save paths and state transitions are where it turns up.

Unrequested cleverness. A working mechanism replaced with a more sophisticated one that breaks the case the simple version handled. This reviews well and fails in play, because the sophisticated version is the one that looks like better code.

Any of these can survive a prototype and a soft launch. They are much harder to remove once features have been built on top of them.

Build the things nobody prompts for

AI generates features readily. It generates infrastructure almost never, because nobody asks for infrastructure. The gaps are consistent: no save system for anything beyond a flat player-data blob, no dead-state detection so players can reach a position with no valid moves and no way out, no audio or haptics, no automated build, no performance profiling on a real device.

Schedule these as work items with owners. They will not arrive as a by-product of feature generation, and each is significantly more expensive to add after the systems around it have hardened. A save system in particular is close to free at the start and close to a rewrite once twelve systems hold state in their own fields.

Before you call it production-ready

Three checks are worth doing deliberately, because each tests something the tooling cannot.

Run the game on the oldest device you intend to support, with the profiler attached, for longer than a demo. Ten minutes, not thirty seconds: thermal throttling does not show up in a short session and it is the difference between shipping and a bad review.

Hand the codebase to a developer who did not write it and time how long it takes them to add something small. That number is your maintenance cost, and it is the one figure nobody measures.

Then pick your most important system and try to change it. Count the files you have to open. If a rules change means editing a MonoBehaviour, a UI script and a save path, the data and visual split did not hold, whatever the folder structure says.

If those go badly you have a structural problem, and finding it now is much cheaper than finding it during a milestone.

The honest version

I have done this with the architecture in place, reviewing every change myself, on a project built the way this article describes. It still produced silent data corruption, working systems broken by improvements nobody asked for, logic triplicated across three code paths, and a game that was left unfinished.

The architecture did not prevent the mistakes. What it did was make them findable, and even then it took a human reading the code to find them. The tool that wrote the code never noticed any of it. That project is written up in full in the teardown.

So the realistic promise is not that following this gives you a clean codebase. It is that it gives you one where the problems are visible and local instead of structural, which is the difference between a fix and a rewrite.

Your game works. Changing it is the part that has got harder.

A fixed-price audit tells you what's wrong, why it's wrong, and what to do about it.

Book a discovery call

[email protected] · replies within 1 business day