Teardown

What happens when you architect properly, then let AI fill in the rest

Unity · C# · Claude/Cursor

Poker Scrabble is a Unity mobile game: poker hand recognition on a Scrabble-like board of tiles. You drag across adjacent cards to form poker hands, score against a target, and beat the turn limit. Nothing about it demands exotic engineering. The codebase, though, was built the way I would build it myself.

Game state lived in plain C# data classes, kept deliberately separate from the MonoBehaviours that drew them, so no gameplay logic ever touched a Unity GameObject directly. Data changes fired events through an observer pattern, and the UI subscribed; nothing called into the UI from logic. Six assembly definitions carved the project into layers with proper boundaries. Nulls were outlawed in favour of LanguageExt.Option<T>, so every failure branch had to be handled rather than silently dereferenced. Coroutines and async void were banned; async went through UniTask or not at all. Even the ScriptableObject configuration was validated at edit time, not at runtime.

That is the architecture you build when you intend to keep the project for a while. Then I handed the features to Claude, running in Cursor, and watched what it did with them.

cards.Reverse(), in place

The hand matcher was the first real feature the AI implemented end to end. To recognise a poker hand you check the selected cards, and hands that only work in one direction, like straights and flushes, need the cards checked both ways. The AI did that by calling cards.Reverse() on the selection.

List<T>.Reverse() mutates the list in place. The same list was shared across every hand check, so the first check reversed it and every check after that saw the cards backwards. Hands that cared about order silently scored wrong. No crash, no error message. Just wrong results that looked right unless you knew what to look for.

The fix was one line: take the reversed sequence without touching the shared list.

List<Option<ICellChildModelInstance>> reversedCards = cards.Rev().ToList();
return Internal_TryMatch(reversedCards);

The comment the fix left behind says it plainly: this prevents mutating the original list, which is shared across all hand checks. A senior engineer knows the difference between an in-place reverse and a reversed copy on day one. The AI never did, because the AI has never been burned by it.

The input bindings, twice

Unity has three input systems, and this project used the modern one with action assets. The AI mixed them up. First it changed the input action binding for touch, and touch detection broke outright; the only recovery was restoring the previous binding. Later it tried again and changed a binding from Button to Value so the touch position could be read as a Vector2.

The code compiled. It ran. Input just didn’t work. The whole second fix was changing one enum value, but you have to know to look there, and nothing in the error surface tells you.

Cell selection, and the direction lock

Drag selection worked. The AI decided it needed a direction lock, a more sophisticated mechanism that locked the drag axis, and selection broke. The fix was to remove the direction lock and go back to the adjacency check and simple grid set that had worked before. The AI’s version was cleverer than the problem, which turned out to be the problem.

"It loads again, but is broken": at one point, the only honest summary of the build was those six words.

The game was broken. The AI made changes. The game loaded. The game was still broken, just differently. The one thing you could honestly say was that it no longer crashed on startup.

Reward logic, in three places

Mission rewards and level-completion rewards are the same operation: look at the currency name, add coins or gems to the right account. In InGameState.OnStateChanged() the same coins/gems chain appears three times. Once for mission rewards, once for the normal hub path, and once more as a legacy fallback that mutates PlayerData directly when no hub instance is available.

string currencyName = reward.Model.Name?.ToLower();
if (currencyName == "coins") {
    _hubInstance.AddCoins(reward.Amount);
} else if (currencyName == "gems") {
    _hubInstance.AddGems(reward.Amount);
}

Fix a bug in one path and the other two still have it. The AI didn’t extract a shared method. It copy-pasted and slightly modified, which is exactly how duplicated logic drifts apart.

The guidelines page

The codebase now carries an llm-coding-guidelines.md page, a bug-prevention checklist written in reaction to AI failures. It is longer than most of the features it protects:

