C.A.T Game Architecture — AI-Driven 3D Game Refactoring
Distilled from Hao Yang (Tencent Photon Studio Group), AI-Driven 3D Game Prototyping, GDC 2026. The framework lets a language model build/edit playable 3D games by making the codebase AI-safe: pure game rules decoupled from platform code, and the 3D world exposed as text.
The C.A.T Standard
| Letter | Principle | Acceptance criterion |
|---|---|---|
| C | Code Reuse | Game logic ships as runtime-agnostic TypeScript. No logic file imports three/react/zustand/DOM/WebAudio. |
| A | Adapter Design | Hard core/adapters split: core/ = rules/state/AI (zero platform deps); engine/ = rendering/input/audio/UI binding. Side effects leave the core only as typed events. |
| T | Token-Friendly (most critical) | The 3D world is serialized to text: domain rules, arena bounds, collider table, named markers, entity state. "Game tools were built for humans. Stop expecting AI to see pixels." |
Workflow
Phase 0 — Audit (produce a verdict table before touching code)
Audit the three letters against concrete evidence. Do not skip this; the report drives the refactor and becomes the doc trail.
- C: Find the largest engine/god-class file (
wc -l/ line count). Grep the candidate logic files for platform imports:rg "from '(three|react|zustand)'|window\.|document\.|HTMLCanvasElement". - A: Check whether simulation paths call rendering/audio/store directly (
scene.createXMesh,audio.playX,store.setGameinside update loops). Identify already-pure modules (types, constants, data tables). - T: Grep for scattered world literals (collider radii, arena bounds, spawn coordinates, boss positions). Check for any textual world description or dev introspection hook.
Record the verdict as a C/A/T table with file:line evidence, e.g. the table in references/worked-example.md.
Phase 1 — C: extract the pure core
- Move platform-agnostic modules into
src/core/(or equivalent):types.ts,constants.ts,math.ts,data/tables (weapons/enemies/bosses/skills). - Update all importers, delete the old directories.
- Verify with
tscbefore proceeding.
Phase 2 — A: extract the simulation, make adapters event-driven
- Define the event union (
core/simulation/events.ts):SimEvent = sound | explosion | fx— the only way the core signals the outside world. - Port logic verbatim into a
Simulationclass (rules, movement, AI, spawning, boss patterns, collisions). Replace every side effect with an event emission:audioManager.playX(...)→{ type: 'sound', sound: 'x' }scene.createExplosion(...)→{ type: 'explosion', pos, color, size }store.triggerX(...)/ camera shake →{ type: 'fx', fx: '...', value }
- Push camera/canvas-dependent computations out of the sim. Anything requiring projection becomes adapter-computed input data passed in each tick (
TickInput):crosshairDir(world aim direction from camera+mouse)aimOrigin(camera world position, for ray tests)smartTargetId(screen-space smart-circle pick)lockStickPoint(normalized screen pos of the lock target, for aim stick)rawAim(raw normalized mouse) The sim stays pure math; the adapter owns all matrices.
- Rewrite the orchestrator (
GameEngine) as a thin adapter:- fixed-step loop (accumulator pattern)
- assemble
TickInputper step, runsim.update(dt, tick) - dispatch returned events (audio/scene/store side effects)
- mesh reconciliation: each frame create meshes for entity ids not yet in the scene maps, remove meshes whose ids vanished — never create/remove meshes inside the sim
- store sync: patch only changed fields (wave, lockOn, bossFight, bossName, score, time) to avoid re-render churn; advance
timeby the fixed step
- Keep render-only visuals in the adapter: camera/FOV breathing, screen shake, lock indicators/outlines, thruster flames, hover bob, brake pitch.
Phase 3 — T: tokenize the world
- World manifest (
core/world/world.ts): single source of truth for arena bounds, collider table (per entity radius), named markers (player start, boss arena, camera home, spawn band), spawn bands, caps, pacing, lock parameters. The sim imports helpers from it (e.g.hitRadiusFor(type)). - Tokenizers (
core/world/worldText.ts): pure functions producing prompt-ready text:describeWorld()— manifest as textdescribeRules()— controls, win/lose, economy, weapon/enemy/boss tablesdescribeEntities(sim)— live state: player HP/EN/weapon/combo, per-enemy pos/hp/state/dist, projectile countsbuildPromptContext(sim)— combined full context
- DEV hook: expose
window.__gameManifest = () => buildPromptContext(sim)andwindow.__simguarded byimport.meta.env.DEV(production builds tree-shake the hook).
Phase 4 — Verify (do not skip)
tsc+ full build green.- Browser gameplay smoke (Playwright): menu → start → intro completes → enemies spawn → shoot → kill → score/combo register → wave clears and advances → lock-on toggles ↔ store ↔ HUD. Zero console errors.
- Token output:
__gameManifest()contains world manifest + rules + live state. - Behavioral equivalence: extraction must be verbatim. If gameplay changed, it is an extraction bug — fix the port, not the tuning. The only accepted deviation is 1-frame staleness on projection-fed input (imperceptible at 60fps).
- Sync docs (TDD file tree, verification report). Never touch docs with concurrent user edits.
Hard rules
core/imports nothing platform-specific. Grep-verify after every batch.- Side effects leave the core only as events.
- During extraction, change behavior never — only move code.
- The sim never touches meshes; the adapter reconciles entity/mesh maps by id each frame.
- Caps (
MAX_ENEMIES,MAX_PROJECTILES) stay in the sim — they are gameplay, not rendering. - Keep per-batch verification: each batch type-checks green before the next.
Known gotchas (full detail in references/worked-example.md)
- Aim-stick feedback is projection-dependent: the adapter computes it from the previous tick's lock target — one frame stale, imperceptible.
- Mesh reconciliation must handle boss minions (their mesh color/size must match the def table or the reconcile lookup diverges).
- Boss attack bullets/meshes must not bypass the entity cap checks that player/enemy fire uses.
- After refactor, verify the HUD clock/score still advance — store-sync drops (e.g.
time) are the classic silent regression. - Store
setPlayerswith the same array reference does not re-render React (zustandObject.is); do not rely on it for change detection.
References
references/cat-framework.md— the GDC 2026 talk summary (principles, Tencent stack, proof points).references/audit-checklist.md— concrete PASS/FAIL criteria with grep commands per letter.references/worked-example.md— full before/after of the Pure White Lancer refactor (audit findings, target structure, TickInput design, verification results).