$ open projects/chrono-crash
Full-stack challenge | realtime, dinheiro e observabilidade
Chrono Crash é um Crash Game full stack tratado como produto real.
Começou como desafio técnico, mas eu puxei para um cenário mais próximo de produção: React/Vite no frontend, NestJS separado em Game e Wallet, RabbitMQ para comandos financeiros, Keycloak, Kong, PostgreSQL, Prisma, Socket.IO, Prometheus, Grafana, Jaeger, OpenTelemetry e testes E2E.
$ cat story.md
História
Peguei o desafio como se fosse um produto de cassino realtime. O ponto não era só apostar, ver multiplicador e sacar antes do crash. Eu queria deixar claro onde cada responsabilidade mora: Game Service não mexe direto no saldo, Wallet Service cuida do ledger em centavos com BigInt, Kong concentra entrada, Keycloak emite identidade, Socket.IO publica os eventos da rodada e a stack de observabilidade deixa a operação auditável.
$ cat architecture.svg
Arquitetura
$ grep selling-points
Por que vale olhar
Consistência financeira
O Game não altera saldo direto. Débito e crédito passam por RabbitMQ, outbox no Game, inbox no Wallet e comandos idempotentes.
Realtime auditável
A rodada publica snapshot, abertura de apostas, ticks, crash, settlement, apostas e cashouts por Socket.IO.
Operação observável
Prometheus mede apostas, cashouts, WebSocket, RTP, auto bet e comandos de wallet. Jaeger recebe traces via OTEL.
Segurança e identidade
Keycloak cuida do login com authorization code flow e PKCE no frontend. Os serviços NestJS validam JWT por JWKS.
Mecânicas de jogo
Provably fair com serverSeedHash antes da rodada, serverSeed revelado depois do crash, HMAC SHA-256, auto cashout e auto bet Martingale.
Qualidade verificável
Bun test, Vitest, Playwright, E2E de API, E2E browser, testes responsivos, Docker healthchecks e quality gate por baseline.
$ cat delivery-report.md
Escopo técnico entregue
runtime + platform
Bun 1.x, Docker Compose, NestJS com TypeScript strict, React/Vite, Tailwind CSS v4, Prisma, PostgreSQL, RabbitMQ, Kong DB-less, Keycloak, Socket.IO, TanStack Query, Zustand, Swagger/OpenAPI, Prometheus, Grafana, Jaeger e OpenTelemetry.
domain + reliability
Dois bancos separados, `games` e `wallets`; dinheiro em centavos com BigInt; multiplicadores em basis points; outbox/inbox para idempotência; retries exponenciais em créditos; leaderboard por lucro líquido; auto bet com estratégias fixed e Martingale, stop-loss e take-profit.
quality + evidence
O README registra `bun run ci:local`, `bun run ci:e2e` e `bun run test:e2e:browser` na última auditoria local: 17 testes E2E de API, 7 testes de browser, coverage global de 84.58%, duplicação de 1.46% e nenhuma vulnerabilidade high/critical.
$ view code --tabs
Viewer de código
import { createHash, createHmac } from "node:crypto";
export class ProvablyFair {
static hashSeed(seed: string): string {
return createHash("sha256").update(seed).digest("hex");
}
static verifySeed(serverSeed: string, serverSeedHash: string): boolean {
return ProvablyFair.hashSeed(serverSeed) === serverSeedHash;
}
static calculateCrashPointBp(input: CrashPointInput): number {
const digest = createHmac("sha256", input.serverSeed)
.update(`${input.clientSeed}:${input.nonce}`)
.digest("hex");
const sample = BigInt(`0x${digest.slice(0, 13)}`);
const maxSample = 2n ** 52n;
const numerator = maxSample * BigInt(10000 - input.houseEdgeBp);
const denominator = maxSample - sample;
const multiplierBp = Number(numerator / denominator);
return Math.max(10000, multiplierBp);
}
}
private async dispatchDebitThroughOutbox(input: {
amountCents: bigint;
betId: string;
playerId: string;
referenceId: string;
roundId: string;
username: string;
}): Promise<{ applied: boolean; balanceCents: bigint }> {
const message = await this.walletOutboxRepository.enqueue({
id: this.idGenerator.generate(),
type: "WALLET_DEBIT",
status: "IN_FLIGHT",
roundId: input.roundId,
betId: input.betId,
playerId: input.playerId,
username: input.username,
amountCents: input.amountCents,
referenceId: input.referenceId,
reason: "BET_PLACED",
});
await this.walletOutboxDispatcher.dispatchMessage(message);
const stored = await this.walletOutboxRepository.findById(message.id);
if (stored?.status !== "SUCCEEDED" || stored.responseBalanceCents === null) {
throw new WalletOperationRejectedError(
stored?.errorCode ?? "WALLET_DEBIT_FAILED",
stored?.errorMessage ?? "Wallet debit failed",
);
}
return {
applied: stored.responseApplied ?? true,
balanceCents: stored.responseBalanceCents,
};
}
async process(command: WalletInboxCommand): Promise<WalletInboxResponse> {
return this.prisma.$transaction(async (prisma) => {
const existing = await prisma.walletInboxMessage.findUnique({
where: { referenceId: command.data.referenceId },
});
if (existing?.status === "PROCESSED" || existing?.status === "FAILED") {
return this.toDuplicateResponse(existing);
}
const wallet = await prisma.wallet.findUnique({
where: { playerId: command.data.playerId },
});
if (!wallet) {
const response = {
ok: false,
error: { code: "WALLET_NOT_FOUND", message: "Wallet not found" },
};
await this.persistResponse(prisma, command.data.referenceId, response);
return response;
}
const amountCents = BigInt(command.data.amountCents);
if (command.pattern === "wallet.debit" && wallet.balanceCents < amountCents) {
const response = {
ok: false,
error: { code: "INSUFFICIENT_FUNDS", message: "Insufficient funds" },
};
await this.persistResponse(prisma, command.data.referenceId, response);
return response;
}
const nextBalance =
command.pattern === "wallet.debit"
? wallet.balanceCents - amountCents
: wallet.balanceCents + amountCents;
await prisma.wallet.update({ where: { id: wallet.id }, data: { balanceCents: nextBalance } });
await prisma.walletTransaction.create({ data: { walletId: wallet.id, amountCents, referenceId: command.data.referenceId } });
});
}
@WebSocketGateway({
namespace: GAME_REALTIME_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class RoundsGateway implements OnGatewayConnection, RoundEventsPublisher {
@WebSocketServer()
private namespace!: Namespace;
async handleConnection(client: Socket): Promise<void> {
const round = await this.getCurrentRoundUseCase.execute();
client.emit(
ROUND_SNAPSHOT_EVENT,
this.roundRealtimeSerializer.toSnapshotPayload(round),
);
this.recordWebSocketEvent(ROUND_SNAPSHOT_EVENT);
}
async publishTick(round: Round): Promise<void> {
this.emitRoundEvent(ROUND_TICK_EVENT, round);
}
async publishCrashed(round: Round): Promise<void> {
this.emitRoundEvent(ROUND_CRASHED_EVENT, round);
}
async publishBetCashedOut(bet: Bet): Promise<void> {
this.emitBetEvent(BET_CASHED_OUT_EVENT, bet);
}
}
@Injectable()
export class KeycloakJwtGuard implements CanActivate {
private readonly issuer =
process.env.KEYCLOAK_ISSUER ?? "http://localhost:8080/realms/crash-game";
private readonly clientId =
process.env.KEYCLOAK_CLIENT_ID ?? "crash-game-client";
private readonly jwksUrl =
process.env.KEYCLOAK_JWKS_URL ??
`${this.issuer}/protocol/openid-connect/certs`;
private readonly jwks = createRemoteJWKSet(new URL(this.jwksUrl));
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RequestWithHeadersAndUser>();
const token = this.extractBearerToken(request.headers.authorization);
if (!token) {
throw new UnauthorizedException("Missing bearer token");
}
const { payload } = await jwtVerify(token, this.jwks, {
issuer: this.issuer,
});
if (payload.azp !== this.clientId) {
throw new UnauthorizedException("Invalid token client");
}
request.user = this.toAuthenticatedUser(payload);
return true;
}
}
export class GameMetrics {
private readonly registry = new Registry();
private readonly betsTotal = new Counter({
name: "crash_game_bets_total",
help: "Total crash game bets by status.",
labelNames: ["status"],
registers: [this.registry],
});
private readonly websocketEventsTotal = new Counter({
name: "crash_game_websocket_events_total",
help: "Total crash game WebSocket events by event name.",
labelNames: ["event"],
registers: [this.registry],
});
private readonly crashPointMultiplier = new Histogram({
name: "crash_game_crash_point_multiplier",
help: "Crash game crash point multiplier distribution.",
buckets: [1.01, 1.5, 2, 3, 5, 10, 25, 50, 100],
registers: [this.registry],
});
constructor() {
collectDefaultMetrics({
prefix: "crash_game_process_",
register: this.registry,
});
}
}