DEV Community

Rodolphe D.
Rodolphe D.

Posted on

Building a Choose-Your-Own-Adventure API with NestJS — Part 3: Game Logic

Part 3 of the Grimoire API series. Part 1 built a validated read-only endpoint, Part 2 added persistence with TypeORM. Now: the actual gamification — XP, levels, and badges.

Where the rules were hiding

Up to this point, choices in the story JSON already carried an xpReward and an optional badgeUnlocked — the data was there since Part 1. What wasn't there was anything that did something with it. ProgressService.advance() just moved the player to the next page; it never looked at the reward attached to the choice they made.

This milestone is about extracting that behavior into its own services, instead of bolting more if statements onto ProgressService.

Why not just add it to ProgressService?

I could have. It would have worked, for a while. The reason I split it out: ProgressService already has one job — reading and writing PlayerProgress rows. XP math and badge-unlock rules are a different job, with different reasons to change (a new leveling curve vs. a new column). Keeping them apart means a bug in the leveling formula can't accidentally corrupt how progress gets saved, and either one can be tested completely on its own, with no database involved at all.

XpService: the level formula

// src/xp/xp.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class XpService {
  /**
   * Level N requires N² × 100 total XP.
   * Level 1: 0xp, Level 2: 400xp, Level 3: 900xp, ...
   */
  levelForXp(xp: number): number {
    let level = 1;
    while (xp >= (level + 1) ** 2 * 100) {
      level += 1;
    }
    return level;
  }

  xpForNextLevel(xp: number): number {
    const currentLevel = this.levelForXp(xp);
    return (currentLevel + 1) ** 2 * 100;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the payoff from Part 2's decision to never store level: it's just a pure function of XP. No injection of a repository, no database call, nothing but a number in and a number out — which makes it almost embarrassingly easy to test:

describe('XpService', () => {
  it('stays at level 1 below the level-2 threshold', () => {
    expect(service.levelForXp(399)).toBe(1);
  });

  it('reaches level 2 exactly at the threshold', () => {
    expect(service.levelForXp(400)).toBe(2);
  });
});
Enter fullscreen mode Exit fullscreen mode

No TestingModule, no mocked repository — just new XpService() and assertions. Pure logic is the cheapest kind of code to test, so it's worth carving as much of the game's rules into this shape as possible.

BadgeService: unlocking without duplicating

Badges are a bit trickier than XP because unlocking one is not idempotent by default — advancing through the same page twice shouldn't award the same badge twice. That means BadgeService does need the database, to check what's already unlocked:

// src/badges/badges.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerBadge } from './entities/player-badge.entity';
import { Badge } from './entities/badge.entity';

@Injectable()
export class BadgesService {
  constructor(
    @InjectRepository(Badge) private readonly badgeRepo: Repository<Badge>,
    @InjectRepository(PlayerBadge)
    private readonly playerBadgeRepo: Repository<PlayerBadge>,
  ) {}

  async unlock(userId: string, badgeCode: string): Promise<void> {
    const alreadyUnlocked = await this.playerBadgeRepo.findOne({
      where: { user: { id: userId }, badge: { code: badgeCode } },
    });
    if (alreadyUnlocked) return;

    const badge = await this.badgeRepo.findOneByOrFail({ code: badgeCode });
    await this.playerBadgeRepo.save(
      this.playerBadgeRepo.create({ user: { id: userId }, badge }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The early if (alreadyUnlocked) return; is the whole idempotency guard — deliberately boring, which is exactly what you want from code responsible for not double-awarding a reward.

Wiring it into advance()

Back in ProgressService, the pieces come together:

async advance(userId: string, choice: PageChoice): Promise<PlayerProgress> {
  const progress = await this.findForUser(userId);

  progress.xp += choice.xpReward;
  progress.currentPageId = choice.goto;
  await this.progressRepo.save(progress);

  const targetPage = this.pagesService.findById(choice.goto);
  if (targetPage.badgeUnlocked) {
    await this.badgesService.unlock(userId, targetPage.badgeUnlocked);
  }

  return progress;
}
Enter fullscreen mode Exit fullscreen mode

ProgressService now depends on XpService (indirectly, via the stored xp — the level itself is computed on read, not here) and BadgesService, both injected through the constructor exactly like PagesService was in Part 1. Same pattern, just one more layer.

Custom exceptions for domain errors

One thing I hadn't needed until now: what happens if a client sends a choiceLabel that doesn't exist on the current page? NestJS ships generic exceptions (NotFoundException, BadRequestException), but a custom one documents intent better:

// src/progress/exceptions/invalid-choice.exception.ts
import { BadRequestException } from '@nestjs/common';

export class InvalidChoiceException extends BadRequestException {
  constructor(choiceLabel: string, pageId: string) {
    super(`"${choiceLabel}" is not a valid choice on page ${pageId}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

It still resolves to a 400 (NestJS understands the inheritance chain), but reading throw new InvalidChoiceException(...) at the call site is more honest about what actually went wrong than a generic BadRequestException('Invalid input') would be.

What's next

Right now, advance() still takes a bare userId string, sourced from that temporary DEFAULT_PLAYER_ID from Part 2. Part 4 replaces it with real accounts, login, and a JWT — so userId finally means "whoever is actually making this request," not a placeholder.

Top comments (0)