GitHub avatar

Fox's Blog

I Spent a Weekend Reading the konosuba-rpg Code and Here's What I Found

A turn-based Discord RPG where every action generates a WebP

I spent a weekend reading the konosuba-rpg code and here's what I found

I've maintained this project for a while, but rereading your own code with fresh eyes is always instructive. konosuba-rpg is a turn-based Discord RPG where every action generates a WebP image on the fly. It's a real composed image, with sprites, health bars, combat messages -- everything.

The stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Fully free hosting. And the Discord bot runs with no persistent server. This post explains how it all holds together.

Initial game state


The core design: the URL as game state

The first thing that strikes you: there is no server-side state for gameplay. A whole fight's state fits in the URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Each segment after the seed is a played action. The server receives that URL, replays everything from the start in order, and returns an image of the fight at that exact moment. No session, no RAM state tied to a user.

Discord works via interactive buttons -- when the player hits "Attack", Discord sends the server the button's custom_id. That custom_id holds the fight's compressed URL with the new action appended. The server recomputes everything from scratch and returns the updated image.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Precompiled outside the function -- not rebuilt on every call

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6th segment, hashed over 8096 values
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

The Set precompiled outside the function is a small detail, but it avoids rebuilding the structure on every call in an edge context where modules can get re-evaluated.

The RNG: modified RC4

The random generator is an RC4 implementation (stream cipher algorithm) repurposed as a PRNG.

export class Random {
  private S: number[]; // 256-entry table
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // swap S[i] and S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Why RC4? Because it's a deterministic PRNG with decent distribution and reasonable seed-collision resistance. Same seed = same number sequence = same fight every time. That lets you "replay" any fight while keeping its URL, and guarantees two different servers (Vercel + Cloudflare) produce exactly the same result for the same URL.


The Discord 100-char limit problem

Discord imposes a 100-char limit on button custom_ids. After a few dozen actions, a fight URL blows way past that limit.

Two mechanisms answer that.

1. RLE compression of actions

Actions are encoded with a single char (a=attack, d=defend, h=hug...) and compressed with run-length encoding:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Simple, but when the player spams Attack x10 it goes from aaaaaaaaaa (10 chars) to a10 (3 chars). The "Attack x4" and "Attack x10" buttons in the UI exist precisely for that -- speeding up combat while compressing the payload nicely.

2. Session tokens when compression isn't enough

If the compressed payload stays too long, it's stored in the DB with a short token:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Groups payloads by battle_key, batch-inserts into Supabase
  // Replaces the custom_id with "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // No lookup if not needed
  }
  // In-memory lookup first, then Supabase if missing
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Checks ownership, TTL (7 days), and turn_version (avoids replaying stale state)
}

Sessions have a 7-day TTL and automatic pruning every 10 minutes. The turnVersion check prevents replaying stale state if the player advanced in the game -- a discreet protection against accidental "going back".

Both in-memory Maps (tokenToSession, latestTurnByBattle) use the same globalThis as unknown as GameSessionGlobals pattern as the image caches, for the same reasons we'll see below.


The image rendering pipeline

Fight start against a Slime

The /konosuba-rpg/:lang/* route doesn't return JSON. It returns a WebP image generated on demand.

The pipeline is organized in 3 composited layers:

Background (board + frame)
    +
Characters layer (player sprites + mob, fixed positions)
    +
UI overlay (HP bars, messages, persona icons via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: two fixed images (the board and frame), loaded from the filesystem and composed once.

Characters layer: sprites positioned at computed coordinates. Dead players are excluded (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Enemy sprites are mirrored horizontally with a custom flipX -- a pixel-by-pixel loop rather than an external dependency.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: the heavy part. The UI JSX (health bars, texts, icons) is described React-like with Satori, rendered to SVG, converted to PNG by @cf-wasm/resvg, then imported into Photon for final composition. Satori + resvg are two WASM modules compiled specifically for Cloudflare Workers with the edge-light flag.

Defend action

Ongoing fight

Hug action


The cache system -- the most crafted part

There are 5 distinct cache levels. Each targets a different pipeline granularity.

// renderImage.ts -- all on globalThis
G.__imageCache  ??= {} as Record; // raw assets
G.__base64Cache ??= {} as Record;       // asset base64 (for Satori)
G.__fontCache   ??= {} as Record; // fonts
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

The ??= on globalThis pattern: JS modules in edge workers can be re-evaluated between requests on some configs. Storing caches on globalThis with ??= guarantees they survive those re-evaluations without being recreated.

WASM eviction

The Photon image caches (photonCache, layerCache, uiPhotonCache) use an eviction callback:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* already freed */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage is a WASM object with memory allocated on the linear WASM side, outside the JS GC. That memory stays allocated until .free() is explicitly called. The LRU eviction triggers .free() automatically -- it's RAII ported to JavaScript.

Cache keys are intentionally lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

The characters layer key doesn't encode the exact HP value -- just 1 (alive) or 0 (dead). Because a player sprite at 40 HP and a player at 15 HP is identical. A cache hit thus survives any damage as long as nobody drops.

The UI key, on the other hand, encodes exact HP (the health bar changes every hit) and a message hash:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // signed 32-bit int
  }
  return hash.toString(16);
}

Math.imul forces 32-bit int multiplication, avoiding float64 conversions and giving a stable polynomial hash. That avoids adding an external dependency for this.

Base64 conversion without stack overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 bytes
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) can blow the stack on big images because args go through the call stack. Chunking by 32KB avoids that. The result is cached -- converting the same image to base64 happens only once per worker instance.


STRIPPER.md -- sequential awaits audit

There's a STRIPPER.md file in the repo documenting a parallelization audit of awaits. A few examples of what's recorded:

  • Player profile loading made 3 sequential Supabase queries (progression, run summary, achievements). They moved to Promise.all -- no dependency between them.
  • End-of-fight reward distribution (accessories + consumables) was sequential. Parallelized the same way.
  • Button session token creation was group by group. Independent groups are now created in parallel.
// progressionService.ts -- before (sequential)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// after
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Nothing revolutionary, but in a serverless context where every millisecond of response time is billed (or contributes to cold start), it counts.


The serverless Discord bot

Victory

An often-misunderstood point: a Discord bot doesn't necessarily need a persistent WebSocket connection. Discord offers an alternative: Interactions Endpoint URL. You provide Discord an HTTPS URL, and Discord sends you a POST for every interaction (slash command, button, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // Discord ping
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord sends a POST, the handler runs 50-200ms on a Vercel function or Cloudflare Worker, replies, and it's done. No permanent connection to maintain, no server to keep alive. The whole Discord bot is hosted on the Vercel free tier.

Ed25519 verification (verifyKey from discord-interactions) is mandatory -- Discord sends a signature in the headers you must validate, otherwise it rejects the endpoint.

The special animation -- the only intentional await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 seconds
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

That voluntary 3-second delay is documented in STRIPPER.md as intentional. Megumin's special attack (Explosion) has an animation on the Discord side -- the message is first updated with an intermediate visual, then patched 3 seconds later with the result. It's the only case where a Vercel function deliberately runs longer than necessary.

Special attack


Deployability on two platforms

The same codebase runs on Vercel (Node.js) and on Cloudflare Workers (V8 isolates) unmodified:

// worker.ts -- Cloudflare entrypoint
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // inject CF secrets into process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- Vercel/Node entrypoint
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

The main difference: static assets. On Vercel, they're read from the filesystem (/var/task//articles/assets/). On Cloudflare Workers, they go through an ASSETS binding (CF static assets) with fallback to an HTTPS mirror (fox3000foxy.com/konosuba-rpg/assets). getAssetBytes in assetLoader.ts handles both paths, trying the filesystem first, then fetch.

The WASM builds (@cf-wasm/photon/edge-light, @cf-wasm/resvg) have separate builds per runtime. The edge-light flag in the package name denotes the Cloudflare Workers-compatible build, which doesn't allow new WebAssembly.Module() at runtime -- the WASM must be precompiled.


Progression: XP, levels, affinity

A boss, 650 HP

Meta-progression relies on the Supabase free tier. The schema has a players table (global XP, level, gold), character_progress (XP/level/affinity per character for Darkness, Aqua, Megumin), runs (fight history), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

The progression model is simple:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP per level
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stats per level
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 points per star, 5 stars max
  return 1.2 ** stars; // exponential progression
}

Those factors apply to character stats at the start of each processGame. Kazuma follows the player's global level, the other three each have their own XP/level. Affinity (earned by collecting drops tied to a character) multiplies its stats independently.

Heal

The drop system uses difficulty-weighted loot tables:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...up to Legendary
};

Tests

Three suites: unit, perf, and leaks.

The leak test is particularly direct:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // max 20MB heap growth
});

1200 iterations of processGame, forced GC before and after, heap delta < 20MB. If that test passes, processGame doesn't leak. The render test (renderImage.spec.ts) instead checks execution time under a practical threshold.

There's also a bench.ts script to profile the full pipeline:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

With RENDER_PERF=1, the withPerf wrapper in each service logs timings:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // zero overhead if disabled
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger returns no-ops if DEV_MODE and RENDER_PERF aren't set to 1. Zero overhead in production.


What it costs to run

  • Vercel free tier: 100GB bandwidth, 1M serverless invocations per month. Image render counts as one invocation.
  • Cloudflare Workers free tier: 100K requests/day, 10ms CPU time per request (render can exceed that on Workers, hence Vercel as primary).
  • Supabase free tier: 500MB DB, 5GB bandwidth. Enough for thousands of players.

The whole backend runs at zero cost up to significant volume. The only friction point is the Cloudflare Workers CPU limit -- image render is CPU-intensive because of WASM, hence the strategy of Vercel as primary and Workers as failover CDN.


The 3 things worth remembering

  1. The URL as game state isn't just a cute trick -- it's a constraint imposed by Discord (buttons have a 100-char limit) that forced a stateless architecture with RLE compression + session token fallback. The constraint dictated the design.

  2. WASM cache with explicit eviction: PhotonImages allocate outside the JS heap and will never get GC'd without .free(). Hooking freePhoton onto LRU eviction is RAII in JavaScript. It's discreet in the code, but without it the worker would leak in production.

  3. A serverless Discord bot without WebSocket: less known than the WebSocket gateway approach, but for a bot doing stateless processing (each interaction is independent), the Interactions Endpoint is strictly better -- no reconnect, no heartbeat, no process to maintain. Discord handles availability on their infra side.


Repo: fox3000foxy/konosuba-rpg

Custom source-available license -- no redistribution, free to use.

J'ai passé un week-end à lire le code de konosuba-rpg et voilà ce que j'ai trouvé

Un RPG tour par tour Discord où chaque action génère une image WebP

J'ai passé un week-end à lire le code de konosuba-rpg et voilà ce que j'ai trouvé

Je maintiens ce projet depuis un moment, mais relire son propre code à tête reposée c'est toujours instructif. konosuba-rpg c'est un RPG tour par tour Discord où chaque action génère une image WebP à la volée. C'est une vraie image composée, avec les sprites, les barres de vie, les messages de combat -- tout.

La stack : TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hébergement entièrement gratuit. Et le bot Discord fonctionne sans serveur persistant. Ce post explique comment tout ça tient ensemble.

État initial du jeu


Le design de base : l'URL comme état du jeu

La première chose qui frappe : il n'y a aucun état côté serveur pour le gameplay. L'état complet d'un combat tient dans l'URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Chaque segment après le seed est une action jouée. Le serveur reçoit cette URL, repart du début, rejoue toutes les actions dans l'ordre, et renvoie une image du combat à cet instant précis. Aucune session, aucun état en RAM lié à un utilisateur.

Discord fonctionne par boutons interactifs -- quand le joueur appuie sur "Attaquer", Discord envoie au serveur le custom_id du bouton. Ce custom_id contient l'URL compressée du combat avec la nouvelle action ajoutée. Le serveur recalcule tout depuis zéro et renvoie l'image mise à jour.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Précompilé hors fonction -- pas recréé à chaque appel

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6ème segment, haché sur 8096 valeurs
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Le Set précompilé en dehors de la fonction c'est un détail, mais ça évite de reconstruire la structure à chaque invocation dans un contexte edge où les modules peuvent être ré-évalués.

Le RNG : RC4 modifié

Le générateur aléatoire est une implémentation RC4 (algorithme de chiffrement stream) détournée en PRNG.

export class Random {
  private S: number[]; // table de 256 entrées
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // swap S[i] et S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Pourquoi RC4 ? Parce que c'est un PRNG déterministe avec une distribution correcte et une résistance aux collisions de seed raisonnable. Même seed = même séquence de nombres = même combat à chaque fois. Ça permet de "rejouer" n'importe quel combat en conservant son URL, et garantit que deux serveurs différents (Vercel + Cloudflare) produisent exactement le même résultat pour la même URL.


Le problème de la limite des 100 caractères Discord

Discord impose une limite de 100 caractères sur les custom_id des boutons. Après quelques dizaines d'actions, une URL de combat dépasse allègrement cette limite.

Deux mécanismes répondent à ça.

1. Compression RLE des actions

Les actions sont encodées avec un seul caractère (a=attack, d=defend, h=hug...) et compressées par run-length encoding :

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Simple, mais quand le joueur spam Attaque x10 ça passe de aaaaaaaaaa (10 chars) à a10 (3 chars). Les boutons "Attaquer x4" et "Attaquer x10" dans l'UI existent justement pour ça -- accélérer le combat tout en compressant bien le payload.

2. Session tokens quand la compression ne suffit plus

Si le payload compressé reste trop long, il est stocké en base avec un token court :

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Groupe les payloads par battle_key, insère en batch dans Supabase
  // Remplace le custom_id par "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Pas de lookup si pas nécessaire
  }
  // Lookup en mémoire d'abord, puis Supabase si absent
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Vérifie ownership, TTL (7 jours), et turn_version (évite de rejouer un ancien état)
}

Les sessions ont un TTL de 7 jours et un pruning automatique toutes les 10 minutes. La vérification turnVersion empêche de rejouer un état périmé si le joueur a avancé dans la partie -- une protection discrète contre le "retour arrière" accidentel.

Les deux Maps en mémoire (tokenToSession, latestTurnByBattle) utilisent le même pattern globalThis as unknown as GameSessionGlobals que les caches d'image, pour les mêmes raisons qu'on verra plus bas.


Le pipeline de rendu d'image

Début de combat contre un Slime

La route /konosuba-rpg/:lang/* ne renvoie pas du JSON. Elle renvoie une image WebP générée à la demande.

Le pipeline est organisé en 3 layers composités :

Background (board + frame)
    +
Characters layer (sprites joueurs + mob, positions fixes)
    +
UI overlay (barres HP, messages, icônes persos via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background : deux images fixes (le plateau et le cadre), chargées depuis le filesystem et composées une fois.

Characters layer : les sprites sont positionnés selon des coordonnées calculées. Les joueurs morts sont exclus (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Les sprites ennemi sont mirrorés horizontalement avec un flipX custom -- une boucle pixel par pixel plutôt qu'une dépendance externe.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay : c'est la partie lourde. Le JSX de l'interface (barres de vie, textes, icônes) est décrit en React-like avec Satori, rendu en SVG, converti en PNG par @cf-wasm/resvg, puis importé dans Photon pour la composition finale. Satori + resvg sont deux modules WASM compilés spécifiquement pour Cloudflare Workers avec le flag edge-light.

Action Défense

Combat en cours

Action Câlin


Le système de cache -- la partie la plus travaillée

Il y a 5 niveaux de cache distincts. Chacun cible une granularité différente du pipeline.

// renderImage.ts -- tous sur globalThis
G.__imageCache  ??= {} as Record; // assets bruts
G.__base64Cache ??= {} as Record;       // base64 des assets (pour Satori)
G.__fontCache   ??= {} as Record; // polices
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Le pattern ??= sur globalThis : les modules JavaScript dans les workers edge peuvent être ré-évalués entre requêtes sur certaines configurations. Stocker les caches sur globalThis avec ??= garantit qu'ils survivent à ces ré-évaluations sans être recréés.

L'eviction WASM

Les caches d'images Photon (photonCache, layerCache, uiPhotonCache) utilisent un callback d'éviction :

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* déjà libéré */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage est un objet WASM avec une mémoire allouée côté linéaire WASM, hors du GC JavaScript. Cette mémoire reste allouée tant que .free() n'est pas appelé explicitement. L'eviction du LRU trigger .free() automatiquement -- c'est du RAII porté en JavaScript.

Les clés de cache sont intentionnellement lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

La clé du characters layer n'encode pas la valeur exacte des HP -- juste 1 (vivant) ou 0 (mort). Parce que le sprite d'un joueur à 40 HP et un joueur à 15 HP est identique. Un hit de cache survit donc à n'importe quel dégât tant que personne ne tombe.

La clé UI par contre encode les HP exacts (la barre de vie change à chaque coup) et un hash des messages :

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // entier 32-bit signé
  }
  return hash.toString(16);
}

Math.imul force la multiplication en entier 32 bits, ce qui évite les conversions float64 et donne un hash polynomial stable. Ça évite d'ajouter une dépendance externe pour ça.

La conversion base64 sans stack overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 octets
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) peut provoquer un stack overflow sur les grosses images parce que les arguments sont passés sur la call stack. Le chunking par 32Ko évite ça. Le résultat est mis en cache -- la conversion base64 d'une même image n'est faite qu'une fois par instance de worker.


STRIPPER.md -- audit des awaits séquentiels

Il y a un fichier STRIPPER.md dans le repo qui documente un audit de parallélisation des await. Quelques exemples de ce qui y est consigné :

  • Le chargement du profil joueur faisait 3 requêtes Supabase en série (progression, résumé de run, achievements). Elles ont été passées en Promise.all -- pas de dépendance entre elles.
  • La distribution des récompenses de fin de combat (accessoires + consommables) était séquentielle. Parallélisée de même.
  • La création des tokens de session pour les boutons se faisait groupe par groupe. Les groupes indépendants sont maintenant créés en parallèle.
// progressionService.ts -- avant (séquentiel)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// après
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Rien de révolutionnaire, mais dans un contexte serverless où chaque milliseconde de temps de réponse est facturée (ou contribue au cold start), ça compte.


Le bot Discord sans serveur persistant

Victoire

Point souvent mal compris : un bot Discord ne nécessite pas forcément une connexion WebSocket persistante. Discord propose une alternative : les Interactions Endpoint URL. Tu fournis une URL HTTPS à Discord, et Discord t'envoie un POST pour chaque interaction (slash command, bouton, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord envoie un POST, le handler tourne 50-200ms sur une fonction Vercel ou un Cloudflare Worker, répond, et c'est fini. Aucune connexion permanente à maintenir, aucun serveur à garder allumé. L'intégralité du bot Discord est hébergée sur le free tier Vercel.

La vérification Ed25519 (verifyKey depuis discord-interactions) est obligatoire -- Discord envoie une signature dans les headers que tu dois valider, sinon il rejette l'endpoint.

L'animation spéciale -- le seul await intentionnel

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 secondes
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Ce délai volontaire de 3 secondes est documenté dans STRIPPER.md comme intentionnel. L'attaque spéciale de Megumin (Explosion) a une animation côté Discord -- le message est d'abord mis à jour avec un visuel intermédiaire, puis modifié 3 secondes plus tard avec le résultat. C'est le seul cas où une fonction Vercel tourne volontairement plus longtemps que nécessaire.

Attaque spéciale


La déployabilité sur deux plateformes

Le même codebase tourne sur Vercel (Node.js) et sur Cloudflare Workers (V8 isolates) sans modification :

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // injecte les secrets CF dans process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- entrypoint Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

La différence principale : les assets statiques. Sur Vercel, ils sont lus depuis le filesystem (/var/task//articles/assets/). Sur Cloudflare Workers, ils passent par un binding ASSETS (assets statiques CF) avec fallback vers un mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). Le getAssetBytes dans assetLoader.ts gère les deux chemins en essayant le filesystem d'abord, puis fetch.

Les WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) ont des builds séparés pour chaque runtime. Le flag edge-light dans le nom du package désigne le build compatible Cloudflare Workers, qui n'autorise pas new WebAssembly.Module() au runtime -- le WASM doit être pré-compilé.


La progression : XP, niveaux, affinité

Un boss, 650 HP

La meta-progression repose sur Supabase free tier. Le schéma comporte une table players (XP global, niveau, gold), character_progress (XP/niveau/affinité par perso pour Darkness, Aqua, Megumin), runs (historique des combats), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Le modèle de progression est simple :

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP par niveau
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stats par niveau
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 points par étoile, 5 étoiles max
  return 1.2 ** stars; // progression exponentielle
}

Ces facteurs sont appliqués aux stats des persos au début de chaque processGame. Kazuma suit le niveau global du joueur, les trois autres ont chacun leur propre XP/niveau. L'affinité (gagnée en récupérant des drops liés à un perso) multiplie ses stats indépendamment.

Soin

Le système de drops utilise des loot tables pondérées par difficulté :

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...jusqu'à Legendary
};

Les tests

Trois suites : unitaires, perf, et leaks.

Le leak test est particulièrement direct :

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // max 20MB de croissance heap
});

1200 itérations de processGame, GC forcé avant et après, delta heap < 20MB. Si ce test passe, processGame ne fuite pas. Le test de render (renderImage.spec.ts) vérifie plutôt le temps d'exécution sous un seuil pratique.

Il y a aussi un script bench.ts pour profiler le pipeline complet :

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Avec RENDER_PERF=1, le wrapper withPerf dans chaque service loggue les timings :

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // zero overhead si désactivé
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger renvoie des no-ops si DEV_MODE et RENDER_PERF ne sont pas à 1. Aucun overhead en production.


Ce que ça coûte à faire tourner

  • Vercel free tier : 100GB de bandwidth, 1M d'invocations serverless par mois. Le render d'image compte comme une invocation.
  • Cloudflare Workers free tier : 100K requêtes/jour, 10ms CPU time par requête (le render peut dépasser ça sur les Workers, d'où Vercel en primaire).
  • Supabase free tier : 500MB de base, 5GB de bandwidth. Suffisant pour des milliers de joueurs.

L'ensemble du backend tourne à coût zéro jusqu'à un volume significatif. Le seul point de friction est la limite CPU de Cloudflare Workers -- le render image est CPU-intensive à cause de WASM, d'où la stratégie de Vercel comme primaire et Workers comme CDN de failover.


Les 3 choses qui méritent d'être retenues

  1. L'URL comme état de jeu n'est pas juste une astuce sympa -- c'est une contrainte imposée par Discord (les boutons ont une limite de 100 chars) qui a forcé une architecture stateless avec compression RLE + token de session comme fallback. La contrainte a dicté le design.

  2. Le cache WASM avec eviction explicite : les PhotonImage allouent hors du heap JavaScript et ne seront jamais GC'd sans .free(). Brancher freePhoton sur l'eviction du LRU c'est du RAII en JavaScript. C'est discret dans le code, mais sans ça le worker fuiterait en production.

  3. Un bot Discord serverless sans WebSocket : c'est moins connu que l'approche WebSocket gateway, mais pour un bot qui fait du traitement stateless (chaque interaction est indépendante), l'Interactions Endpoint est strictement supérieur -- pas de reconnexion, pas de heartbeat, pas de processus à maintenir. Discord gère la disponibilité côté leur infra.


Repo : fox3000foxy/konosuba-rpg

Licence source-available custom -- pas de redistribution, free to use.

花了一个周末读 konosuba-rpg 的代码,这是我的发现

Discord 回合制 RPG,每步操作实时生成一张 WebP 图:URL 即游戏状态、确定性 RNG、WASM 流水线、5 层缓存、无服务器机器人。

花了一个周末读 konosuba-rpg 的代码,这是我的发现

这个项目我维护挺久了,但静下心重读自己的代码总有收获。konosuba-rpg 是个 Discord 回合制 RPG,每次操作实时生成一张 WebP 图片。是真正的合成图,带立绘、血条、战斗消息,全都有。

技术栈:TypeScript、Hono、Vercel、Cloudflare Workers、Supabase。全免费托管。Discord 机器人无持久服务器。这篇讲它们是怎么拼到一起的。

游戏初始状态


基础设计:URL 即游戏状态

最先注意到的:服务端 gameplay 没有任何状态。整场战斗的状态全在 URL 里。

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

seed 后面每一段都是一个操作。服务器收到这个 URL,从头开始,按顺序重放全部操作,返回这一刻的战斗图。没有任何 session,没有内存里跟用户绑定的状态。

Discord 靠可交互按钮工作,玩家点"攻击",Discord 把按钮的 custom_id 发给服务器。这个 custom_id 里是压缩后的战斗 URL 加上新操作。服务器从零重算,返回更新后的图。

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// 函数外预编译,不用每次调用重建

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 第 6 段,哈希到 8096 个值
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

函数外预编译的 Set 是个细节,但在模块可能被反复求值的 edge 环境里,避免每次调用重建结构。

RNG:魔改 RC4

随机数发生器是 RC4 实现(流加密算法),拿来当 PRNG 用。

export class Random {
  private S: number[]; // 256 项的表
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // 交换 S[i] 和 S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

为什么 RC4?因为它是确定性 PRNG,分布不错,seed 碰撞抗性也还行。同 seed = 同样的数字序列 = 同样的战斗。这样任何战斗靠 URL 就能"重放",还保证两台服务器(Vercel + Cloudflare)对同一 URL 算出完全一样的结果。


Discord 100 字符 custom_id 上限问题

Discord 给按钮的 custom_id 限 100 字符。战斗打几十步,URL 轻松超限。

两招应对。

1. 操作的 RLE 压缩

操作编码成单个字符(a=攻击、d=防御、h=拥抱……),再做 run-length 编码:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

简单,但玩家连点 10 次攻击时,aaaaaaaaaa(10 字符)变 a10(3 字符)。UI 里"攻击 x4""攻击 x10"按钮就是干这个的,加速战斗的同时把 payload 压得很好。

2. 压不住就用 session token

压缩完还太长,就存库里,换个短 token:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // 按 battle_key 分组,批量插入 Supabase
  // 把 custom_id 换成 "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // 不需要查就别查
  }
  // 先查内存,再查 Supabase
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // 验归属、TTL(7 天)、turn_version(防止重放旧状态)
}

session 7 天 TTL,每 10 分钟自动清理。turnVersion 校验防止玩家点回过期状态,不小心"读档"回去的保护,很隐蔽。

两个内存 Map(tokenToSession、latestTurnByBattle)和图片缓存一样,用 globalThis as unknown as GameSessionGlobals 模式,原因后面讲缓存时再说。


图片渲染流水线

和史莱姆开战

/konosuba-rpg/:lang/* 路由返回的不是 JSON。是按需生成的 WebP 图。

流水线是 3 层合成:

Background(棋盘 + 边框)
    +
Characters layer(玩家和怪的立绘,固定坐标)
    +
UI overlay(血条、消息、人物图标,经 Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP 输出

Background:两张固定图(棋盘和边框),从文件系统加载,合成一次。

Characters layer:立绘按算好的坐标放。死了的玩家剔除(activeSlots = slots.filter(s => playerHp[s.i] > 0))。敌方立绘水平镜像,用手写的 flipX,逐像素循环,不引外部依赖。

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay:最重的部分。界面 JSX(血条、文字、图标)用类 React 写,Satori 渲染成 SVG,@cf-wasm/resvg 转 PNG,再导进 Photon 做最终合成。Satori + resvg 都是专门给 Cloudflare Workers 编译的 WASM 模块,带 edge-light 标志。

防御动作

战斗中

拥抱动作


缓存系统,最下功夫的部分

5 层不同的缓存。每一层管流水线里不同粒度的东西。

// renderImage.ts -- 全挂 globalThis
G.__imageCache  ??= {} as Record; // 原始资源
G.__base64Cache ??= {} as Record;       // 资源的 base64(给 Satori 用)
G.__fontCache   ??= {} as Record; // 字体
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

??= 加 globalThis 模式:在某些配置的 edge worker 里,JS 模块两次请求之间可能被重新求值。缓存挂 globalThis 上加 ??=,保证重求值也不丢、不重建。

WASM 驱逐

Photon 图片缓存(photonCache、layerCache、uiPhotonCache)用驱逐回调:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* 已经释放了 */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage 是 WASM 对象,内存 alloc 在 WASM 线性内存里,JS GC 管不着。不显式 .free() 就永远不释放。LRU 驱逐时自动调 .free(),等于把 RAII 搬到了 JavaScript 里。

缓存 key 故意做成有损的

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

角色层 key 不编码精确 HP,只记 1(活)或 0(死)。因为 40 血和 15 血的玩家立绘一模一样。只要没人倒下,挨多少打都不影响这层缓存命中。

UI 层 key 则相反,编码精确 HP(血条每刀都变)和消息哈希:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // 有符号 32 位整数
  }
  return hash.toString(16);
}

Math.imul 强制 32 位整数乘法,不走 float64 转换,哈希多项式稳定。省掉一个外部依赖。

