Volver al Blog
Game Development24 de julio de 202610 min de lectura

Lo Que el Desarrollo de Juegos Me Enseñó Sobre Arquitectura Front-End

DR

Doug Ribeiro

10 min de lectura

Construyo aplicaciones Angular desde 2017. Pensaba que tenía un modelo mental sólido de arquitectura de componentes, gestión de estado y reactividad. Luego empecé a construir un RPG táctico en Godot 4 con C#, y todo lo que creía saber fue puesto a prueba de maneras que ningún proyecto empresarial logró.

El desarrollo de juegos es implacable de una manera que el desarrollo web no lo es. Una app Angular mal arquitecturada podría cargar lentamente o producir renders inesperados. Un juego mal arquitecturado crashea a mitad de batalla, corrompe el estado de guardado, o cae a 10 FPS justo cuando el jugador está tomando su movimiento más importante. El ciclo de feedback es inmediato y brutal.

Lo que me sorprendió no fue cuán diferente es la arquitectura de juegos de la arquitectura frontend, sino cuán similares son. A principios de 2026, un año después de empezar a construir mi RPG táctico, ya veía los mismos patrones en todas partes. Sistemas entity-component, reactividad basada en signals, árboles jerárquicos, máquinas de estado deterministas. Los nombres son diferentes pero los problemas que se resuelven son idénticos.

Entity-Component Systems vs Componentes Angular

En desarrollo de juegos, el Entity-Component System (ECS) es un patrón arquitectónico fundamental. Una entidad es solo un ID, un número. Un componente es data pura adjunta a esa entidad. Los sistemas procesan entidades que tienen combinaciones específicas de componentes. No hay herencia, no hay acoplamiento fuerte, no hay jerarquías profundas de clases.

Godot en sí no es un motor ECS. Compone comportamiento a partir de nodos. Pero cuando empecé a construir personajes con nodos componentes pequeños, pensé: esto es Angular con vocabulario diferente. Los componentes Angular no son data pura: son unidades autocontenidas de UI y comportamiento. Pero la filosofía es la misma. Compones comportamiento a través de composición, no herencia. Un personaje en mi juego tiene un MovementComponent, un HealthComponent, un AttackComponent. Un formulario Angular podría tener una ValidationDirective, una LoadingDirective, una AccessibilityDirective. Diferente dominio, mismo instinto composicional.

La lección que se transfirió más directamente: deja de poner todo en un solo lugar. En mis primeras escenas de Godot, estaba construyendo God Nodes: nodos únicos que manejaban movimiento, combate, animaciones e IA a la vez. Tuve exactamente el mismo problema en Angular cuando era desarrollador junior: God Components que hacían routing, data fetching, validación de formularios y rendering en un solo archivo de 800 líneas. La solución es la misma en ambos mundos. Separa responsabilidades. Haz que cada pieza haga una cosa. Compónlas al nivel correcto.

Árboles de Escena vs Árboles de Componentes

Godot organiza todo en un árbol de escenas. Cada nodo vive en algún lugar de una jerarquía. Una escena puede instanciarse como hija de otra escena. Puedes guardar un subárbol como su propia escena y reutilizarla. ¿Suena familiar? Esto es el árbol de componentes de Angular. Cada componente tiene un lugar en la jerarquía. Un componente puede contener otros componentes. Puedes crear bibliotecas de componentes reutilizables.

El insight práctico que esto me dio sobre Angular: piensa más en dónde vive un componente en el árbol, no solo en qué hace. En Godot, si un nodo necesita comunicarse con un nodo primo tres niveles arriba y dos niveles al lado, inmediatamente sientes el dolor. La señal tiene que burbujear hacia arriba, ser capturada por un mediador, y luego fluir de vuelta hacia abajo. En Angular, casualmente lanzamos cadenas de EventEmitter a través de cinco niveles de componentes y nos preguntamos por qué nuestras apps son difíciles de mantener.

Godot te enseña a diseñar el árbol intencionalmente. En términos de Angular: si dos componentes se están comunicando constantemente a través de una jerarquía profundamente anidada, probablemente deberían ser hermanos o uno debería ser padre. Reestructura el árbol antes de agregar más plomería de comunicación.

