I have been building Angular applications since 2017. I thought I had a solid mental model of component architecture, state management, and reactivity. Then I started building a tactical RPG in Godot 4 with C#, and everything I thought I knew got stress-tested in ways no enterprise project ever managed to do.
Game development is unforgiving in a way that web development is not. A poorly architected Angular app might load slowly or produce unexpected renders. A poorly architected game crashes mid-battle, corrupts save state, or drops to 10 FPS right when a player is making their most important move. The feedback loop is immediate and brutal.
What surprised me was not how different game architecture is from frontend architecture. It was how similar they are. By early 2026, a year into building my tactical RPG, I was seeing the same patterns everywhere. Entity-component systems, signal-based reactivity, hierarchical trees, deterministic state machines. The names are different but the problems being solved are identical.
Entity-Component Systems vs Angular Components
In game development, the Entity-Component System (ECS) is a foundational architectural pattern. An entity is just an ID, a number. A component is pure data attached to that entity. Systems process entities that have specific component combinations. There is no inheritance, no tight coupling, no deep class hierarchies.
Godot itself is not an ECS engine. It composes behavior out of nodes. But when I started building characters out of small component nodes, I thought: this is just Angular with different vocabulary. Angular components are not pure data. They are self-contained units of UI and behavior. But the philosophy is the same. You compose behavior through composition, not inheritance. A character in my game has a MovementComponent, a HealthComponent, an AttackComponent. An Angular form might have a ValidationDirective, a LoadingDirective, an AccessibilityDirective. Different domain, same compositional instinct.
The lesson that transferred most directly: stop putting everything in one place. In my early Godot scenes, I was building God Nodes: single nodes that handled movement, combat, animations, and AI all at once. I had the exact same problem in Angular when I was a junior developer: God Components that did routing, data fetching, form validation, and rendering in a single 800-line file. The fix is the same in both worlds. Split responsibilities. Make each piece do one thing. Compose them at the right level.
Scene Trees vs Component Trees
Godot organizes everything into a scene tree. Every node lives somewhere in a hierarchy. A scene can be instantiated as a child of another scene. You can save a subtree as its own scene and reuse it. Sound familiar? This is Angular's component tree. Every component has a place in the hierarchy. A component can contain other components. You can create reusable component libraries.
The practical insight this gave me about Angular: think harder about where in the tree a component lives, not just what it does. In Godot, if a node needs to communicate with a cousin node three levels up and two levels across, you immediately feel the pain. The signal has to bubble up, get caught by a mediator, then flow back down. In Angular, we casually throw EventEmitter chains across five levels of components and wonder why our apps are hard to maintain.
Godot teaches you to design the tree intentionally. In Angular terms: if two components are constantly talking to each other through a deeply nested hierarchy, they should probably be siblings or one should be a parent. Restructure the tree before you add more communication plumbing.
// 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));
}
}Signal Patterns in Games vs Angular Signals
Godot 4 has a first-class signal system. Nodes emit signals. Other nodes connect to them. The connection is decoupled. The emitter does not know who is listening. When I started using Angular Signals in earnest, I felt immediate familiarity. The mental model was already wired in from months of Godot development.
But game dev taught me something Angular documentation does not emphasize enough: signals are most powerful when they represent state, not events. In Godot, I initially used signals for everything. A unit moved, so I emitted a signal. A turn ended, so I emitted a signal. A health value changed, so I emitted a signal. The problem was that late subscribers had no way to know the current state. They could only react to the event, not query the truth.
The pattern I settled on in Godot, and then immediately applied to my Angular SaaS, was to make signals the source of truth and let computed values derive from them. Do not signal that health changed. Signal the current health value. Let everything that cares about health derive from that reactive value. This is exactly what Angular Signals encourage with computed() and the reactive graph. Game dev made me understand why, not just how.
// 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
Every game has a game loop. It typically targets 60 frames per second. Every frame, it processes input, updates state, and renders. The fundamental question game developers ask is: what work can I avoid doing this frame? Performance in games is about skipping unnecessary computation.
Angular's change detection is its game loop equivalent. In a Zone.js app, it runs after every event, timer, and HTTP response. The fundamental question Angular developers should ask is the same: what change detection can I skip? The answer in Angular is OnPush strategy, Signals, or async pipes. The mental model is identical to game loop optimization: do not process what has not changed.
Game dev made me ruthless about OnPush. Before, I would use Default change detection because it was easier. After months of thinking about frame budgets and what work costs per frame, I cannot write a component without at least asking whether it needs to be on Default strategy. Almost always, the answer is no. Almost always, OnPush with a signal-based store is cleaner and faster.
There is another game loop lesson that applies: separate your update tick from your render tick. Games update physics and AI at a fixed time step, then interpolate rendering at whatever rate the display allows. In Angular terms: separate your business logic from your rendering triggers. Do not update state and trigger DOM updates in the same synchronous block when you can batch them. That is exactly what Angular's effect scheduling does, and why you should not call signal updates in tight loops without understanding how batching works.
State Machines in Games vs NgRx
Finite state machines are non-negotiable in game development. A character has states: idle, moving, attacking, stunned, dead. Transitions between states are explicit and guarded. You cannot transition from dead to attacking. You cannot transition from moving to moving. The state machine makes impossible states unrepresentable.
When I came back to NgRx after building the state machine for my game's battle system, I understood reducers in a completely new way. A reducer is a state machine transition function. The action is the event. The current state is the input. The next state is the output. The fact that NgRx encourages you to model all possible states explicitly, loading, loaded, error, is exactly the same discipline game dev enforces with combat states.
The biggest lesson: model your states explicitly first, then write the transitions. In game dev, if you do not know all your states up front, you get bugs that are impossible to reproduce because you hit an unmodeled state transition. In Angular, the equivalent is undefined behavior when a component receives data in an order you did not anticipate. Model the states. Make the impossible states impossible.
// 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
};
}Performance Budgets as a Design Constraint
In games, you have 16.67ms per frame at 60 FPS. That is your budget. Every system, every calculation, every draw call comes out of that budget. The constraint is real, immediate, and non-negotiable. If you go over budget, the player sees a stutter. There is no hiding it.
Web development rarely imposes this discipline so bluntly. We have Lighthouse scores and Core Web Vitals, but they are aggregated metrics. Even INP, the one that measures interactions, reports a single value per page visit, not a per-frame constraint on ongoing interaction. Building a game made me start thinking about my Angular apps with a similar frame-budget mentality. What is the cost of this computed signal? What is the cost of this pipe running on every change detection cycle? What is the cost of materializing this list of 10,000 items into the DOM?
Virtual scrolling, OnPush, lazy loading, deferrable views. These are not just Angular features. They are budget management tools. Game dev made me treat them as such.
Separation of Data and Presentation
One of the clearest lessons from game dev: your game logic should be completely decoupled from rendering. The physics simulation does not care what the sprite looks like. The AI pathfinding does not know whether the game is displayed in 2D or 3D. You can run the entire game headlessly for testing. Presentation is a layer, not the core.
This is a discipline that Angular actually supports well through services and reactive stores, but that developers often violate by putting too much logic in component TypeScript. The component should be mostly presentational. It should translate store state into template bindings. The logic belongs in services, pure functions, and stores.
My rule now, in both Godot and Angular: if I cannot test a piece of logic without rendering anything, I have put it in the wrong place. Pure functions. Injected services. Testable state machines. The rendering layer just reads and reflects.
What I Brought Back to My Day Job
Working at BMO on complex Angular applications, I now approach architecture questions differently. When a feature feels complicated to implement, I ask whether the component tree is structured to support it. When state management feels tangled, I ask whether I have modeled all the states explicitly. When performance is sluggish, I think in terms of budgets and unnecessary work per cycle.
Game development is one of the most demanding software engineering disciplines precisely because the feedback loop is so unforgiving. If your architecture is wrong, the game tells you immediately: through bugs, through performance problems, through the impossibility of adding new features without breaking existing ones.
I did not expect to become a better Angular developer by building a tactical RPG. But the cross-domain pressure of having to make real architectural decisions, not just follow framework conventions, turned out to be exactly what I needed. If you are a frontend developer who has never built a game, even a small one, I strongly recommend trying. You will come back with a different vocabulary for problems you have been solving by habit.
The best architecture lessons come from domains where bad architecture is immediately and painfully visible. Game development does not forgive architectural debt the way web projects sometimes can.
References
- 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