base64 转换防栈溢出

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 字节
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...大数组) 参数走调用栈,大图直接栈溢出。按 32K 分块就没事。结果进缓存,同一张图每个 worker 实例只转一次。


STRIPPER.md:串行 await 审计

仓库里有个 STRIPPER.md,记录了一次 await 并行化审计。举几个记下来的:

  • 读玩家档案原来是 3 个 Supabase 请求串行(进度、run 历史、成就)。改成 Promise.all,三者无依赖。
  • 战斗结算发奖励(饰品加消耗品)原来串行。同样并行化。
  • 按钮 session token 创建原来按组串行。独立组并行创建。
// progressionService.ts -- 改之前(串行)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// 改之后
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

没什么革命性的,但在 serverless 里每毫秒响应时间都算钱(冷启动也算),值得抠。


无持久服务器的 Discord 机器人

胜利

常被误解的一点:Discord 机器人不一定需要 WebSocket 长连接。Discord 提供了替代方案:Interactions Endpoint URL。你给 Discord 一个 HTTPS 地址,Discord 每次交互(slash 命令、按钮、自动补全)POST 过来。

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // Discord ping
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord POST 过来,handler 在 Vercel 函数或 Cloudflare Worker 上跑 50-200ms,回包,结束。不用维持长连接,不用养服务器。整个 Discord 机器人跑在 Vercel 免费套餐上。

Ed25519 验签(discord-interactions 的 verifyKey)是强制的,Discord 发的每个请求都带签名头,必须验,不然 endpoint 通不过。

特殊动画,唯一故意的 await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 秒
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

这 3 秒延迟是故意的,STRIPPER.md 里有记录。Megumin 的 Explosion 特攻在 Discord 侧有个动画,消息先更新成中间态,3 秒后再改成结果。这是唯一一个让 Vercel 函数故意多跑一会儿的地方。

特殊攻击


双平台部署

同一套代码在 Vercel(Node.js)和 Cloudflare Workers(V8 isolates)上不改跑:

// worker.ts -- Cloudflare 入口
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // 把 CF secrets 同步进 process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- Vercel/Node 入口
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

主要区别:静态资源。Vercel 上从文件系统读(/var/task//articles/assets/)。Cloudflare Workers 上走 ASSETS 绑定(CF 静态资源),失败回退到镜像 fox3000foxy.com/konosuba-rpg/assets。assetLoader.ts 里的 getAssetBytes 两条路都走,先试文件系统,再 fetch。

WASM(@cf-wasm/photon/edge-light、@cf-wasm/resvg)给每个运行时分别有构建。包名里的 edge-light 就是 Cloudflare Workers 专用构建,不允许运行时 new WebAssembly.Module(),WASM 必须预编译。


成长线:XP、等级、好感

Boss,650 血

meta 成长靠 Supabase 免费套餐。表有 players(全局 XP、等级、金币)、character_progress(Darkness、Aqua、Megumin 各自 XP/等级/好感)、runs(战斗历史)、inventory_items、daily_quests_progress、achievements_unlocked、game_sessions。

成长模型很简单:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP 一级
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // 每级 +20% 属性
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 点一星,最高 5 星
  return 1.2 ** stars; // 指数成长
}

这些系数在每局 processGame 开头作用到人物属性。Kazuma 跟玩家全局等级,另外三人各有 XP/等级。好感(靠捡人物相关掉落涨)独立乘区。

治疗

掉落用按难度加权的 loot table:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...一直到 Legendary
};

测试

三套:单元、性能、泄漏。

泄漏测试最直接:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // 堆增长不超过 20MB
});

1200 次 processGame,前后强制 GC,堆差 < 20MB。过了说明 processGame 不漏。渲染测试(renderImage.spec.ts)则看执行时间在不在实用阈值内。

还有个 bench.ts 给整条流水线 profiling:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

RENDER_PERF=1 时各服务的 withPerf wrapper 打点:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // 关掉时零开销
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

DEV_MODE 和 RENDER_PERF 都不是 1 时 createPerfLogger 返回 no-op。生产零开销。


跑起来多少钱

  • Vercel 免费套餐:100GB 流量,每月 100 万次 serverless 调用。渲一张图算一次调用。
  • Cloudflare Workers 免费套餐:每天 10 万请求,每请求 10ms CPU 时间(渲染吃 WASM CPU,Workers 上容易超,所以 Vercel 做主力)。
  • Supabase 免费套餐:500MB 库,5GB 流量。几千玩家够了。

用户量起来之前,整套后端跑在零成本。唯一的摩擦是 Cloudflare Workers 的 CPU 上限,图片渲染是 WASM 重活,所以策略是 Vercel 主力、Workers 做 failover CDN。


最值得记住的三件事

  1. URL 即游戏状态不只是小聪明,是 Discord 强加的(按钮 100 字符上限)逼出来的无状态设计,RLE 压缩加 session token 兜底。约束决定了设计。

  2. WASM 缓存加显式释放:PhotonImage 的内存 alloc 在 JS 堆外,不 .free() 永远不释放。LRU 驱逐上挂 freePhoton 就是 JavaScript 版 RAII。代码里不起眼,生产上没它 worker 必漏。

  3. 无 WebSocket 的 serverless Discord 机器人:比 Gateway WebSocket 方案冷门,但对无状态处理(每次交互独立)的机器人来说,Interactions Endpoint 全方位更优,不用重连,不用心跳,不用养进程。可用性 Discord 那边兜底。


仓库:fox3000foxy/konosuba-rpg

自定义 source-available 许可,不许再分发,随便用。

konosuba-rpgのコードを週末かけて読んだら見つけたこと

各行動がWebP画像をその場生成するDiscordターンベースRPG。URLをゲーム状態に、決定的RNG、WASMパイプライン、5層キャッシュ、サーバーレスボット。

konosuba-rpgのコードを週末かけて読んだら見つけたこと

このプロジェクトは前から保守してるけど、頭の休まった状態で自分のコードを読み返すのはいつも勉強になる。konosuba-rpgは各行動がWebP画像をその場生成するDiscordターンベースRPGだ。本物の合成画像だよ。スプライトも体力バーも戦闘メッセージも全部だ。

スタックはTypeScript、Hono、Vercel、Cloudflare Workers、Supabaseだ。ホスティングは完全無料だ。Discordボットは永続サーバーなしで動く。この記事では全部どう保ってるか説明する。

ゲームの初期状態


基本設計:URLをゲーム状態にする

最初に打たれるのは、ゲームプレイ用のサーバー側状態がゼロってことだ。戦闘の完全状態はURLに入ってる。

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

seedの後の各セグメントは遊んだ行動1つだ。サーバーはこのURLを受けて、頭から全行動を順に再生して、その瞬間の戦闘画像を返す。セッションなし、ユーザー紐付きRAM状態なしだ。

Discordは対話ボタンで動く。プレイヤーが「攻撃」を押すと、Discordはボタンcustom_idをサーバーに送る。このcustom_idには新行動を足した圧縮戦闘URLが入ってる。サーバーはゼロから全部再計算して、更新画像を返す。

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// 関数外でプリコンパイル。呼び出しごとに作り直さない

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seedは6番目セグメント、8096値にハッシュ
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

関数外プリコンパイルのSetは細かいけど、モジュールが再評価されうるedge文脈で呼び出しごとに構造を作り直さない。

RNG:改変RC4

乱数ジェネレータはRC4実装(stream暗号アルゴリズム)をPRNGに流用したものだ。

export class Random {
  private S: number[]; // 256エントリのテーブル
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // S[i]とS[j]を交換
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

なぜRC4かって。分布がまともでseed衝突耐性もまともな決定的PRNGだからだ。同じseedは同じ数列、同じ戦闘に毎回なる。URLを保てば任意戦闘を「再生」できて、違う2サーバー(VercelとCloudflare)が同じURLにまったく同じ結果を出すことが保証される。


Discordの100文字制限問題

Discordはボタンcustom_idに100文字制限を課す。数十行動もすれば戦闘URLは楽々超える。

2仕組みがこれに答える。

1. 行動のRLE圧縮

行動は1文字でエンコードされて(a=攻撃、d=防御、h=ハグなど)、ランレングス圧縮される。

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

単純だけど、攻撃x10連打はaaaaaaaaaa(10文字)からa10(3文字)になる。UIに「攻撃x4」と「攻撃x10」ボタンがあるのはまさにそのためだ。戦闘を速めつつペイロードもよく圧縮する。

2. 圧縮で足りなければセッショントークン

圧縮ペイロードがまだ長すぎたら、短いトークン付きでベースに保存される。

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // battle_keyごとにペイロードをまとめてSupabaseにバッチ挿入
  // custom_idを"gs.{token}:{userId}"に置換
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // 不要なら参照なし
  }
  // まずメモリ参照、なければSupabase
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // 所有権、TTL(7日)、turn_versionを確認(古い状態の再生を防ぐ)
}

セッションはTTL7日で10分ごとに自動pruneする。turnVersion確認で、プレイヤーが先に進んでたら古い状態再生を防ぐ。うっかり「巻き戻し」に対するさりげない保護だ。

メモリ内2 Map(tokenToSession、latestTurnByBattle)は画像キャッシュと同じglobalThis as unknown as GameSessionGlobalsパターンを使う。下で見るのと同じ理由だ。


画像レンダーパイプライン

スライム相手の戦闘開始

/konosuba-rpg/:lang/*ルートはJSONを返さない。要求に応じて生成したWebP画像を返す。

パイプラインは合成3層で組まれてる。

Background (board + frame)
    +
Characters layer (players sprites + mob, fixed positions)
    +
UI overlay (HP bars, messages, Satori → SVG → PNG経由のキャラアイコン)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background:2つの固定画像(盤と枠)で、ファイルシステムから読んで1回合成する。

Characters layer:スプライトは計算座標で配置する。死んだプレイヤーは除外する(activeSlots = slots.filter(s => playerHp[s.i] > 0))。敵スプライトは外部依存じゃなく自作flipXで水平ミラーする。ピクセルごとのループだ。

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay:重い部分だ。UIのJSX(体力バー、テキスト、アイコン)はSatoriでReact風に記述して、SVGにレンダーして、@cf-wasm/resvgでPNG変換して、最終合成用にPhotonへimportする。Satoriとresvgはedge-lightフラグ付きでCloudflare Workers専用コンパイルされた2つのWASMモジュールだ。

防御行動

戦闘中

ハグ行動


キャッシュシステム。一番作り込んだ部分

粒度の違う5層キャッシュがある。パイプラインの各粒度を狙う。

// renderImage.ts。全部globalThis上
G.__imageCache  ??= {} as Record; // 生アセット
G.__base64Cache ??= {} as Record;       // アセットのbase64(Satori用)
G.__fontCache   ??= {} as Record; // フォント
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

globalThis上の??=パターン:edgeワーカーのJavaScriptモジュールは設定によってリクエスト間で再評価されうる。??=付きでglobalThisにキャッシュを置けば、再評価で作り直されず生き延びることが保証される。

WASMの eviction

Photon画像キャッシュ(photonCache、layerCache、uiPhotonCache)は eviction コールバックを使う。

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* すでに解放済み */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImageはJavaScript GC外の線形WASM側に確保したメモリを持つWASMオブジェクトだ。このメモリは.free()を明示呼ばない限り確保されたままだ。LRUの eviction が.free()を自動で起こす。JavaScriptに持ってきたRAIIだ。

キャッシュキーはわざと lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

characters層のキーはHP正確値はエンコードしない。1(生存)か0(死亡)だけだ。HP40のプレイヤーとHP15のプレイヤーのスプライトは同じだからね。誰も倒れない限りどんなダメージでもキャッシュヒットが生き延びる。

UIキーは逆に正確HPをエンコードする(体力バーは一撃ごとに変わる)。メッセージのハッシュもだ。

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // 符号付き32ビット整数
  }
  return hash.toString(16);
}

Math.imulで32ビット整数乗算を強制して、float64変換を避けて安定多項式ハッシュにする。外部依存足さずに済む。

スタック溢れなしbase64変換

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 octets
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray)は引数がコールスタックに載るから大きい画像でスタック溢れを起こしうる。32KB刻みで避ける。結果はキャッシュする。同じ画像のbase64変換はワーカーインスタンスごとに1回だけだ。


STRIPPER.md。逐次awaitの監査

リポジトリにSTRIPPER.mdファイルがあって、await並列化の監査を文書化してる。記されてる例をいくつか挙げる。

  • プレイヤープロフィール読み込みはSupabase 3リクエスト直列だった(進行、run要約、実績)。Promise.all化した。間に依存なしだ。
  • 戦闘終了報酬配布(装飾品と消耗品)は直列だった。同様に並列化した。
  • ボタン用セッショントークン作成はグループごとだった。独立グループは今並列作成する。
// progressionService.ts。前(直列)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// 後
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

革命的じゃないけど、応答1ミリ秒が課金(やコールドスタート寄与)されるサーバーレス文脈では効く。


永続サーバーなしDiscordボット

勝利

よく誤解される点:Discordボットに永続WebSocket接続は必ずしも要らない。Discordは別案を出す。Interactions Endpoint URLだ。HTTPS URLをDiscordに渡すと、各やり取り(スラッシュコマンド、ボタン、オートコンプリート)ごとにPOSTをくれる。

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

DiscordがPOSTを送って、ハンドラがVercel関数かCloudflare Worker上で50から200ms回って、返して終わりだ。維持する常時接続なし、点けとくサーバーなしだ。Discordボット全体がVercel無料枠ホスティングだ。

Ed25519検証(discord-interactionsのverifyKey)は必須だ。Discordは検証しなきゃいけない署名をヘッダーで送ってくる。しないとエンドポイントを拒否する。

特殊アニメ。唯一意図的なawait

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3秒
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

この3秒の自発遅延はSTRIPPER.mdで意図的と文書化されてる。めぐみんの特殊攻撃(Explosion)にはDiscord側アニメがある。メッセージはまず中間ビジュアルで更新されて、3秒後に結果で変更される。Vercel関数が必要以上に長く回る唯一のケースだ。

特殊攻撃


二平台のデプロイ容易性

同じコードベースが無変更でVercel(Node.js)とCloudflare Workers(V8 isolates)両方で回る。

// worker.ts。Cloudflareエントリーポイント
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // CFシークレットをprocess.envへ注入
    return app.fetch(request, env, ctx);
  }
};

// index.ts。VercelやNodeエントリーポイント
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

主な違い:静的アセットだ。Vercelではファイルシステムから読む(/var/task//articles/assets/)。Cloudflare WorkersではASSETSバインディング経由だ(CF静的アセット)。HTTPSミラー(fox3000foxy.com/konosuba-rpg/assets)へのフォールバック付きだ。assetLoader.tsのgetAssetBytesは両道を扱う。まずファイルシステムを試して、次にfetchだ。

WASM(@cf-wasm/photon/edge-light、@cf-wasm/resvg)は各ランタイム用に別buildがある。パッケージ名のedge-lightフラグはCloudflare Workers対応buildを示す。実行時new WebAssembly.Module()不可で、WASMはプリコンパイル必須だ。


進行:XP、レベル、好感度

ボス、HP650

メタ進行はSupabase無料枠が土台だ。スキーマはplayersテーブル(全局XP、レベル、ゴールド)、character_progress(ダクネス、アクア、めぐみんごとのキャラ別XPやレベルや好感度)、runs(戦闘履歴)、inventory_items、daily_quests_progress、achievements_unlocked、game_sessionsを持つ。

進行モデルは単純だ。

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP par niveau
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stats par niveau
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 points par étoile, 5 étoiles max
  return 1.2 ** stars; // progression exponentielle
}

この係数は各processGame頭にキャラ stats に適用される。カズマはプレイヤー全局レベルに従う。他3人は各々独自XPやレベルだ。好感度(キャラ紐付きドロップ回収で稼ぐ)は stats を独立に倍増する。

回復

ドロップ機構は難易度重みのルートテーブルを使う。

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...Legendaryまで
};

テスト

3スイートある。単体、性能、leakだ。

leakテストは特に直接的だ。

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // ヒープ増加は最大20MB
});

1200回processGameを回して、前後で強制GCして、ヒープ差20MB未満だ。通ればprocessGameは漏らさない。レンダーテスト(renderImage.spec.ts)は逆に実用的なしきい値下の実行時間を確認する。

bench.tsスクリプトで完全パイプラインをプロファイルもする。

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

RENDER_PERF=1付きで各サービスのwithPerfラッパーがタイミングをログする。

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // 無効ならオーバーヘッドゼロ
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

DEV_MODEとRENDER_PERFが1じゃないとcreatePerfLoggerはno-opを返す。本番オーバーヘッドゼロだ。


回すといくらかかるか

  • Vercel無料枠:帯域100GB、月サーバーレス呼び出し100万回だ。画像レンダーは呼び出し1回に数える。
  • Cloudflare Workers無料枠:1日10万リクエスト、リクエストあたりCPU時間10msだ(レンダーはWASMで超えうる。だからVercelが主だ)。
  • Supabase無料枠:ベース500MB、帯域5GBだ。数千プレイヤーに十分だ。

有意な量までバックエンド全体がゼロコストで回る。唯一の摩擦はCloudflare WorkersのCPU制限だ。画像レンダーはWASMでCPU集約的だ。だからVercel主、WorkersをフェイルオーバーCDNにする戦略だ。


覚えるべき3つ

  1. URLをゲーム状態にするのはただの素敵裏技じゃない。Discord制約(ボタン100文字制限)が強いたアーキテクチャで、RLE圧縮とフォールバック用セッショントークンのステートレス設計を強制した。制約が設計を決めた。

  2. 明示 eviction 付きWASMキャッシュ:PhotonImageはJavaScriptヒープ外に確保して.free()なしではGCされない。LRUの eviction にfreePhotonを繋ぐのはJavaScriptのRAIIだ。コードではさりげないけど、これなしではワーカーが本番で漏れる。

  3. WebSocketなしサーバーレスDiscordボット:WebSocketゲートウェイ手法より知名度低いけど、ステートレス処理のボット(各やり取りが独立)にはInteractions Endpointが厳密に優れてる。再接続なし、ハートビートなし、維持プロセスなしだ。可用性は向こうの土台側でDiscordが管理する。


リポジトリ:fox3000foxy/konosuba-rpg

カスタム source-available ライセンス。再配布不可、利用無料。

konosuba-rpg 코드를 주말에 읽고 찾은 것

매 행동마다 즉석에서 WebP 이미지를 만드는 Discord 턴제 RPG. 상태로서의 URL, 결정적 RNG, WASM 파이프라인, 5단계 캐시, 서버리스 봇.

konosuba-rpg 코드를 주말에 읽고 찾은 것

이 프로젝트를 한동안 유지해 왔지만, 머리를 식히고 자기 코드를 다시 읽는 것은 항상 배움이 있어요. konosuba-rpg는 매 행동마다 즉석에서 WebP 이미지를 만드는 Discord 턴제 RPG예요. 스프라이트와 체력 바, 전투 메시지가 있는 완전한 합성 이미지, 전부요.

스택은 TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. 호스팅은 완전히 무료. 그리고 Discord 봇은 영속 서버 없이 돌아요. 이 글에서는 이 모든 것이 어떻게 하나로 맞물리는지 설명해요.

게임 초기 상태


기본 설계: 게임 상태로서의 URL

가장 먼저 눈에 띄는 것은 게임플레이에 서버 쪽 상태가 전혀 없다는 거예요. 전투의 완전한 상태가 URL에 들어 있어요.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

시드 뒤의 각 구간이 플레이한 행동 하나예요. 서버는 이 URL을 받아서 처음부터 다시 시작하고 모든 행동을 순서대로 다시 실행한 뒤, 바로 그 순간 전투 이미지를 돌려줘요. 세션도 없고, 사용자와 연결된 RAM 상태도 없어요.

Discord는 상호작용 버튼으로 동작해요. 플레이어가 "공격"을 누르면 Discord가 버튼의 custom_id를 서버로 보내요. 이 custom_id에는 새 행동이 추가된 압축 전투 URL이 들어 있어요. 서버는 처음부터 전부 다시 계산하고 고쳐진 이미지를 돌려줘요.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// 함수 밖에 미리 컴파일 -- 매 호출마다 다시 만들지 않아요

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6번째 구간, 8096개 값으로 해시
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

함수 밖에 미리 컴파일된 Set은 세부 사항이지만, 모듈이 다시 평가될 수 있는 엣지 맥락에서 매 호출마다 구조를 다시 만드는 것을 피해요.

RNG: 개조 RC4

난수 생성기는 PRNG로 전용된 RC4 구현(스트림 암호화 알고리즘)이에요.

export class Random {
  private S: number[]; // 256개 항목 표
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // S[i]와 S[j] 바꾸기
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

왜 RC4냐고요? 분포가 괜찮고 시드 충돌 내성이 합리적인 결정적 PRNG라서요. 같은 시드 = 같은 숫자 연속 = 매번 같은 전투. 어떤 전투든 URL을 간직해서 "다시 실행"할 수 있게 하고, 서로 다른 두 서버(Vercel + Cloudflare)가 같은 URL에 정확히 같은 결과를 내도록 보장해요.


Discord 100자 한도 문제

Discord는 버튼 custom_id에 100자 한도를 강제해요. 행동이 수십 개 쌓이면 전투 URL이 이 한도를 훌쩍 넘겨요.

두 구조가 이를 받아요.

1. 행동의 RLE 압축

행동은 한 글자로 인코딩되고(a=attack, d=defend, h=hug...) 런렝스 인코딩으로 압축돼요.

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

단순하지만, 플레이어가 공격 x10을 연타하면 aaaaaaaaaa(10자)에서 a10(3자)으로 바뀌어요. UI의 "공격 x4"와 "공격 x10" 버튼이 바로 그 때문에 있어요. 전투를 빠르게 하면서 페이로드를 잘 압축해요.

2. 압축이 부족하면 세션 토큰

압축된 페이로드가 여전히 너무 길면, 짧은 토큰과 함께 자료에 저장돼요.

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // battle_key별로 페이로드를 묶어서 Supabase에 배치 삽입
  // custom_id를 "gs.{token}:{userId}"로 바꿔요
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // 필요 없으면 조회 없어요
  }
  // 메모리에서 먼저 조회, 없으면 Supabase
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // 소유권, TTL (7일), turn_version 확인 (낡은 상태 재실행 방지)
}

세션은 TTL 7일에 10분마다 자동 정리돼요. turnVersion 검사는 플레이어가 게임에서 앞으로 나아갔을 때 낡은 상태를 재실행하지 않게 막아요. 우연한 "뒤로 가기"에 대한 은근한 보호예요.

메모리 Map 둘(tokenToSession, latestTurnByBattle)은 아래에서 볼 이미지 캐시와 같은 이유로 globalThis as unknown as GameSessionGlobals 패턴을 써요.


이미지 렌더링 파이프라인

Slime과의 전투 시작

/konosuba-rpg/:lang/* 라우트는 JSON을 돌려주지 않아요. 요청에 따라 만든 WebP 이미지를 돌려줘요.

파이프라인은 3개 층 합성으로 짜여 있어요.

Background (board + frame)
    +
Characters layer (player sprites + mob, fixed positions)
    +
UI overlay (HP bars, messages, personal icons via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: 고정 이미지 둘(판과 틀). 파일시스템에서 불러와서 한 번 합성해요.

Characters layer: 계산된 좌표에 따라 스프라이트를 배치해요. 죽은 플레이어는 빠져요(activeSlots = slots.filter(s => playerHp[s.i] > 0)). 적 스프라이트는 외부 의존성 대신 픽셀별 루프 커스텀 flipX로 수평 뒤집어요.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: 무거운 부분이에요. 인터페이스 JSX(체력 바, 텍스트, 아이콘)를 Satori로 React처럼 기술하고, SVG로 렌더링하고, @cf-wasm/resvg로 PNG로 바꾸고, 최종 합성을 위해 Photon으로 가져와요. Satori와 resvg는 edge-light 플래그로 Cloudflare Workers용으로 특별히 컴파일된 WASM 모듈 둘이에요.

방어 행동

진행 중 전투

포옹 행동


캐시 시스템: 가장 공들인 부분

수준이 다른 캐시 5개가 있어요. 파이프라인의 서로 다른 세밀함을 노려요.

// renderImage.ts -- 전부 globalThis 위
G.__imageCache  ??= {} as Record; // 날것 에셋
G.__base64Cache ??= {} as Record;       // 에셋 base64 (Satori용)
G.__fontCache   ??= {} as Record; // 글꼴
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

globalThis의 ??= 패턴. 엣지 워커의 JavaScript 모듈은 특정 설정에서 요청 사이에 다시 평가될 수 있어요. 캐시를 ??=와 함께 globalThis에 저장하면 다시 만들어지지 않고 이런 재평가를 살아남아요.

WASM 제거

Photon 이미지 캐시(photonCache, layerCache, uiPhotonCache)는 제거 콜백을 써요.

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* 이미 해제됨 */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage은 JavaScript GC 밖에 선형 WASM 메모리를 할당한 WASM 객체예요. .free()를 명시적으로 부르지 않으면 이 메모리는 할당된 채로 남아요. LRU 제거가 자동으로 .free()를 일으켜요. JavaScript로 옮긴 RAII예요.

캐시 키는 일부러 손실돼요

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

캐릭터 층 키는 HP 정확한 값을 인코딩하지 않아요. 1(생존) 또는 0(사망)뿐이에요. HP 40인 플레이어와 HP 15인 플레이어의 스프라이트가 똑같으니까요. 그래서 아무도 쓰러지지 않는 한 어떤 피해를 입어도 캐시 적중이 살아남아요.

UI 키는 정확한 HP(매 타격마다 체력 바가 바뀌어요)와 메시지 해시를 인코딩해요.

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // 부호 있는 32비트 정수
  }
  return hash.toString(16);
}

Math.imul은 곱셈을 32비트 정수로 강제해서, float64 변환을 피하고 안정적인 다항 해시를 줘요. 외부 의존성을 더하지 않아요.

스택 오버플로우 없는 base64 변환

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 바이트
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray)는 인수가 호출 스택으로 넘어가서 큰 이미지에 스택 오버플로우를 일으킬 수 있어요. 32KB 청킹이 이를 피해요. 결과는 캐시돼요. 같은 이미지의 base64 변환은 워커 인스턴스당 한 번만 해요.


STRIPPER.md: 순차 await 감사

순차 await 병렬화 감사를 문서화한 STRIPPER.md 파일이 저장소에 있어요. 기록된 예시 몇 가지예요.

  • 플레이어 프로필 불러오기가 Supabase 요청 셋을 연속으로 했어요 (진행, 실행 요약, 업적). Promise.all로 넘어갔어요. 서로 의존 없어요.
  • 전투 종료 보상 분배(액세서리와 소모품)가 순차였어요. 마찬가지로 병렬화했어요.
  • 버튼용 세션 토큰 만들기가 그룹별로 일어났어요. 독립 그룹은 이제 병렬로 만들어요.
// progressionService.ts -- 전 (순차)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// 후
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

혁명적인 것은 아니지만, 응답 시간 1밀리초가 청구되거나(콜드 스타트에 기여하는) 서버리스 맥락에서는 중요해요.


영속 서버 없는 Discord 봇

승리

흔히 잘못 이해하는 점이에요. Discord 봇이 꼭 영속 WebSocket 연결을 필요로 하지는 않아요. Discord는 대안을 제공해요. Interactions Endpoint URL. Discord에 HTTPS URL 하나를 주고, Discord가 각 인터랙션(슬래시 명령어, 버튼, 자동 완성)마다 POST를 보내요.

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // Discord 핑
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord가 POST를 보내고, 핸들러가 Vercel 함수나 Cloudflare Worker에서 50~200ms 돌고, 답하고, 끝이에요. 유지할 영속 연결도 없고, 켜둘 서버도 없어요. Discord 봇 전체가 Vercel 무료 티어에 호스팅돼요.

Ed25519 검증(discord-interactions의 verifyKey)은 필수예요. Discord가 헤더에 서명을 보내고 검증해야 해요. 아니면 엔드포인트를 거부해요.

특별 애니메이션: 유일하게 의도적인 await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3초
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

의도적인 3초 지연은 STRIPPER.md에 의도적이라고 문서화되어 있어요. Megumin의 특별 공격(Explosion)에 Discord 쪽 애니메이션이 있어요. 메시지가 먼저 중간 비주얼로 고쳐졌다가 3초 뒤에 결과로 수정돼요. Vercel 함수를 필요보다 일부러 오래 돌리는 유일한 경우예요.

특별 공격


두 플랫폼 배포 가능성

같은 코드베이스가 수정 없이 Vercel(Node.js)과 Cloudflare Workers(V8 아이솔레이트)에서 돌아요.

// worker.ts -- Cloudflare 진입점
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // CF 시크릿을 process.env에 주입
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- Vercel과 Node 진입점
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

