Skip to main content

Architecture overview

Kaizen is built with Angular 21, leveraging modern features like standalone components, signals for reactive state, and dependency injection for service management. This guide explores the architectural decisions and patterns used throughout the application.

Project structure

The application follows a clean, organized structure:
src/
├── app/
│   ├── components/          # Reusable UI components
│   │   ├── navbar/
│   │   └── prestige-upgrade-card/
│   ├── models/              # TypeScript interfaces
│   │   ├── character.model.ts
│   │   ├── milestone.model.ts
│   │   └── prestige.model.ts
│   ├── pages/               # Route components
│   │   ├── auth/
│   │   ├── campaign/
│   │   ├── character/
│   │   ├── dashboard/
│   │   └── milestones/
│   ├── services/            # Business logic layer
│   │   ├── character-service.ts
│   │   ├── combat-service.ts
│   │   ├── autosave-service.ts
│   │   └── gamestate-service.ts
│   ├── app.config.ts        # Application configuration
│   ├── app.routes.ts        # Route definitions
│   └── app.ts               # Root component
├── environments/            # Environment configs
└── main.ts                  # Application bootstrap

Standalone components

Kaizen exclusively uses Angular 21’s standalone components, eliminating the need for NgModules:
import { Component, inject } from '@angular/core';
import { CharacterService } from '../../services/character-service';
import { Character } from '../../models/character.model';

@Component({
  selector: 'app-dashboard',
  imports: [],
  templateUrl: './dashboard.html',
})
export class Dashboard {
  characterService = inject(CharacterService);
  get character() {
    return this.characterService.character;
  }
}
The standalone: true option is now the default in Angular 21 when using schematics, as configured in angular.json:9-12.

Application bootstrap

The application bootstraps with a functional configuration approach:
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));

Application configuration

All providers are defined in app.config.ts using functional providers:
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideConvex(environment.convexPublicUrl),
    { provide: CLERK_AUTH, useClass: ClerkAuthService },
    provideClerkAuth(),
    { provide: CONVEX_AUTH_GUARD_CONFIG, useValue: { loginRoute: '/auth/login' } },
  ],
};
This configuration includes:
  • Error handling with global error listeners
  • Change detection with event coalescing for performance
  • Routing with lazy-loaded components
  • Convex for real-time backend
  • Clerk for authentication with custom service
  • Auth guards to protect routes

Routing architecture

