La mayoría de los tutoriales de Godot te muestran cómo construir un juego pequeño. Muy pocos muestran qué pasa cuando el proyecto crece, cuando tienes más de un puñado de escenas, cuando tus scripts empiezan a referenciarse entre sí de maneras que no planeaste, cuando un cambio en un sistema rompe algo en un sistema que olvidaste que estaba conectado. He estado construyendo un RPG táctico en Godot 4 con C# durante un tiempo, y el proyecto ha crecido. Los números que uso aquí son una instantánea que tomé en febrero de 2026: 123+ scripts, 334+ tests entre las suites unitarias, de integración y de rendimiento, y 28 shaders. Este post trata sobre las decisiones arquitectónicas que han mantenido el proyecto manejable a esa escala.
Quiero ser directo sobre el contexto: soy un desarrollador solo. Los patrones que uso están optimizados para una persona que necesita entender todo el codebase y mantenerlo a través de meses de desarrollo intermitente. Algunos de estos patrones se verían diferentes en un contexto de equipo.
Estructura de Carpetas: Reflejar el Dominio, No el Motor
La estructura de proyecto predeterminada de Godot pone todo en un directorio plano o ligeramente organizado. Para proyectos pequeños, está bien. Para un proyecto a escala, necesitas una estructura de carpetas que haga la navegación intuitiva, donde puedas encontrar cualquier script sin buscar porque su ubicación se deduce de lo que hace.
Mi organización de nivel superior refleja los dominios del juego, no las categorías técnicas de Godot. No tengo una carpeta llamada Scripts en el nivel superior: tengo carpetas llamadas Combat, Units, Grid, Skills, AI, UI, Campaign, Audio y Shaders. Dentro de cada carpeta de dominio sí tengo sub-organización por tipo técnico, models, services, events, components, pero el eje principal de organización es el dominio del juego.
- src/Combat/. Cálculo de daño, resolución de ataques, aplicación de efectos de estado, log de combate
- src/Units/. Datos de unidades, sistemas de stats, máquinas de estado de unidades, factory de unidades
- src/Grid/. Representación del mapa de cuadrícula, pathfinding, cálculo de rango de movimiento, consultas de tiles
- src/Skills/. Definiciones de habilidades como Resources, pipeline de ejecución de habilidades, lógica de targeting
- src/AI/. Árboles de decisión enemigos, evaluación de amenazas, selección de acciones
- src/UI/. Todos los componentes de UI, organizados por pantalla o panel
- src/Campaign/. Secuenciación de batallas, estado de campaña, guardar/cargar
- assets/resources/. Todos los archivos Resource .tres, organizados para reflejar src/
- tests/. Refleja la estructura de src/ exactamente para fácil descubrimiento de tests
Que el directorio tests/ refleje src/ es intencional. Cuando abro CombatCalculator.cs, puedo navegar inmediatamente a tests/Combat/CombatCalculatorTests.cs sin pensar dónde podría estar el archivo de test. La predictibilidad de la estructura reduce la carga cognitiva al cambiar de contexto entre implementación y tests.
Autoload vs Dependencia: Saber Cuándo Usar Cada Uno
El sistema Autoload de Godot son efectivamente singletons globales. Registras una escena o script como Autoload y es accesible desde cualquier lugar del juego sin gestión explícita de referencias. Esto es poderoso y peligroso en igual medida.
Mi regla es que los Autoloads son para preocupaciones a nivel de aplicación que genuinamente abarcan todo el ciclo de vida del juego: el audio manager, el sistema de guardado, el input manager, el event bus. Estas son cosas que cada sistema legítimamente podría necesitar referenciar, y hacerlas globalmente accesibles es apropiado porque representan infraestructura transversal, no lógica de dominio.
Los servicios específicos del dominio no son Autoloads. El CombatResolver no necesita ser globalmente accesible: solo los sistemas involucrados en combate necesitan una referencia a él. Inyecto referencias a estos servicios a través de composición de escenas: un nodo BattleScene mantiene referencias a CombatResolver, MovementController, TurnManager y SkillExecutor, y pasa esas referencias a los nodos hijos que las necesitan. Esto mantiene las dependencias explícitas y testeables.
// BattleScene.cs - Composition root for battle context
public partial class BattleScene : Node
{
[Export] private BattleConfiguration _config;
// Services constructed and owned here
private CombatResolver _combatResolver;
private MovementController _movementController;
private TurnManager _turnManager;
private SkillExecutor _skillExecutor;
public override void _Ready()
{
// Build the service graph
var gridQuery = new GridQueryService(GetNode<GridMap>("GridLayer"));
var pathfinder = new AStarPathfinder(gridQuery);
var movementRange = new MovementRangeCalculator(gridQuery);
_movementController = new MovementController(pathfinder, movementRange, gridQuery);
_combatResolver = new CombatResolver(_config.CombatRules);
_skillExecutor = new SkillExecutor(_combatResolver, gridQuery);
_turnManager = new TurnManager();
// Inject into child nodes that need them
GetNode<UnitCommandController>("UnitCommandController")
.Initialize(_movementController, _skillExecutor, _turnManager);
GetNode<EnemyAIController>("EnemyAIController")
.Initialize(_movementController, _skillExecutor, _turnManager, gridQuery);
}
}El Patrón Signal Bus
El sistema de señales integrado de Godot conecta señales de un emisor específico a un receptor específico. Esto funciona bien para relaciones directas padre-hijo, pero crea acoplamiento fuerte cuando dos sistemas que no tienen por qué conocerse necesitan comunicarse, lo cual sucede frecuentemente en un juego con muchos sistemas interactuando.
El patrón Signal Bus resuelve esto con un único Autoload que actúa como un message broker. Los sistemas emiten eventos al bus; otros sistemas se suscriben a eventos en el bus. Ningún sistema tiene una referencia directa al otro. Esto es efectivamente un event bus o un sistema pub/sub, territorio familiar para cualquiera que haya trabajado con services de Angular o Redux.
// GameEvents.cs - Autoload signal bus
public partial class GameEvents : Node
{
public static GameEvents Instance { get; private set; }
// Combat events
[Signal] public delegate void UnitDamagedEventHandler(Unit target, DamageResult result);
[Signal] public delegate void UnitDefeatedEventHandler(Unit unit);
[Signal] public delegate void StatusEffectAppliedEventHandler(Unit target, StatusEffect effect);
[Signal] public delegate void StatusEffectRemovedEventHandler(Unit target, StatusEffect effect);
// Movement events
[Signal] public delegate void UnitMovedEventHandler(Unit unit, Vector2I fromCell, Vector2I toCell);
[Signal] public delegate void UnitTeleportedEventHandler(Unit unit, Vector2I toCell);
// Turn events
[Signal] public delegate void TurnStartedEventHandler(Unit activeUnit);
[Signal] public delegate void TurnEndedEventHandler(Unit unit);
[Signal] public delegate void BattleEndedEventHandler(BattleOutcome outcome);
public override void _Ready()
{
Instance = this;
}
}
// Usage - emitting
GameEvents.Instance.EmitSignal(GameEvents.SignalName.UnitDamaged, unit, damageResult);
// Usage - subscribing
GameEvents.Instance.UnitDamaged += OnUnitDamaged;Una restricción de la que depende el ejemplo anterior: los parámetros de señales en C# dentro de Godot tienen que ser compatibles con Variant. Los tipos integrados, los structs de Godot como Vector2I y cualquier cosa que derive de GodotObject o Resource funcionan bien. Una clase C# común no, y el source generator rechaza de plano la declaración [Signal]. Los tipos de payload como DamageResult o StatusEffect o derivan de GodotObject o Resource, o los aplanas en primitivos antes de emitir.
El Signal Bus tiene un beneficio significativo para testing: en los tests, puedo conectarme al bus para verificar que un sistema emitió el evento esperado, sin necesidad de tener un árbol de escenas completo ensamblado. También puedo inyectar un test double del bus para verificar que se emitieron señales específicas con parámetros específicos.
Configuración Basada en Resources: El Patrón Data-Driven
La decisión arquitectónica más impactante que tomé temprano en el proyecto fue comprometerme con la configuración basada en Resources para todo el contenido del juego. Cada habilidad, cada plantilla de unidad, cada tipo de terreno, cada ítem y cada configuración de encuentro es un Resource de Godot, un objeto de datos serializable almacenado como archivo .tres y editable en el inspector de Godot.
Esto significa que casi nunca hardcodeo datos del juego en los scripts. Los stats base de un guerrero no están definidos en una clase C#: están definidos en un archivo UnitDefinition.tres. La fórmula de daño de una bola de fuego, su rango, área de efecto y efectos visuales están definidos en un archivo AbilityDefinition.tres. Cuando quiero balancear el juego, abro el archivo resource en el inspector y cambio los valores. Cuando quiero agregar una nueva habilidad, duplico un AbilityDefinition resource existente y modifico las propiedades. No se requieren cambios de código.
// AbilityDefinition.cs - A Resource subclass for ability configuration
[GlobalClass]
public partial class AbilityDefinition : Resource
{
[Export] public string AbilityId { get; set; } = string.Empty;
[Export] public string DisplayName { get; set; } = string.Empty;
[Export(PropertyHint.MultilineText)] public string Description { get; set; } = string.Empty;
[ExportGroup("Cost")]
[Export] public int ActionPointCost { get; set; } = 1;
[Export] public int ManaCost { get; set; } = 0;
[ExportGroup("Targeting")]
[Export] public TargetingType TargetingType { get; set; } = TargetingType.SingleEnemy;
[Export] public int Range { get; set; } = 1;
[Export] public int AreaOfEffect { get; set; } = 0;
[ExportGroup("Effects")]
[Export] public AbilityEffect[] Effects { get; set; } = Array.Empty<AbilityEffect>();
[ExportGroup("Visual")]
[Export] public PackedScene AnimationScene { get; set; }
[Export] public AudioStream SoundEffect { get; set; }
}Composición de Escenas vs Herencia: Una Lección Ganada con Dolor
Al principio del proyecto, me apoyé mucho en la herencia de escenas: Godot permite crear una escena base y heredar de ella para crear variaciones. Tenía una escena BaseUnit, y luego WarriorUnit, MageUnit y ArcherUnit heredaban de ella. Parecía limpio al principio. Se convirtió en un problema de mantenimiento rápidamente.
El problema con las jerarquías de escenas basadas en herencia es que los cambios en la escena base se propagan a todos los hijos de maneras que no siempre son predecibles. Agregar un nuevo nodo a la escena base reestructura la jerarquía de cada escena que hereda. Cuando las escenas heredadas tienen propiedades sobrescritas, esas sobrescrituras pueden conflictar con cambios en la escena base de maneras sutiles. Debuggear un comportamiento que existe en cinco escenas heredadas pero no en la escena base es doloroso.
Refactoricé hacia composición: todos los tipos de unidad usan la misma escena base Unit, y su comportamiento específico se define completamente a través de su UnitDefinition resource y a través de nodos componente agregados al árbol de escenas para capacidades específicas. Una unidad que puede volar tiene un FlyingMovementComponent adjunto. Una unidad que tiene un trigger de habilidad pasiva tiene un PassiveAbilityComponent adjunto. La escena base no se subclasifica: se configura.
Este es el mismo principio que el diseño basado en componentes en motores como Unity, y es el principio correcto. Composición sobre herencia no es solo un buen consejo para software orientado a objetos: es especialmente importante en desarrollo de juegos basado en grafos de escenas.
Namespaces de C# en Godot: No Te Saltes Esto
Los proyectos de C# en Godot no imponen namespaces por defecto. Al principio del proyecto, escribí clases sin namespaces, y para cuando tenía 30-40 scripts, tenía problemas de colisión de nombres. Una clase Unit, una clase UnitManager, una clase Unit resource: sin namespaces, estas necesitaban nombres largos incómodos o colisionaban.
Refactoricé todo para usar namespaces que reflejan la estructura de carpetas. Todas las clases en src/Combat/ están en el namespace TacticalRPG.Combat. Todas las clases en src/Grid/ están en TacticalRPG.Grid. Esta es práctica estándar de C# pero requiere configuración intencional en Godot porque el motor no la impone.
Una advertencia: los namespaces se detienen en el compilador de C#. Godot registra un tipo [GlobalClass] bajo su nombre de clase corto, no bajo su nombre con namespace, así que las clases Resource globales igual necesitan nombres únicos en todo el proyecto.
Manteniendo 334+ Tests Bajo Control
334+ tests entre las suites unitarias, de integración y de rendimiento suena como mucho para un proyecto de juego, y es poco común en el espacio indie. Pero al nivel de complejidad de un RPG táctico, es genuinamente necesario. Así es como los organizo y mantengo sin que la suite de tests se convierta en una carga.
- Los tests están organizados por el dominio que testean, en un árbol tests/ que refleja src/, no en una sola carpeta plana. Encontrar tests para un sistema específico es inmediato.
- Nomenclatura de clases de test: [SystemName]Tests.cs. Nomenclatura de métodos: [MethodName]_[Scenario]_[ExpectedOutcome]. Esto hace que los mensajes de fallo de tests sean auto-documentados.
- Los tests están etiquetados por categoría: Unit, Integration, Performance. Vuelvo a correr la categoría Unit desde el test runner de Rider mientras trabajo, los tests Integration antes de cada commit, y los tests Performance periódicamente.
- Los fixtures compartidos y factories están en un directorio TestUtilities/ que tanto los tests unitarios como los de integración pueden usar. Un GridMapFactory que construye grillas de test pequeñas es un ejemplo típico.
- Nunca testeo comportamiento de nodos de Godot en tests unitarios. Esos cubren solo lógica pura de C#, bajo xUnit, sin proceso del motor. El comportamiento de nodos se testea a través de tests de integración GdUnit4 que instancian una escena mínima. Esto mantiene la suite unitaria rápida.
La Consideración de Performance Que Nadie Menciona
C# en Godot 4 usa el runtime de .NET, lo que significa que el garbage collection es una realidad que hay que gestionar para lógica de juego sensible al rendimiento. En un RPG táctico, las operaciones más sensibles al GC son el pathfinding (que asigna colecciones intermedias) y el pipeline de resolución de combate (que crea muchos objetos de vida corta durante una sola resolución).
Mi enfoque: perfilar antes de optimizar, y usar object pooling solo donde el profiling muestre que es necesario. El profiler integrado de Godot no perfila scripts C#, así que encontrar hotspots de presión de GC requiere herramientas de .NET; la documentación de Godot apunta a JetBrains Rider y dotTrace. Para el pathfinding específicamente, pre-asigno los conjuntos abiertos y cerrados y los limpio entre llamadas en lugar de asignar nuevas colecciones en cada solicitud de pathfinding. Para objetos de resultado de daño que se crean y descartan rápidamente, uso un pool simple.
La estructura por turnos del RPG táctico realmente ayuda aquí: la mayoría de los cálculos complejos ocurren durante momentos de transición (cuando un turno comienza, cuando una acción se resuelve), no durante cada frame. No estoy haciendo pathfinding para 50 unidades 60 veces por segundo: lo hago una vez cuando un jugador selecciona una unidad, y una vez por unidad enemiga durante el turno de la IA. El presupuesto de rendimiento es generoso, y la presión del GC es manejable con atención modesta.
La arquitectura es el conjunto de decisiones que se vuelven caras de cambiar. En un proyecto de juego, las decisiones caras son las que tocan tu modelo de datos y tu sistema de eventos. Acértalas temprano, y el resto es iteración.
Lecciones para Otros Devs Solo Escalando un Proyecto en Godot
- Configura namespaces desde el día uno. Retrofittearlos después es tedioso y propenso a errores.
- Usa Resources tipados para todo el contenido del juego desde el inicio. La integración con el inspector vale el costo de configuración inicial.
- Establece el Signal Bus antes de tener más de cinco o seis sistemas. Agregarlo después requiere tocar muchos scripts existentes.
- Escribe tests para lógica determinista desde el inicio. Agregar tests a código existente sin tests es significativamente más difícil que escribir tests junto con código nuevo.
- Prefiere composición sobre herencia en el diseño de escenas. Vas a refactorizar esto de todas formas: mejor empezar con el patrón correcto.
- Las carpetas organizadas por dominio superan a las carpetas por categoría técnica a cualquier escala no trivial.
- Haz la lista de Autoloads lo más corta posible. Cada Autoload es una dependencia global que dificulta el testing.
Gestionar un proyecto grande de Godot en solitario es un problema de diseño de sistemas tanto como un problema de diseño de juegos. Los patrones que he descrito no son exóticos: son prácticas estándar de ingeniería de software aplicadas a un contexto de desarrollo de juegos. Viniendo de un background profesional de software, tuve una ventaja significativa al conocer estos patrones. Si vienes de un background de desarrollo de juegos y los conceptos de ingeniería de software son nuevos, invertir tiempo en entender gestión de dependencias, diseño orientado a eventos y configuración data-driven pagará dividendos que se componen durante toda la vida de tu proyecto.
Referencias
- Godot C# scripting. https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/index.html
- GdUnit4 testing framework. https://github.com/MikeSchulze/gdUnit4
- Godot GDScript basics. https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html