주된 차이는 정적 에셋이에요. Vercel에서는 파일시스템(/var/task//articles/assets/)에서 읽어요. Cloudflare Workers에서는 정적 에셋 바인딩 ASSETS(CF 정적 에셋)가 있고 HTTPS 미러(fox3000foxy.com/konosuba-rpg/assets)로 폴백해요. assetLoader.ts의 getAssetBytes가 파일시스템 먼저, 다음 fetch로 두 경로를 다뤄요.

WASM(@cf-wasm/photon/edge-light, @cf-wasm/resvg)은 각 런타임용 빌드가 따로 있어요. 패키지 이름의 edge-light 플래그는 Cloudflare Workers 호환 빌드를 가리켜요. 런타임에 new WebAssembly.Module()을 허용하지 않아서 WASM이 미리 컴파일되어야 해요.


진행: XP, 레벨, 호감도

보스, HP 650

메타 진행은 Supabase 무료 티어에 기대요. 스키마에 players 표(전체 XP, 레벨, 골드), character_progress(Darkness, Aqua, Megumin별 XP와 레벨, 호감도), runs(전투 기록), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions 표가 있어요.

진행 모델은 단순해요.

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 레벨당 XP 100
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // 레벨당 스탯 20퍼센트 증가
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 별당 20점, 최대 5개
  return 1.2 ** stars; // 지수 진행
}

이 계수들은 매 processGame 시작 때 캐릭터 스탯에 적용돼요. Kazuma는 플레이어 전체 레벨을 따르고, 나머지 셋은 각자 XP와 레벨이 있어요. 호감도(캐릭터 관련 드롭을 회수해서 얻어요)는 스탯을 독립적으로 곱해요.

치유

드롭 시스템은 난이도별 가중 전리품 표를 써요.

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...Legendary까지
};

테스트

세 모음: 단위, 성능, 누수.

누수 테스트가 특히 직접적이에요.

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // 힙 증가 최대 20MB
});

processGame 1200번 반복, 전후 강제 GC, 힙 차이 20MB 미만. 이 테스트를 통과하면 processGame은 새지 않아요. 렌더 테스트(renderImage.spec.ts)는 실용 임계값 아래 실행 시간을 확인해요.

파이프라인 전체를 프로파일하는 bench.ts 스크립트도 있어요.

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

RENDER_PERF=1이면 각 서비스의 withPerf 래퍼가 타이밍을 로그해요.

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // 끄면 오버헤드 제로
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

DEV_MODE와 RENDER_PERF가 1이 아니면 createPerfLogger는 no-op을 돌려줘요. 프로덕션 오버헤드 없어요.


돌리는 비용

  • Vercel 무료 티어: 월 대역폭 100GB, 서버리스 호출 100만 회. 이미지 렌더가 호출 하나로 쳐요.
  • Cloudflare Workers 무료 티어: 하루 요청 10만 건, 요청당 CPU 시간 10ms (렌더가 Workers에서 이를 넘을 수 있어서 Vercel이 주예요).
  • Supabase 무료 티어: 자료 500MB, 대역폭 5GB. 수천 플레이어에 충분해요.

상당한 규모까지 백엔드 전체가 비용 제로로 돌아요. 유일한 마찰 지점은 Cloudflare Workers CPU 한도예요. WASM 때문에 이미지 렌더가 CPU 집약적이라서, Vercel을 주로 쓰고 Workers를 페일오버 CDN으로 쓰는 전략이에요.


기억할 3가지

  1. 게임 상태로서의 URL은 그저 멋진 잔꾀가 아니에요. Discord가 강제하는 제약(버튼 100자 한도)이 RLE 압축 + 폴백 세션 토큰의 무상태 아키텍처를 강제했어요. 제약이 설계를 정했어요.

  2. 명시적 해제가 있는 WASM 캐시: PhotonImage은 JavaScript 힙 밖에 할당하고 .free() 없이는 절대 GC되지 않아요. LRU 제거에 freePhoton을 붙이는 것이 JavaScript의 RAII예요. 코드에서 은근하지만, 이것이 없으면 워커가 프로덕션에서 새요.

  3. WebSocket 없는 서버리스 Discord 봇: WebSocket 게이트웨이 방식보다 덜 알려졌지만, 무상태 처리(각 인터랙션이 독립적)를 하는 봇에는 Interactions Endpoint가 엄격히 우월해요. 재연결도 없고, 하트비트도 없고, 유지할 프로세스도 없어요. Discord가 자기 인프라 쪽 가용성을 맡아요.


저장소: fox3000foxy/konosuba-rpg

커스텀 소스어베일러블 라이선스: 재배포 금지, 사용 무료.

konosuba-rpg kodunu bir hafta sonu okudum ve işte bulduklarım

Her aksiyonun anında bir WebP görseli ürettiği sıra tabanlı bir Discord RPGsi:

konosuba-rpg kodunu bir hafta sonu okudum ve işte bulduklarım

Bu projeyi bir süredir sürdürüyorum ama kendi kodunu dinlenmiş kafayla yeniden okumak hep öğreticidir. konosuba-rpg her aksiyonun anında bir WebP görseli ürettiği sıra tabanlı bir Discord RPGsidir. Spritelar, can barları, savaş mesajlarıyla gerçek bir beste görseldir: hepsi.

Yığın: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Tamamen ücretsiz barındırma. Ve Discord botu kalıcı sunucusuz çalışır. Bu yazı bütün bunların nasıl bir arada durduğunu açıklar.

Oyunun başlangıç durumu


Temel tasarım: oyun durumu olarak URL

İlk çarpan şey: oynanış için sunucu tarafında durum yoktur. Bir savaşın tam durumu URLdedir.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Seedden sonraki her parça oynanmış bir aksiyondur. Sunucu bu URLyi alır, baştan başlar, tüm aksiyonları sırayla yeniden oynar ve o andaki savaşın bir görselini döndürür. Kullanıcıya bağlı RAMde oturum yok, durum yok.

Discord etkileşimli düğmelerle çalışır: oyuncu "Saldır"a bastığında Discord sunucuya düğmenin custom_id değerini gönderir. Bu custom_id yeni aksiyon eklenmiş savaşın sıkıştırılmış URLsini içerir. Sunucu her şeyi sıfırdan yeniden hesaplar ve güncellenmiş görseli döndürür.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Fonksiyon dışında önceden derlenmiş: her çağrıda yeniden oluşturulmaz

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6. parça, 8096 değer üzerine hashlenmiş
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Fonksiyon dışında önceden derlenmiş Set bir detaydır ama modüllerin yeniden değerlendirilebildiği bir edge bağlamında her çağrıda yapıyı yeniden inşa etmekten kaçınır.

RNG: değiştirilmiş RC4

Rastgele üreteç PRNGye çevrilmiş bir RC4 uygulamasıdır (akış şifreleme algoritması).

export class Random {
  private S: number[]; // 256 girdili tablo
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // S[i] ve S[j] takası
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Neden RC4? Çünkü doğru dağılımlı ve makul seed çakışma dirençli deterministik bir PRNGdir. Aynı seed = aynı sayı dizisi = her seferinde aynı savaş. Bu URLsi korunarak herhangi bir savaşın "yeniden oynanmasına" izin verir ve iki farklı sunucunun (Vercel + Cloudflare) aynı URL için tam aynı sonucu üretmesini garanti eder.


Discordun 100 karakter limiti sorunu

Discord düğmelerin custom_id değerlerine 100 karakter limiti koyar. Birkaç düzine aksiyondan sonra bir savaş URLsi bu limiti rahat aşar.

Buna iki mekanizma yanıt verir.

1. Aksiyonların RLE sıkıştırması

Aksiyonlar tek karakterle kodlanır (a=saldırı, d=savunma, h=sarılma...) ve run-length encoding ile sıkıştırılır:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Basittir ama oyuncu Saldırı x10 spamladığında aaaaaaaaaa (10 karakter) ifadesinden a10 (3 karakter) ifadesine geçer. Arayüzdeki "Saldır x4" ve "Saldır x10" düğmeleri tam bunun içindir: savaşı hızlandırırken payloadu iyi sıkıştırmak.

2. Sıkıştırma yetmeyince oturum jetonları

Sıkıştırılmış payload hâlâ çok uzunsa kısa bir jetonla veritabanında saklanır:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Payloadları battle_keye göre gruplar, Supabase toplu ekler
  // custom_id değerini "gs.{token}:{userId}" ile değiştirir
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // gerek yoksa arama yok
  }
  // Önce bellekten arama, yoksa Supabase
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Sahiplik, TTL (7 gün) ve turn_version doğrular (eski bir durumu yeniden oynatmayı engeller)
}

Oturumların 7 günlük TTLsi ve 10 dakikada bir otomatik temizliği vardır. turnVersion doğrulaması oyuncu oyunda ilerlediyse eski bir durumu yeniden oynatmayı engeller: kazara "geri dön"e karşı gizli bir koruma.

Bellekteki iki Map (tokenToSession, latestTurnByBattle) aşağıda göreceğimiz nedenlerle görsel önbellekleriyle aynı globalThis as unknown as GameSessionGlobals desenini kullanır.


Görsel render pipelineı

Bir Slime karşı savaş başlangıcı

/konosuba-rpg/:lang/* rotası JSON döndürmez. İstek üzerine üretilmiş bir WebP görseli döndürür.

Pipeline 3 bestenmiş katmanda organize edilir:

Background (board + frame)
    +
Characters layer (oyuncu + mob spriteları, sabit konumlar)
    +
UI overlay (Satori → SVG → PNG üzerinden can barları, mesajlar, kişisel ikonlar)
    ↓
Photon.watermark() × 2
    ↓
WebP çıktısı

Background: iki sabit görsel (tahta ve çerçeve), dosya sisteminden yüklenir ve bir kez bestenir.

Characters layer: spritelar hesaplanan koordinatlara göre konumlandırılır. Ölü oyuncular hariç tutulur (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Düşman spriteları harici bağımlılık yerine özel bir flipX ile yatay aynalanır: piksel piksel bir döngü.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: ağır kısımdır bu. Arayüzün JSXsi (can barları, metinler, ikonlar) Satori ile React benzeri tanımlanır, SVG olarak renderlanır, @cf-wasm/resvg ile PNGye çevrilir, sonra final beste için Photona içe aktarılır. Satori + resvg, edge-light bayrağıyla Cloudflare Workers için özel derlenmiş iki WASM modülüdür.

Savunma Aksiyonu

Devam eden savaş

Sarılma Aksiyonu


Önbellek sistemi: en çok işlenen kısım

5 ayrı önbellek seviyesi vardır. Her biri pipelineın farklı bir ayrıntı düzeyini hedefler.

// renderImage.ts -- hepsi globalThis üzerinde
G.__imageCache  ??= {} as Record; // ham assetler
G.__base64Cache ??= {} as Record;       // assetlerin base64si (Satori için)
G.__fontCache   ??= {} as Record; // yazı tipleri
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

globalThis üzerindeki ??= deseni: bazı yapılandırmalarda edge workerlardaki JavaScript modülleri istekler arasında yeniden değerlendirilebilir. Önbellekleri ??= ile globalThis üzerinde saklamak yeniden oluşturulmadan bu yeniden değerlendirmelerden sağ çıkmalarını garanti eder.

WASM tahliyesi

Photon görsel önbellekleri (photonCache, layerCache, uiPhotonCache) bir tahliye geri çağrısı kullanır:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* zaten serbest */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage, JavaScript çöp toplayıcının dışında doğrusal WASM tarafında ayrılmış belleği olan bir WASM nesnesidir. Bu bellek .free() açıkça çağrılana kadar ayrılmış kalır. LRU tahliyesi .free() fonksiyonunu otomatik tetikler: bu JavaScriptte taşınmış RAII dir.

Önbellek anahtarları bilinçli kayıplıdır

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

Characters katmanı anahtarı HPnin tam değerini kodlamaz: sadece 1 (canlı) ya da 0 (ölü). Çünkü 40 HPli bir oyuncunun spriti ile 15 HPli bir oyuncunun spriti aynıdır. Kimse düşmediği sürece bir önbellek isabeti her hasarda hayatta kalır.

UI anahtarı ise tam HPleri (can barı her vuruşta değişir) ve mesajların bir hashini kodlar:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // 32-bit işaretli tam sayı
  }
  return hash.toString(16);
}

Math.imul çarpmayı 32 bit tam sayıda zorlar, bu float64 dönüşümlerini engeller ve kararlı bir polinom hash verir. Bunun için harici bağımlılık eklemekten kaçınır.

Yığın taşmasız base64 dönüşümü

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 bayt
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) argümanlar çağrı yığınından geçirildiği için büyük görsellerde yığın taşmasına neden olabilir. 32KB parçalama bunu engeller. Sonuç önbelleğe alınır: aynı görselin base64 dönüşümü worker örneği başına bir kez yapılır.


STRIPPER.md: sıralı await denetimi

Depoda sıralı awaitlerin paralelleştirme denetimini belgeleyen bir STRIPPER.md dosyası vardır. İçinde kaydedilenlerden birkaç örnek:

  • Oyuncu profili yükleme Supabase 3 seri istek yapıyordu (ilerleme, run özeti, başarımlar). Promise.all içine alındı: aralarında bağımlılık yok.
  • Savaş sonu ödül dağıtımı (aksesuarlar + tüketilebilirler) sıralıydı. Aynı şekilde paralelleştirildi.
  • Düğmeler için oturum jetonu oluşturma grup grup yapılıyordu. Bağımsız gruplar artık paralel oluşturuluyor.
// progressionService.ts -- önce (sıralı)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// sonra
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Devrimci bir şey yok ama yanıt süresinin her milisaniyesinin faturalandığı (ya da soğuk başlatmaya katkıda bulunduğu) sunucusuz bir bağlamda bu önemlidir.


Kalıcı sunucusuz Discord botu

Zafer

Sık yanlış anlaşılan nokta: bir Discord botu illa kalıcı bir WebSocket bağlantısı gerektirmez. Discord bir alternatif sunar: Interactions Endpoint URL. Discorda bir HTTPS URL verirsin ve Discord her etkileşim için sana bir POST gönderir (slash komutu, düğme, otomatik tamamlama).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // Discord pingi
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord bir POST gönderir, handler bir Vercel fonksiyonu ya da Cloudflare Worker üzerinde 50-200 ms döner, yanıtlar ve biter. Bakımı yapılacak kalıcı bağlantı yok, açık tutulacak sunucu yok. Discord botunun tamamı Vercel ücretsiz katmanında barındırılır.

Ed25519 doğrulaması (discord-interactions içinden verifyKey) zorunludur: Discord başlıklarda doğrulaman gereken bir imza gönderir, yoksa endpointi reddeder.

Özel animasyon: tek bilinçli await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 saniye
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Bu 3 saniyelik bilinçli gecikme STRIPPER.md içinde kasıtlı olarak belgelenmiştir. Meguminin özel saldırısının (Explosion) Discord tarafında bir animasyonu vardır: mesaj önce ara bir görselle güncellenir, sonra 3 saniye sonra sonuçla değiştirilir. Bir Vercel fonksiyonunun gerekenden bilinçli uzun döndüğü tek durum budur.

Özel saldırı


İki platforma dağıtılabilirlik

Aynı kod tabanı değişiklik olmadan Vercel (Node.js) ve Cloudflare Workers (V8 isolate) üzerinde döner:

// worker.ts -- Cloudflare giriş noktası
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // CF sırlarını process.enve enjekte eder
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- Vercel/Node giriş noktası
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

Ana fark: statik assetler. Vercelde dosya sisteminden okunur (/var/task//articles/assets/). Cloudflare Workerlarda bir ASSETS bindingi üzerinden geçer (CF statik assetleri) ve bir HTTPS aynasına yedeklenir (fox3000foxy.com/konosuba-rpg/assets). assetLoader.ts içindeki getAssetBytes önce dosya sistemini deneyerek iki yolu yönetir, sonra fetch.

WASMler (@cf-wasm/photon/edge-light, @cf-wasm/resvg) her çalışma ortamı için ayrı derlemelere sahiptir. Paket adındaki edge-light bayrağı Cloudflare Workers uyumlu derlemeyi belirtir; çalışma anında new WebAssembly.Module() izin vermez: WASM önceden derlenmiş olmalıdır.


İlerleme: XP, seviyeler, yakınlık

Bir boss, 650 HP

Meta ilerleme Supabase ücretsiz katmanına dayanır. Şema players tablosu (global XP, seviye, altın), character_progress (Darkness, Aqua, Megumin için karakter başına XP/seviye/yakınlık), runs (savaş tarihçesi), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions içerir.

İlerleme modeli basittir:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // seviye başına 100 XP
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // seviye başına +%20 istatistik
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // yıldız başına 20 puan, maksimum 5 yıldız
  return 1.2 ** stars; // üstel ilerleme
}

Bu çarpanlar her processGame başında karakter istatistiklerine uygulanır. Kazuma oyuncunun global seviyesini izler, diğer üçünün her birinin kendi XP/seviyesi vardır. Yakınlık (bir karaktere bağlı dropları alarak kazanılır) istatistiklerini bağımsız çarpar.

İyileşme

Drop sistemi zorluğa göre ağırlıklı ganimet tabloları kullanır:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...Legendarye kadar
};

Testler

Üç paket: birim, performans ve sızıntı.

Sızıntı testi özellikle nettir:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // maksimum 20MB heap büyümesi
});

1200 processGame yinelemesi, önce ve sonra zorla GC, heap farkı < 20MB. Bu test geçerse processGame sızdırmaz. Render testi (renderImage.spec.ts) daha çok pratik bir eşik altında çalışma süresini doğrular.

Tam pipelineı profilleyen bir bench.ts scripti de vardır:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

RENDER_PERF=1 ile her servisteki withPerf sarmalayıcı zamanlamaları loglar:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // devre dışıyken sıfır maliyet
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger, DEV_MODE ve RENDER_PERF 1 değilse no-op döndürür. Üretimde maliyet yok.


Çalıştırmanın maliyeti nedir

  • Vercel ücretsiz katmanı: 100GB bant genişliği, ayda 1M sunucusuz çağrı. Görsel render bir çağrı sayılır.
  • Cloudflare Workers ücretsiz katmanı: günde 100 bin istek, istek başına 10 ms CPU süresi (render WASM yüzünden Workerlarda bunu aşabilir, o yüzden birincil Vercel).
  • Supabase ücretsiz katmanı: 500MB veritabanı, 5GB bant genişliği. Binlerce oyuncu için yeterli.

Backendin tamamı anlamlı bir hacme kadar sıfır maliyetle döner. Tek sürtünme noktası Cloudflare Workers CPU limitidir: görsel render WASM yüzünden CPU yoğundur, o yüzden birincil Vercel ve yedek CDN olarak Workers stratejisi.


Akılda tutulmaya değer 3 şey

  1. Oyun durumu olarak URL sadece hoş bir numara değildir: Discordun dayattığı bir kısıtlamadır (düğmelerde 100 karakter limiti) ve yedek olarak RLE sıkıştırma + oturum jetonlu stateless bir mimariyi zorunlu kılmıştır. Kısıtlama tasarımı dikte etmiştir.

  2. Açık tahliyeli WASM önbelleği: PhotonImage nesneleri JavaScript heapinin dışında yer ayırır ve .free() olmadan asla GClenmez. freePhoton fonksiyonunu LRU tahliyesine bağlamak JavaScriptte RAII dir. Kodda gizlidir ama o olmadan worker üretimde sızdırırdı.

  3. WebSocketsiz sunucusuz bir Discord botu: WebSocket gateway yaklaşımından daha az bilinir ama stateless işlem yapan bir bot için (her etkileşim bağımsızdır) Interactions Endpoint kesinlikle üstündür: yeniden bağlanma yok, heartbeat yok, bakımı yapılacak süreç yok. Discord kullanılabilirliği kendi altyapısı tarafında yönetir.


Repo: fox3000foxy/konosuba-rpg

Özel kaynak mevcut lisans: yeniden dağıtım yok, kullanımı ücretsiz.

Ho passato un weekend a leggere il codice di konosuba-rpg ed ecco cosa ho trovato

Un RPG a turni Discord dove ogni azione genera un'immagine WebP al volo: URL come stato di gioco, RNG deterministico, pipeline WASM, cache a 5 livelli, bot serverless.

Ho passato un weekend a leggere il codice di konosuba-rpg ed ecco cosa ho trovato

Mantengo questo progetto da un po', ma rileggere il proprio codice a testa riposata è sempre istruttivo. konosuba-rpg è un RPG a turni Discord dove ogni azione genera un'immagine WebP al volo. È una vera immagine composta, con gli sprite, le barre di vita, i messaggi di combattimento: tutto.

Lo stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hosting interamente gratuito. E il bot Discord funziona senza server persistente. Questo post spiega come tutto questo sta insieme.

État initial du jeu


Il design di base: l'URL come stato del gioco

La prima cosa che colpisce: non c'è alcuno stato lato server per il gameplay. Lo stato completo di un combattimento sta nell'URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Ogni segmento dopo il seed è un'azione giocata. Il server riceve questo URL, riparte dall'inizio, rigioca tutte le azioni in ordine, e rinvia un'immagine del combattimento a quell'istante preciso. Nessuna sessione, nessuno stato in RAM legato a un utente.

Discord funziona con pulsanti interattivi: quando il giocatore preme "Attacca", Discord invia al server il custom_id del pulsante. Questo custom_id contiene l'URL compresso del combattimento con la nuova azione aggiunta. Il server ricalcola tutto da zero e rinvia l'immagine aggiornata.

// processUrl.ts
// Set precompilato: mosse valide riconosciute nell'URL
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Précompilé hors fonction -- pas recréé à chaque appel

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6ème segment, haché sur 8096 valeurs
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Il Set precompilato fuori dalla funzione è un dettaglio, ma evita di ricostruire la struttura a ogni invocazione in un contesto edge dove i moduli possono essere rivalutati.

Il RNG: RC4 modificato

Il generatore casuale è un'implementazione RC4 (algoritmo di cifratura stream) deviata in PRNG.

// PRNG deterministico stile RC4 per rigiocare i combattimenti
export class Random {
  private S: number[]; // tabella di 256 voci
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // scambia S[i] e S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Perché RC4? Perché è un PRNG deterministico con una distribuzione corretta e una resistenza ragionevole alle collisioni di seed. Stesso seed = stessa sequenza di numeri = stesso combattimento ogni volta. Permette di "rigiocare" qualsiasi combattimento conservandone l'URL, e garantisce che due server diversi (Vercel + Cloudflare) producano esattamente lo stesso risultato per lo stesso URL.


Il problema del limite di 100 caratteri Discord

Discord impone un limite di 100 caratteri sui custom_id dei pulsanti. Dopo qualche decina di azioni, un URL di combattimento supera abbondantemente questo limite.

Due meccanismi rispondono a questo.

1. Compressione RLE delle azioni

Le azioni sono codificate con un solo carattere (a=attack, d=defend, h=hug...) e compresse con run-length encoding:

// movesUtils.ts
// comprime sequenze ripetute: "aaaaaadddh" → "a6d3h"
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Semplice, ma quando il giocatore spamma Attacco x10 passa da aaaaaaaaaa (10 char) a a10 (3 char). I pulsanti "Attacca x4" e "Attacca x10" nella UI esistono proprio per questo: accelerare il combattimento comprimendo bene il payload.

2. Token di sessione quando la compressione non basta più

Se il payload compresso resta troppo lungo, è salvato in DB con un token corto:

// gameSessionService.ts
// token corto che punta al payload completo in DB
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Groupe les payloads par battle_key, insère en batch dans Supabase
  // Remplace le custom_id par "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Pas de lookup si pas nécessaire
  }
  // Lookup en mémoire d'abord, puis Supabase si absent
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Vérifie ownership, TTL (7 jours), et turn_version (évite de rejouer un ancien état)
}

Le sessioni hanno un TTL di 7 giorni e un pruning automatico ogni 10 minuti. La verifica turnVersion impedisce di rigiocare uno stato scaduto se il giocatore è avanzato nella partita: una protezione discreta contro il "ritorno indietro" accidentale.

Entrambe le Map in memoria (tokenToSession, latestTurnByBattle) usano lo stesso pattern globalThis as unknown as GameSessionGlobals delle cache di immagini, per le stesse ragioni che vedremo più sotto.


La pipeline di rendering immagini

Début de combat contre un Slime

La route /konosuba-rpg/:lang/* non rinvia JSON. Rinvia un'immagine WebP generata su richiesta.

La pipeline è organizzata in 3 layer compositi:

Background (board + frame)
    +
Characters layer (sprite giocatori + mob, posizioni fisse)
    +
UI overlay (barre HP, messaggi, icone personaggi via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: due immagini fisse (il tavolo e la cornice), caricate dal filesystem e composte una volta.

Characters layer: gli sprite sono posizionati secondo coordinate calcolate. I giocatori morti sono esclusi (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Gli sprite nemici sono specchiati orizzontalmente con un flipX custom: un ciclo pixel per pixel invece di una dipendenza esterna.

// specchia uno sprite orizzontalmente pixel per pixel
function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: è la parte pesante. Il JSX dell'interfaccia (barre di vita, testi, icone) è descritto in React-like con Satori, reso in SVG, convertito in PNG da @cf-wasm/resvg, poi importato in Photon per la composizione finale. Satori + resvg sono due moduli WASM compilati apposta per Cloudflare Workers col flag edge-light.

Action Défense

Combat en cours

Action Câlin


Il sistema di cache: la parte più lavorata

Ci sono 5 livelli di cache distinti. Ognuno punta a una granularità diversa della pipeline.

// renderImage.ts -- tutti su globalThis
G.__imageCache  ??= {} as Record; // asset grezzi
G.__base64Cache ??= {} as Record;       // base64 degli asset (per Satori)
G.__fontCache   ??= {} as Record; // font
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Il pattern ??= su globalThis: i moduli JavaScript nei worker edge possono essere rivalutati tra richieste su certe configurazioni. Salvare le cache su globalThis con ??= garantisce che sopravvivano a queste rivalutazioni senza essere ricreate.

L'eviction WASM

Le cache di immagini Photon (photonCache, layerCache, uiPhotonCache) usano una callback di eviction:

// libera la memoria WASM all'eviction LRU
function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* già liberato */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage è un oggetto WASM con memoria allocata lato lineare WASM, fuori dal GC JavaScript. Questa memoria resta allocata finché .free() non è chiamato in modo esplicito. L'eviction dell'LRU innesca .free() automaticamente: è RAII portato in JavaScript.

Le chiavi di cache sono volutamente lossy

// chiave lossy: solo vivo/morto, non gli HP esatti
function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

La chiave del characters layer non codifica il valore esatto degli HP: solo 1 (vivo) o 0 (morto). Perché lo sprite di un giocatore a 40 HP e un giocatore a 15 HP è identico. Un hit di cache sopravvive quindi a qualsiasi danno finché nessuno cade.

La chiave UI invece codifica gli HP esatti (la barra di vita cambia a ogni colpo) e un hash dei messaggi:

// hash polinomiale stabile su intero 32 bit
function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // intero 32-bit con segno
  }
  return hash.toString(16);
}

Math.imul forza la moltiplicazione in intero a 32 bit, il che evita conversioni float64 e dà un hash polinomiale stabile. Evita di aggiungere una dipendenza esterna per questo.

La conversione base64 senza stack overflow

// conversione base64 a chunk per evitare stack overflow
function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 byte
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) può provocare uno stack overflow sulle grosse immagini perché gli argomenti sono passati sul call stack. Il chunking da 32Ko evita questo. Il risultato è messo in cache: la conversione base64 di una stessa immagine è fatta una sola volta per istanza di worker.


STRIPPER.md: audit degli await sequenziali

C'è un file STRIPPER.md nel repo che documenta un audit di parallelizzazione degli await. Qualche esempio di ciò che vi è annotato:

  • Il caricamento del profilo giocatore faceva 3 richieste Supabase in serie (progressione, riassunto di run, achievement). Sono state passate in Promise.all: nessuna dipendenza tra loro.
  • La distribuzione delle ricompense di fine combattimento (accessori + consumabili) era sequenziale. Parallelizzata allo stesso modo.
  • La creazione dei token di sessione per i pulsanti avveniva gruppo per gruppo. I gruppi indipendenti sono ormai creati in parallelo.
// progressionService.ts -- prima (sequenziale)
// progressioneService.ts -- prima (sequenziale)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// dopo
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Niente di rivoluzionario, ma in un contesto serverless dove ogni millisecondo di tempo di risposta è fatturato (o contribuisce al cold start), conta.


Il bot Discord senza server persistente

Victoire

Punto spesso frainteso: un bot Discord non richiede per forza una connessione WebSocket persistente. Discord propone un'alternativa: le Interactions Endpoint URL. Fornisci un URL HTTPS a Discord, e Discord ti invia un POST per ogni interazione (slash command, pulsante, autocomplete).

