Construo aplicações Angular desde 2017. Achava que tinha um modelo mental sólido de arquitetura de componentes, gerenciamento de estado e reatividade. Então comecei a construir um RPG tático no Godot 4 com C#, e tudo que achava que sabia foi testado sob pressão de formas que nenhum projeto enterprise jamais conseguiu.
Desenvolvimento de jogos é implacável de uma forma que desenvolvimento web não é. Uma aplicação Angular mal arquitetada pode carregar devagar ou produzir renders inesperados. Um jogo mal arquitetado crasha no meio da batalha, corrompe o save state ou cai para 10 FPS exatamente quando o jogador está fazendo sua jogada mais importante. O ciclo de feedback é imediato e brutal.
O que me surpreendeu não foi o quão diferente a arquitetura de jogos é da arquitetura front-end. Foi o quão similar são. No começo de 2026, um ano construindo meu RPG tático, eu já via os mesmos padrões em todos os lugares. Sistemas entity-component, reatividade baseada em signals, árvores hierárquicas, máquinas de estado determinísticas. Os nomes são diferentes mas os problemas sendo resolvidos são idênticos.
Entity-Component Systems vs Componentes Angular
No desenvolvimento de jogos, o Entity-Component System (ECS) é um padrão arquitetural fundamental. Uma entity é apenas um ID. Um número. Um component é dados puros atrelados a essa entity. Systems processam entities que têm combinações específicas de components. Não há herança, não há acoplamento forte, não há hierarquias profundas de classes.
O Godot em si não é uma engine ECS. Ele compõe comportamento a partir de nodes. Mas quando comecei a montar personagens com nodes de componente pequenos, pensei: isso é apenas Angular com vocabulário diferente. Componentes Angular não são dados puros. São unidades autocontidas de UI e comportamento. Mas a filosofia é a mesma. Você compõe comportamento por composição, não herança. Um personagem no meu jogo tem um MovementComponent, um HealthComponent, um AttackComponent. Um formulário Angular pode ter uma ValidationDirective, uma LoadingDirective, uma AccessibilityDirective. Domínio diferente, mesmo instinto composicional.
A lição que se transferiu mais diretamente: pare de colocar tudo em um lugar. Nas minhas primeiras cenas Godot, eu construía God Nodes: nodes únicos que lidavam com movimento, combate, animações e IA de uma vez. Tive o mesmo problema exato em Angular quando era desenvolvedor junior: God Components que faziam roteamento, data fetching, validação de formulário e renderização em um único arquivo de 800 linhas. A solução é a mesma nos dois mundos. Divida responsabilidades. Faça cada peça fazer uma coisa. Componha no nível certo.
Scene Trees vs Component Trees
Godot organiza tudo em uma scene tree. Todo node vive em algum lugar na hierarquia. Uma cena pode ser instanciada como filha de outra cena. Você pode salvar uma subárvore como sua própria cena e reutilizá-la. Parece familiar? Isso é a component tree do Angular. Todo componente tem um lugar na hierarquia. Um componente pode conter outros componentes. Você pode criar bibliotecas reutilizáveis de componentes.
O insight prático que isso me deu sobre Angular: pense mais sobre onde na árvore um componente vive, não apenas o que ele faz. No Godot, se um node precisa se comunicar com um node primo três níveis acima e dois ao lado, você sente a dor imediatamente. O sinal precisa subir em bolha, ser capturado por um mediador, então fluir de volta para baixo. Em Angular, casualmente jogamos cadeias de EventEmitter através de cinco níveis de componentes e nos perguntamos por que nossos apps são difíceis de manter.
Godot te ensina a projetar a árvore intencionalmente. Em termos Angular: se dois componentes estão constantemente conversando entre si através de uma hierarquia profundamente aninhada, eles provavelmente deveriam ser irmãos ou um deveria ser pai. Reestruture a árvore antes de adicionar mais encanamento de comunicação.
// 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));
}
}Padrões de Signal em Jogos vs Angular Signals
Godot 4 tem um sistema de signals de primeira classe. Nodes emitem signals. Outros nodes se conectam a eles. A conexão é desacoplada. O emissor não sabe quem está ouvindo. Quando comecei a usar Angular Signals de verdade, senti familiaridade imediata. O modelo mental já estava cablado por meses de desenvolvimento Godot.
Mas game dev me ensinou algo que a documentação Angular não enfatiza o suficiente: signals são mais poderosos quando representam estado, não eventos. No Godot, inicialmente usei signals para tudo. Uma unidade se moveu, então emiti um signal. Um turno acabou, então emiti um signal. Um valor de vida mudou, então emiti um signal. O problema era que subscribers atrasados não tinham como saber o estado atual. Só podiam reagir ao evento, não consultar a verdade.
O padrão em que me fixei no Godot, e então imediatamente apliquei ao meu Angular SaaS, foi tornar signals a fonte da verdade e deixar valores computados derivarem deles. Não sinalize que a vida mudou. Sinalize o valor atual da vida. Deixe tudo que se importa com vida derivar desse valor reativo. É exatamente o que Angular Signals encorajam com computed() e o grafo reativo. Game dev me fez entender o porquê, não apenas o como.
// 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 Change Detection
Todo jogo tem um game loop. Ele normalmente mira 60 frames por segundo. A cada frame, processa input, atualiza estado e renderiza. A pergunta fundamental que desenvolvedores de jogos fazem é: que trabalho posso evitar fazer neste frame? Performance em jogos é sobre pular computação desnecessária.
O change detection do Angular é o equivalente do game loop. Num app com Zone.js, ele roda depois de cada evento, cada timer e cada resposta HTTP. A pergunta fundamental que desenvolvedores Angular deveriam fazer é a mesma: que change detection posso pular? A resposta em Angular é estratégia OnPush, Signals ou async pipes. O modelo mental é idêntico à otimização do game loop: não processe o que não mudou.
Game dev me tornou implacável com OnPush. Antes, usava change detection Default porque era mais fácil. Após meses pensando em orçamentos de frame e quanto trabalho custa por frame, não consigo escrever um componente sem pelo menos perguntar se ele precisa estar na estratégia Default. Quase sempre, a resposta é não. Quase sempre, OnPush com uma store baseada em signal é mais limpo e rápido.
Há outra lição do game loop que se aplica: separe seu tick de atualização do tick de renderização. Jogos atualizam física e IA em um time step fixo, depois interpolam a renderização em qualquer taxa que o display permita. Em termos Angular: separe sua lógica de negócio dos gatilhos de renderização. Não atualize estado e dispare atualizações de DOM no mesmo bloco síncrono quando pode agrupá-los. É exatamente isso que o agendamento de effect do Angular faz, e por que você não deveria chamar atualizações de signal em loops apertados sem entender como o batching funciona.
Máquinas de Estado em Jogos vs NgRx
Máquinas de estado finitas são inegociáveis no desenvolvimento de jogos. Um personagem tem estados: idle, movendo, atacando, atordoado, morto. Transições entre estados são explícitas e guardadas. Você não pode transicionar de morto para atacando. Você não pode transicionar de movendo para movendo. A máquina de estados torna estados impossíveis irrepresentáveis.
Quando voltei ao NgRx após construir a máquina de estados para o sistema de batalha do meu jogo, entendi reducers de uma forma completamente nova. Um reducer é uma função de transição de máquina de estados. A action é o evento. O estado atual é a entrada. O próximo estado é a saída. O fato de que NgRx encoraja você a modelar todos os estados possíveis explicitamente, loading, loaded, error, é exatamente a mesma disciplina que game dev aplica com estados de combate.
A maior lição: modele seus estados explicitamente primeiro, depois escreva as transições. Em game dev, se você não conhece todos os seus estados antecipadamente, recebe bugs impossíveis de reproduzir porque atingiu uma transição de estado não modelada. Em Angular, o equivalente é comportamento indefinido quando um componente recebe dados em uma ordem que você não antecipou. Modele os estados. Torne os estados impossíveis impossíveis.
// 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
};
}Orçamentos de Performance Como Restrição de Design
Em jogos, você tem 16.67ms por frame a 60 FPS. Esse é seu orçamento. Todo sistema, todo cálculo, toda draw call sai desse orçamento. A restrição é real, imediata e inegociável. Se estourar o orçamento, o jogador vê um engasgo. Não tem como esconder.
Desenvolvimento web raramente impõe essa disciplina tão diretamente. Temos scores do Lighthouse e Core Web Vitals, mas são métricas agregadas. Até o INP, que é o que mede interações, reporta um único valor por visita de página, não uma restrição por frame na interação contínua. Construir um jogo me fez começar a pensar nos meus apps Angular com uma mentalidade similar de orçamento por frame. Qual é o custo desse signal computado? Qual é o custo desse pipe rodando a cada ciclo de change detection? Qual é o custo de materializar essa lista de 10.000 itens no DOM?
Virtual scrolling, OnPush, lazy loading, deferrable views. Não são apenas features do Angular. São ferramentas de gestão de orçamento. Game dev me fez tratá-las como tal.
Separação de Dados e Apresentação
Uma das lições mais claras do game dev: sua lógica de jogo deve ser completamente desacoplada da renderização. A simulação de física não se importa com como o sprite se parece. O pathfinding da IA não sabe se o jogo é exibido em 2D ou 3D. Você pode rodar o jogo inteiro headless para testes. Apresentação é uma camada, não o núcleo.
Essa é uma disciplina que Angular realmente suporta bem através de serviços e stores reativas, mas que desenvolvedores frequentemente violam colocando lógica demais no TypeScript do componente. O componente deveria ser majoritariamente apresentacional. Deveria traduzir estado da store em bindings do template. A lógica pertence a serviços, funções puras e stores.
Minha regra agora, tanto no Godot quanto no Angular: se não consigo testar uma peça de lógica sem renderizar nada, coloquei no lugar errado. Funções puras. Serviços injetados. Máquinas de estado testáveis. A camada de renderização apenas lê e reflete.
O Que Trouxe de Volta Para o Trabalho
Trabalhando no BMO em aplicações Angular complexas, agora abordo questões de arquitetura de forma diferente. Quando uma feature parece complicada de implementar, pergunto se a árvore de componentes está estruturada para suportá-la. Quando o gerenciamento de estado parece embaralhado, pergunto se modelei todos os estados explicitamente. Quando a performance está lenta, penso em termos de orçamentos e trabalho desnecessário por ciclo.
Desenvolvimento de jogos é uma das disciplinas de engenharia de software mais exigentes precisamente porque o ciclo de feedback é tão implacável. Se sua arquitetura está errada, o jogo te diz imediatamente: por meio de bugs, por meio de problemas de performance, por meio da impossibilidade de adicionar novas features sem quebrar as existentes.
Não esperava me tornar um desenvolvedor Angular melhor construindo um RPG tático. Mas a pressão cross-domínio de ter que tomar decisões arquiteturais reais, não apenas seguir convenções de framework, acabou sendo exatamente o que eu precisava. Se você é um desenvolvedor front-end que nunca construiu um jogo, mesmo que pequeno, recomendo fortemente tentar. Você volta com um vocabulário diferente para problemas que vinha resolvendo por hábito.
As melhores lições de arquitetura vêm de domínios onde má arquitetura é imediata e dolorosamente visível. Desenvolvimento de jogos não perdoa dívida arquitetural da forma que projetos web às vezes conseguem.
Referências
- Godot scene organization. https://docs.godotengine.org/en/stable/tutorials/best_practices/scene_organization.html
- Angular Signals. https://angular.dev/guide/signals
- Godot signals. https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html#signals