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.

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.

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 :

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_totalpour 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érificationowner_id. - Idempotency-Key obligatoire sur tous les writes.
- Validation Zod,
application/jsonstrict. - Timeouts p95 < 200 ms, retries idempotents, circuit breaker.
- Ne jamais exposer
SERVER_SECRETni 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 migratepour appliquer les migrations.- Provision Postgres/Redis, création du secret
SERVER_SECRETetSERVER_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
Author at Codolie
Passionate about technology, innovation, and sharing knowledge with the developer community.