// interactions.ts
// smista POST Discord: ping, slash, pulsanti, autocomplete
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord invia un POST, l'handler gira 50-200ms su una funzione Vercel o un Cloudflare Worker, risponde, ed è finita. Nessuna connessione permanente da mantenere, nessun server da tenere acceso. L'intero bot Discord è ospitato sul free tier Vercel.

La verifica Ed25519 (verifyKey da discord-interactions) è obbligatoria: Discord invia una firma negli header che devi validare, altrimenti rigetta l'endpoint.

L'animazione speciale: l'unico await intenzionale

// handleSpecialButton.ts
// ritardo volontario per l'animazione Explosion di Megumin
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 secondi
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Questo ritardo volontario di 3 secondi è documentato in STRIPPER.md come intenzionale. L'attacco speciale di Megumin (Explosion) ha un'animazione lato Discord: il messaggio è prima aggiornato con un visual intermedio, poi modificato 3 secondi dopo col risultato. È l'unico caso dove una funzione Vercel gira volutamente più a lungo del necessario.

Attaque spéciale


La deployabilità su due piattaforme

Lo stesso codebase gira su Vercel (Node.js) e su Cloudflare Workers (isolati V8) senza modifiche:

// worker.ts -- entrypoint Cloudflare
// adatta i secret CF e inoltra ad Hono
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // inietta i secret CF in process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- entrypoint Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

La differenza principale: gli asset statici. Su Vercel, sono letti dal filesystem (/var/task//articles/assets/). Su Cloudflare Workers, passano da un binding ASSETS (asset statici CF) con fallback verso un mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). Il getAssetBytes in assetLoader.ts gestisce entrambi i percorsi provando il filesystem prima, poi fetch.

I WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) hanno build separate per ogni runtime. Il flag edge-light nel nome del package indica il build compatibile Cloudflare Workers, che non autorizza new WebAssembly.Module() al runtime: il WASM deve essere pre-compilato.


La progressione: XP, livelli, affinità

Un boss, 650 HP

La meta-progressione poggia su Supabase free tier. Lo schema comporta una tabella players (XP globale, livello, gold), character_progress (XP/livello/affinità per personaggio per Darkness, Aqua, Megumin), runs (storico dei combattimenti), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Il modello di progressione è semplice:

// characterService.ts
// livello, fattore livello e fattore affinità
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP per livello
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stat per livello
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 punti per stella, 5 stelle max
  return 1.2 ** stars; // progressione esponenziale
}

Questi fattori sono applicati alle stat dei personaggi all'inizio di ogni processGame. Kazuma segue il livello globale del giocatore, gli altri tre hanno ognuno il loro XP/livello. L'affinità (guadagnata recuperando drop legati a un personaggio) moltiplica le sue stat in modo indipendente.

Soin

Il sistema di drop usa loot table pesate per difficoltà:

// loot table pesate per difficoltà
const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...fino a Legendary
};

I test

Tre suite: unitari, perf, e leak.

Il leak test è particolarmente diretto:

// leaks.spec.ts
// 1200 run senza crescita heap oltre 20MB
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // max 20MB di crescita heap
});

1200 iterazioni di processGame, GC forzato prima e dopo, delta heap < 20MB. Se questo test passa, processGame non perde. Il test di render (renderImage.spec.ts) verifica invece il tempo di esecuzione sotto una soglia pratica.

C'è anche uno script bench.ts per profilare la pipeline completa:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Con RENDER_PERF=1, il wrapper withPerf in ogni servizio logga i timing:

// wrapper perf con overhead zero se disattivato
export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // zero overhead se disattivato
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger restituisce no-op se DEV_MODE e RENDER_PERF non sono a 1. Nessun overhead in produzione.


Cosa costa farlo girare

  • Vercel free tier: 100GB di bandwidth, 1M di invocazioni serverless al mese. Il render di immagini conta come un'invocazione.
  • Cloudflare Workers free tier: 100K richieste/giorno, 10ms CPU time per richiesta (il render può superare questo sui Worker, da cui Vercel in primario).
  • Supabase free tier: 500MB di DB, 5GB di bandwidth. Bastano per migliaia di giocatori.

L'insieme del backend gira a costo zero fino a un volume significativo. L'unico punto di frizione è il limite CPU dei Cloudflare Workers: il render immagini è CPU-intensive per via di WASM, da cui la strategia di Vercel come primario e Worker come CDN di failover.


Le 3 cose che meritano di essere trattenute

  1. L'URL come stato di gioco non è solo un trucco simpatico: è un vincolo imposto da Discord (i pulsanti hanno un limite di 100 char) che ha forzato un'architettura stateless con compressione RLE + token di sessione come fallback. Il vincolo ha dettato il design.

  2. La cache WASM con eviction esplicita: i PhotonImage allocano fuori dall'heap JavaScript e non saranno mai GC senza .free(). Collegare freePhoton all'eviction dell'LRU è RAII in JavaScript. È discreto nel codice, ma senza questo il worker perderebbe in produzione.

  3. Un bot Discord serverless senza WebSocket: è meno noto dell'approccio WebSocket gateway, ma per un bot che fa elaborazione stateless (ogni interazione è indipendente), l'Interactions Endpoint è strettamente superiore: niente riconnessione, niente heartbeat, niente processo da mantenere. Discord gestisce la disponibilità lato loro infra.


Repo: fox3000foxy/konosuba-rpg

Licenza source-available custom: niente ridistribuzione, free to use.

Ich habe ein Wochenende damit verbracht, konosuba-rpg-Code zu lesen, und das habe ich gefunden

Ein rundenbasiertes Discord-RPG, bei dem jede Aktion on the fly ein WebP-Bild

Ich habe ein Wochenende damit verbracht, konosuba-rpg-Code zu lesen, und das habe ich gefunden

Ich pflege dieses Projekt seit einer Weile, aber seinen eigenen Code mit ausgeruhtem Kopf neu zu lesen ist immer lehrreich. konosuba-rpg ist ein rundenbasiertes Discord-RPG, bei dem jede Aktion on the fly ein WebP-Bild generiert. Ein echtes komponiertes Bild, mit Sprites, Lebensbalken, Kampfnachrichten, allem.

Der Stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Vollständig gratis Hosting. Und der Discord-Bot funktioniert ohne persistenten Server. Dieser Post erklärt, wie das alles zusammenhält.

Anfangszustand des Spiels


Das Basisdesign: die URL als Spielzustand

Das Erste, was auffällt: Es gibt keinerlei serverseitigen Zustand fürs Gameplay. Der komplette Zustand eines Kampfs steckt in der URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Jedes Segment nach dem Seed ist eine gespielte Aktion. Der Server empfängt diese URL, startet von vorn, spielt alle Aktionen der Reihe nach erneut und gibt ein Bild des Kampfs genau in diesem Moment zurück. Keine Session, kein RAM-Zustand an einen Nutzer gebunden.

Discord funktioniert über interaktive Buttons, wenn der Spieler "Angreifen" drückt, schickt Discord dem Server die custom_id des Buttons. Diese custom_id enthält die komprimierte Kampf-URL mit der neu hinzugefügten Aktion. Der Server berechnet alles von null neu und gibt das aktualisierte Bild zurück.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Außerhalb der Funktion vorkompiliert, nicht bei jedem Aufruf neu erstellt

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // Seed = 6. Segment, gehasht auf 8096 Werte
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Das außerhalb der Funktion vorkompilierte Set ist ein Detail, vermeidet aber, die Struktur bei jeder Invocation in einem Edge-Kontext neu zu bauen, wo Module re-evaluiert werden können.

Der RNG: modifiziertes RC4

Der Zufallsgenerator ist eine als PRNG zweckentfremdete RC4-Implementierung (Stream-Verschlüsselungsalgorithmus).

export class Random {
  private S: number[]; // Tabelle mit 256 Einträgen
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // S[i] und S[j] tauschen
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Warum RC4? Weil es ein deterministischer PRNG mit korrekter Verteilung und vernünftiger Seed-Kollisionsresistenz ist. Gleicher Seed = gleiche Zahlenfolge = jedes Mal derselbe Kampf. Das erlaubt, jeden Kampf über seine URL zu "replayen", und garantiert, dass zwei verschiedene Server (Vercel + Cloudflare) für dieselbe URL exakt dasselbe Ergebnis produzieren.


Das Problem des 100-Zeichen-Limits von Discord

Discord setzt ein 100-Zeichen-Limit auf die custom_id der Buttons. Nach einigen Dutzend Aktionen sprengt eine Kampf-URL dieses Limit locker.

Zwei Mechanismen beantworten das.

1. RLE-Kompression der Aktionen

Die Aktionen werden mit einem einzigen Zeichen kodiert (a=Attack, d=Defend, h=Hug...) und per Run-Length-Encoding komprimiert:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Simpel, aber wenn der Spieler Attacke x10 spammt, geht das von aaaaaaaaaa (10 Zeichen) auf a10 (3 Zeichen). Die "Angreifen x4"- und "Angreifen x10"-Buttons in der UI existieren genau dafür, den Kampf beschleunigen und gleichzeitig den Payload gut komprimieren.

2. Session-Tokens, wenn die Kompression nicht reicht

Wenn der komprimierte Payload zu lang bleibt, wird er mit einem kurzen Token in der DB gespeichert:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Gruppiert Payloads nach battle_key, fügt batchweise in Supabase ein
  // Ersetzt die custom_id durch "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Kein Lookup wenn unnötig
  }
  // Lookup zuerst im Speicher, dann Supabase wenn fehlend
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Prüft Ownership, TTL (7 Tage), und turn_version (verhindert Replay eines alten Zustands)
}

Die Sessions haben 7 Tage TTL und automatisches Pruning alle 10 Minuten. Die turnVersion-Prüfung verhindert, einen veralteten Zustand zu replayen, wenn der Spieler in der Partie vorangeschritten ist, ein diskreter Schutz gegen versehentliches "Zurückgehen".

Die zwei speicherresidenten Maps (tokenToSession, latestTurnByBattle) nutzen dasselbe globalThis as unknown as GameSessionGlobals-Pattern wie die Bildcaches, aus denselben Gründen, die wir weiter unten sehen.


Die Bildrender-Pipeline

Kampfbeginn gegen einen Slime

Die Route /konosuba-rpg/:lang/* gibt kein JSON zurück. Sie gibt ein on demand generiertes WebP-Bild zurück.

Die Pipeline ist in 3 komponierte Layer organisiert:

Background (board + frame)
    +
Characters layer (Spieler- + Mob-Sprites, fixe Positionen)
    +
UI overlay (HP-Balken, Nachrichten, Char-Icons via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: Zwei fixe Bilder (das Brett und der Rahmen), aus dem Dateisystem geladen und einmal komponiert.

Characters layer: Die Sprites werden nach berechneten Koordinaten positioniert. Tote Spieler werden ausgeschlossen (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Die Gegnersprites werden horizontal gespiegelt mit einem Custom-flipX, einer Pixel-für-Pixel-Schleife statt einer externen Abhängigkeit.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: Das ist der schwere Teil. Das Interface-JSX (Lebensbalken, Texte, Icons) wird React-artig mit Satori beschrieben, in SVG gerendert, von @cf-wasm/resvg in PNG konvertiert und dann in Photon für die finale Komposition importiert. Satori + resvg sind zwei speziell für Cloudflare Workers kompilierte WASM-Module mit dem edge-light-Flag.

Verteidigungsaktion

Laufender Kampf

Umarmungsaktion


Das Cache-System, der am meisten ausgearbeitete Teil

Es gibt 5 unterschiedliche Cache-Level. Jeder zielt auf eine andere Granularität der Pipeline.

// renderImage.ts, alle auf globalThis
G.__imageCache  ??= {} as Record; // Roassets
G.__base64Cache ??= {} as Record;       // Base64 der Assets (für Satori)
G.__fontCache   ??= {} as Record; // Fonts
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Das ??=-Pattern auf globalThis: JavaScript-Module in Edge-Workern können zwischen Requests auf manchen Konfigurationen re-evaluiert werden. Caches auf globalThis mit ??= zu speichern garantiert, dass sie diese Re-Evaluierungen überleben, ohne neu erstellt zu werden.

Die WASM-Eviction

Die Photon-Bildcaches (photonCache, layerCache, uiPhotonCache) nutzen einen Eviction-Callback:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* schon freigegeben */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage ist ein WASM-Objekt mit allokiertem Speicher auf linearer WASM-Seite, außerhalb des JavaScript-GC. Dieser Speicher bleibt allokiert, bis .free() explizit aufgerufen wird. Die LRU-Eviction triggert .free() automatisch, das ist nach JavaScript portiertes RAII.

Die Cache-Schlüssel sind absichtlich lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

Der Characters-Layer-Schlüssel kodiert nicht den exakten HP-Wert, nur 1 (lebend) oder 0 (tot). Weil das Sprite eines Spielers bei 40 HP und eines bei 15 HP identisch ist. Ein Cache-Hit überlebt also jeden Schaden, solange niemand fällt.

Der UI-Schlüssel dagegen kodiert die exakten HP (der Lebensbalken ändert sich bei jedem Treffer) und einen Hash der Nachrichten:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // signierte 32-bit Ganzzahl
  }
  return hash.toString(16);
}

Math.imul erzwingt die Multiplikation in 32-bit Ganzzahlen, was Float64-Konvertierungen vermeidet und einen stabilen Polynomial-Hash gibt. Das vermeidet, dafür eine externe Abhängigkeit hinzuzufügen.

Die Base64-Konvertierung ohne Stack Overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 Bytes
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) kann bei großen Bildern einen Stack Overflow provozieren, weil die Argumente über den Call Stack übergeben werden. Das Chunking per 32Ko vermeidet das. Das Ergebnis wird gecacht, die Base64-Konvertierung desselben Bilds passiert nur einmal pro Worker-Instanz.


STRIPPER.md, Audit sequenzieller Awaits

Es gibt eine STRIPPER.md-Datei im Repo, die ein Parallelisierungsaudit der await dokumentiert. Einige Beispiele dessen, was dort festgehalten ist:

  • Das Laden des Spielerprofils machte 3 Supabase-Queries in Serie (Progression, Run-Zusammenfassung, Achievements). Sie wurden auf Promise.all umgestellt, keine Abhängigkeit dazwischen.
  • Die Verteilung der Kampfend-Belohnungen (Accessoires + Consumables) war sequenziell. Ebenso parallelisiert.
  • Die Erstellung der Session-Tokens für die Buttons passierte Gruppe für Gruppe. Unabhängige Gruppen werden jetzt parallel erstellt.
// progressionService.ts, vorher (sequenziell)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// nachher
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Nichts Revolutionäres, aber in einem Serverless-Kontext, wo jede Millisekunde Antwortzeit fakturiert wird (oder zum Cold Start beiträgt), zählt das.


Der Discord-Bot ohne persistenten Server

Sieg

Oft missverstandener Punkt: Ein Discord-Bot braucht nicht unbedingt eine persistente WebSocket-Verbindung. Discord bietet eine Alternative: Die Interactions Endpoint URL. Du gibst Discord eine HTTPS-URL, und Discord schickt dir einen POST für jede Interaktion (Slash Command, Button, Autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // Discord-Ping
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord schickt einen POST, der Handler läuft 50-200ms auf einer Vercel-Funktion oder einem Cloudflare Worker, antwortet, und fertig. Keine permanent zu pflegende Verbindung, kein anzulassender Server. Die Gesamtheit des Discord-Bots ist auf dem gratis Vercel-Tier gehostet.

Die Ed25519-Verifizierung (verifyKey aus discord-interactions) ist Pflicht, Discord schickt eine Signatur in den Headern, die du validieren musst, sonst weist es den Endpoint zurück.

Die Spezialanimation, der einzige intentionale Await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 Sekunden
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Diese freiwillige 3-Sekunden-Verzögerung ist in STRIPPER.md als intentional dokumentiert. Megumins Spezialattacke (Explosion) hat eine Animation auf Discord-Seite, die Nachricht wird zuerst mit einem Zwischenvisual aktualisiert, dann 3 Sekunden später mit dem Ergebnis modifiziert. Das ist der einzige Fall, wo eine Vercel-Funktion freiwillig länger läuft als nötig.

Spezialattacke


Die Deploybarkeit auf zwei Plattformen

Dieselbe Codebase läuft auf Vercel (Node.js) und auf Cloudflare Workers (V8-Isolates) ohne Änderung:

// worker.ts, Cloudflare-Entrypoint
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // injiziert CF-Secrets in process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts, Vercel/Node-Entrypoint
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

Der Hauptunterschied: Die statischen Assets. Auf Vercel werden sie aus dem Dateisystem gelesen (/var/task//articles/assets/). Auf Cloudflare Workers laufen sie über ein ASSETS-Binding (statische CF-Assets) mit Fallback auf einen HTTPS-Mirror (fox3000foxy.com/konosuba-rpg/assets). Das getAssetBytes in assetLoader.ts handhabt beide Pfade, indem es zuerst das Dateisystem versucht, dann Fetch.

Die WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) haben separate Builds für jede Runtime. Das edge-light-Flag im Paketnamen bezeichnet den Cloudflare-Workers-kompatiblen Build, der new WebAssembly.Module() zur Runtime nicht erlaubt, das WASM muss präkompiliert sein.


Die Progression: XP, Levels, Affinität

Ein Boss, 650 HP

Die Metaprogression beruht auf Supabase Free Tier. Das Schema umfasst eine players-Tabelle (globale XP, Level, Gold), character_progress (XP/Level/Affinität pro Char für Darkness, Aqua, Megumin), runs (Kampfhistorie), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Das Progressionsmodell ist simpel:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP pro Level
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% Stats pro Level
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 Punkte pro Stern, 5 Sterne max
  return 1.2 ** stars; // exponentielle Progression
}

Diese Faktoren werden auf die Charstats zu Beginn jedes processGame angewendet. Kazuma folgt dem globalen Spielerlevel, die drei anderen haben jeweils eigene XP/Level. Die Affinität (gewonnen durch Einsammeln char-gebundener Drops) multipliziert seine Stats unabhängig.

Heilung

Das Dropsystem nutzt nach Schwierigkeit gewichtete Loot-Tabellen:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...bis Legendary
};

Die Tests

Drei Suiten: Unit, Perf und Leaks.

Der Leak-Test ist besonders direkt:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // max 20MB Heap-Wachstum
});

1200 Iterationen von processGame, forcierter GC davor und danach, Heap-Delta < 20MB. Wenn dieser Test passt, leakt processGame nicht. Der Render-Test (renderImage.spec.ts) prüft eher die Ausführungszeit unter einer praktischen Schwelle.

Es gibt auch ein bench.ts-Skript, um die komplette Pipeline zu profilen:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Mit RENDER_PERF=1 loggt der withPerf-Wrapper in jedem Service die Timings:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // null Overhead wenn deaktiviert
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger gibt No-Ops zurück, wenn DEV_MODE und RENDER_PERF nicht auf 1 stehen. Kein Overhead in Produktion.


Was der Betrieb kostet

  • Vercel Free Tier: 100GB Bandwidth, 1M Serverless-Invocations pro Monat. Das Bildrendering zählt als eine Invocation.
  • Cloudflare Workers Free Tier: 100K Requests/Tag, 10ms CPU-Time pro Request (das Rendering kann das auf Workers überschreiten, daher Vercel als primär).
  • Supabase Free Tier: 500MB DB, 5GB Bandwidth. Ausreichend für Tausende Spieler.

Das gesamte Backend läuft bis zu signifikantem Volumen zu null Kosten. Der einzige Reibungspunkt ist das CPU-Limit von Cloudflare Workers, das Bildrendering ist wegen WASM CPU-intensiv, daher die Strategie Vercel als primär und Workers als Failover-CDN.


Die 3 Dinge, die es wert sind, behalten zu werden

  1. Die URL als Spielzustand ist nicht nur ein netter Trick, es ist eine von Discord auferlegte Bedingung (Buttons haben ein 100-Zeichen-Limit), die eine stateless Architektur mit RLE-Kompression + Session-Token als Fallback erzwungen hat. Die Bedingung hat das Design diktiert.

  2. Der WASM-Cache mit expliziter Eviction: PhotonImage allozieren außerhalb des JavaScript-Heaps und werden ohne .free() nie GCt. freePhoton an die LRU-Eviction zu hängen ist RAII in JavaScript. Diskret im Code, aber ohne das würde der Worker in Produktion leaken.

  3. Ein serverless Discord-Bot ohne WebSocket: Weniger bekannt als der WebSocket-Gateway-Ansatz, aber für einen Bot, der stateless verarbeitet (jede Interaktion ist unabhängig), ist der Interactions Endpoint strikt überlegen, kein Reconnect, kein Heartbeat, kein zu pflegender Prozess. Discord handhabt die Verfügbarkeit auf ihrer Infraseite.


Repo: fox3000foxy/konosuba-rpg

Custom Source-available-Lizenz, keine Redistribution, free to use.

Я провёл выходные за чтением кода konosuba-rpg, и вот что нашёл

Пошаговая Discord-RPG, где каждое действие генерирует WebP-картинку

Я провёл выходные за чтением кода konosuba-rpg, и вот что нашёл

Я поддерживаю этот проект уже давно, но перечитывать собственный код на свежую голову всегда поучительно. konosuba-rpg это пошаговая Discord-RPG, где каждое действие генерирует WebP-картинку на лету. Настоящая скомпонованная картинка со спрайтами, полосками жизней, сообщениями боя -- всё.

Стек : TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Хостинг полностью бесплатный. А Discord-бот работает без персистентного сервера. Этот пост объясняет, как всё это держится вместе.

Начальное состояние игры


Базовый дизайн : URL как состояние игры

Первое, что бросается в глаза : никакого серверного состояния для геймплея. Полное состояние боя умещается в URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Каждый сегмент после сида это сыгранное действие. Сервер получает этот URL, стартует с начала, переигрывает все действия по порядку и возвращает картинку боя ровно на этот момент. Никаких сессий, никакого состояния в RAM, привязанного к пользователю.

Discord работает интерактивными кнопками -- когда игрок жмёт "Атаковать", Discord шлёт на сервер custom_id кнопки. Этот custom_id содержит сжатый URL боя с добавленным новым действием. Сервер пересчитывает всё с нуля и возвращает обновлённую картинку.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Предскомпилирован вне функции -- не пересоздаётся при каждом вызове

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6-й сегмент, хешированный на 8096 значений
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Предскомпилированный Set вне функции это деталь, но он избегает перестраивать структуру при каждом вызове в edge-контексте, где модули могут перевычисляться.

RNG : модифицированный RC4

Генератор случайных чисел это реализация RC4 (алгоритм поточного шифрования), угнанная под PRNG.

export class Random {
  private S: number[]; // таблица на 256 входов
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // обмен S[i] и S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Почему RC4? Потому что это детерминированный PRNG с корректным распределением и разумной устойчивостью к коллизиям сидов. Тот же сид = та же последовательность чисел = тот же бой каждый раз. Это позволяет "переигрывать" любой бой, сохраняя его URL, и гарантирует, что два разных сервера (Vercel + Cloudflare) дают ровно тот же результат для того же URL.


Проблема лимита Discord в 100 символов

Discord навязывает лимит в 100 символов на custom_id кнопок. После нескольких десятков действий URL боя весело превышает этот лимит.

Два механизма отвечают на это.

1. RLE-сжатие действий

Действия кодируются одним символом (a=attack, d=defend, h=hug...) и сжимаются run-length encoding :

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Просто, но когда игрок спамит Атаку x10, это переходит с aaaaaaaaaa (10 символов) на a10 (3 символа). Кнопки "Атаковать x4" и "Атаковать x10" в UI существуют как раз для этого -- ускорить бой, хорошо сжимая пейлоад.

2. Токены сессий, когда сжатия уже не хватает

Если сжатый пейлоад остаётся слишком длинным, он хранится в базе с коротким токеном :

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Группирует пейлоады по battle_key, вставляет батчем в Supabase
  // Заменяет custom_id на "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Без лукапа, если не нужно
  }
  // Лукап сначала в памяти, затем в Supabase, если отсутствует
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Проверяет владение, TTL (7 дней) и turn_version (избегает переигрывания старого состояния)
}

У сессий TTL 7 дней и автопрунинг каждые 10 минут. Проверка turnVersion не даёт переиграть просроченное состояние, если игрок продвинулся в партии -- незаметная защита от случайного "отката назад".

Обе Maps в памяти (tokenToSession, latestTurnByBattle) используют тот же паттерн globalThis as unknown as GameSessionGlobals, что и кэши картинок, по тем же причинам, что увидим ниже.


Пайплайн рендера картинок

Начало боя против Slime

Роут /konosuba-rpg/:lang/* возвращает не JSON. Он возвращает WebP-картинку, сгенерированную по требованию.

Пайплайн организован в 3 компонуемых слоя :

Background (board + frame)
    +
Characters layer (спрайты игроков + моб, фиксированные позиции)
    +
UI overlay (полоски HP, сообщения, иконки персов через Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background : две фиксированные картинки (доска и рамка), грузятся с файловой системы и компонуются один раз.

Characters layer : спрайты позиционируются по вычисленным координатам. Мёртвые игроки исключаются (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Спрайты врага отзеркаливаются горизонтально кастомным flipX -- циклом попиксельно вместо внешней зависимости.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay : тяжёлая часть. JSX интерфейса (полоски жизней, тексты, иконки) описывается в React-like через Satori, рендерится в SVG, конвертируется в PNG через @cf-wasm/resvg, затем импортируется в Photon для финальной компоновки. Satori + resvg это два WASM-модуля, скомпилированные специально для Cloudflare Workers с флагом edge-light.

Действие Защита

Бой в разгаре

Действие Обнимашки


Система кэша -- самая проработанная часть

Есть 5 отдельных уровней кэша. Каждый целит разную гранулярность пайплайна.

// renderImage.ts -- всё на globalThis
G.__imageCache  ??= {} as Record; // сырые ассеты
G.__base64Cache ??= {} as Record;       // base64 ассетов (для Satori)
G.__fontCache   ??= {} as Record; // шрифты
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Паттерн ??= на globalThis : JS-модули в edge-воркерах могут перевычисляться между запросами на некоторых конфигурациях. Хранение кэшей на globalThis с ??= гарантирует, что они переживают эти перевычисления, не пересоздаваясь.

WASM-эвикция

Кэши картинок Photon (photonCache, layerCache, uiPhotonCache) используют колбэк эвикции :

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* уже освобождён */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage это WASM-объект с памятью, аллоцированной на линейной WASM-стороне, вне JS GC. Эта память остаётся аллоцированной, пока .free() не вызван явно. Эвикция LRU триггерит .free() автоматически -- это RAII, портированный в JavaScript.

Ключи кэша намеренно lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

Ключ characters layer не кодирует точное значение HP -- только 1 (жив) или 0 (мёртв). Потому что спрайт игрока на 40 HP и игрока на 15 HP идентичен. Хит кэша переживает любой урон, пока никто не падает.

Ключ UI, напротив, кодирует точные HP (полоска жизни меняется при каждом ударе) и хэш сообщений :

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // знаковое 32-битное целое
  }
  return hash.toString(16);
}

Math.imul форсирует умножение в 32-битные целые, избегая float64-конверсий и давая стабильный полиномиальный хэш. Это избегает добавления внешней зависимости ради этого.

Base64-конверсия без переполнения стека

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 байтов
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) может вызвать переполнение стека на больших картинках, потому что аргументы передаются через call stack. Чанкинг по 32КБ избегает этого. Результат кэшируется -- base64-конверсия одной картинки делается раз на инстанс воркера.


STRIPPER.md -- аудит последовательных await

В репо есть файл STRIPPER.md, документирующий аудит распараллеливания await. Несколько примеров того, что там зафиксировано :

  • Загрузка профиля игрока делала 3 запроса Supabase подряд (прогресс, резюме рана, achievements). Они переведены в Promise.all -- зависимостей между ними нет.
  • Раздача наград конца боя (аксессуары + расходники) была последовательной. Распараллелена так же.
  • Создание токенов сессий для кнопок делалось группа за группой. Независимые группы теперь создаются параллельно.
// progressionService.ts -- до (последовательно)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// после
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Ничего революционного, но в serverless-контексте, где каждая миллисекунда времени ответа тарифицируется (или вносит в cold start), это считается.


Discord-бот без персистентного сервера

Победа

