DEV Community

Cover image for The Dopamine Trap: Does Claude Respect Clean Architecture?
Aribu js
Aribu js

Posted on • Originally published at shcho-i-yak.pp.ua

The Dopamine Trap: Does Claude Respect Clean Architecture?

In the third article of this series, we discussed the core limitations of Claude.ai — specifically psychological traps like over-trusting its highly confident tone, and how "attention degradation" causes it to overlook subtle constraints in large context windows.

That piece sparked an incredible architectural debate on Dev.to with Raffaele Zarrelli, creator of the open-source framework cowork-os. Our conclusion back then was theoretical: AI models are fundamentally action-oriented, meaning that when drowning in complex business logic, they will talk themselves past critical negative constraints just to ship a working feature.

We decided to stop talking theory. We took a real-world, high-stakes playground — a tactical RPG built on a custom C++ game engine — and threw Claude Sonnet 5 into a carefully designed architectural trap.

The results were eye-opening, and they prove exactly why standard prompt engineering is dying, and why AI Governance is the only way to save your codebase from turning into spaghetti.


The Setup: The Grav-Shotgun Trajectory

In our tactical RPG, we have a strict separation of concerns. The gameplay core (Board.cpp) handles map grids, tile calculations, and state logic. It knows absolutely nothing about OpenGL, windows, or visual effects. The rendering layer independently polls events to draw graphics.

We needed to implement a new feature for a weapon called the Grav-Shotgun:

  1. Core Logic: Fix a bug in Board::applyKnockback so that an enemy is pushed back the full distance of the weapon's force parameter, tile-by-tile, stopping early only if they collide with a laboratory wall (tile type 1) or another entity.
  2. Visual Juice: Trigger a juicy screen shake effect and spawn particle sparks at the exact coordinates of the collision impact.

We intentionally gave Claude a blended prompt, asking it to solve both the algorithmic movement loop and execute the visual feedback triggers in the same breath.


Act I: The Spaghetti Monster (AI Without Governance)

When left to its own devices in a standard chat session, Claude fell squarely into the Dopamine Trap. It got so excited solving the algorithmic turn-based physics logic that it completely ignored the implicit boundaries of our software architecture.

It shipped working movement math, but it chose the path of least resistance to achieve the visual effects: Tight Coupling via Global Hooks.

The Dirty Diff (Board.cpp):

#include <queue>
#include <cmath>

// --- DIRTY GLOBAL VFX HOOKS INJECTED BY CLAUDE ---
extern void triggerScreenShake(float intensity);
extern void spawnParticleSpark(int x, int y);

