Ai

Microservice RPG sur Codolie : progression, ressources, combats

Damien LarqueyDamien Larquey
February 19, 2026
7 min read
Microservice RPG sur Codolie : progression, ressources, combats

Ce guide vous explique comment déployer un microservice RPG server-authoritative modulaire sur Codolie en 2–4 h pour un POC local et 1–2 jours pour une préprod avec OTel et métriques.

Objectifs

  • Progression et niveaux (XP, courbes configurables, anti-double incrément, versioning de progression).
  • Gestion de ressources (PV/PM/énergie) avec contrôle de concurrence (optimistic lock et advisory lock).
  • Combat déterministe (RNG seedée) pour rejouabilité, anti-triche et support.
  • API REST/GraphQL, persistance PostgreSQL 15+, cache Redis 7+, events Kafka/NATS.
  • Observabilité (OpenTelemetry, Prometheus, Grafana, logs structurés).
  • Sécurité (JWT, idempotency), scalabilité (autoscaling), résilience.

Prérequis

  • Projet Codolie avec registre d’images et secrets.
  • Docker & docker-compose, Node.js 20+ ou Go 1.22+, make.
  • PostgreSQL 15+, Redis 7+, Kafka/NATS (optionnel).
  • OpenTelemetry Collector/Tempo, Prometheus, Grafana opérationnels.

1) Modélisation du domaine

Écriture append-only pour audit et idempotence. Chaque modification d’XP passe par xp_ledger avec clé composite, et on stocke progression_version pour piloter les courbes.

Visualizing RPG mechanics as strategic business systems
Visualizing RPG mechanics as strategic business systems

SQL minimal (PostgreSQL) :

CREATE TABLE characters (
  id UUID PRIMARY KEY,
  owner_id UUID NOT NULL,
  level INT NOT NULL DEFAULT 1,
  xp BIGINT NOT NULL DEFAULT 0,
  progression_version INT NOT NULL DEFAULT 1,
  hp_current INT NOT NULL DEFAULT 50,
  hp_max INT NOT NULL DEFAULT 50,
  mp_current INT NOT NULL DEFAULT 10,
  mp_max INT NOT NULL DEFAULT 10,
  version BIGINT NOT NULL DEFAULT 0, -- optimistic lock
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE xp_ledger (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  character_id UUID NOT NULL REFERENCES characters(id),
  delta_xp BIGINT NOT NULL,
  reason TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(character_id, idempotency_key)
);

CREATE TABLE combat_snapshots (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  encounter_id UUID NOT NULL,
  rng_seed TEXT NOT NULL,
  server_secret_version INT NOT NULL,
  request JSONB NOT NULL,
  result JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Prisma schema correspondant :

model Character {
  id                  String     @id @default(uuid())
  ownerId             String
  level               Int        @default(1)
  xp                  BigInt     @default(0)
  progressionVersion  Int        @default(1)
  hpCurrent           Int        @default(50)
  hpMax               Int        @default(50)
  mpCurrent           Int        @default(10)
  mpMax               Int        @default(10)
  version             BigInt     @default(0)
  createdAt           DateTime   @default(now())
  updatedAt           DateTime   @updatedAt
  xpLedgers           XpLedger[]
}

model XpLedger {
  id              String   @id @default(uuid())
  characterId     String
  deltaXp         BigInt
  reason          String
  idempotencyKey  String
  createdAt       DateTime @default(now())
  character       Character @relation(fields: [characterId], references: [id])
  @@unique([characterId, idempotencyKey])
}

2) Service HTTP et configuration

Arborescence : src/, prisma/schema.prisma ou SQL brut, otel/, Dockerfile. Variables d’environnement : DATABASE_URL, REDIS_URL, OTEL_EXPORTER_OTLP_ENDPOINT, JWT_PUBLIC_KEY, SERVER_SECRET, SERVER_SECRET_VERSION.

The four strategic phases of RPG mechanics projects
The four strategic phases of RPG mechanics projects

Extrait TypeScript + Fastify + Zod + OTel :

import Fastify from "fastify";
import { z } from "zod";
import { awardXp, resolveCombat } from "./services";
import { trace } from "@opentelemetry/api";

const app = Fastify({ logger: true });

app.addHook("onRequest", async (req) => {
  // Vérification JWT et owner_id
});

app.post("/characters/:id/xp", async (req, res) => {
  const input = z.object({
    delta: z.number().int().positive(),
    reason: z.string().min(1),
    idempotencyKey: z.string().min(8)
  }).parse(req.body);

  const span = trace.getTracer("rpg").startSpan("award_xp");
  try {
    const result = await awardXp(req.params.id, input);
    res.header("Idempotency-Key", input.idempotencyKey).send(result);
  } finally {
    span.end();
  }
});

app.post("/encounters/:id/resolve", async (req, res) => {
  const input = z.object({
    rngSeed: z.string().optional(),
    progressionVersion: z.number().int().optional(),
    party: z.array(z.object({ characterId: z.string().uuid() })),
    enemies: z.array(z.object({ template: z.string() }))
  }).parse(req.body);

  const result = await resolveCombat(req.params.id, input);
  res.send(result);
});

app.listen({ port: +process.env.PORT! || 8080, host: "0.0.0.0" });

3) Progression & idempotence