Часто плохо понимаемый момент : Discord-боту вовсе не обязательно персистентное WebSocket-соединение. Discord предлагает альтернативу : Interactions Endpoint URL. Ты даёшь Discord HTTPS URL, и Discord шлёт тебе POST на каждое взаимодействие (slash command, кнопка, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // пинг Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord шлёт POST, хендлер крутится 50-200ms на функции Vercel или Cloudflare Worker, отвечает, и конец. Никакого постоянного соединения для поддержания, никакого сервера для держания включённым. Весь Discord-бот хостится на бесплатном тире Vercel.

Проверка Ed25519 (verifyKey из discord-interactions) обязательна -- Discord шлёт подпись в заголовках, которую ты должен валидировать, иначе он реджектит эндпоинт.

Спецанимация -- единственный намеренный await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 секунды
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Эта добровольная задержка в 3 секунды задокументирована в STRIPPER.md как намеренная. У спецатаки Megumin (Explosion) есть анимация на стороне Discord -- сообщение сначала обновляется промежуточным визуалом, затем модифицируется 3 секунды спустя с результатом. Это единственный случай, когда функция Vercel крутится добровольно дольше нужного.

Спецатака


Деплоябельность на две платформы

Тот же кодбейз крутится на Vercel (Node.js) и на Cloudflare Workers (V8 isolates) без модификации :

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // впрыскивает секреты CF в process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- entrypoint Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

Главная разница : статичные ассеты. На Vercel они читаются с файловой системы (/var/task//articles/assets/). На Cloudflare Workers они идут через биндинг ASSETS (статичные ассеты CF) с fallback на HTTPS-зеркало (fox3000foxy.com/konosuba-rpg/assets). getAssetBytes в assetLoader.ts управляет обоими путями, пробуя сначала файловую систему, затем fetch.

WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) имеют отдельные сборки под каждый рантайм. Флаг edge-light в имени пакета обозначает сборку, совместимую с Cloudflare Workers, не разрешающую new WebAssembly.Module() в рантайме -- WASM должен быть предскомпилирован.


Прогрессия : XP, уровни, аффинити

Босс, 650 HP

Метапрогрессия опирается на бесплатный тир Supabase. Схема содержит таблицу players (глобальный XP, уровень, gold), character_progress (XP/уровень/аффинити на перса для Darkness, Aqua, Megumin), runs (история боёв), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Модель прогрессии проста :

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP на уровень
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% статов на уровень
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 очков на звезду, 5 звёзд макс
  return 1.2 ** stars; // экспоненциальная прогрессия
}

Эти факторы применяются к статам персов в начале каждого processGame. Kazuma следует глобальному уровню игрока, у трёх других у каждого свой XP/уровень. Аффинити (зарабатывается подбором дропов, связанных с персом) умножает его статы независимо.

Лечение

Система дропов использует loot-таблицы, взвешенные по сложности :

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...до Legendary
};

Тесты

Три набора : юниты, perf и leaks.

Leak-тест особенно прямой :

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // макс 20MB роста heap
});

1200 итераций processGame, форсированный GC до и после, дельта heap < 20MB. Если этот тест проходит, processGame не течёт. Тест рендера (renderImage.spec.ts) проверяет скорее время исполнения под практичным порогом.

Есть ещё скрипт bench.ts для профилирования полного пайплайна :

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

С RENDER_PERF=1 обёртка withPerf в каждом сервисе логирует тайминги :

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // нулевой оверхед, если выключено
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger возвращает no-op, если DEV_MODE и RENDER_PERF не равны 1. Нулевой оверхед в проде.


Сколько стоит крутить

  • Бесплатный тир Vercel : 100GB bandwidth, 1M serverless-инвокаций в месяц. Рендер картинки считается инвокацией.
  • Бесплатный тир Cloudflare Workers : 100K запросов/день, 10ms CPU time на запрос (рендер может превышать это на Workers, отсюда Vercel первичен).
  • Бесплатный тир Supabase : 500MB базы, 5GB bandwidth. Достаточно на тысячи игроков.

Весь бэкенд крутится за ноль до значимого объёма. Единственная точка трения это CPU-лимит Cloudflare Workers -- рендер картинок CPU-intensive из-за WASM, отсюда стратегия Vercel как первичного и Workers как CDN failover.


3 вещи, стоящие запоминания

  1. URL как состояние игры это не просто милая хитрость -- это ограничение, навязанное Discord (у кнопок лимит 100 символов), форсировавшее stateless-архитектуру с RLE-сжатием + токеном сессии как fallback. Ограничение продиктовало дизайн.

  2. WASM-кэш с явной эвикцией : PhotonImage аллоцируют вне JS heap и никогда не будут GC'd без .free(). Подвешивание freePhoton на эвикцию LRU это RAII в JavaScript. Незаметно в коде, но без этого воркер тёк бы в проде.

  3. Serverless Discord-бот без WebSocket : это менее известно, чем подход WebSocket gateway, но для бота со stateless-обработкой (каждое взаимодействие независимо) Interactions Endpoint строго лучше -- без переподключений, без heartbeat, без процесса для поддержания. Discord управляет доступностью на своей инфре.


Репо : fox3000foxy/konosuba-rpg

Лицензия source-available custom -- без редистрибуции, free to use.

Pasé un fin de semana leyendo el código de konosuba-rpg y esto encontré

Un RPG por turnos en Discord donde cada acción genera una imagen WebP

Pasé un fin de semana leyendo el código de konosuba-rpg y esto encontré

Mantengo ese proyecto desde hace tiempo, pero releer tu propio código con la cabeza despejada siempre es instructivo. konosuba-rpg es un RPG por turnos en Discord donde cada acción genera una imagen WebP al vuelo. Es una verdadera imagen compuesta, con los sprites, las barras de vida, los mensajes de combate: todo.

La stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Alojamiento totalmente gratuito. Y el bot Discord funciona sin servidor persistente. Este post explica cómo todo eso se sostiene.

Estado inicial del juego


El diseño base: la URL como estado del juego

Lo primero que llama: no hay ningún estado en servidor para el gameplay. El estado completo de un combate cabe en la URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Cada segmento tras el seed es una acción jugada. El servidor recibe esa URL, parte del principio, vuelve a jugar todas las acciones en orden, y devuelve una imagen del combate en ese instante preciso. Ninguna sesión, ningún estado en RAM ligado a un usuario.

Discord funciona por botones interactivos: cuando el jugador pulsa "Atacar", Discord envía al servidor el custom_id del botón. Ese custom_id contiene la URL comprimida del combate con la nueva acción añadida. El servidor recalcula todo desde cero y devuelve la imagen actualizada.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Precompilado fuera de función: no recreado en cada llamada

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6º segmento, hasheado sobre 8096 valores
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

El Set precompilado fuera de la función es un detalle, pero evita reconstruir la estructura en cada invocación en un contexto edge donde los módulos pueden reevaluarse.

El RNG: RC4 modificado

El generador aleatorio es una implementación RC4 (algoritmo de cifrado stream) desviada a PRNG.

export class Random {
  private S: number[]; // tabla de 256 entradas
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // swap S[i] y S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

¿Por qué RC4? Porque es un PRNG determinista con una distribución correcta y una resistencia razonable a las colisiones de seed. Mismo seed = misma secuencia de números = mismo combate cada vez. Eso permite "volver a jugar" cualquier combate conservando su URL, y garantiza que dos servidores distintos (Vercel + Cloudflare) produzcan exactamente el mismo resultado para la misma URL.


El problema del límite de 100 caracteres de Discord

Discord impone un límite de 100 caracteres en los custom_id de los botones. Tras unas decenas de acciones, una URL de combate supera ampliamente ese límite.

Dos mecanismos responden a eso.

1. Compresión RLE de las acciones

Las acciones se codifican con un solo carácter (a=attack, d=defend, h=hug...) y se comprimen por run-length encoding:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Simple, pero cuando el jugador spamea Ataque x10 eso pasa de aaaaaaaaaa (10 chars) a a10 (3 chars). Los botones "Atacar x4" y "Atacar x10" en la UI existen justo para eso: acelerar el combate comprimiendo bien el payload.

2. Session tokens cuando la compresión no basta

Si el payload comprimido sigue siendo demasiado largo, se guarda en base con un token corto:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Agrupa los payloads por battle_key, inserta en batch en Supabase
  // Reemplaza el custom_id por "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Sin lookup si no hace falta
  }
  // Lookup en memoria primero, luego Supabase si falta
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Comprueba ownership, TTL (7 días), y turn_version (evita rejugar un estado viejo)
}

Las sesiones tienen un TTL de 7 días y un pruning automático cada 10 minutos. La comprobación turnVersion impide rejugar un estado caducado si el jugador avanzó en la partida: una protección discreta contra el "retroceso" accidental.

Ambos Maps en memoria (tokenToSession, latestTurnByBattle) usan el mismo patrón globalThis as unknown as GameSessionGlobals que los cachés de imagen, por las mismas razones que veremos más abajo.


El pipeline de render de imagen

Inicio de combate contra un Slime

La ruta /konosuba-rpg/:lang/* no devuelve JSON. Devuelve una imagen WebP generada bajo demanda.

El pipeline está organizado en 3 layers compuestas:

Background (board + frame)
    +
Characters layer (sprites jugadores + mob, posiciones fijas)
    +
UI overlay (barras HP, mensajes, iconos persos vía Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: dos imágenes fijas (el tablero y el marco), cargadas desde el filesystem y compuestas una vez.

Characters layer: los sprites se posicionan según coordenadas calculadas. Los jugadores muertos se excluyen (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Los sprites enemigos se espejan horizontalmente con un flipX custom: un bucle píxel por píxel en vez de una dependencia externa.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: es la parte pesada. El JSX de la interfaz (barras de vida, textos, iconos) se describe en React-like con Satori, renderizado en SVG, convertido en PNG por @cf-wasm/resvg, y luego importado en Photon para la composición final. Satori + resvg son dos módulos WASM compilados específicamente para Cloudflare Workers con el flag edge-light.

Acción Defensa

Combate en curso

Acción Abrazo


El sistema de caché: la parte más trabajada

Hay 5 niveles de caché distintos. Cada uno apunta a una granularidad distinta del pipeline.

// renderImage.ts: todos sobre globalThis
G.__imageCache  ??= {} as Record; // assets en bruto
G.__base64Cache ??= {} as Record;       // base64 de los assets (para Satori)
G.__fontCache   ??= {} as Record; // fuentes
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

El patrón ??= sobre globalThis: los módulos JavaScript en los workers edge pueden reevaluarse entre peticiones en ciertas configuraciones. Guardar los cachés en globalThis con ??= garantiza que sobrevivan a esas reevaluaciones sin recrearse.

La evicción WASM

Los cachés de imágenes Photon (photonCache, layerCache, uiPhotonCache) usan un callback de evicción:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* ya liberado */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage es un objeto WASM con memoria asignada en el lado lineal WASM, fuera del GC JavaScript. Esa memoria queda asignada mientras no se llame a .free() explícitamente. La evicción del LRU dispara .free() automáticamente: es RAII llevado a JavaScript.

Las claves de caché son intencionadamente lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

La clave del characters layer no codifica el valor exacto de los HP: solo 1 (vivo) o 0 (muerto). Porque el sprite de un jugador a 40 HP y un jugador a 15 HP es idéntico. Un hit de caché sobrevive por tanto a cualquier daño mientras nadie caiga.

La clave UI en cambio codifica los HP exactos (la barra de vida cambia en cada golpe) y un hash de los mensajes:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // entero 32-bit con signo
  }
  return hash.toString(16);
}

Math.imul fuerza la multiplicación en entero 32 bits, lo que evita las conversiones float64 y da un hash polinomial estable. Eso evita añadir una dependencia externa para eso.

La conversión base64 sin stack overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 bytes
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) puede provocar un stack overflow en las imágenes grandes porque los argumentos pasan por la call stack. El chunking por 32KB lo evita. El resultado se cachea: la conversión base64 de una misma imagen solo se hace una vez por instancia de worker.


STRIPPER.md: auditoría de los awaits secuenciales

Hay un archivo STRIPPER.md en el repo que documenta una auditoría de paralelización de los await. Algunos ejemplos de lo consignado:

  • La carga del perfil jugador hacía 3 peticiones Supabase en serie (progresión, resumen de run, achievements). Se pasaron a Promise.all: sin dependencia entre ellas.
  • La distribución de las recompensas de fin de combate (accesorios + consumibles) era secuencial. Paralelizada igual.
  • La creación de los tokens de sesión para los botones se hacía grupo por grupo. Los grupos independientes ahora se crean en paralelo.
// progressionService.ts: antes (secuencial)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// después
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Nada revolucionario, pero en un contexto serverless donde cada milisegundo de tiempo de respuesta se factura (o contribuye al cold start), cuenta.


El bot Discord sin servidor persistente

Victoria

Punto a menudo mal entendido: un bot Discord no necesita forzosamente una conexión WebSocket persistente. Discord ofrece una alternativa: las Interactions Endpoint URL. Das una URL HTTPS a Discord, y Discord te envía un POST por cada interacción (slash command, botón, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord envía un POST, el handler corre 50-200ms en una función Vercel o un Cloudflare Worker, responde, y se acabó. Ninguna conexión permanente que mantener, ningún servidor que mantener encendido. Todo el bot Discord está alojado en el free tier Vercel.

La verificación Ed25519 (verifyKey desde discord-interactions) es obligatoria: Discord envía una firma en las cabeceras que tienes que validar, si no rechaza el endpoint.

La animación especial: el único await intencional

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 segundos
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Ese retardo voluntario de 3 segundos está documentado en STRIPPER.md como intencional. El ataque especial de Megumin (Explosion) tiene una animación en Discord: el mensaje primero se actualiza con un visual intermedio, y luego se modifica 3 segundos después con el resultado. Es el único caso donde una función Vercel corre voluntariamente más tiempo del necesario.

Ataque especial


La desplegabilidad en dos plataformas

El mismo codebase corre en Vercel (Node.js) y en Cloudflare Workers (V8 isolates) sin modificación:

// worker.ts: entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // inyecta los secrets CF en process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts: entrypoint Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

La diferencia principal: los assets estáticos. En Vercel, se leen desde el filesystem (/var/task//articles/assets/). En Cloudflare Workers, pasan por un binding ASSETS (assets estáticos CF) con fallback hacia un mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). El getAssetBytes en assetLoader.ts gestiona ambos caminos probando el filesystem primero, y luego fetch.

Los WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) tienen builds separados para cada runtime. El flag edge-light en el nombre del paquete designa el build compatible Cloudflare Workers, que no autoriza new WebAssembly.Module() en runtime: el WASM tiene que estar precompilado.


La progresión: XP, niveles, afinidad

Un boss, 650 HP

La metaprogresión se apoya en Supabase free tier. El esquema tiene una tabla players (XP global, nivel, gold), character_progress (XP/nivel/afinidad por perso para Darkness, Aqua, Megumin), runs (historial de los combates), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

El modelo de progresión es simple:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP por nivel
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stats por nivel
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 puntos por estrella, 5 estrellas máx
  return 1.2 ** stars; // progresión exponencial
}

Esos factores se aplican a las stats de los persos al inicio de cada processGame. Kazuma sigue el nivel global del jugador, los otros tres tienen cada uno su propio XP/nivel. La afinidad (ganada recuperando drops ligados a un perso) multiplica sus stats independientemente.

Cura

El sistema de drops usa loot tables ponderadas por dificultad:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...hasta Legendary
};

Los tests

Tres suites: unitarios, perf, y leaks.

El leak test es particularmente directo:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // máx 20MB de crecimiento heap
});

1200 iteraciones de processGame, GC forzado antes y después, delta heap < 20MB. Si ese test pasa, processGame no fuga. El test de render (renderImage.spec.ts) comprueba más bien el tiempo de ejecución bajo un umbral práctico.

También hay un script bench.ts para perfilar el pipeline completo:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Con RENDER_PERF=1, el wrapper withPerf en cada servicio loguea los timings:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // zero overhead si desactivado
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger devuelve no-ops si DEV_MODE y RENDER_PERF no están a 1. Ningún overhead en producción.


Lo que cuesta hacerlo correr

  • Vercel free tier: 100GB de bandwidth, 1M de invocaciones serverless al mes. El render de imagen cuenta como una invocación.
  • Cloudflare Workers free tier: 100K peticiones/día, 10ms CPU time por petición (el render puede superar eso en los Workers, de ahí Vercel en primario).
  • Supabase free tier: 500MB de base, 5GB de bandwidth. Suficiente para miles de jugadores.

Todo el backend corre a coste cero hasta un volumen significativo. El único punto de fricción es el límite CPU de Cloudflare Workers: el render imagen es CPU-intensive por WASM, de ahí la estrategia de Vercel como primario y Workers como CDN de failover.


Las 3 cosas que merecen recordarse

  1. La URL como estado de juego no es solo un truco simpático: es una restricción impuesta por Discord (los botones tienen un límite de 100 chars) que forzó una arquitectura stateless con compresión RLE + token de sesión como fallback. La restricción dictó el diseño.

  2. El caché WASM con evicción explícita: los PhotonImage asignan fuera del heap JavaScript y jamás serán GC sin .free(). Enchufar freePhoton en la evicción del LRU es RAII en JavaScript. Es discreto en el código, pero sin eso el worker fugaría en producción.

  3. Un bot Discord serverless sin WebSocket: es menos conocido que el enfoque WebSocket gateway, pero para un bot que hace procesamiento stateless (cada interacción es independiente), el Interactions Endpoint es estrictamente superior: sin reconexión, sin heartbeat, sin proceso que mantener. Discord gestiona la disponibilidad en su infra.


Repo: fox3000foxy/konosuba-rpg

Licencia source-available custom: sin redistribución, free to use.

Passei um fim de semana lendo o código do konosuba-rpg e eis o que encontrei

Um RPG de turno no Discord onde cada ação gera uma imagem WebP na

Passei um fim de semana lendo o código do konosuba-rpg e eis o que encontrei

Mantenho este projeto há um tempo, mas reler o próprio código com calma é sempre instrutivo. konosuba-rpg é um RPG de turno no Discord onde cada ação gera uma imagem WebP na hora. Não é um embed de texto. Uma imagem composta de verdade, com sprites, barras de vida, mensagens de combate -- tudo.

A stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hospedagem totalmente gratuita. E o bot do Discord funciona sem servidor persistente. Este post explica como tudo se mantém junto.

État initial du jeu


O design básico: a URL como estado do jogo

A primeira coisa que impressiona: não há nenhum estado no lado do servidor para o gameplay. O estado completo de uma batalha está na URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Cada segmento após a seed é uma ação jogada. O servidor recebe essa URL, volta ao início, reproduz todas as ações na ordem e retorna uma imagem da batalha naquele instante. Nenhuma sessão, nenhum estado em RAM vinculado a um usuário.

O Discord funciona com botões interativos -- quando o jogador aperta "Atacar", o Discord envia ao servidor o custom_id do botão. Esse custom_id contém a URL comprimida da batalha com a nova ação adicionada. O servidor recalcula tudo do zero e retorna a imagem atualizada.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Pré-compilado fora da função -- não recriado a cada chamada

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6º segmento, hasheado em 8096 valores
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

O Set pré-compilado fora da função é um detalhe, mas evita reconstruir a estrutura a cada invocação em um contexto edge onde os módulos podem ser reavaliados.

O RNG: RC4 modificado

O gerador aleatório é uma implementação RC4 (algoritmo de cifra de fluxo) desviada para PRNG.

export class Random {
  private S: number[]; // tabela de 256 entradas
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // swap S[i] e S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Por que RC4? Porque é um PRNG determinístico com distribuição correta e resistência razoável a colisões de seed. Mesma seed = mesma sequência de números = mesma batalha a cada vez. Isso permite "reproduzir" qualquer batalha mantendo sua URL, e garante que dois servidores diferentes (Vercel + Cloudflare) produzam exatamente o mesmo resultado para a mesma URL.


O problema do limite de 100 caracteres do Discord

O Discord impõe um limite de 100 caracteres nos custom_id dos botões. Após algumas dezenas de ações, uma URL de batalha excede facilmente esse limite.

Dois mecanismos respondem a isso.

1. Compressão RLE das ações

As ações são codificadas com um único caractere (a=attack, d=defend, h=hug...) e comprimidas por run-length encoding:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Simples, mas quando o jogador spammeia Ataque x10, passa de aaaaaaaaaa (10 caracteres) para a10 (3 caracteres). Os botões "Atacar x4" e "Atacar x10" na interface existem justamente para isso -- acelerar a batalha enquanto comprime bem o payload.

2. Tokens de sessão quando a compressão não é suficiente

Se o payload comprimido ainda for muito longo, ele é armazenado no banco com um token curto:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Agrupa os payloads por battle_key, insere em batch no Supabase
  // Substitui o custom_id por "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Sem lookup se não necessário
  }
  // Lookup em memória primeiro, depois Supabase se ausente
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Verifica ownership, TTL (7 dias), e turn_version (evita reproduzir um estado antigo)
}

As sessões têm um TTL de 7 dias e uma limpeza automática a cada 10 minutos. A verificação turnVersion impede de reproduzir um estado desatualizado se o jogador avançou na partida -- uma proteção discreta contra o "retrocesso" acidental.

Os dois Maps em memória (tokenToSession, latestTurnByBattle) usam o mesmo padrão globalThis as unknown as GameSessionGlobals que os caches de imagem, pelas mesmas razões que veremos mais abaixo.


O pipeline de renderização de imagem

Début de combat contre un Slime

A rota /konosuba-rpg/:lang/* não retorna JSON. Ela retorna uma imagem WebP gerada sob demanda.

O pipeline é organizado em 3 camadas compostas:

Background (board + frame)
    +
Characters layer (sprites jogadores + mob, posições fixas)
    +
UI overlay (barras HP, mensagens, ícones personagens via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: duas imagens fixas (o tabuleiro e a moldura), carregadas do sistema de arquivos e compostas uma vez.

Camada de personagens: os sprites são posicionados de acordo com coordenadas calculadas. Jogadores mortos são excluídos (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Os sprites inimigos são espelhados horizontalmente com um flipX customizado -- um loop pixel a pixel em vez de uma dependência externa.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

Sobreposição de UI: é a parte pesada. O JSX da interface (barras de vida, textos, ícones) é descrito em React-like com Satori, renderizado em SVG, convertido para PNG por @cf-wasm/resvg, e então importado no Photon para a composição final. Satori + resvg são dois módulos WASM compilados especificamente para Cloudflare Workers com a flag edge-light.

Action Défense

Combat en cours

Action Câlin


O sistema de cache -- a parte mais trabalhada

Há 5 níveis de cache distintos. Cada um visa uma granularidade diferente do pipeline.

// renderImage.ts -- todos em globalThis
G.__imageCache  ??= {} as Record; // assets brutos
G.__base64Cache ??= {} as Record;       // base64 dos assets (para Satori)
G.__fontCache   ??= {} as Record; // fontes
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

O padrão ??= no globalThis: os módulos JavaScript nos workers edge podem ser reavaliados entre requisições em algumas configurações. Armazenar os caches no globalThis com ??= garante que eles sobrevivam a essas reavaliações sem serem recriados.

A evicção WASM

Os caches de imagem Photon (photonCache, layerCache, uiPhotonCache) usam um callback de evicção:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* já liberado */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage é um objeto WASM com memória alocada no lado linear do WASM, fora do GC do JavaScript. Sem uma chamada explícita a .free(), essa memória nunca é liberada. A evicção do LRU aciona .free() automaticamente -- é RAII trazido para JavaScript.

As chaves de cache são intencionalmente lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

A chave da camada de personagens não codifica o valor exato dos HP -- apenas 1 (vivo) ou 0 (morto). Porque o sprite de um jogador com 40 HP e de um jogador com 15 HP é idêntico. Um cache hit sobrevive a qualquer dano, desde que ninguém caia.

A chave da UI, por outro lado, codifica os HP exatos (a barra de vida muda a cada golpe) e um hash das mensagens:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // inteiro 32-bit sinalizado
  }
  return hash.toString(16);
}

Math.imul força a multiplicação em inteiro de 32 bits, o que evita conversões float64 e dá um hash polinomial estável. Nenhuma dependência externa para isso.

A conversão base64 sem stack overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 bytes
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) pode causar um stack overflow em imagens grandes porque os argumentos são passados na call stack. O chunking de 32KB evita isso. O resultado é armazenado em cache -- a conversão base64 de uma mesma imagem é feita apenas uma vez por instância de worker.


STRIPPER.md -- auditoria de awaits sequenciais

Há um arquivo STRIPPER.md no repositório que documenta uma auditoria de paralelização dos await. Alguns exemplos do que está registrado:

  • O carregamento do perfil do jogador fazia 3 requisições Supabase em série (progressão, resumo de run, achievements). Elas foram passadas para Promise.all -- não há dependência entre elas.
  • A distribuição das recompensas de fim de batalha (acessórios + consumíveis) era sequencial. Paralelizada da mesma forma.
  • A criação dos tokens de sessão para os botões era feita grupo por grupo. Os grupos independentes agora são criados em paralelo.
// progressionService.ts -- antes (sequencial)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// depois
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Nada de revolucionário, mas em um contexto serverless onde cada milissegundo de tempo de resposta é faturado (ou contribui para o cold start), isso importa.


O bot do Discord sem servidor persistente

Victoire

Ponto frequentemente mal compreendido: um bot do Discord não necessariamente requer uma conexão WebSocket persistente. O Discord oferece uma alternativa: as Interactions Endpoint URL. Você fornece uma URL HTTPS para o Discord, e o Discord envia um POST para cada interação (slash command, botão, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

O Discord envia um POST, o handler executa em 50-200ms em uma função Vercel ou um Cloudflare Worker, responde, e pronto. Nenhuma conexão permanente para manter, nenhum servidor para manter ligado. A totalidade do bot do Discord está hospedada no free tier da Vercel.

A verificação Ed25519 (verifyKey do discord-interactions) é obrigatória -- o Discord envia uma assinatura nos headers que você deve validar, caso contrário ele rejeita o endpoint.

A animação especial -- o único await intencional

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 segundos
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Esse atraso voluntário de 3 segundos está documentado no STRIPPER.md como intencional. O ataque especial da Megumin (Explosion) tem uma animação no Discord -- a mensagem é primeiro atualizada com um visual intermediário, e depois modificada 3 segundos depois com o resultado. É o único caso em que uma função Vercel executa voluntariamente por mais tempo que o necessário.

Attaque spéciale


A capacidade de deploy em duas plataformas

O mesmo codebase executa no Vercel (Node.js) e no Cloudflare Workers (V8 isolates) sem modificação:

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // injeta os secrets CF no process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- entrypoint Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

A diferença principal: os assets estáticos. No Vercel, eles são lidos do sistema de arquivos (/var/task//articles/assets/). No Cloudflare Workers, eles passam por um binding ASSETS (assets estáticos CF) com fallback para um mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). O getAssetBytes no assetLoader.ts gerencia ambos os caminhos tentando o sistema de arquivos primeiro, depois fetch.

Os WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) têm builds separados para cada runtime. A flag edge-light no nome do pacote designa o build compatível com Cloudflare Workers, que não permite new WebAssembly.Module() em runtime -- o WASM deve ser pré-compilado.


A progressão: XP, níveis, afinidade

Un boss, 650 HP

A meta-progressão se baseia no Supabase free tier. O esquema inclui uma tabela players (XP global, nível, gold), character_progress (XP/nível/afinidade por personagem para Darkness, Aqua, Megumin), runs (histórico de batalhas), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

O modelo de progressão é simples:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP por nível
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stats por nível
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 pontos por estrela, 5 estrelas max
  return 1.2 ** stars; // progressão exponencial
}

Esses fatores são aplicados às stats dos personagens no início de cada processGame. Kazuma segue o nível global do jogador, os outros três têm cada um seu próprio XP/nível. A afinidade (ganha ao recuperar drops relacionados a um personagem) multiplica suas stats independentemente.

Soin

O sistema de drops utiliza loot tables ponderadas por dificuldade:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...até Legendary
};

Os testes

Três suites: unitários, perf e leaks.

O teste de leak é particularmente direto:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // max 20MB de crescimento heap
});

1200 iterações de processGame, GC forçado antes e depois, delta heap < 20MB. Se esse teste passar, processGame não vaza. O teste de render (renderImage.spec.ts) verifica o tempo de execução sob um limite prático.

Há também um script bench.ts para perfilar o pipeline completo:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Com RENDER_PERF=1, o wrapper withPerf em cada serviço registra os timings:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // zero overhead se desativado
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger retorna no-ops se DEV_MODE e RENDER_PERF não estiverem em 1. Nenhum overhead em produção.


O que custa para manter funcionando

  • Vercel free tier: 100GB de banda, 1M de invocações serverless por mês. A renderização de imagem conta como uma invocação.
  • Cloudflare Workers free tier: 100K requisições/dia, 10ms de CPU time por requisição (a renderização pode exceder isso nos Workers, daí o Vercel como primário).
  • Supabase free tier: 500MB de banco, 5GB de banda. Suficiente para milhares de jogadores.

Todo o backend funciona a custo zero até um volume significativo. O único ponto de atrito é o limite de CPU do Cloudflare Workers -- a renderização de imagem é CPU-intensive por causa do WASM, daí a estratégia do Vercel como primário e Workers como CDN de failover.