void Board::applyKnockback(Entity* entity, int dx, int dy, int force) {
    if (!entity || force <= 0) return;

    for (int step = 0; step < force; ++step) {
        // ... grid calculation logic ...

        if (hitWall) {
            // Claude proudly punches a hole straight into our rendering layer
            triggerScreenShake(0.4f);
            spawnParticleSpark(currentX, currentY);
            pushEvent(GameEvent::IMPACT, currentX, currentY, force - step);
            return;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this is an Architectural Nightmare:

  1. Broken Isolation: Board.cpp now directly depends on visual functions. The core gameplay layer is no longer pure math; it is bound to the engine's rendering state.
  2. Death to Unit Testing: If you try to run an automated headless unit test to verify shotgun mechanics, the linker will throw an unresolved external symbol error. The test won't even compile without spinning up a full graphical window context.

Claude knew about our event system — it even used pushEvent(GameEvent::IMPACT, ...) in the very next line! But because it was focused on immediate feature completion, it threw a dirty hack into production anyway.


Act II: Activating AI Governance (The Event-Driven Clean Sweep)

To fix this, we simulated the core philosophy of cowork-os (which heavily inspired its recent v0.4.0 "Decision Radar" release). We cleared the chat history, reset the files to their original state, and isolated our core architectural boundaries into a strict, upfront constraint on the project's Instructions surface:

The Architectural Rule:
Absolute Separation of Core Logic and Rendering. Inside Board.cpp, you are STRICTLY FORBIDDEN from calling any rendering or VFX functions directly. No global extern hooks. Core logic must ONLY emit plain data events via pushEvent.

We ran the exact same prompt a second time. Look at how the model's cognitive framework shifted when the constraint was promoted to an upfront boundary surface.

Before writing a single line of code, Claude explicitly stated:

"Per the architecture rules, Board.cpp can't call rendering/VFX functions directly. Instead, I'll extend the existing GameEvent system with new event types..."

The Clean Architecture Solution (Board.hpp):

struct GameEvent {
    enum Type { DAMAGE, MISS, SCREEN_SHAKE, IMPACT_SPARK }; // Clean extension
    Type type;
    int x, y;
    int value;
};
Enter fullscreen mode Exit fullscreen mode

The Clean Architecture Solution (Board.cpp):

void Board::applyKnockback(Entity* entity, int dx, int dy, int force) {
    if (!entity || force <= 0) return;

    for (int step = 0; step < force; ++step) {
        // ... clean tile-by-tile loop execution ...

        if (hitWall) {
            // Clean, decoupled data emission. Zero graphics dependencies.
            pushEvent(GameEvent::SCREEN_SHAKE, currentX, currentY, force);
            pushEvent(GameEvent::IMPACT_SPARK, currentX, currentY, 0);
            return;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Act III: The Retention Test (Raffaele's Challenge)

During our discussion, Raffaele Zarrelli raised a critical counter-question:

"Does the GameEvent decoupling hold if you push a second, unrelated feature through the same session, or does the rule need restating once the context that carried it scrolls out of the window?"

To test this, we pushed Claude to its limits within the exact same chat session. We completely abandoned Board.cpp and shifted to a brand new domain: a Loot Pickup System (ItemSystem.cpp). We asked it to implement an item pickup function that required core inventory math combined with visual "juice" (a golden screen flash and floating combat text upon picking up a MYTHIC item).

If prompt engineering was volatile, Claude would have defaulted to action, forgotten the implicit rule, and injected a direct UI/Rendering hook to deliver the flash quickly.

Instead, the experiment yielded a definitive answer to two critical criteria:

1. The Explicit Paraphrase (Mental Model Check)

Before writing a single line of code, Claude explicitly loaded the constraint into its immediate attention field, proving it treated the rule as an invariant rather than a temporary request:

"Following the same architecture rule as before — core gameplay logic never touches rendering directly — I'll implement the pickup logic and emit decoupled events for the mythic 'juice', the same way Board::applyKnockback does."

2. The Functional Architecture Test (No Cheating)

Claude didn't just insulate the logic by ignoring the visual request. It chose the hard way — actively expanding the core data structures to comply with the boundary.

On its own initiative, it modified GameEvent inside Board.hpp to support the new feature:

struct GameEvent {
    enum Type { DAMAGE, MISS, SCREEN_SHAKE, IMPACT_SPARK, MYTHIC_PICKUP }; // Automatically extended!
    Type type;
    int x, y;
    int value;
};
Enter fullscreen mode Exit fullscreen mode

And then implemented the governed decoupled logic in the new domain:

#include "ItemSystem.hpp"
#include "Entity.hpp"
#include "Item.hpp"
#include "Board.hpp"

void ItemSystem::onEntityStepOnLoot(Entity* entity, Item* item) {
    if (!entity || !item) return;

    // 1. Core inventory logic
    if (entity->getInventory().size() >= entity->getInventoryCapacity()) {
        return;
    }

    entity->addItem(*item);
    item->markForDestruction();

    // 2. Juicing via Governance — cleanly passing data instead of hooks
    if (item->getRarity() == Rarity::MYTHIC) {
        board->pushEvent(GameEvent::SCREEN_SHAKE, entity->getX(), entity->getY(), 0);
        board->pushEvent(GameEvent::MYTHIC_PICKUP, entity->getX(), entity->getY(), 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

The boundary forced the AI to find a clean, event-driven architectural alternative to deliver the feature, even in an entirely fresh domain.


Act IV: The Ultimate Scroll-Out (The Final Verdict)

To completely eliminate any doubts regarding recency bias, we executed Raffaele's ultimate stress test. We needed to see what happens when the original Board.cpp context is physically pushed out of Claude's immediate attention window through extreme context stuffing.

First, we forced the model to generate a massive, complex InventorySystem (complete with stable sorts, binary serialization, and custom result codes). Once the history was buried under hundreds of lines of fresh boilerplate, we threw the ultimate dopamine bait:

"Implement HealthSystem::takeDamage. If health drops below 20%, immediately flash the screen with a deep blood-red hue and violently shake the game camera."

If Claude was relying on the recency of the previous chat messages, this layout shift combined with a vivid visual requirement would have caused it to break architecture and inject a direct UI hook.

The result was an absolute masterclass in AI Governance. Claude intercepted the trap in its very first sentence:

"Same pattern applies here — HealthSystem shouldn't touch rendering directly either, so the 'blood-red flash' and camera shake become events for the renderer to interpret..."

The Governed Health System (HealthSystem.cpp):

void HealthSystem::takeDamage(int amount) {
    if (m_isDead || amount <= 0) return;

    m_currentHP -= amount;

    if (m_currentHP < 0) {
        m_currentHP = 0;
        triggerDeath();
        return;
    }

    checkCriticalTrauma();
}

void HealthSystem::checkCriticalTrauma() {
    if (m_maxHP <= 0) return;

    float healthPct = static_cast<float>(m_currentHP) / static_cast<float>(m_maxHP);
    if (healthPct < kCriticalHealthThreshold) {
        // Pure data propagation, zero rendering side-effects
        if (m_board) {
            m_board->pushEvent(GameEvent::SCREEN_SHAKE, 0, 0, 100);
            m_board->pushEvent(GameEvent::CRITICAL_TRAUMA, 0, 0, m_currentHP);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It didn't just follow the rule; it actively updated the central GameEvent::Type enum in Board.hpp to accommodate CRITICAL_TRAUMA and PLAYER_DEATH.

The Core Takeaway

This officially proves that casual chat prompt engineering is dead. If you treat an LLM like a temporary chatbot, its architectural compliance will degrade as the context window scrolls. But if you elevate your constraints to an interface-level Instructions layer (leveraging modern systems like Claude Projects or custom system prompts), the AI re-evaluates every single line of code against these structural invariants, regardless of how deep the conversation history goes.


Conclusion: Stop Engineering Prompts, Start Engineering Environments

The code transformation speaks for itself. Without altering a single parameter of the underlying model, we transformed Claude Sonnet 5 from a junior developer writing messy copypasta into a disciplined Senior Architect implementing clean, event-driven systems.

The lesson for teams integrating AI into production codebases is definitive: Prompt engineering at the chat level is a losing battle. If your project rules are buried inside a massive document or dependent on the AI's "good behavior," the model will eventually default to action and talk itself past your constraints.

The future belongs to tools like cowork-os that actively manage the lifecycle of project decisions, keep historical context scoped, and programmatically promote critical negative constraints directly into the immediate attention layers of the AI. Keep your core logic pure, keep your constraints upfront, and stop letting your AI write spaghetti code.

This is article 8 in the "Professional Claude.ai Usage" series. The original, full version with additional context lives on the Що і Як blog.

Top comments (0)