Kaizen uses lazy-loaded routes with authentication guards:
export const routes: Routes = [
  { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
  {
    path: 'dashboard',
    loadComponent: () => {
      return import('./pages/dashboard/dashboard').then((m) => m.Dashboard);
    },
    canActivate: [convexAuthGuard],
  },
  {
    path: 'character',
    loadComponent: () => {
      return import('./pages/character/character').then((m) => m.Character);
    },
    canActivate: [convexAuthGuard],
  },
  {
    path: 'auth/login',
    loadComponent: () => {
      return import('./pages/auth/login/login').then((m) => m.Login);
    },
  },
];
Lazy loading reduces the initial bundle size by only loading route components when they’re accessed.

Service layer architecture

Kaizen’s business logic resides in a well-structured service layer. All services use Angular’s providedIn: 'root' for singleton instances.

Character service

The CharacterService manages character state using Angular signals:
@Injectable({
  providedIn: 'root',
})
export class CharacterService {
  character = signal<Character>(this.returnDefaultCharacter());

  private getCharacterFromDatabase = injectQuery(api.character.getCharacter, () => ({}));
  private databaseUpdateMutation = injectMutation(api.character.updateCharacter);
  hasLoadedFromDb = signal(false);

  constructor() {
    const defaultCharacter = this.returnDefaultCharacter();
    effect(() => {
      const dbCharacter = this.getCharacterFromDatabase.data();
      if (!dbCharacter) return;
      if (this.hasLoadedFromDb()) {
        this.character.update((char) => ({
          ...char,
          ...dbCharacter,
        }));
      } else {
        const merged = { ...defaultCharacter, ...dbCharacter };
        this.character.set(merged);
        this.hasLoadedFromDb.set(true);
      }
    });
  }
}
Key patterns:
  • Signals for reactive state management
  • Effects for synchronizing with the database
  • Convex queries for real-time data
  • Mutations for persisting changes

Game state service

The GameStateService orchestrates multiple services:
@Injectable({
  providedIn: 'root',
})
export class GameStateService {
  constructor(
    public goldUpgradeService: GoldUpgradeService,
    public prestigeUpgradeService: PrestigeUpgradeService,
    public characterService: CharacterService,
  ) {}

  getState(): GameState {
    return {
      goldUpgrades: this.goldUpgradeService.allUpgrades(),
      prestigeUpgrades: this.prestigeUpgradeService.allUpgrades(),
      character: this.characterService.character(),
    };
  }

  pushUpdatesToDatabase() {
    this.goldUpgradeService.updateDatabase();
    this.prestigeUpgradeService.updateDatabase();
    this.characterService.updateDatabase();
  }
}
This service acts as a facade, providing a unified interface for managing the entire game state.

Combat service

The CombatService handles all combat mechanics:
@Injectable({
  providedIn: 'root',
})
export class CombatService {
  characterService = inject(CharacterService);
  prestigeUpgradeService = inject(PrestigeUpgradeService);
  goldUpgradeService = inject(GoldUpgradeService);
  
  isFighting = signal(false);
  enemyHP = signal(this.calculateEnemyHP());
  isSwiftAttacking = signal(false);

  startFighting() {
    this.isFighting.set(true);
    this.performAttack();
    this.fightIntervalID = setTimeout(() => {
      this.startFighting();
    }, this.calculateAttackSpeed());
  }

  calculateDamage(): number {
    const character = this.characterService.character();
    let damage =
      (character.baseStrength +
        this.goldUpgradeService.getTotalEffect(UpgradeEffectType.FLAT_STAT_BOOST)) *
      character.strengthModifier *
      character.prestigeMultipliers.strength *
      character.prestigeLevel;

    const strengthBoost = this.prestigeUpgradeService.getTotalEffect(
      UpgradeEffectType.FLAT_STAT_BOOST
    );
    damage *= 1 + strengthBoost;

    return Math.floor(damage);
  }
}

Dependency injection patterns

Kaizen uses Angular’s dependency injection extensively:

Constructor injection (traditional)

export class App implements OnDestroy {
  constructor(private autoSaveService: AutoSaveService) {}

  ngOnDestroy() {
    this.autoSaveService.stopAutoSave();
  }
}

inject() function (modern)

export class CombatService {
  characterService = inject(CharacterService);
  prestigeUpgradeService = inject(PrestigeUpgradeService);
  goldUpgradeService = inject(GoldUpgradeService);
}
The inject() function is the modern approach in Angular, providing better type inference and flexibility.

State management with signals

Kaizen uses Angular signals for reactive state management without external libraries:
character = signal<Character>(this.returnDefaultCharacter());

// Computed values work automatically
get character() {
  return this.characterService.character;
}

// Update state immutably
this.character.update((char) => ({
  ...char,
  gold: char.gold + amount
}));

// React to changes with effects
effect(() => {
  const dbCharacter = this.getCharacterFromDatabase.data();
  if (!dbCharacter) return;
  this.character.set(merged);
});

Benefits of signals

  • Fine-grained reactivity - Only updates when values change
  • Better performance - Automatic change detection optimization
  • Type safety - Full TypeScript support
  • No subscriptions - No manual cleanup required

Base service pattern

Kaizen uses abstract base classes for common service logic:
@Injectable()
export abstract class BaseUpgradeService<
  T extends { id: string; currentLevel: number; effectType: UpgradeEffectType },
> {
  protected upgrades = signal<T[]>([]);
  readonly allUpgrades = this.upgrades.asReadonly();
  hasLoadedFromDb = signal(false);

  protected abstract getCurrentCurrency(): number;
  protected abstract spendCurrency(amount: number): void;
  public abstract updateDatabase(): void;

  purchaseUpgrade(upgradeID: string): boolean {
    if (!this.canPurchase(upgradeID)) return false;

    const upgrade = this.getUpgradeByID(upgradeID) as any;
    const cost = this.calculateCost(upgrade);
    this.spendCurrency(cost);
    this.upgrades.update((upgrades) =>
      upgrades.map((u) => (u.id === upgradeID ? { ...u, currentLevel: u.currentLevel + 1 } : u)),
    );
    return true;
  }
}
This pattern eliminates code duplication between GoldUpgradeService and PrestigeUpgradeService.

Real-time persistence

Kaizen integrates with Convex for real-time database synchronization:

Convex schema

export default defineSchema({
  users: defineTable({
    name: v.string(),
    tokenIdentifier: v.string(),
  }).index('by_token', ['tokenIdentifier']),

  character: defineTable({
    id: v.string(),
    userId: v.id('users'),
    prestigeLevel: v.number(),
    prestigeMultipliers: v.object({
      strength: v.number(),
      intelligence: v.number(),
      endurance: v.number(),
    }),
    prestigeCores: v.number(),
    gold: v.number(),
    currentStage: v.number(),
    currentWave: v.number(),
  })
    .index('by_user', ['userId'])
    .index('by_user_and_id', ['userId', 'id']),
});

Auto-save service

The AutoSaveService periodically saves state to Convex:
@Injectable({
  providedIn: 'root',
})
export class AutoSaveService {
  private saveIntervalID: number | undefined;
  private lastSavedGameState: GameState | undefined;
  
  AUTO_SAVE_INTERVAL = 300000; // 5 minutes

  constructor(private GameStateService: GameStateService) {
    effect(() => {
      if (this.autoSaveHasStarted) return;
      if (this.GameStateService.characterService.hasLoadedFromDb()) {
        this.startAutoSave();
        this.autoSaveHasStarted = true;
      }
    });
  }

  checkAndSave() {
    const currentGameState = this.GameStateService.getState();

    if (JSON.stringify(currentGameState) !== JSON.stringify(this.lastSavedGameState)) {
      this.GameStateService.pushUpdatesToDatabase();
      this.lastSavedGameState = currentGameState;
    }
  }
}
The auto-save service is instantiated in the root component to ensure it starts when the app loads.

Type safety with models

Kaizen uses TypeScript interfaces to ensure type safety:
export interface Character {
  id: string;
  name: string;
  level: number;

  baseStrength: number;
  baseIntelligence: number;
  baseEndurance: number;

  strengthModifier: number;
  intelligenceModifier: number;
  enduranceModifier: number;

  prestigeLevel: number;
  prestigeMultipliers: {
    strength: number;
    intelligence: number;
    endurance: number;
  };
  prestigeCores: number;

  gold: number;

  currentStage: number;
  currentWave: number;

  createdAt: Date;
  lastActiveAt: Date;
}
export interface Upgrade {
  id: string;
  name: string;
  description: string;

  baseCost: number;
  costScaling: number;

  effectType: UpgradeEffectType;
  effectValue: number;
  effectScaling: UpgradeScalingType;

  currentLevel: number;
}

export enum UpgradeEffectType {
  FLAT_STAT_BOOST = 'flat_stat_boost',
  MULTIPLIER_BOOST = 'multiplier_boost',
  ATTACK_SPEED = 'attack_speed',
  ENEMY_HEALTH_REDUCTION = 'enemy_health_reduction',
  DYNAMIC_PER_CORE = 'dynamic_per_core',
  CRITICAL_CHANCE_BOOST = 'critical_chance_boost',
  CRITICAL_DAMAGE_BOOST = 'critical_damage_boost',
}

Next steps

Now that you understand the architecture:
  • Explore individual services to see specific implementations
  • Review the component structure to understand the UI layer
  • Check out the Convex functions to see backend logic
  • Learn about the prestige mechanics and upgrade calculations

Build docs developers (and LLMs) love