As 3 coisas que merecem ser lembradas

  1. A URL como estado do jogo não é apenas um truque legal -- é uma restrição imposta pelo Discord (os botões têm um limite de 100 caracteres) que forçou uma arquitetura stateless com compressão RLE + token de sessão como fallback. A restrição ditou o design.

  2. O cache WASM com evicção explícita: os PhotonImage alocam fora do heap do JavaScript e nunca serão coletados pelo GC sem .free(). Conectar freePhoton à evicção do LRU é RAII em JavaScript. É discreto no código, mas sem isso o worker vazaria em produção.

  3. Um bot Discord serverless sem WebSocket: é menos conhecido que a abordagem WebSocket gateway, mas para um bot que faz processamento stateless (cada interação é independente), o Interactions Endpoint é estritamente superior -- sem reconexão, sem heartbeat, sem processo para manter. O Discord gerencia a disponibilidade do lado da infraestrutura deles.


Repo : fox3000foxy/konosuba-rpg

Licença source-available customizada -- sem redistribuição, free to use.

Saya menghabiskan akhir pekan membaca kode konosuba-rpg dan inilah yang saya temukan

RPG turn-based Discord di mana setiap aksi membangkitkan gambar WebP

Saya menghabiskan akhir pekan membaca kode konosuba-rpg dan inilah yang saya temukan

Saya memelihara proyek ini sudah lama, tapi membaca ulang kode sendiri dengan kepala dingin selalu instruktif. konosuba-rpg adalah RPG turn-based Discord di mana setiap aksi membangkitkan gambar WebP langsung. Ini gambar sungguhan yang disusun, dengan sprite, bar nyawa, pesan tempur -- semuanya.

Stack : TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hosting sepenuhnya gratis. Dan bot Discord berjalan tanpa server persisten. Post ini menjelaskan bagaimana semuanya nyambung.

Keadaan awal permainan


Desain dasar : URL sebagai keadaan permainan

Hal pertama yang mencolok : tidak ada keadaan sisi server untuk gameplay. Keadaan lengkap satu tempur muat dalam URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Setiap segmen setelah seed adalah aksi yang dimainkan. Server menerima URL ini, mulai dari awal, memainkan ulang semua aksi berurutan, dan mengembalikan gambar tempur pada momen tepat itu. Tanpa sesi, tanpa keadaan di RAM yang terkait pengguna.

Discord bekerja lewat tombol interaktif -- ketika pemain menekan "Serang", Discord mengirim ke server custom_id tombol. Custom_id ini berisi URL tempur terkompresi dengan aksi baru ditambahkan. Server menghitung ulang semuanya dari nol dan mengembalikan gambar yang diperbarui.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Dikompilasi sebelumnya di luar fungsi -- tidak dibuat ulang di setiap panggilan

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = segmen ke-6, di hash ke 8096 nilai
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Set yang dikompilasi sebelumnya di luar fungsi itu detail, tapi menghindari membangun ulang struktur di setiap invokasi dalam konteks edge di mana modul bisa dievaluasi ulang.

RNG : RC4 yang dimodifikasi

Pembangkit acak adalah implementasi RC4 (algoritma stream cipher) yang dialihkan jadi PRNG.

export class Random {
  private S: number[]; // tabel 256 entri
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // tukar S[i] dan S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Kenapa RC4? Karena ini PRNG deterministik dengan distribusi yang benar dan ketahanan tabrakan seed yang lumayan. Seed sama = urutan angka sama = tempur sama setiap kali. Ini memungkinkan "memutar ulang" tempur mana pun dengan menyimpan URL-nya, dan menjamin dua server berbeda (Vercel + Cloudflare) menghasilkan tepat hasil yang sama untuk URL yang sama.


Masalah batas 100 karakter Discord

Discord memberlakukan batas 100 karakter pada custom_id tombol. Setelah beberapa puluh aksi, URL tempur melampaui batas itu dengan mudah.

Dua mekanisme menjawab itu.

1. Kompresi RLE pada aksi

Aksi dienkode dengan satu karakter (a=attack, d=defend, h=hug...) dan dikompresi lewat run-length encoding :

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Sederhana, tapi ketika pemain spam Serang x10 itu berubah dari aaaaaaaaaa (10 char) menjadi a10 (3 char). Tombol "Serang x4" dan "Serang x10" di UI memang ada untuk itu -- mempercepat tempur sekaligus mengompresi payload dengan baik.

2. Token sesi ketika kompresi tidak cukup

Kalau payload terkompresi tetap terlalu panjang, ia disimpan di basis data dengan token pendek :

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Mengelompokkan payload per battle_key, menyisipkan batch ke Supabase
  // Mengganti custom_id dengan "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Tanpa lookup kalau tidak perlu
  }
  // Lookup di memori dulu, lalu Supabase kalau tidak ada
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Memeriksa kepemilikan, TTL (7 hari), dan turn_version (mencegah memutar ulang keadaan lama)
}

Sesi punya TTL 7 hari dan pruning otomatis setiap 10 menit. Pemeriksaan turnVersion mencegah memutar ulang keadaan kedaluwarsa kalau pemain sudah maju dalam permainan -- perlindungan diam-diam terhadap "mundur" yang tidak disengaja.

Kedua Map di memori (tokenToSession, latestTurnByBattle) memakai pola yang sama globalThis as unknown as GameSessionGlobals seperti cache gambar, karena alasan yang sama yang akan kita lihat di bawah.


Pipeline render gambar

Awal tempur melawan Slime

Rute /konosuba-rpg/:lang/* tidak mengembalikan JSON. Ia mengembalikan gambar WebP yang dibangkitkan berdasarkan permintaan.

Pipeline disusun dalam 3 layer yang dikompositkan :

Background (board + frame)
    +
Characters layer (sprite pemain + mob, posisi tetap)
    +
UI overlay (bar HP, pesan, ikon karakter lewat Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background : dua gambar tetap (papan dan bingkai), dimuat dari filesystem dan dikompositkan sekali.

Characters layer : sprite diposisikan menurut koordinat yang dihitung. Pemain mati dikecualikan (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Sprite musuh di mirror horizontal dengan flipX kustom -- loop piksel per piksel alih-alih dependensi eksternal.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay : ini bagian beratnya. JSX antarmuka (bar nyawa, teks, ikon) dideskripsikan ala React dengan Satori, di render jadi SVG, dikonversi jadi PNG oleh @cf-wasm/resvg, lalu diimpor ke Photon untuk komposisi akhir. Satori + resvg adalah dua modul WASM yang dikompilasi khusus untuk Cloudflare Workers dengan flag edge-light.

Aksi Bertahan

Tempur berjalan

Aksi Pelukan


Sistem cache -- bagian yang paling digarap

Ada 5 tingkat cache berbeda. Masing-masing menarget granularitas berbeda dari pipeline.

// renderImage.ts -- semua di globalThis
G.__imageCache  ??= {} as Record; // aset mentah
G.__base64Cache ??= {} as Record;       // base64 aset (untuk Satori)
G.__fontCache   ??= {} as Record; // font
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Pola ??= di globalThis : modul JavaScript di worker edge bisa dievaluasi ulang antar permintaan pada konfigurasi tertentu. Menyimpan cache di globalThis dengan ??= menjamin mereka bertahan dari evaluasi ulang tanpa dibuat ulang.

Eviksi WASM

Cache gambar Photon (photonCache, layerCache, uiPhotonCache) memakai callback eviksi :

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* sudah dibebaskan */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage adalah objek WASM dengan memori yang dialokasikan di sisi linear WASM, di luar GC JavaScript. Memori ini tetap dialokasikan selama .free() tidak dipanggil eksplisit. Eeviksi LRU memicu .free() otomatis -- ini RAII yang dibawa ke JavaScript.

Kunci cache sengaja lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

Kunci characters layer tidak mengenkode nilai HP persis -- hanya 1 (hidup) atau 0 (mati). Karena sprite pemain ber HP 40 dan pemain ber HP 15 identik. Hit cache jadi bertahan dari damage apa pun selama tidak ada yang jatuh.

Kunci UI sebaliknya mengenkode HP persis (bar nyawa berubah di setiap pukulan) dan hash pesan :

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // integer 32-bit bertanda
  }
  return hash.toString(16);
}

Math.imul memaksa perkalian dalam integer 32 bit, yang menghindari konversi float64 dan memberikan hash polinomial yang stabil. Ini menghindari penambahan dependensi eksternal untuk itu.

Konversi base64 tanpa stack overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 oktet
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) bisa menyebabkan stack overflow pada gambar besar karena argumen dilewatkan di call stack. Chunking per 32Ko menghindari itu. Hasilnya di cache -- konversi base64 gambar yang sama hanya dilakukan sekali per instans worker.


STRIPPER.md -- audit await sekuensial

Ada berkas STRIPPER.md di repo yang mendokumentasikan audit paralelisasi await. Beberapa contoh dari yang tercatat di sana :

  • Pemuatan profil pemain melakukan 3 query Supabase seri (progresi, ringkasan run, achievement). Mereka dipindah ke Promise.all -- tanpa dependensi di antara mereka.
  • Distribusi hadiah akhir tempur (aksesori + consumable) dulunya sekuensial. Diparalelkan juga.
  • Pembuatan token sesi untuk tombol dilakukan grup per grup. Grup independen sekarang dibuat paralel.
// progressionService.ts -- sebelum (sekuensial)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// sesudah
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Tidak ada yang revolusioner, tapi dalam konteks serverless di mana setiap milidetik waktu respons ditagih (atau berkontribusi pada cold start), itu penting.


Bot Discord tanpa server persisten

Kemenangan

Poin yang sering disalahpahami : bot Discord tidak selalu membutuhkan koneksi WebSocket persisten. Discord menawarkan alternatif : Interactions Endpoint URL. Kamu memberikan URL HTTPS ke Discord, dan Discord mengirimmu POST untuk setiap interaksi (slash command, tombol, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord mengirim POST, handler berjalan 50-200ms di fungsi Vercel atau Cloudflare Worker, menjawab, dan selesai. Tanpa koneksi permanen yang dipelihara, tanpa server yang dijaga menyala. Seluruh bot Discord dihosting di free tier Vercel.

Verifikasi Ed25519 (verifyKey dari discord-interactions) wajib -- Discord mengirim tanda tangan di header yang harus kamu validasi, kalau tidak ia menolak endpoint.

Animasi spesial -- satu-satunya await yang disengaja

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 detik
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Penundaan sukarela 3 detik ini didokumentasikan di STRIPPER.md sebagai disengaja. Serangan spesial Megumin (Explosion) punya animasi di sisi Discord -- pesan pertama diperbarui dengan visual antara, lalu diubah 3 detik kemudian dengan hasilnya. Ini satu-satunya kasus di mana fungsi Vercel berjalan sengaja lebih lama dari yang perlu.

Serangan spesial


Keterdeployan di dua platform

Codebase yang sama berjalan di Vercel (Node.js) dan di Cloudflare Workers (V8 isolates) tanpa modifikasi :

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // menyuntikkan secret CF ke process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- entrypoint Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

Perbedaan utama : aset statis. Di Vercel, mereka dibaca dari filesystem (/var/task//articles/assets/). Di Cloudflare Workers, mereka lewat binding ASSETS (aset statis CF) dengan fallback ke mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). getAssetBytes di assetLoader.ts menangani kedua path dengan mencoba filesystem dulu, lalu fetch.

WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) punya build terpisah untuk tiap runtime. Flag edge-light pada nama paket menunjuk build yang kompatibel Cloudflare Workers, yang tidak mengizinkan new WebAssembly.Module() saat runtime -- WASM harus dikompilasi sebelumnya.


Progresi : XP, level, afinitas

Satu bos, 650 HP

Meta-progresi bersandar pada Supabase free tier. Skemanya punya tabel players (XP global, level, gold), character_progress (XP/level/afinitas per karakter untuk Darkness, Aqua, Megumin), runs (riwayat tempur), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Model progresinya sederhana :

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP per level
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stat per level
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 poin per bintang, 5 bintang maks
  return 1.2 ** stars; // progresi eksponensial
}

Faktor ini diterapkan pada stat karakter di awal setiap processGame. Kazuma mengikuti level global pemain, tiga lainnya masing-masing punya XP/level sendiri. Afinitas (didapat dengan mengambil drop yang terkait karakter) mengalikan statnya secara independen.

Penyembuhan

Sistem drop memakai loot table yang dibobot per kesulitan :

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...hingga Legendary
};

Tes

Tiga rangkaian : uniter, perf, dan leak.

Leak test terutama langsung :

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // maks 20MB pertumbuhan heap
});

1200 iterasi processGame, GC dipaksa sebelum dan sesudah, delta heap < 20MB. Kalau tes ini lolos, processGame tidak bocor. Tes render (renderImage.spec.ts) memeriksa waktu eksekusi di bawah ambang praktis.

Ada juga skrip bench.ts untuk memprofilkan pipeline lengkap :

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Dengan RENDER_PERF=1, wrapper withPerf di setiap layanan mencatat timing :

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // nol overhead kalau dinonaktifkan
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger mengembalikan no-op kalau DEV_MODE dan RENDER_PERF tidak disetel ke 1. Tanpa overhead di produksi.


Berapa biaya menjalankannya

  • Vercel free tier : 100GB bandwidth, 1 juta invokasi serverless per bulan. Render gambar dihitung sebagai satu invokasi.
  • Cloudflare Workers free tier : 100K permintaan/hari, 10ms waktu CPU per permintaan (render bisa melebihi itu di Workers, makanya Vercel sebagai primer).
  • Supabase free tier : 500MB basis data, 5GB bandwidth. Cukup untuk ribuan pemain.

Seluruh backend berjalan dengan biaya nol hingga volume signifikan. Satu-satunya titik gesekan adalah batas CPU Cloudflare Workers -- render gambar intensif CPU karena WASM, makanya strateginya Vercel sebagai primer dan Workers sebagai CDN failover.


3 hal yang layak diingat

  1. URL sebagai keadaan permainan bukan sekadar trik manis -- ini batasan yang dipaksakan Discord (tombol punya batas 100 char) yang memaksa arsitektur stateless dengan kompresi RLE + token sesi sebagai fallback. Batasannya mendikte desain.

  2. Cache WASM dengan eviksi eksplisit : PhotonImage mengalokasikan di luar heap JavaScript dan tidak akan pernah di GC tanpa .free(). Menghubungkan freePhoton pada eviksi LRU itu RAII dalam JavaScript. Ini diam-diam di kode, tapi tanpanya worker bocor di produksi.

  3. Bot Discord serverless tanpa WebSocket : ini kurang dikenal daripada pendekatan gateway WebSocket, tapi untuk bot yang melakukan pemrosesan stateless (setiap interaksi independen), Interactions Endpoint jauh lebih unggul -- tanpa reconnect, tanpa heartbeat, tanpa proses yang dipelihara. Discord menangani ketersediaan di sisi infra mereka.


Repo : fox3000foxy/konosuba-rpg

Lisensi source-available kustom -- tanpa redistribusi, bebas dipakai.

मैंने konosuba-rpg का कोड पढ़ने में सप्ताहांत बिताया और यह मिला

Discord turn by turn RPG जहाँ हर क्रिया तुरंत WebP छवि बनाती है : खेल स्थिति के रूप में URL, नियतात्मक RNG, WASM पाइपलाइन, 5 स्तर cache, serverless बॉट।

मैंने konosuba-rpg का कोड पढ़ने में सप्ताहांत बिताया और यह मिला

मैं यह प्रोजेक्ट काफी समय से बनाए रखता हूँ, लेकिन शांत दिमाग से अपना कोड फिर से पढ़ना हमेशा शिक्षाप्रद होता है। konosuba-rpg एक Discord turn by turn RPG है जहाँ हर क्रिया तुरंत WebP छवि बनाती है। यह असली संयोजित छवि है, sprite, जीवन पट्टियों, युद्ध संदेशों के साथ, सब कुछ।

स्टैक : TypeScript, Hono, Vercel, Cloudflare Workers, Supabase। पूरी तरह मुफ्त होस्टिंग। और Discord बॉट स्थायी सर्वर के बिना चलता है। यह पोस्ट समझाता है कि यह सब एक साथ कैसे टिका है।

खेल की प्रारंभिक स्थिति


बुनियादी डिजाइन : खेल स्थिति के रूप में URL

पहली चीज जो चुभती है : gameplay के लिए serveur तरफ कोई स्थिति नहीं है। युद्ध की पूरी स्थिति URL में समाती है।

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

seed के बाद हर खंड खेली क्रिया है। serveur यह URL पाता है, शुरुआत से फिर शुरू करता है, सभी क्रियाएँ क्रम से फिर से खेलता है, और ठीक उस क्षण के युद्ध की छवि लौटाता है। कोई सत्र नहीं, किसी उपयोगकर्ता से जुड़ी RAM में कोई स्थिति नहीं।

Discord इंटरैक्टिव बटन से काम करता है, जब खिलाड़ी "Attaquer" दबाता है, तो Discord serveur को बटन का custom_id भेजता है। इस custom_id में जोड़ी नई क्रिया के साथ युद्ध का संपीड़ित URL होता है। serveur शून्य से सब फिर से गणना करता है और अद्यतन छवि लौटाता है।

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// फंक्शन के बाहर पूर्व संकलित, हर कॉल पर फिर से नहीं बनाया जाता

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6वाँ खंड, 8096 मानों पर hash किया गया
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

फंक्शन के बाहर पूर्व संकलित Set एक विवरण है, लेकिन यह edge संदर्भ में हर आह्वान पर संरचना फिर से बनाने से बचाता है जहाँ मॉड्यूल फिर से मूल्यांकित हो सकते हैं।

RNG : संशोधित RC4

बेतरतीब जनरेटर RC4 कार्यान्वयन (stream एन्क्रिप्शन एल्गोरिदम) है जिसे PRNG में मोड़ा गया है।

export class Random {
  private S: number[]; // 256 प्रविष्टियों की तालिका
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // S[i] और S[j] अदला बदली
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

RC4 क्यों ? क्योंकि यह सही वितरण और उचित seed टक्कर प्रतिरोध वाला नियतात्मक PRNG है। वही seed = संख्याओं का वही क्रम = हर बार वही युद्ध। यह अपना URL रखकर किसी भी युद्ध को "फिर से खेलने" देता है, और गारंटी देता है कि दो अलग serveur (Vercel + Cloudflare) उसी URL के लिए बिल्कुल वही परिणाम बनाते हैं।


Discord की 100 अक्षर सीमा की समस्या

Discord बटन के custom_id पर 100 अक्षर सीमा थोपता है। कुछ दस क्रियाओं के बाद, युद्ध URL इस सीमा को आसानी से पार कर जाता है।

दो तंत्र इसका उत्तर देते हैं।

1. क्रियाओं का RLE संपीड़न

क्रियाएँ एक अक्षर से एन्कोड की जाती हैं (a=attack, d=defend, h=hug...) और run-length encoding से संपीड़ित होती हैं :

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

सरल, लेकिन जब खिलाड़ी Attaque x10 spam करता है तो यह aaaaaaaaaa (10 अक्षर) से a10 (3 अक्षर) जाता है। UI में "Attaquer x4" और "Attaquer x10" बटन ठीक इसके लिए मौजूद हैं, युद्ध तेज करने के साथ payload अच्छा संपीड़ित करना।

2. जब संपीड़न काफी न हो तो सत्र token

अगर संपीड़ित payload अभी भी बहुत लंबा है, तो वह छोटे token के साथ आधार में संग्रहित होता है :

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // battle_key से payload समूह करता है, Supabase में batch डालता है
  // custom_id को "gs.{token}:{userId}" से बदलता है
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // जरूरत न होने पर कोई lookup नहीं
  }
  // पहले स्मृति में lookup, फिर अनुपस्थित होने पर Supabase
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // स्वामित्व, TTL (7 दिन), और turn_version जाँचता है (पुरानी स्थिति फिर से खेलने से रोकता है)
}

सत्रों का TTL 7 दिन है और हर 10 मिनट स्वचालित pruning। turnVersion जाँच पुरानी स्थिति फिर से खेलने से रोकती है अगर खिलाड़ी खेल में आगे बढ़ चुका है, आकस्मिक "पीछे जाने" के खिलाफ विवेकपूर्ण सुरक्षा।

स्मृति में दो Map (tokenToSession, latestTurnByBattle) छवि cache वाला globalThis as unknown as GameSessionGlobals पैटर्न इस्तेमाल करते हैं, उन्हीं कारणों से जो हम नीचे देखेंगे।


छवि रेंडर पाइपलाइन

Slime के खिलाफ युद्ध शुरुआत

/konosuba-rpg/:lang/* route JSON नहीं लौटाती। वह माँग पर उत्पन्न WebP छवि लौटाती है।

पाइपलाइन 3 संयोजित layer में व्यवस्थित है :

Background (board + frame)
    +
Characters layer (खिलाड़ी sprite + mob, स्थिर स्थितियाँ)
    +
UI overlay (HP पट्टियाँ, संदेश, Satori → SVG → PNG से व्यक्तिगत आइकन)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background : दो स्थिर छवियाँ (पट्ट और फ्रेम), filesystem से लोड और एक बार संयोजित।

Characters layer : sprite गणना निर्देशांक अनुसार स्थित हैं। मरे खिलाड़ी बाहर हैं (activeSlots = slots.filter(s => playerHp[s.i] > 0))। शत्रु sprite कस्टम flipX से क्षैतिज mirror किए गए हैं, बाहरी निर्भरता के बजाय pixel दर pixel लूप।

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay : यह भारी हिस्सा है। इंटरफेस JSX (जीवन पट्टियाँ, पाठ, आइकन) Satori से React जैसे वर्णित है, SVG में रेंडर, @cf-wasm/resvg से PNG में बदला, फिर अंतिम संयोजन के लिए Photon में आयात। Satori + resvg दो WASM मॉड्यूल हैं जो edge-light flag के साथ Cloudflare Workers के लिए विशेष संकलित हैं।

Défense क्रिया

चलता युद्ध

Câlin क्रिया


cache तंत्र, सबसे मेहनत वाला हिस्सा

5 अलग cache स्तर हैं। हर एक पाइपलाइन की अलग दानेदारी लक्षित करता है।

// renderImage.ts , सब globalThis पर
G.__imageCache  ??= {} as Record; // कच्चे assets
G.__base64Cache ??= {} as Record;       // assets का base64 (Satori के लिए)
G.__fontCache   ??= {} as Record; // font
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

globalThis पर ??= पैटर्न : edge worker में JavaScript मॉड्यूल कुछ कॉन्फ़िगरेशन पर अनुरोधों के बीच फिर से मूल्यांकित हो सकते हैं। ??= के साथ globalThis पर cache संग्रहित करना गारंटी देता है कि वे इन पुनर्मूल्यांकन से बिना फिर से बनाए बचते हैं।

WASM निष्कासन

Photon छवि cache (photonCache, layerCache, uiPhotonCache) निष्कासन callback इस्तेमाल करते हैं :

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* पहले ही मुक्त */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage WASM ऑब्जेक्ट है जिसकी स्मृति रैखिक WASM तरफ आवंटित है, JavaScript GC के बाहर। यह स्मृति आवंटित रहती है जब तक .free() स्पष्ट न बुलाया जाए। LRU निष्कासन .free() अपने आप trigger करता है, यह JavaScript में लाया RAII है।

cache कुंजियाँ जानबूझकर lossy हैं

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

characters layer कुंजी HP का सटीक मान एन्कोड नहीं करती, सिर्फ 1 (जीवित) या 0 (मरा)। क्योंकि 40 HP वाले खिलाड़ी और 15 HP वाले खिलाड़ी का sprite समान है। cache hit इसलिए किसी भी नुकसान से बचता है जब तक कोई गिरता नहीं।

UI कुंजी इसके विपरीत सटीक HP एन्कोड करती है (जीवन पट्टी हर चोट पर बदलती है) और संदेशों का hash :

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // हस्ताक्षरित 32-बिट पूर्णांक
  }
  return hash.toString(16);
}

Math.imul गुणा को 32 बिट पूर्णांक में बाध्य करता है, जो float64 रूपांतरण से बचाता है और स्थिर बहुपद hash देता है। यह इसके लिए बाहरी निर्भरता जोड़ने से बचाता है।

बिना stack overflow base64 रूपांतरण

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 octet
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) बड़ी छवियों पर stack overflow कर सकता है क्योंकि तर्क call stack पर भेजे जाते हैं। 32Ko chunking इससे बचाता है। परिणाम cache किया जाता है, एक ही छवि का base64 रूपांतरण worker उदाहरण में सिर्फ एक बार किया जाता है।


STRIPPER.md, क्रमिक await का ऑडिट

repo में STRIPPER.md फाइल है जो await समानांतरीकरण ऑडिट प्रलेखित करती है। इसमें दर्ज कुछ उदाहरण :

  • खिलाड़ी प्रोफाइल लोड 3 Supabase अनुरोध क्रम में करता था (प्रगति, run सारांश, achievement)। उन्हें Promise.all में डाला गया, उनके बीच कोई निर्भरता नहीं।
  • युद्ध अंत पुरस्कार वितरण (सहायक वस्तुएँ + उपभोग्य) क्रमिक था। वैसे ही समानांतर किया गया।
  • बटन के लिए सत्र token निर्माण समूह दर समूह होता था। स्वतंत्र समूह अब समानांतर बनाए जाते हैं।
// progressionService.ts , पहले (क्रमिक)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// बाद में
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

कुछ क्रांतिकारी नहीं, लेकिन serverless संदर्भ में जहाँ उत्तर समय का हर मिलीसेकंड बिल किया जाता है (या cold start में योगदान देता है), यह मायने रखता है।


स्थायी सर्वर रहित Discord बॉट

Victoire

अक्सर गलत समझा बिंदु : Discord बॉट को जरूरी नहीं स्थायी WebSocket कनेक्शन चाहिए। Discord विकल्प देता है : Interactions Endpoint URL। तुम Discord को HTTPS URL देते हो, और Discord हर interaction (slash command, बटन, autocomplete) के लिए POST भेजता है।

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // Discord ping
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord POST भेजता है, handler Vercel फंक्शन या Cloudflare Worker पर 50-200ms चलता है, जवाब देता है, और खत्म। बनाए रखने को कोई स्थायी कनेक्शन नहीं, जलाए रखने को कोई serveur नहीं। पूरा Discord बॉट Vercel free tier पर होस्ट है।

Ed25519 जाँच (discord-interactions से verifyKey) अनिवार्य है, Discord हेडर में हस्ताक्षर भेजता है जिसे तुम्हें मान्य करना होगा, वरना वह endpoint अस्वीकार करता है।

विशेष एनीमेशन, एकमात्र जानबूझकर await

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 सेकंड
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

3 सेकंड का यह स्वैच्छिक विलंब STRIPPER.md में जानबूझकर प्रलेखित है। Megumin का विशेष हमला (Explosion) Discord तरफ एनीमेशन रखता है, संदेश पहले मध्यवर्ती दृश्य से अद्यतन होता है, फिर 3 सेकंड बाद परिणाम से संशोधित होता है। यही एकमात्र मामला है जहाँ Vercel फंक्शन जानबूझकर जरूरत से ज्यादा चलता है।

विशेष हमला


दो प्लेटफॉर्म पर परिनियोजन योग्यता

वही codebase बिना संशोधन Vercel (Node.js) और Cloudflare Workers (V8 isolates) पर चलता है :

// worker.ts , Cloudflare entrypoint
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // CF secret process.env में डालता है
    return app.fetch(request, env, ctx);
  }
};

// index.ts , Vercel/Node entrypoint
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

मुख्य अंतर : स्थिर asset। Vercel पर, वे filesystem से पढ़े जाते हैं (/var/task//articles/assets/)। Cloudflare Workers पर, वे ASSETS binding (CF स्थिर asset) से गुजरते हैं, HTTPS mirror (fox3000foxy.com/konosuba-rpg/assets) की ओर fallback के साथ। assetLoader.ts में getAssetBytes filesystem पहले, फिर fetch आजमाकर दोनों रास्ते संभालता है।

WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) के हर runtime के लिए अलग build हैं। पैकेज नाम में edge-light flag Cloudflare Workers संगत build दर्शाता है, जो runtime पर new WebAssembly.Module() अनुमति नहीं देता, WASM पूर्व संकलित होना चाहिए।


प्रगति : XP, स्तर, आत्मीयता

Boss, 650 HP

meta-प्रगति Supabase free tier पर टिकी है। schema में players तालिका (वैश्विक XP, स्तर, gold), character_progress (Darkness, Aqua, Megumin के लिए प्रति पात्र XP/स्तर/आत्मीयता), runs (युद्ध इतिहास), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions हैं।

प्रगति मॉडल सरल है :

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // प्रति स्तर 100 XP
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // प्रति स्तर +20% stats
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // प्रति तारा 20 अंक, अधिकतम 5 तारे
  return 1.2 ** stars; // घातांकीय प्रगति
}