La courbe est paramétrée en JSON. Ex :

{
  "levelCap": 60,
  "xpCurve": "poly",
  "coefficients": { "a": 0.5, "b": 5, "c": 100 }
}
// xpNeeded(L) = a*L^2 + b*L + c

Fonction awardXp (Prisma + advisory lock) :

import { prisma } from "./prisma";
import crypto from "crypto";
import Redis from "ioredis";
import Redlock from "redlock";

const redis = new Redis(process.env.REDIS_URL!);
const redlock = new Redlock([redis], { retryCount: 3, retryDelay: 200 });

export async function awardXp(
  characterId: string,
  { delta, reason, idempotencyKey }: { delta: number; reason: string; idempotencyKey: string }
) {
  return prisma.$transaction(async (tx) => {
    // 1) Idempotence Prisma
    const existing = await tx.xpLedger.findUnique({
      where: { characterId_idempotencyKey: { characterId, idempotencyKey } }
    });
    if (existing) {
      return tx.character.findUnique({ where: { id: characterId } });
    }

    // 2) Advisory lock PostgreSQL
    await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${characterId})::bigint)`;

    // 3) Lecture et calcul de niveau
    const ch = await tx.character.findUnique({ where: { id: characterId } });
    if (!ch) throw new Error("Character not found");

    let newXp = ch.xp + BigInt(delta);
    let newLevel = ch.level;
    while (newLevel < config.levelCap &&
           newXp >= BigInt(xpNeeded(newLevel + 1, ch.progressionVersion))) {
      newLevel++;
    }

    // 4) Persist
    await tx.xpLedger.create({
      data: { characterId, deltaXp: BigInt(delta), reason, idempotencyKey }
    });
    await tx.character.update({
      where: { id: characterId },
      data: {
        xp: newXp,
        level: newLevel,
        hpMax: ch.hpMax + (newLevel - ch.level) * 5,
        mpMax: ch.mpMax + (newLevel - ch.level) * 2
      }
    });

    // 5) Event
    emitEvent("rpg.level_up", {
      character_id: characterId,
      from: ch.level,
      to: newLevel,
      xp_total: newXp,
      progression_version: ch.progressionVersion
    });

    return tx.character.findUnique({ where: { id: characterId } });
  });
}

// Migration :
// ALTER TABLE characters ADD COLUMN progression_version INT NOT NULL DEFAULT 1;
// Rollback :
// ALTER TABLE characters DROP COLUMN progression_version;

4) Ressources & concurrence optimiste

Patch /characters/:id/resources, on passe version dans le WHERE. Si l’UPDATE ne touche aucune ligne, renvoyer 409. Exemple SQL :

Cross-functional teams aligning RPG design with business outcomes
Cross-functional teams aligning RPG design with business outcomes
UPDATE characters
SET hp_current = $1, mp_current = $2, version = version + 1
WHERE id = $3 AND version = $4;
-- Si rowCount = 0, répondre 409

Pour des updates sériels rapides, on peut utiliser Redis Redlock :

const lock = await redlock.acquire([`lock:char:${characterId}`], 500);
try {
  // lecture, calcul, write
} finally {
  await lock.release();
}
  • Validation server-side (0 ≤ PV ≤ hp_max).
  • Idempotency-Key sur tous les writes d’état.
  • Métrique rpg.resource_corrections_total pour détecter anti-cheat.

5) Combat déterministe & rotation de seed

On dérive la seed via HMAC de encounterId, serverSecretVersion et timestamp. Ex :

import crypto from "crypto";

function deriveSeed(encounterId: string, timestamp: number): string {
  const key = `${process.env.SERVER_SECRET}:${process.env.SERVER_SECRET_VERSION}`;
  return crypto.createHmac("sha256", key)
    .update(`${encounterId}:${timestamp}`)
    .digest("hex");
}

Lors de la résolution, on stocke rng_seed et server_secret_version dans combat_snapshots. Rotation mensuelle du secret conseillée, tout en gardant l’historique des versions.

import seedrandom from "seedrandom";
import { prisma } from "./prisma";

export async function resolveCombat(
  encounterId: string,
  input: ResolveInput
) {
  const timestamp = Math.floor(Date.now() / 10000);
  const seed = input.rngSeed ?? deriveSeed(encounterId, timestamp);
  const rng = seedrandom(seed);
  const actors = [...input.party, ...input.enemies];
  actors.sort((a, b) => a.agility - b.agility + Math.floor(rng() * 6));

  let turn = 1;
  while (turn <= 20 && !stopCondition(actors)) {
    // logique de combat...
    turn++;
  }

  const result = summarizeCombat(actors);
  await prisma.combatSnapshots.create({
    data: {
      encounterId,
      rngSeed: seed,
      serverSecretVersion: parseInt(process.env.SERVER_SECRET_VERSION!),
      request: input,
      result
    }
  });

  emitEvent("rpg.encounter_resolved", {
    encounter_id: encounterId,
    duration_ms: result.durationMs,
    party_down: result.partyDefeated
  });
  return { seed, ...result };
}

6) Cache & invalidation

Clé Redis : rpg:view:{id}:v{version}, TTL 10 s. Invalidation via NOTIFY PostgreSQL ou Kafka/NATS.

7) Sécurité & résilience

  • JWT (sub, scp), vérification owner_id.
  • Idempotency-Key obligatoire sur tous les writes.
  • Validation Zod, application/json strict.
  • Timeouts p95 < 200 ms, retries idempotents, circuit breaker.
  • Ne jamais exposer SERVER_SECRET ni son rotation interne.

8) Observabilité

  • Spans : award_xp, resolve_combat (attributs : character.level, encounter.duration_ms, seed_truncated).
  • Métriques : rpg.level_up_count, rpg.encounter_duration_ms (histogram), rpg.api_errors_total{route,code}.
  • Logs JSON corrélés (trace_id, span_id), échantillonnage adaptatif.

9) Déploiement

  • docker build & push, make migrate pour appliquer les migrations.
  • Provision Postgres/Redis, création du secret SERVER_SECRET et SERVER_SECRET_VERSION.
  • Configurer HPA (CPU 70 %, min=2, max=10), readiness/liveness probes.
  • Limiter à 100 rps/IP, WAF activé, mTLS interne.

Tests & dépannage

  • Simuler 1 000 gains d’XP concurrents, vérifier ledger et pas de double incrément.
  • Valider combats déterministes avec ou sans rngSeed.
  • Observer métriques & traces dans Grafana.
  • Tester idempotence et JWT invalides (401/403).
  • Deadlocks ? Vérifier advisory locks ou réduire portée des transactions.
  • Conflits de version : code 409, recommander retry backoff côté client.
Damien Larquey

Damien Larquey

Author at Codolie

Passionate about technology, innovation, and sharing knowledge with the developer community.

Back to Blog