AI tendency Correct approach
Task or async void UniTask / UniTaskVoid + .Forget()
Null checks with == null Option<T>.Match() / .Exists()
GetComponent / FindObjectOfType Constructor injection via CoreServices
Direct UI manipulation from data Event via ForEachActionSubscriber()
new MonoBehaviour() ResourceKeys.X.LoadAndInstantiate<T>()
var everywhere Explicit types
.Value on Option<T> .Match() or .ValueOrErrorIfNone()
Switch on type in a coordinator Push behaviour into the type’s own class

That page exists because the same mistakes came back on every feature. Each new feature required correcting the same patterns. The guidelines are the project’s institutional memory, written because I got tired of correcting the same things.

What the project still lacks

All of this happened inside a project with no audio, no haptics, no power-ups, no monetisation data behind the shop UI, no mission system beyond the reward hooks, no world map, commented-out win and lose transitions, and no way to detect that no valid hands remain. The AI could generate features all day, but it couldn’t ship a product. Every feature just added another patch to the pile.

The scene it could not read

Every failure above has the same cause underneath it, and it isn’t a reasoning failure.

Most projects serialise scenes and prefabs as YAML, so they are text, and being text is not the same as being readable. One real scene runs to tens of thousands of lines of fileID and guid references, and a guid doesn’t name anything on its own: it resolves through a separate .meta file before it points at an asset. A studio can also flip serialisation to binary in Project Settings whenever file size or merge times demand it, at which point the text isn’t there at all.

How a scene reference resolves to an asset A scene file names a prefab only by a GUID. That GUID resolves through a separate .meta file before it identifies an asset, so reading the scene text on its own never reveals which object the scene meant. Board.unity m_Prefab: {fileID: 1949…, guid: 8f3c1a…} the guid names nothing yet CardTile.prefab.meta guid: 8f3c1a… now it has a path CardTile.prefab the object the scene meant Three files, in order, to answer one question.
Reading the scene gives you a guid. Answering what the guid is takes two more files.

Opening the scene in the editor answers all of this in about thirty seconds. That option isn’t available to something reading only the text files, and no amount of improvement to the model changes it, because it was never a reasoning problem. The agent was asked about a hierarchy it could not see, and it answered anyway. Every mixed input system, every hallucinated node path, every plausible-looking script that behaved wrong came out of that gap.

This is also why it isn’t the same problem as a vibe-coded web app, where the framework’s structure is in the same text the tool is reading.

The lesson

This is the best case for vibe coding: a deliberately architected codebase with a senior engineer watching over the AI. It still produced silent data corruption, repeated breakage of working systems, triplicated logic, a defensive document longer than most features, and a partially broken game.

Now imagine the same process without the architecture. Without the Instance/Object split. Without Option<T>. Without assembly definitions. Without a senior engineer catching the list mutation, the input system mix, the broken direction lock. That is what a codebase looks like when AI fills in the rest on its own. That is the code we get called in to fix.

What we would do differently

If this codebase were audited for a client, the report would flag these findings, in order:

CriticalList mutation in BasicHand.TryMatch. Silent data corruption in core game logic
CriticalNo dead-state detection. Players can get stuck with no valid moves
HighDuplicated reward processing. Three code paths that will diverge
HighNo save system for complex game state. Only basic PlayerData with coins, gems, and level
HighNo audio or haptic system. Shipped feel will be flat
MediumNo CI pipeline. No automated testing or builds
MediumNo test coverage beyond hand-matching unit tests
MediumPerformance unprofiled. No Profiler data on device

The remediation roadmap phases into three stages: stabilise, which means fixing the mutation bug and adding dead-state detection; structure, which means extracting the reward logic and adding a save system; and sustain, which means audio, CI, testing, and a performance pass.

The architecture held up through all of it. It didn’t stop the AI from making mistakes, but it made those mistakes findable, and even then it took a human reading the code to find them. The tool that wrote the code never noticed.

Recognise these failure patterns?

An audit will tell you exactly which ones are in your codebase, and what to do about them.

Book a discovery call

[email protected] · replies within 1 business day