ये कारक हर processGame शुरुआत में पात्र stats पर लागू होते हैं। Kazuma खिलाड़ी के वैश्विक स्तर का पालन करता है, अन्य तीनों का अपना XP/स्तर है। आत्मीयता (किसी पात्र से जुड़े drop पाकर जीती) उसकी stats स्वतंत्र गुणा करती है।

उपचार

drop तंत्र कठिनाई से भारित loot table इस्तेमाल करता है :

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...Legendary तक
};

परीक्षण

तीन सुइट : इकाई, perf, और leak।

leak परीक्षण विशेष रूप से सीधा है :

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // अधिकतम 20MB heap वृद्धि
});

processGame के 1200 दोहराव, पहले और बाद में बाध्य GC, heap delta < 20MB। अगर यह परीक्षण पास होता है, तो processGame लीक नहीं करता। render परीक्षण (renderImage.spec.ts) बल्कि व्यावहारिक सीमा के नीचे निष्पादन समय जाँचता है।

पूरा पाइपलाइन profile करने के लिए bench.ts स्क्रिप्ट भी है :

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

RENDER_PERF=1 के साथ, हर service में withPerf wrapper timing log करता है :

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // निष्क्रिय होने पर zero overhead
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger no-op लौटाता है अगर DEV_MODE और RENDER_PERF 1 पर नहीं हैं। production में कोई overhead नहीं।


इसे चलाने की लागत क्या है

  • Vercel free tier : 100GB bandwidth, प्रति माह 1M serverless invocation। छवि render invocation गिना जाता है।
  • Cloudflare Workers free tier : 100K अनुरोध/दिन, प्रति अनुरोध 10ms CPU time (WASM के कारण render Workers पर इससे पार कर सकता है, इसीलिए Vercel प्राथमिक)।
  • Supabase free tier : 500MB आधार, 5GB bandwidth। हजारों खिलाड़ियों के लिए काफी।

पूरा backend महत्वपूर्ण आयतन तक शून्य लागत चलता है। एकमात्र घर्षण बिंदु Cloudflare Workers CPU सीमा है, छवि render WASM के कारण CPU गहन है, इसीलिए Vercel प्राथमिक और Workers failover CDN रणनीति है।


3 चीजें जो याद रखने योग्य हैं

  1. खेल स्थिति के रूप में URL सिर्फ अच्छी चाल नहीं है, यह Discord थोपी बाधा है (बटन 100 अक्षर सीमा) जिसने fallback के रूप में RLE संपीड़न + सत्र token वाली stateless आर्किटेक्चर बाध्य की। बाधा ने डिजाइन तय किया।

  2. स्पष्ट निष्कासन वाला WASM cache : PhotonImage JavaScript heap के बाहर आवंटित करते हैं और .free() के बिना कभी GC नहीं होंगे। LRU निष्कासन पर freePhoton जोड़ना JavaScript में RAII है। कोड में विवेकपूर्ण है, लेकिन इसके बिना worker production में लीक करेगा।

  3. बिना WebSocket serverless Discord बॉट : यह WebSocket gateway दृष्टिकोण से कम ज्ञात है, लेकिन बॉट के लिए जो stateless प्रोसेसिंग करता है (हर interaction स्वतंत्र है), Interactions Endpoint कड़ाई से बेहतर है, कोई पुनः जुड़ाव नहीं, कोई heartbeat नहीं, बनाए रखने को कोई प्रक्रिया नहीं। Discord उनकी infra तरफ उपलब्धता संभालता है।


Repo : fox3000foxy/konosuba-rpg

कस्टम source-available लाइसेंस, कोई पुनर्वितरण नहीं, free to use।

قضيت عطلة نهاية أسبوع أقرأ كود konosuba-rpg وإليكم ما وجدت

RPG أدوار Discord حيث يولد كل فعل صورة WebP لحظيا : الرابط

قضيت عطلة نهاية أسبوع أقرأ كود konosuba-rpg وإليكم ما وجدت

أصون هذا المشروع منذ مدة، لكن إعادة قراءة كود المرء برأس مرتاح مفيدة دائما. konosuba-rpg هو RPG أدوار في Discord حيث يولد كل فعل صورة WebP لحظيا. إنها صورة مركبة فعلا، مع sprites وأشرطة حياة ورسائل قتال، كل شيء.

الحزمة : TypeScript وHono وVercel وCloudflare Workers وSupabase. استضافة مجانية تماما. ويعمل بوت Discord دون خادم دائم. يشرح هذا المنشور كيف يتماسك كل ذلك.

الحالة الأولية للعبة


التصميم الأساسي : الرابط كحالة اللعبة

أول ما يدهش : لا توجد حالة جهة الخادم للعب. حالة القتال الكاملة تقف في الرابط.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

كل مقطع بعد البذرة فعل ملعوب. يستلم الخادم هذا الرابط، وينطلق من البداية، ويعيد تشغيل كل الأفعال بالترتيب، ويعيد صورة القتال في تلك اللحظة بالضبط. لا جلسة، ولا حالة في RAM مرتبطة بمستخدم.

يعمل Discord بأزرار تفاعلية، فعندما يضغط اللاعب "هجوم"، يرسل Discord للخادم custom_id الخاص بالزر. يحوي custom_id الرابط المضغوط للقتال مع الفعل الجديد المضاف. يعيد الخادم حساب كل شيء من الصفر ويعيد الصورة المحدثة.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// مُجمَّع مسبقا خارج الدالة، لا يُعاد إنشاؤه مع كل استدعاء

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // البذرة = المقطع السادس، مُجزَّأة على 8096 قيمة
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Set المُجمَّع مسبقا خارج الدالة تفصيل، لكنه يتجنب إعادة بناء البنية مع كل استدعاء في سياق edge حيث يمكن إعادة تقييم الوحدات.

RNG : نسخة معدلة من RC4

المولد العشوائي تنفيذ RC4 (خوارزمية تشفير تدفق) مُحوَّل إلى PRNG.

export class Random {
  private S: number[]; // جدول 256 مدخلا
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // تبديل S[i] وS[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

لماذا RC4 ؟ لأنه PRNG حتمي بتوزيع صحيح ومقاومة معقولة لتصادمات البذور. نفس البذرة = نفس تسلسل الأرقام = نفس القتال في كل مرة. وهذا يسمح "بإعادة تشغيل" أي قتال بالاحتفاظ برابطه، ويضمن أن خادمين مختلفين (Vercel + Cloudflare) ينتجان بالضبط نفس النتيجة لنفس الرابط.


مشكلة حد 100 حرف في Discord

يفرض Discord حد 100 حرف على custom_id الخاصة بالأزرار. بعد بضع عشرات من الأفعال، يتجاوز رابط قتال هذا الحد بارتياح.

تجيب آليتان عن ذلك.

1. ضغط RLE للأفعال

تُرمَّز الأفعال بحرف واحد (a=هجوم وd=دفاع وh=عناق...) وتُضغَط بترميز طول التشغيل :

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

بسيط، لكن عندما يغمر اللاعب الهجوم x10 ينتقل من aaaaaaaaaa (10 حروف) إلى a10 (3 حروف). زرا "هجوم x4" و"هجوم x10" في الواجهة موجودان لهذا بالضبط، تسريع القتال مع ضغط الحمولة جيدا.

2. رموز جلسة عندما لا يكفي الضغط

إذا بقيت الحمولة المضغوطة طويلة أكثر من اللازم، تُخزَّن في القاعدة مع رمز قصير :

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // يجمّع الحمولات حسب battle_key، ويدخل دفعيا في Supabase
  // يستبدل custom_id بـ"gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // لا بحث إذا لم يلزم
  }
  // البحث في الذاكرة أولا، ثم Supabase إذا غاب
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // يتحقق من الملكية وTTL (7 أيام) وturn_version (يتجنب إعادة تشغيل حالة قديمة)
}

للجلسات TTL من 7 أيام وتقليم آلي كل 10 دقائق. يمنع فحص turnVersion إعادة تشغيل حالة متقادمة إذا تقدم اللاعب في المباراة، حماية خفية ضد "الرجوع" العرضي.

تستخدم خريطتا الذاكرة (tokenToSession وlatestTurnByBattle) نفس نمط globalThis as unknown as GameSessionGlobals مثل كاشات الصور، لنفس الأسباب التي سنراها أدناه.


خط عرض الصور

بداية قتال ضد Slime

لا يعيد طريق /konosuba-rpg/:lang/* JSON. بل يعيد صورة WebP مولدة عند الطلب.

الخط منظم في 3 طبقات مركبة :

Background (لوحة + إطار)
    +
Characters layer (sprites لاعبين + وحش، مواضع ثابتة)
    +
UI overlay (أشرطة HP ورسائل وأيقونات شخصيات عبر Satori ← SVG ← PNG)
    ↓
Photon.watermark() × 2
    ↓
مخرجات WebP

الخلفية : صورتان ثابتتان (اللوحة والإطار)، تُحمَّلان من نظام الملفات وتُركَّبان مرة.

طبقة الشخصيات : توضع sprites حسب إحداثيات محسوبة. يُستبعَد اللاعبون الموتى (activeSlots = slots.filter(s => playerHp[s.i] > 0)). تُعكَس sprites العدو أفقيا مع flipX مخصص، حلقة بكسل ببكسل بدلا من تبعية خارجية.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

تراكب الواجهة : إنه الجزء الثقيل. واجهة JSX (أشرطة حياة ونصوص وأيقونات) موصوفة بنمط React مع Satori، وتُعرض SVG، وتُحوَّل PNG بواسطة @cf-wasm/resvg، ثم تُستورَد في Photon للتركيب النهائي. Satori وresvg وحدتا WASM مُجمَّعتان خصيصا لـCloudflare Workers مع علم edge-light.

فعل دفاع

قتال جارٍ

فعل عناق


نظام الكاش، الجزء الأكثر عملا

هناك 5 مستويات كاش متميزة. يستهدف كل واحد دقة مختلفة من الخط.

// renderImage.ts، كلها على globalThis
G.__imageCache  ??= {} as Record; // أصول خام
G.__base64Cache ??= {} as Record;       // base64 الأصول (لـSatori)
G.__fontCache   ??= {} as Record; // خطوط
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

نمط ??= على globalThis : يمكن إعادة تقييم وحدات JavaScript في workers الحافة بين الطلبات على بعض الإعدادات. تخزين الكاشات على globalThis مع ??= يضمن نجاتها من إعادة التقييم دون إعادة إنشائها.

إخلاء WASM

تستخدم كاشات صور Photon (photonCache وlayerCache وuiPhotonCache) رد إخلاء :

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* مُحرَّرة فعلا */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage كائن WASM بذاكرة مخصصة جهة WASM الخطية، خارج GC الخاص بـJavaScript. تبقى هذه الذاكرة مخصصة ما لم يُستدعَ .free() صراحة. يحفّز إخلاء LRU دالة .free() تلقائيا، إنه RAII منقول إلى JavaScript.

مفاتيح الكاش ضبابية عمدا

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

لا يرمّز مفتاح طبقة الشخصيات قيمة HP الدقيقة، فقط 1 (حي) أو 0 (ميت). لأن sprite لاعب بـ40 HP ولاعب بـ15 HP متماثل. إذن تنجو إصابة كاش من أي ضرر ما لم يسقط أحد.

بينما يرمّز مفتاح الواجهة HP الدقيقة (يتغير شريط الحياة مع كل ضربة) وhash للرسائل :

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // عدد صحيح 32-bit موقَّع
  }
  return hash.toString(16);
}

يجبر Math.imul الضرب في عدد صحيح 32 بت، مما يتجنب تحويلات float64 ويعطي hash متعدد الحدود مستقر. وهذا يتجنب إضافة تبعية خارجية لذلك.

تحويل base64 دون فيض كومة

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 بايت
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

يمكن أن يسبب String.fromCharCode(...largeArray) فيض كومة على الصور الكبيرة لأن الوسائط تُمرَّر على كومة الاستدعاء. يتجنب التقطيع بـ32Ko ذلك. تُوضَع النتيجة في الكاش، فلا يُجرَى تحويل base64 لنفس الصورة إلا مرة لكل نسخة worker.


STRIPPER.md، تدقيق awaits المتسلسلة

هناك ملف STRIPPER.md في المستودع يوثق تدقيق موازاة await. بعض أمثلة ما هو مدوَّن فيه :

  • تحميل ملف اللاعب كان ينفذ 3 طلبات Supabase متسلسلة (تقدم وملخص جولة وإنجازات). نُقلت إلى Promise.all، لا تبعية بينها.
  • توزيع مكافآت نهاية القتال (إكسسوارات + مستهلكات) كان متسلسلا. وُوزي بنفس الطريقة.
  • إنشاء رموز جلسة للأزرار كان يتم مجموعة بمجموعة. تُنشأ الآن المجموعات المستقلة بالتوازي.
// progressionService.ts، قبل (متسلسل)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// بعد
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

لا شيء ثوري، لكن في سياق serverless حيث كل مللي ثانية من زمن الاستجابة مُفوترَة (أو تسهم في الإقلاع البارد)، يُحتسَب ذلك.


بوت Discord دون خادم دائم

نصر

نقطة يُساء فهمها غالبا : لا يتطلب بوت Discord بالضرورة اتصال WebSocket دائما. يقترح Discord بديلا : Interactions Endpoint URL. تعطي رابط HTTPS لـDiscord، ويرسل لك Discord POST مع كل تفاعل (slash command وزر وإكمال تلقائي).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping الخاص بـDiscord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

يرسل Discord POST، يعمل المعالج 50-200ms على دالة Vercel أو Cloudflare Worker، ويرد، وانتهى. لا اتصال دائم يُصان، ولا خادم يُبقَى مشتعلا. بوت Discord كله مستضاف على المستوى المجاني في Vercel.

التحقق Ed25519 (verifyKey من discord-interactions) إلزامي، فيرسل Discord توقيعا في الترويسات يجب التحقق منه، وإلا يرفض نقطة النهاية.

الحركة الخاصة، await الوحيد المقصود

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 ثوانٍ
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

هذا التأخير الطوعي لـ3 ثوانٍ موثق في STRIPPER.md كمقصود. لهجوم Megumin الخاص (Explosion) حركة جهة Discord، يُحدَّث أولا الرسالة ببصري وسيط، ثم تُعدَّل بعد 3 ثوانٍ بالنتيجة. إنها الحالة الوحيدة حيث تعمل دالة Vercel طوعا أطول من اللازم.

هجوم خاص


قابلية النشر على منصتين

يعمل نفس قاعدة الكود على Vercel (Node.js) وعلى Cloudflare Workers (V8 isolates) دون تعديل :

// worker.ts، مدخل Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // يحقن أسرار CF في process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts، مدخل Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

الفرق الرئيسي : الأصول الثابتة. على Vercel، تُقرَأ من نظام الملفات (/var/task//articles/assets/). على Cloudflare Workers، تمر عبر ربط ASSETS (أصول CF الثابتة) مع fallback نحو مرآة HTTPS (fox3000foxy.com/konosuba-rpg/assets). تدير دالة getAssetBytes في assetLoader.ts المسارين بتجربة نظام الملفات أولا، ثم fetch.

لدى WASM (@cf-wasm/photon/edge-light و@cf-wasm/resvg) بناءات منفصلة لكل runtime. يشير علم edge-light في اسم الحزمة للبناء المتوافق مع Cloudflare Workers، الذي لا يسمح بـnew WebAssembly.Module() عند التشغيل، فيجب تجميع WASM مسبقا.


التقدم : XP ومستويات وألفة

زعيم، 650 HP

يرتكز التقدم الوصفي على المستوى المجاني في Supabase. يتضمن المخطط جدول players (XP شامل ومستوى وذهب) وcharacter_progress (XP/مستوى/ألفة لكل شخصية لـDarkness وAqua وMegumin) وruns (سجل القتالات) وinventory_items وdaily_quests_progress وachievements_unlocked وgame_sessions.

نموذج التقدم بسيط :

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP لكل مستوى
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% إحصاءات لكل مستوى
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 نقطة لكل نجمة، 5 نجوم حدا
  return 1.2 ** stars; // تقدم أسّي
}

تُطبَّق هذه العوامل على إحصاءات الشخصيات في بداية كل processGame. يتبع Kazuma المستوى الشامل للاعب، وللثلاثة الآخرين XP/مستوى خاص لكل واحد. تضرب الألفة (المكتسبة باسترجاع إسقاطات مرتبطة بشخصية) إحصاءاته مستقلة.

شفاء

يستخدم نظام الإسقاطات جداول غنائم موزونة بالصعوبة :

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...حتى Legendary
};

الاختبارات

ثلاث سلاسل : وحدات وأداء وتسريبات.

اختبار التسريب مباشر خصوصا :

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // حد 20MB نمو كومة
});

1200 تكرار لـprocessGame، وGC مُجبَر قبل وبعد، وفرق كومة < 20MB. إذا نجح هذا الاختبار، فلا يسرّب processGame. بينما يتحقق اختبار العرض (renderImage.spec.ts) من زمن التنفيذ تحت عتبة عملية.

وهناك أيضا سكربت bench.ts لتنميط الخط الكامل :

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

مع RENDER_PERF=1، يسجل الغلاف withPerf في كل خدمة التوقيتات :

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // صفر عبء إذا عُطّل
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

يعيد createPerfLogger عمليات معدومة إذا لم تكن DEV_MODE وRENDER_PERF على 1. لا عبء في الإنتاج.


ما kost تشغيله

  • المستوى المجاني في Vercel : 100GB عرض نطاق و1M استدعاء serverless شهريا. يُحتسَب عرض الصورة كاستدعاء.
  • المستوى المجاني في Cloudflare Workers : 100K طلب/يوم و10ms زمن CPU لكل طلب (قد يتجاوز العرض ذلك على Workers، ومن هنا Vercel أوليا).
  • المستوى المجاني في Supabase : 500MB قاعدة و5GB عرض نطاق. كافٍ لآلاف اللاعبين.

يعمل backend كله بkost صفر حتى حجم ملحوظ. نقطة الاحتكاك الوحيدة هي حد CPU في Cloudflare Workers، فعرض الصور مكثف CPU بسبب WASM، ومن هنا استراتيجية Vercel أوليا وWorkers كـCDN احتياطي.


الأشياء الثلاثة الجديرة بالتذكر

  1. الرابط كحالة لعب ليس مجرد حيلة لطيفة، بل قيد فرضه Discord (للأزرار حد 100 حرف) الذي أجبر بنية عديمة الحالة مع ضغط RLE + رمز جلسة كاحتياطي. أملى القيد التصميم.

  2. كاش WASM مع إخلاء صريح : تخصّص PhotonImage خارج كومة JavaScript ولن تُجمَع قمامة أبدا دون .free(). ربط freePhoton بإخلاء LRU هو RAII في JavaScript. إنه خفي في الكود، لكن دونه كان worker سيسرّب في الإنتاج.

  3. بوت Discord بلا خادم دون WebSocket : إنه أقل شهرة من مقاربة بوابة WebSocket، لكن لبوت يقوم بمعالجة عديمة الحالة (كل تفاعل مستقل)، نقطة النهاية Interactions متفوقة تماما، لا إعادة اتصال ولا نبضات ولا عملية تُصان. يدير Discord التوافر جهة بنيته.


المستودع : fox3000foxy/konosuba-rpg

رخصة custom متاحة المصدر، لا إعادة توزيع، والاستخدام مجاني.

Tôi đã dành cuối tuần đọc mã konosuba-rpg và đây là những gì tôi tìm thấy

RPG theo lượt trên Discord nơi mỗi hành động sinh ảnh WebP

Tôi đã dành cuối tuần đọc mã konosuba-rpg và đây là những gì tôi tìm thấy

Tôi duy trì dự án này một thời gian, nhưng đọc lại chính mã của mình với đầu óc tỉnh táo luôn bổ ích. konosuba-rpg là RPG theo lượt trên Discord nơi mỗi hành động sinh ảnh WebP tức thì. Đó là ảnh dựng thật, với sprite, thanh máu, tin nhắn trận -- đủ hết.

Stack : TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Host hoàn toàn miễn phí. Và bot Discord chạy không cần máy chủ bền vững. Bài này giải thích mọi thứ gắn nhau thế nào.

Trạng thái game ban đầu


Thiết kế nền : URL làm trạng thái game

Điều đầu tiên đập vào mắt : không có trạng thái phía máy chủ cho gameplay. Toàn bộ trạng thái trận nằm trong URL.

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

Mỗi đoạn sau seed là một hành động đã chơi. Máy chủ nhận URL này, chạy lại từ đầu, diễn lại mọi hành động theo thứ tự, và trả về ảnh trận tại đúng thời điểm đó. Không phiên, không trạng thái RAM gắn với người dùng.

Discord chạy bằng nút tương tác -- khi người chơi nhấn "Tấn công", Discord gửi cho máy chủ custom_id của nút. custom_id này chứa URL trận đã nén với hành động mới thêm vào. Máy chủ tính lại tất cả từ con số không và trả về ảnh cập nhật.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Tiền biên dịch ngoài hàm -- không tạo lại mỗi lần gọi

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = đoạn thứ 6, băm trên 8096 giá trị
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

Set tiền biên dịch ngoài hàm là chi tiết nhỏ, nhưng tránh dựng lại cấu trúc mỗi lần gọi trong ngữ cảnh edge nơi module có thể bị đánh giá lại.

RNG : RC4 biến tấu

Bộ sinh ngẫu nhiên là triển khai RC4 (thuật toán mã hóa dòng) bị dùng làm PRNG.

export class Random {
  private S: number[]; // bảng 256 mục
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // hoán đổi S[i] và S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

Vì sao RC4? Vì đó là PRNG xác định với phân bố ổn và kháng va chạm seed hợp lý. Cùng seed = cùng chuỗi số = cùng trận mỗi lần. Việc này cho phép "diễn lại" bất kỳ trận nào chỉ bằng URL, và đảm bảo hai máy chủ khác nhau (Vercel + Cloudflare) cho đúng cùng kết quả với cùng URL.


Bài toán giới hạn 100 ký tự của Discord

Discord áp giới hạn 100 ký tự lên custom_id của nút. Sau vài chục hành động, URL trận vượt giới hạn này ngon lành.

Hai cơ chế trả lời việc đó.

1. Nén RLE các hành động

Các hành động được mã hóa bằng một ký tự (a=attack, d=defend, h=hug...) và nén bằng mã hóa run-length :

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

Đơn giản, nhưng khi người chơi spam Tấn công x10 nó đi từ aaaaaaaaaa (10 ký tự) còn a10 (3 ký tự). Các nút "Tấn công x4" và "Tấn công x10" trong UI tồn tại đúng vì vậy -- tăng tốc trận đồng thời nén tốt payload.

2. Token phiên khi nén không đủ

Nếu payload đã nén vẫn quá dài, nó được lưu trong DB với token ngắn :

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Nhóm payload theo battle_key, chèn batch vào Supabase
  // Thay custom_id bằng "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Không lookup nếu không cần
  }
  // Lookup trong bộ nhớ trước, rồi Supabase nếu thiếu
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Kiểm tra sở hữu, TTL (7 ngày), và turn_version (tránh diễn lại trạng thái cũ)
}

Phiên có TTL 7 ngày và dọn tự động mỗi 10 phút. Kiểm tra turnVersion ngăn diễn lại trạng thái hết hạn nếu người chơi đã tiến trong ván -- bảo vệ kín đáo chống "quay lại" vô tình.

Hai Map trong bộ nhớ (tokenToSession, latestTurnByBattle) dùng cùng mẫu globalThis as unknown as GameSessionGlobals như cache ảnh, vì cùng lý do ta sẽ thấy bên dưới.


Pipeline render ảnh

Bắt đầu trận với Slime

Route /konosuba-rpg/:lang/* không trả JSON. Nó trả ảnh WebP sinh theo yêu cầu.

Pipeline tổ chức thành 3 lớp chồng :

Nền (bảng + khung)
    +
Lớp nhân vật (sprite người chơi + quái, vị trí cố định)
    +
Phủ UI (thanh HP, tin nhắn, biểu tượng nhân vật qua Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
Đầu ra WebP

Nền : hai ảnh cố định (bảng và khung), tải từ filesystem và chồng một lần.

Lớp nhân vật : sprite đặt theo tọa độ tính toán. Người chơi chết bị loại (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Sprite quái bị lật ngang bằng flipX custom -- vòng lặp từng pixel thay vì phụ thuộc ngoài.

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

Phủ UI : đây là phần nặng. JSX của giao diện (thanh máu, văn bản, biểu tượng) được mô tả kiểu React bằng Satori, render thành SVG, đổi sang PNG bằng @cf-wasm/resvg, rồi nhập vào Photon để chồng cuối. Satori + resvg là hai module WASM biên dịch riêng cho Cloudflare Workers với cờ edge-light.

Hành động Phòng thủ

Trận đang diễn ra

Hành động Ôm


Hệ thống cache -- phần trau chuốt nhất

Có 5 tầng cache riêng. Mỗi tầng nhắm độ hạt khác nhau của pipeline.

// renderImage.ts -- mọi thứ trên globalThis
G.__imageCache  ??= {} as Record; // asset thô
G.__base64Cache ??= {} as Record;       // base64 của asset (cho Satori)
G.__fontCache   ??= {} as Record; // phông
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Mẫu ??= trên globalThis : module JavaScript trong worker edge có thể bị đánh giá lại giữa các yêu cầu trên vài cấu hình. Lưu cache trên globalThis với ??= đảm bảo chúng sống sót qua các lần đánh giá lại mà không bị tạo lại.

Thu hồi WASM

Cache ảnh Photon (photonCache, layerCache, uiPhotonCache) dùng callback thu hồi :

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* đã giải phóng */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage là object WASM với bộ nhớ cấp phát phía tuyến tính WASM, ngoài GC JavaScript. Bộ nhớ này còn cấp phát chừng nào .free() chưa được gọi rõ. Thu hồi LRU kích hoạt .free() tự động -- đó là RAII mang sang JavaScript.

Khóa cache cố ý lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

Khóa lớp nhân vật không mã hóa giá trị HP chính xác -- chỉ 1 (sống) hay 0 (chết). Vì sprite của người chơi 40 HP và người chơi 15 HP giống hệt. Lần trúng cache vì vậy sống sót qua bất kỳ sát thương nào chừng nào không ai gục.

Khóa UI ngược lại mã hóa HP chính xác (thanh máu đổi mỗi đòn) và băm tin nhắn :

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // số nguyên 32-bit có dấu
  }
  return hash.toString(16);
}

Math.imul ép phép nhân thành số nguyên 32 bit, tránh chuyển đổi float64 và cho băm đa thức ổn định. Việc này tránh thêm phụ thuộc ngoài cho chuyện đó.

Đổi base64 không tràn ngăn xếp

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 octet
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) có thể gây tràn ngăn xếp trên ảnh lớn vì đối số truyền trên call stack. Chia đoạn 32Ko tránh việc đó. Kết quả được cache -- đổi base64 của cùng ảnh chỉ làm một lần cho mỗi instance worker.


STRIPPER.md -- kiểm toán await nối tiếp

Có tệp STRIPPER.md trong repo ghi lại kiểm toán song song hóa các await. Vài ví dụ được ghi :

  • Tải hồ sơ người chơi làm 3 yêu cầu Supabase nối tiếp (tiến trình, tóm tắt run, thành tựu). Chúng được chuyển sang Promise.all -- không phụ thuộc nhau.
  • Phân phối thưởng cuối trận (phụ kiện + vật phẩm) nối tiếp. Song song hóa tương tự.
  • Tạo token phiên cho nút làm từng nhóm. Các nhóm độc lập giờ tạo song song.
// progressionService.ts -- trước (nối tiếp)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// sau
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Không có gì cách mạng, nhưng trong ngữ cảnh serverless nơi mỗi mili giây thời gian phản hồi bị tính tiền (hay góp vào cold start), việc đó đáng kể.


Bot Discord không máy chủ bền vững

Chiến thắng

Điểm thường bị hiểu sai : bot Discord không nhất thiết cần kết nối WebSocket bền vững. Discord cung cấp lựa chọn : Interactions Endpoint URL. Bạn đưa URL HTTPS cho Discord, và Discord gửi bạn POST cho mỗi tương tác (slash command, nút, autocomplete).

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord gửi POST, handler chạy 50-200ms trên hàm Vercel hay Cloudflare Worker, trả lời, và xong. Không kết nối thường trực cần duy trì, không máy chủ cần giữ bật. Toàn bộ bot Discord host trên free tier Vercel.

Xác minh Ed25519 (verifyKey từ discord-interactions) bắt buộc -- Discord gửi chữ ký trong header mà bạn phải kiểm tra, nếu không nó từ chối endpoint.

Animation đặc biệt -- await cố ý duy nhất

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 giây
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