Concepto en GodotÁrbol de escenas (jerarquía de …Signals (sistema de eventos int…Game loop (_process / _physics_…Máquina de estados (enum + matc…Composición de nodos sobre here…Singletons con autoloadEquivalente en AngularÁrbol de componentes (jerarquía…RxJS Subjects / Angular SignalsChange detection (Zone.js / OnP…Reducer de NgRx (action → nuevo…Composición de componentes (ng-…Servicios inyectables (provided…VS
typescript
// The Angular pattern that game dev instilled in me
// Instead of this: a parent relaying events down and up through 4 levels
@Component({
  selector: 'app-battle-board',
  imports: [UnitGridComponent, UnitRowComponent],
  template: `
    <app-unit-grid
      [units]="units"
      (unitSelected)="onUnitSelected($event)"
    >
      @for (row of rows; track row.id) {
        <app-unit-row
          [row]="row"
          (cellClicked)="onCellClicked($event)"
        ></app-unit-row>
      }
    </app-unit-grid>
  `
})
export class BattleBoardComponent {
  // units, rows, and the handlers relaying events through every level
}

// Do this: hoist shared state to a service and let the tree breathe
@Injectable({ providedIn: 'root' })
export class BattleStateService {
  selectedUnit = signal<Unit | null>(null);
  highlightedCells = signal<Cell[]>([]);

  selectUnit(unit: Unit): void {
    this.selectedUnit.set(unit);
    this.highlightedCells.set(this.calculateMovementRange(unit));
  }
}

Patrones de Signals en Juegos vs Angular Signals

Godot 4 tiene un sistema de signals de primera clase. Los nodos emiten signals. Otros nodos se conectan a ellos. La conexión es desacoplada: el emisor no sabe quién está escuchando. Cuando empecé a usar Angular Signals en serio, sentí familiaridad inmediata. El modelo mental ya estaba conectado por meses de desarrollo en Godot.

Pero el desarrollo de juegos me enseñó algo que la documentación de Angular no enfatiza suficiente: los signals son más poderosos cuando representan estado, no eventos. En Godot, inicialmente usaba signals para todo: una unidad se movió, así que emitía un signal. Un turno terminó, así que emitía un signal. Un valor de salud cambió, así que emitía un signal. El problema era que los suscriptores tardíos no tenían manera de conocer el estado actual. Solo podían reaccionar al evento, no consultar la verdad.

El patrón al que llegué en Godot, y que luego apliqué inmediatamente a mi SaaS Angular, fue hacer que los signals fueran la fuente de verdad y dejar que los valores computados deriven de ellos. No señalar que la salud cambió. Señalar el valor actual de salud. Dejar que todo lo que se preocupa por la salud derive de ese valor reactivo. Esto es exactamente lo que Angular Signals fomenta con computed() y el grafo reactivo. El desarrollo de juegos me hizo entender por qué, no solo cómo.

typescript
// Angular Signals approach informed by game dev thinking
@Injectable({ providedIn: 'root' })
export class UnitStore {
  // Source of truth: state, not events
  private _units = signal<Unit[]>([]);
  private _selectedUnitId = signal<string | null>(null);
  private _currentPhase = signal<BattlePhase>('selection');

  // Derived state: computed automatically, no manual subscriptions
  readonly selectedUnit = computed(() =>
    this._units().find(u => u.id === this._selectedUnitId()) ?? null
  );

  readonly activeUnits = computed(() =>
    this._units().filter(u => u.health > 0)
  );

  readonly canAct = computed(() =>
    this._currentPhase() === 'player-turn' && this.selectedUnit() !== null
  );

  // Actions mutate state, not broadcast events
  selectUnit(id: string): void {
    this._selectedUnitId.set(id);
  }

  advancePhase(): void {
    const phases: BattlePhase[] = ['selection', 'player-turn', 'enemy-turn'];
    const current = this._currentPhase();
    const next = phases[(phases.indexOf(current) + 1) % phases.length];
    this._currentPhase.set(next);
  }
}

Game Loops vs Detección de Cambios

Todo juego tiene un game loop. Normalmente apunta a 60 frames por segundo. Cada frame, procesa input, actualiza estado y renderiza. La pregunta fundamental que los desarrolladores de juegos hacen es: ¿qué trabajo puedo evitar hacer este frame? El rendimiento en juegos se trata de saltarse computación innecesaria.

La detección de cambios de Angular es su equivalente del game loop. En una app con Zone.js, corre después de cada evento, cada timer y cada respuesta HTTP. La pregunta fundamental que los desarrolladores Angular deberían hacer es la misma: ¿qué detección de cambios puedo saltar? La respuesta en Angular es la estrategia OnPush, Signals, o async pipes. El modelo mental es idéntico a la optimización del game loop: no proceses lo que no ha cambiado.

El desarrollo de juegos me hizo implacable con OnPush. Antes, usaba detección de cambios Default porque era más fácil. Después de meses pensando en presupuestos de frame y cuánto cuesta el trabajo por frame, no puedo escribir un componente sin al menos preguntar si necesita estar en estrategia Default. Casi siempre, la respuesta es no. Casi siempre, OnPush con un store basado en signals es más limpio y rápido.

Hay otra lección del game loop que aplica: separa tu tick de actualización de tu tick de renderizado. Los juegos actualizan física e IA a un paso de tiempo fijo, luego interpolan el renderizado a cualquier tasa que la pantalla permita. En términos de Angular: separa tu lógica de negocio de tus triggers de renderizado. No actualices estado y dispares actualizaciones del DOM en el mismo bloque síncrono cuando puedes agruparlos. Eso es exactamente lo que hace el scheduling de effects de Angular, y por qué no deberías llamar actualizaciones de signals en bucles cerrados sin entender cómo funciona el batching.

Máquinas de Estado en Juegos vs NgRx

Las máquinas de estado finitas son no negociables en el desarrollo de juegos. Un personaje tiene estados: idle, moviéndose, atacando, aturdido, muerto. Las transiciones entre estados son explícitas y protegidas. No puedes transicionar de muerto a atacando. No puedes transicionar de moviéndose a moviéndose. La máquina de estado hace que los estados imposibles sean irrepresentables.

Cuando volví a NgRx después de construir la máquina de estado para el sistema de batalla de mi juego, entendí los reducers de una manera completamente nueva. Un reducer es una función de transición de máquina de estado. La action es el evento. El estado actual es la entrada. El siguiente estado es la salida. El hecho de que NgRx te anime a modelar todos los estados posibles explícitamente, loading, loaded, error, es exactamente la misma disciplina que el desarrollo de juegos impone con los estados de combate.

La lección más grande: modela tus estados explícitamente primero, luego escribe las transiciones. En el desarrollo de juegos, si no conoces todos tus estados de antemano, obtienes bugs imposibles de reproducir porque alcanzaste una transición de estado no modelada. En Angular, el equivalente es comportamiento indefinido cuando un componente recibe datos en un orden que no anticipaste. Modela los estados. Haz que los estados imposibles sean imposibles.

csharp
// Game state machine in C# (Godot)
public enum BattlePhase
{
    PlayerSelection,
    PlayerMovement,
    PlayerAttack,
    EnemyTurn,
    Victory,
    Defeat
}

public partial class BattleManager : Node
{
    [Signal]
    public delegate void PhaseChangedEventHandler(int phase);

    private BattlePhase _currentPhase = BattlePhase.PlayerSelection;

    public void TransitionTo(BattlePhase newPhase)
    {
        if (!IsValidTransition(_currentPhase, newPhase))
        {
            GD.PrintErr($"Invalid transition: {_currentPhase} -> {newPhase}");
            return;
        }
        _currentPhase = newPhase;
        EmitSignal(SignalName.PhaseChanged, (int)newPhase);
    }

    private bool IsValidTransition(BattlePhase from, BattlePhase to) => (from, to) switch
    {
        (BattlePhase.PlayerSelection, BattlePhase.PlayerMovement) => true,
        (BattlePhase.PlayerMovement, BattlePhase.PlayerAttack) => true,
        (BattlePhase.PlayerAttack, BattlePhase.EnemyTurn) => true,
        (BattlePhase.EnemyTurn, BattlePhase.PlayerSelection) => true,
        (_, BattlePhase.Victory) => true,
        (_, BattlePhase.Defeat) => true,
        _ => false
    };
}

Presupuestos de Rendimiento Como Restricción de Diseño

En juegos, tienes 16.67ms por frame a 60 FPS. Ese es tu presupuesto. Cada sistema, cada cálculo, cada draw call sale de ese presupuesto. La restricción es real, inmediata y no negociable. Si te pasas del presupuesto, el jugador ve un tartamudeo. No hay forma de ocultarlo.

El desarrollo web raramente impone esta disciplina tan directamente. Tenemos puntuaciones de Lighthouse y Core Web Vitals, pero son métricas agregadas. Incluso INP, la que mide interacciones, reporta un único valor por visita de página, no una restricción por frame en la interacción continua. Construir un juego me hizo empezar a pensar sobre mis apps Angular con una mentalidad similar de presupuesto por frame. ¿Cuál es el costo de este computed signal? ¿Cuál es el costo de este pipe ejecutándose en cada ciclo de detección de cambios? ¿Cuál es el costo de materializar esta lista de 10,000 items en el DOM?

Virtual scrolling, OnPush, lazy loading, deferrable views: estos no son solo features de Angular. Son herramientas de gestión de presupuesto. El desarrollo de juegos me hizo tratarlos como tales.

Separación de Datos y Presentación

Una de las lecciones más claras del desarrollo de juegos: tu lógica de juego debería estar completamente desacoplada del renderizado. La simulación de física no le importa cómo se ve el sprite. El pathfinding de IA no sabe si el juego se muestra en 2D o 3D. Puedes ejecutar el juego completo headlessly para testing. La presentación es una capa, no el núcleo.

Esta es una disciplina que Angular realmente soporta bien a través de servicios y stores reactivos, pero que los desarrolladores frecuentemente violan poniendo demasiada lógica en el TypeScript del componente. El componente debería ser mayormente presentacional. Debería traducir el estado del store en bindings de template. La lógica pertenece a servicios, funciones puras y stores.

Mi regla ahora, tanto en Godot como en Angular: si no puedo testear una pieza de lógica sin renderizar nada, la he puesto en el lugar equivocado. Funciones puras. Servicios inyectados. Máquinas de estado testeables. La capa de renderizado solo lee y refleja.

Lo Que Traje de Vuelta a Mi Trabajo Diario

Trabajando en BMO en aplicaciones Angular complejas, ahora abordo las preguntas de arquitectura diferente. Cuando un feature se siente complicado de implementar, pregunto si el árbol de componentes está estructurado para soportarlo. Cuando la gestión de estado se siente enredada, pregunto si he modelado todos los estados explícitamente. Cuando el rendimiento es lento, pienso en términos de presupuestos y trabajo innecesario por ciclo.

El desarrollo de juegos es una de las disciplinas de ingeniería de software más demandantes precisamente porque el ciclo de feedback es tan implacable. Si tu arquitectura está mal, el juego te lo dice inmediatamente: a través de bugs, problemas de rendimiento, o la imposibilidad de agregar nuevas funcionalidades sin romper las existentes.

No esperaba convertirme en mejor desarrollador Angular construyendo un RPG táctico. Pero la presión cross-domain de tener que tomar decisiones arquitectónicas reales, no solo seguir convenciones del framework, resultó ser exactamente lo que necesitaba. Si eres un desarrollador frontend que nunca ha construido un juego, aunque sea pequeño, te recomiendo fuertemente intentarlo. Volverás con un vocabulario diferente para problemas que has estado resolviendo por hábito.

Las mejores lecciones de arquitectura vienen de dominios donde la mala arquitectura es inmediata y dolorosamente visible. El desarrollo de juegos no perdona la deuda arquitectónica como los proyectos web a veces pueden.

Referencias

Game DevelopmentAngularArchitectureGodot