Trễ tự nguyện 3 giây này được ghi trong STRIPPER.md là cố ý. Đòn đặc biệt của Megumin (Explosion) có animation phía Discord -- tin nhắn đầu được cập nhật bằng hình ảnh trung gian, rồi sửa 3 giây sau bằng kết quả. Đó là trường hợp duy nhất hàm Vercel cố chạy lâu hơn cần thiết.

Đòn đặc biệt


Triển khai được trên hai nền tảng

Cùng codebase chạy trên Vercel (Node.js) và Cloudflare Workers (V8 isolates) không sửa đổi :

// worker.ts -- điểm vào Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // bơm secret CF vào process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- điểm vào Vercel/Node
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

Khác biệt chính : asset tĩnh. Trên Vercel, chúng đọc từ filesystem (/var/task//articles/assets/). Trên Cloudflare Workers, chúng đi qua binding ASSETS (asset tĩnh CF) với dự phòng về mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). getAssetBytes trong assetLoader.ts xử lý cả hai đường bằng cách thử filesystem trước, rồi fetch.

Các WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) có build riêng cho mỗi runtime. Cờ edge-light trong tên gói chỉ build tương thích Cloudflare Workers, không cho phép new WebAssembly.Module() lúc runtime -- WASM phải tiền biên dịch.


Tiến trình : XP, cấp, thiện cảm

Một boss, 650 HP

Meta-tiến trình dựa trên free tier Supabase. Schema gồm bảng players (XP toàn cục, cấp, gold), character_progress (XP/cấp/thiện cảm cho mỗi nhân vật Darkness, Aqua, Megumin), runs (lịch sử trận), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Mô hình tiến trình đơn giản :

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP cho mỗi cấp
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stat cho mỗi cấp
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 điểm cho mỗi sao, tối đa 5 sao
  return 1.2 ** stars; // tiến trình lũy thừa
}

Các hệ số này áp vào stat nhân vật lúc đầu mỗi processGame. Kazuma theo cấp toàn cục của người chơi, ba người còn lại mỗi người có XP/cấp riêng. Thiện cảm (kiếm bằng cách lấy drop gắn với nhân vật) nhân stat của họ độc lập.

Hồi máu

Hệ thống drop dùng bảng loot có trọng số theo độ khó :

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...tới Legendary
};

Test

Ba suite : đơn vị, hiệu năng, và rò rỉ.

Leak test đặc biệt thẳng :

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // tối đa 20MB tăng heap
});

1200 vòng processGame, GC ép trước và sau, chênh heap < 20MB. Nếu test này qua, processGame không rò. Test render (renderImage.spec.ts) kiểm tra thời gian chạy dưới ngưỡng thực tế.

Ngoài ra có script bench.ts để profile pipeline đầy đủ :

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

Với RENDER_PERF=1, wrapper withPerf trong mỗi service log thời gian :

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // không chi phí khi tắt
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger trả về no-op nếu DEV_MODE và RENDER_PERF không ở 1. Không chi phí trong production.


Chạy tốn bao nhiêu

  • Free tier Vercel : 100GB băng thông, 1M lần gọi serverless mỗi tháng. Render ảnh tính là một lần gọi.
  • Free tier Cloudflare Workers : 100K yêu cầu/ngày, 10ms thời gian CPU cho mỗi yêu cầu (render có thể vượt trên Workers, vì vậy Vercel làm chính).
  • Free tier Supabase : 500MB DB, 5GB băng thông. Đủ cho hàng nghìn người chơi.

Toàn bộ backend chạy chi phí 0 tới khối lượng đáng kể. Điểm ma sát duy nhất là giới hạn CPU của Cloudflare Workers -- render ảnh nặng CPU vì WASM, vì vậy chiến lược Vercel làm chính và Workers làm CDN dự phòng.


3 điều đáng giữ lại

  1. URL làm trạng thái game không chỉ là mẹo hay -- đó là ràng buộc Discord áp (nút giới hạn 100 ký tự) ép ra kiến trúc stateless với nén RLE + token phiên dự phòng. Ràng buộc định ra thiết kế.

  2. Cache WASM với thu hồi rõ : PhotonImage cấp phát ngoài heap JavaScript và sẽ không bao giờ GC nếu thiếu .free(). Móc freePhoton vào thu hồi LRU đúng là RAII trong JavaScript. Nó kín đáo trong mã, nhưng thiếu nó worker sẽ rò rỉ trong production.

  3. Bot Discord serverless không WebSocket : ít biết hơn cách gateway WebSocket, nhưng với bot xử lý stateless (mỗi tương tác độc lập), Interactions Endpoint vượt hẳn -- không reconnect, không heartbeat, không tiến trình cần duy trì. Discord lo sẵn sàng phía hạ tầng của họ.


Repo : fox3000foxy/konosuba-rpg

Giấy phép custom source-available -- không phân phối lại, dùng tự do.

ฉันใช้เวลาสุดสัปดาห์อ่านโค้ด konosuba-rpg และนี่คือสิ่งที่ฉันพบ

RPG ผลัดกันเล่นบน Discord ที่ทุกการกระทำสร้างภาพ WebP ทันที: URL เป็นสถานะเกม,

ฉันใช้เวลาสุดสัปดาห์อ่านโค้ด konosuba-rpg และนี่คือสิ่งที่ฉันพบ

ฉันดูแลโปรเจกต์นี้มาระยะหนึ่งแล้ว แต่การอ่านโค้ดของตัวเองอีกครั้งอย่างใจเย็นก็ให้บทเรียนเสมอ konosuba-rpg คือ RPG ผลัดกันเล่นบน Discord ที่ทุกการกระทำสร้างภาพ WebP ทันที ไม่ใช่ embed ข้อความ แต่เป็นภาพจริงที่ประกอบด้วยสไปรต์, แถบพลังชีวิต, ข้อความต่อสู้ -- ทุกอย่าง

stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase โฮสต์ฟรีทั้งหมด และบอท Discord ทำงานโดยไม่ต้องมีเซิร์ฟเวอร์ถาวร โพสต์นี้อธิบายว่าทุกอย่างทำงานร่วมกันอย่างไร

สถานะเริ่มต้นของเกม


การออกแบบพื้นฐาน: URL เป็นสถานะเกม

สิ่งแรกที่สะดุดตา: ไม่มีสถานะฝั่งเซิร์ฟเวอร์สำหรับการเล่นเกม สถานะทั้งหมดของการต่อสู้อยู่ใน URL

/konosuba-rpg/fr/abc123/ATK/DEF/ATK/HUG?monster=Vanir&difficulty=hard

แต่ละ segment หลังจาก seed คือการกระทำที่เล่นแล้ว เซิร์ฟเวอร์ได้รับ URL นี้ เริ่มต้นใหม่ เล่นการกระทำทั้งหมดตามลำดับ และส่งคืนภาพของการต่อสู้ ณ ขณะนั้น ไม่มี session, ไม่มีสถานะใน RAM ที่ผูกกับผู้ใช้

Discord ทำงานด้วยปุ่มโต้ตอบ -- เมื่อผู้เล่นกด "โจมตี" Discord จะส่ง custom_id ของปุ่มไปยังเซิร์ฟเวอร์ custom_id นี้มี URL ที่บีบอัดของการต่อสู้พร้อมการกระทำใหม่ที่เพิ่มเข้าไป เซิร์ฟเวอร์คำนวณทุกอย่างใหม่ตั้งแต่ต้นและส่งคืนภาพที่อัปเดต

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Precompiled outside function -- not recreated on every call

export default function processUrl(url: string): [Random, string[], string, string | null, string | null] {
  const urlParts = url.split("/");
  const moves: string[] = [];
  for (const part of urlParts) {
    if (VALID_MOVES_SET.has(part.toUpperCase())) moves.push(part.toUpperCase());
  }
  // seed = 6th segment, hashed to 8096 values
  const seedStr = (urlParts[5] || "").toLowerCase();
  let seed = 0;
  for (let i = 0; i < seedStr.length; i++) {
    seed = (seed + seedStr.charCodeAt(i)) % 8096;
  }
  return [new Random(seed), moves, seedStr, monster, difficulty];
}

การมี Set ที่ precompiled ไว้นอกฟังก์ชันเป็นรายละเอียดเล็กน้อย แต่มันช่วยไม่ให้ต้องสร้างโครงสร้างใหม่ทุกครั้งที่มีการเรียกใช้ในบริบท edge ที่โมดูลอาจถูกประเมินค่าใหม่

RNG: RC4 ที่ถูกดัดแปลง

ตัวสร้างตัวเลขสุ่มเป็นการนำ RC4 (อัลกอริธึมเข้ารหัสแบบ stream) มาใช้เป็น PRNG

export class Random {
  private S: number[]; // table of 256 entries
  private i: number;
  private j: number;

  constructor(seed?: number) {
    this.S = Array.from({ length: 256 }, (_, i) => i);
    let j = 0;
    let workingSeed = seed || Date.now();
    for (let i = 0; i < 256; i++) {
      j = (j + this.S[i] + (workingSeed & 0xff)) & 0xff;
      // swap S[i] and S[j]
      [this.S[i], this.S[j]] = [this.S[j], this.S[i]];
      workingSeed >>>= 8;
    }
  }

  next(): number { /* ... */ }
  randint(min: number, max: number): number { /* ... */ }
  choice(array: T[]): T { /* ... */ }
}

ทำไมต้อง RC4? เพราะมันเป็น PRNG แน่นอนที่มีการกระจายตัวที่ถูกต้องและทนทานต่อการชนของ seed ได้สมเหตุสมผล seed เดียวกัน = ลำดับตัวเลขเดียวกัน = การต่อสู้เดียวกันทุกครั้ง ทำให้สามารถ "เล่นซ้ำ" การต่อสู้ใด ๆ โดยเก็บ URL ไว้ และรับประกันว่าเซิร์ฟเวอร์สองตัวที่แตกต่างกัน (Vercel + Cloudflare) ให้ผลลัพธ์ที่เหมือนกันทุกประการสำหรับ URL เดียวกัน


ปัญหาขีดจำกัด 100 ตัวอักษรของ Discord

Discord กำหนดขีดจำกัด 100 ตัวอักษรบน custom_id ของปุ่ม หลังจากผ่านไปหลายสิบท่า URL การต่อสู้จะเกินขีดจำกัดนี้อย่างสบาย

มีสองกลไกที่จัดการกับปัญหานี้

1. การบีบอัด RLE ของท่า

ท่าถูกเข้ารหัสด้วยอักขระเดียว (a=attack, d=defend, h=hug...) และบีบอัดด้วย run-length encoding:

// movesUtils.ts
export function compressMoves(moves: string): string {
  // "aaaaaadddh" → "a6d3h"
  let result = "";
  let count = 1;
  for (let i = 1; i <= actions.length; i++) {
    if (actions[i] === actions[i - 1]) {
      count++;
    } else {
      result += actions[i - 1] + (count > 1 ? String(count) : "");
      count = 1;
    }
  }
  return head + result;
}

ง่าย แต่เมื่อผู้เล่นกดโจมตี x10 มันเปลี่ยนจาก aaaaaaaaaa (10 ตัวอักษร) เป็น a10 (3 ตัวอักษร) ปุ่ม "โจมตี x4" และ "โจมตี x10" ใน UI มีไว้เพื่อสิ่งนี้ -- เร่งการต่อสู้ในขณะที่บีบอัด payload ได้ดี

2. Session tokens เมื่อการบีบอัดไม่พอ

ถ้า payload ที่บีบอัดแล้วยังยาวเกินไป มันจะถูกเก็บในฐานข้อมูลด้วย token สั้น:

// gameSessionService.ts
const TOKEN_PREFIX = "gs.";
const TOKEN_SIZE = 10; // "gs.aBcDeFgHiJ"

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Groups payloads by battle_key, inserts batch into Supabase
  // Replaces custom_id with "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // No lookup if not needed
  }
  // Memory lookup first, then Supabase if absent
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Checks ownership, TTL (7 days), and turn_version (prevents replaying old state)
}

Session มี TTL 7 วัน และการ pruning อัตโนมัติทุก 10 นาที การตรวจสอบ turnVersion ป้องกันการเล่นซ้ำสถานะที่ล้าสมัยหากผู้เล่นได้ดำเนินการในเกมต่อไปแล้ว -- การป้องกันเล็กน้อยต่อการ "ย้อนกลับ" โดยไม่ตั้งใจ

Map ในหน่วยความจำทั้งสอง (tokenToSession, latestTurnByBattle) ใช้ pattern globalThis as unknown as GameSessionGlobals เดียวกับแคชภาพ ด้วยเหตุผลเดียวกับที่จะกล่าวถึงด้านล่าง


Pipeline การเรนเดอร์ภาพ

เริ่มการต่อสู้กับ Slime

เส้นทาง /konosuba-rpg/:lang/* ไม่ได้ส่งคืน JSON มันส่งคืนภาพ WebP ที่สร้างตามคำขอ

pipeline จัดเรียงเป็น 3 ชั้นประกอบ:

Background (board + frame)
    +
Characters layer (สไปรต์ผู้เล่น + มอนสเตอร์, ตำแหน่งคงที่)
    +
UI overlay (แถบ HP, ข้อความ, ไอคอนตัวละครผ่าน Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: ภาพคงที่สองภาพ (กระดานและกรอบ) โหลดจาก filesystem และประกอบครั้งเดียว

Characters layer: สไปรต์ถูกวางตามพิกัดที่คำนวณ ผู้เล่นที่ตายจะถูกแยกออก (activeSlots = slots.filter(s => playerHp[s.i] > 0)) สไปรต์ศัตรูถูกสะท้อนแนวนอนด้วย flipX แบบกำหนดเอง -- วนลูปทีละพิกเซลแทนการพึ่งพาภายนอก

function flipX(img: Photon.PhotonImage): Photon.PhotonImage {
  const w = img.get_width(), h = img.get_height();
  const raw = img.get_raw_pixels();
  const flipped = new Uint8Array(raw.length);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const src = (y * w + x) * 4;
      const dst = (y * w + (w - 1 - x)) * 4;
      flipped[dst] = raw[src]; flipped[dst+1] = raw[src+1];
      flipped[dst+2] = raw[src+2]; flipped[dst+3] = raw[src+3];
    }
  }
  return new Photon.PhotonImage(flipped, w, h);
}

UI overlay: ส่วนที่หนักหน่วง JSX ของอินเทอร์เฟซ (แถบชีวิต, ข้อความ, ไอคอน) ถูกอธิบายในรูปแบบ React-like ด้วย Satori, เรนเดอร์เป็น SVG, แปลงเป็น PNG ด้วย @cf-wasm/resvg, แล้วนำเข้าไปยัง Photon สำหรับการประกอบขั้นสุดท้าย Satori + resvg เป็นโมดูล WASM สองตัวที่ถูกคอมไพล์เฉพาะสำหรับ Cloudflare Workers ด้วย flag edge-light

ท่า Defend

การต่อสู้กำลังดำเนิน

ท่ากอด


ระบบแคช -- ส่วนที่ถูกพัฒนามากที่สุด

มีแคช 5 ระดับ แต่ละระดับกำหนดเป้าหมาย granularity ที่แตกต่างกันของ pipeline

// renderImage.ts -- all on globalThis
G.__imageCache  ??= {} as Record; // raw assets
G.__base64Cache ??= {} as Record;       // base64 of assets (for Satori)
G.__fontCache   ??= {} as Record; // fonts
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

pattern ??= บน globalThis: โมดูล JavaScript ใน edge workers อาจถูกประเมินค่าใหม่ระหว่างคำขอบางการกำหนดค่า การเก็บแคชบน globalThis ด้วย ??= รับประกันว่าพวกมันอยู่รอดจากการประเมินค่าใหม่เหล่านี้โดยไม่ถูกสร้างใหม่

การ eviction แบบ WASM

แคชภาพ Photon (photonCache, layerCache, uiPhotonCache) ใช้ callback การ eviction:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* already freed */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage คือออบเจกต์ WASM ที่มีหน่วยความจำที่จัดสรรไว้ใน linear memory ของ WASM อยู่นอก GC ของ JavaScript หากไม่เรียก .free() อย่างชัดเจน หน่วยความจำนี้จะไม่มีวันถูกปลดปล่อย การ eviction ของ LRU จะ trigger .free() โดยอัตโนมัติ -- มันคือ RAII ที่นำมาใช้ใน JavaScript

คีย์แคชตั้งใจให้ lossy

function buildCharactersKey(playerImages: string[][], playerHp: number[], creatureImages: string[], creatureHp: number): string {
  const players = playerImages.map((imgs, i) => `${imgs[0]}:${playerHp[i] > 0 ? 1 : 0}`).join("|");
  return `chars::${players}::${creatureImages[0]}:${creatureHp > 0 ? 1 : 0}`;
}

คีย์ของ characters layer ไม่ได้เข้ารหัสค่า HP ที่แน่นอน -- แค่ 1 (มีชีวิต) หรือ 0 (ตาย) เพราะสไปรต์ของผู้เล่นที่ 40 HP กับผู้เล่นที่ 15 HP นั้นเหมือนกัน cache hit จึงอยู่รอดจากการถูกโจมตีใด ๆ ตราบใดที่ไม่มีใครตาย

ส่วนคีย์ UI กลับเข้ารหัส HP ที่แน่นอน (แถบชีวิตเปลี่ยนทุกครั้งที่ถูกโจมตี) และ hash ของข้อความ:

function hashString(value: string): string {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; // signed 32-bit integer
  }
  return hash.toString(16);
}

Math.imul บังคับการคูณเป็นจำนวนเต็ม 32 บิต ซึ่งหลีกเลี่ยงการแปลง float64 และให้ hash polynomial ที่เสถียร ไม่ต้องพึ่งพาภายนอกสำหรับสิ่งนี้

การแปลง base64 โดยไม่มี stack overflow

function getBase64Cached(key: string, buf: ArrayBuffer): string {
  if (base64Cache[key]) return base64Cache[key];
  const bytes = new Uint8Array(buf);
  const chunkSize = 0x8000; // 32768 bytes
  let binary = "";
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
  }
  const b64 = btoa(binary);
  base64Cache[key] = b64;
  return b64;
}

String.fromCharCode(...largeArray) อาจทำให้เกิด stack overflow บนภาพขนาดใหญ่เพราะ argument ถูกส่งผ่าน call stack การแบ่งเป็น chunk 32KB ช่วยหลีกเลี่ยงปัญหา ผลลัพธ์ถูกเก็บในแคช -- การแปลง base64 ของภาพเดียวกันจะทำเพียงครั้งเดียวต่อ instance ของ worker


STRIPPER.md -- การตรวจสอบ await แบบตามลำดับ

มีไฟล์ STRIPPER.md ใน repo ที่บันทึกการตรวจสอบการทำ await แบบขนาน ตัวอย่างบางส่วนที่บันทึกไว้:

  • การโหลดโปรไฟล์ผู้เล่นเคยทำ 3 คำขอ Supabase แบบเรียงต่อกัน (progression, run summary, achievements) ถูกเปลี่ยนเป็น Promise.all -- ไม่มีการพึ่งพากันระหว่างคำขอ
  • การแจกจ่ายรางวัลหลังจบการต่อสู้ (accessories + consumables) เคยเป็นแบบตามลำดับ ถูกทำให้ขนานเช่นกัน
  • การสร้าง token session สำหรับปุ่มเคยทำทีละกลุ่ม กลุ่มที่เป็นอิสระตอนนี้ถูกสร้างแบบขนาน
// progressionService.ts -- before (sequential)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// after
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

ไม่มีอะไรปฏิวัติ แต่ในบริบท serverless ที่ทุก millisecond ของเวลาตอบสนองมีค่าใช้จ่าย (หรือมีส่วนทำให้ cold start) มันสำคัญ


บอท Discord โดยไม่มีเซิร์ฟเวอร์ถาวร

ชัยชนะ

จุดที่มักเข้าใจผิด: บอท Discord ไม่จำเป็นต้องมีการเชื่อมต่อ WebSocket แบบถาวรเสมอไป Discord มีทางเลือกอื่น: Interactions Endpoint URL คุณให้ URL HTTPS แก่ Discord และ Discord จะส่ง POST ให้คุณสำหรับทุก interaction (slash command, ปุ่ม, autocomplete)

// interactions.ts
export async function handleInteractions(c: Context) {
  const body = await c.req.text();
  const isVerified = await verifySignature(c, body); // Ed25519
  if (!isVerified) return c.text("Invalid signature", 401);

  const interaction: Interaction = JSON.parse(body);
  if (interaction.type === 1) return c.json({ type: 1 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord ส่ง POST, handler ทำงาน 50-200ms บนฟังก์ชัน Vercel หรือ Cloudflare Worker, ตอบกลับ, แล้วจบ ไม่ต้องรักษาการเชื่อมต่อถาวร, ไม่ต้องมีเซิร์ฟเวอร์ที่เปิดทิ้งไว้ บอท Discord ทั้งหมดโฮสต์บน free tier ของ Vercel

การตรวจสอบ Ed25519 (verifyKey จาก discord-interactions) เป็นสิ่งจำเป็น -- Discord ส่งลายเซ็นใน header ที่คุณต้องตรวจสอบ มิฉะนั้นมันจะปฏิเสธ endpoint

ท่าพิเศษ -- await ที่ตั้งใจเพียงอันเดียว

// handleSpecialButton.ts
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 seconds
await fetch(`${DISCORD_API_URL}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, {
  method: "PATCH",
  // ...
});

การหน่วงเวลา 3 วินาทีโดยตั้งใจนี้ถูกบันทึกใน STRIPPER.md ว่าตั้งใจ ท่าพิเศษของ Megumin (Explosion) มี animation ฝั่ง Discord -- ข้อความจะถูกอัปเดตด้วยภาพระหว่างกลางก่อน จากนั้นจึงเปลี่ยน 3 วินาทีต่อมาด้วยผลลัพธ์ นี่เป็นกรณีเดียวที่ฟังก์ชัน Vercel ทำงานนานเกินความจำเป็นโดยตั้งใจ

ท่าพิเศษ


การ deploy บนสองแพลตฟอร์ม

codebase เดียวกันทำงานบน Vercel (Node.js) และ Cloudflare Workers (V8 isolates) โดยไม่ต้องแก้ไข:

// worker.ts -- Cloudflare entrypoint
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // injects CF secrets into process.env
    return app.fetch(request, env, ctx);
  }
};

// index.ts -- Vercel/Node entrypoint
const isVercelRuntime = process.env.VERCEL === "1";
if (!isVercelRuntime) { start(); }

ความแตกต่างหลัก: static assets บน Vercel อ่านจาก filesystem (/var/task//articles/assets/) บน Cloudflare Workers ผ่าน binding ASSETS (CF static assets) โดยมี fallback ไปยัง HTTPS mirror (fox3000foxy.com/konosuba-rpg/assets) getAssetBytes ใน assetLoader.ts จัดการทั้งสองเส้นทางโดยลอง filesystem ก่อน แล้วค่อย fetch

WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) มี builds แยกสำหรับแต่ละ runtime flag edge-light ในชื่อ package ระบุ build ที่เข้ากันได้กับ Cloudflare Workers ซึ่งไม่อนุญาต new WebAssembly.Module() ใน runtime -- WASM ต้องถูก pre-compiled


การดำเนินเรื่อง: XP, เลเวล, ความสัมพันธ์

บอส 650 HP

meta-progression อาศัย Supabase free tier โครงสร้างประกอบด้วยตาราง players (XP รวม, เลเวล, gold), character_progress (XP/เลเวล/ความสัมพันธ์ต่อตัวละครสำหรับ Darkness, Aqua, Megumin), runs (ประวัติการต่อสู้), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions

โมเดลการดำเนินเรื่องเรียบง่าย:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP per level
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% stats per level
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 points per star, max 5 stars
  return 1.2 ** stars; // exponential progression
}

ปัจจัยเหล่านี้ถูกนำไปใช้กับ stats ของตัวละครตอนเริ่มต้นทุก processGame Kazuma ตามเลเวลรวมของผู้เล่น ส่วนอีกสามตัวมี XP/เลเวลของตัวเอง ความสัมพันธ์ (ได้จากการเก็บดรอปที่เกี่ยวข้องกับตัวละคร) คูณ stats ของมันอย่างอิสระ

รักษา

ระบบดรอปใช้ loot table ถ่วงน้ำหนักตามความยาก:

const LOOT_TABLE_BY_DIFFICULTY: Record = {
  [MonsterDifficulty.Easy]: {
    baseRolls: 2, bonusRollChance: 0.1, maxBonusRolls: 2,
    rarityWeights: [
      { rarity: Rarity.Bronze, weight: 68 },
      { rarity: Rarity.Silver, weight: 25 },
      { rarity: Rarity.Gold,   weight: 6  },
      { rarity: Rarity.Epic,   weight: 1  },
    ],
  },
  // ...up to Legendary
};

การทดสอบ

สามชุด: unit, performance, และ leak

leak test โดยเฉพาะตรงไปตรงมา:

// leaks.spec.ts
it('does not show strong heap growth across repeated runs', async () => {
  global.gc();
  const before = heapUsedMb();

  for (let i = 0; i < 1200; i++) {
    await processGame(new Random(), ['ATK', 'DEF', 'HUG', 'ATK', 'DEF'], 'Dragon', Lang.English);
  }

  global.gc();
  const after = heapUsedMb();
  expect(after - before).toBeLessThan(20); // max 20MB heap growth
});

1200 รอบของ processGame, บังคับ GC ก่อนและหลัง, delta heap < 20MB ถ้าเทสนี้ผ่าน processGame ก็ไม่ leak เทส render (renderImage.spec.ts) ตรวจสอบเวลาทำงานภายใต้เกณฑ์ที่ใช้งานได้จริง

นอกจากนี้ยังมีสคริปต์ bench.ts สำหรับ profile pipeline ทั้งหมด:

RENDER_PERF=1 npx tsx bench.ts --runs=20 --warmup=3 --monster=Dragon

เมื่อ RENDER_PERF=1, wrapper withPerf ในทุก service จะ log timings:

export async function withPerf(scope: string, label: string, work: () => Promise): Promise {
  const perf = createPerfLogger(scope);
  if (!perf.enabled) return work(); // zero overhead if disabled
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger ส่งคืน no-ops ถ้า DEV_MODE และ RENDER_PERF ไม่ได้เป็น 1 ไม่มี overhead ใน production


ค่าใช้จ่ายในการรัน

  • Vercel free tier: 100GB bandwidth, 1M serverless invocations ต่อเดือน การเรนเดอร์ภาพนับเป็นหนึ่ง invocation
  • Cloudflare Workers free tier: 100K คำขอ/วัน, 10ms CPU time ต่อคำขอ (การเรนเดอร์อาจเกินนี้บน Workers ดังนั้น Vercel จึงเป็น primary)
  • Supabase free tier: 500MB database, 5GB bandwidth เพียงพอสำหรับผู้เล่นหลายพันคน

backend ทั้งหมดทำงานด้วยต้นทุนศูนย์จนถึงปริมาณที่มีนัยสำคัญ จุดเสียดทานเดียวคือขีดจำกัด CPU ของ Cloudflare Workers -- การเรนเดอร์ภาพใช้ CPU มากเนื่องจาก WASM ดังนั้นกลยุทธ์คือใช้ Vercel เป็น primary และ Workers เป็น CDN failover


3 สิ่งที่น่าจดจำ

  1. URL เป็นสถานะเกม ไม่ใช่แค่เทคนิคเจ๋ง ๆ -- มันเป็นข้อจำกัดที่ถูกบังคับโดย Discord (ปุ่มมีขีดจำกัด 100 ตัวอักษร) ซึ่งบังคับให้มีสถาปัตยกรรมแบบ stateless พร้อมการบีบอัด RLE และ token session เป็นตัวสำรอง ข้อจำกัดกำหนดการออกแบบ

  2. แคช WASM พร้อม eviction แบบชัดเจน: PhotonImage จัดสรรหน่วยความจำนอก heap ของ JavaScript และจะไม่มีวันถูก GC หากไม่มี .free() การเชื่อม freePhoton เข้ากับการ eviction ของ LRU คือ RAII ใน JavaScript มันดูเล็กน้อยในโค้ด แต่ถ้าไม่มีมัน worker จะรั่วใน production

  3. บอท Discord แบบ serverless โดยไม่ต้องใช้ WebSocket: วิธีการนี้รู้จักกันน้อยกว่าวิธี WebSocket gateway แต่สำหรับบอทที่ทำงานแบบ stateless (แต่ละ interaction เป็นอิสระ) การใช้ Interactions Endpoint เหนือกว่าอย่างชัดเจน -- ไม่ต้อง reconnect, ไม่ต้อง heartbeat, ไม่ต้องรักษา process Discord จัดการความพร้อมใช้งานฝั่งโครงสร้างพื้นฐานของพวกเขา


Repo : fox3000foxy/konosuba-rpg

Licence source-available custom -- ห้ามแจกจ่าย ใช้ได้ฟรี

Related Articles