GitHub avatar

Fox's Blog

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

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

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

I've been maintaining this project for a while, but re-reading your own code with a clear head is always instructive. konosuba-rpg is a turn-based Discord RPG where every action generates a WebP image on the fly. Not a text embed. A real composed image, with sprites, health bars, combat messages -- everything.

The stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Entirely free hosting. And the Discord bot runs without a persistent server. This post explains how it all fits together.

Initial game state


The basic design: the URL as game state

The first thing that stands out: there is no server-side state for gameplay. The complete state of a fight is in the URL.

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

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

Discord works through interactive buttons -- when the player presses "Attack", Discord sends the button's custom_id to the server. This custom_id contains the compressed fight URL with the new action appended. The server recalculates everything from scratch and returns the updated image.

// 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 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 precompiled Set outside the function is a detail, but it avoids rebuilding the structure on every invocation in an edge context where modules can be 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. This allows "replaying" any fight by keeping its URL, and guarantees that two different servers (Vercel + Cloudflare) produce exactly the same result for the same URL.


The 100-character Discord limit problem

Discord imposes a 100-character limit on custom_id values for buttons. After a few dozen actions, a fight URL easily exceeds this limit.

Two mechanisms address this.

1. RLE compression of actions

Actions are encoded with a single character (a=attack, d=defend, h=hug...) and compressed using 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 this -- speeding up the fight while compressing the payload well.

2. Session tokens when compression is not enough

If the compressed payload is still too long, it's stored in the database 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, inserts in 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
  }
  // Lookup in memory first, then Supabase if absent
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Checks ownership, TTL (7 days), and turn_version (prevents replaying an old state)
}

Sessions have a TTL of 7 days and automatic pruning every 10 minutes. The turnVersion check prevents replaying a stale state if the player has progressed -- a discreet protection against accidental "rollback."

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

Start of fight against a Slime

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

The pipeline is organized into 3 composited layers:

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

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

Characters layer: sprites are positioned according to calculated 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: this is the heavy part. The interface JSX (health bars, texts, icons) is described in React-like style with Satori, rendered to SVG, converted to PNG by @cf-wasm/resvg, then imported into Photon for the final composition. Satori + resvg are two WASM modules compiled specifically for Cloudflare Workers with the edge-light flag.

Defense action

Ongoing combat

Hug action


The caching system -- the most refined part

There are 5 distinct cache levels. Each targets a different granularity of the 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);

The ??= pattern on globalThis: JavaScript modules in edge workers can be re-evaluated between requests on certain configurations. Storing caches on globalThis with ??= ensures they survive these 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 WASM linear memory side, outside the JavaScript GC. Without an explicit call to .free(), this memory is never released. 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 does not encode the exact HP value -- just 1 (alive) or 0 (dead). Because the sprite of a player at 40 HP and a player at 15 HP is identical. A cache hit therefore survives any amount of damage as long as no one falls.

The UI key, on the other hand, encodes the exact HP (the health bar changes with every hit) and a hash of the 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; // signed 32-bit integer
  }
  return hash.toString(16);
}

Math.imul forces 32-bit integer multiplication, which avoids float64 conversions and gives a stable polynomial hash. No external dependency needed.

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 cause a stack overflow on large images because the arguments are passed on the call stack. Chunking by 32KB avoids this. The result is cached -- the base64 conversion of the same image is only done once per worker instance.


STRIPPER.md -- audit of sequential awaits

There is a STRIPPER.md file in the repo that documents an audit of parallelizing awaits. A few examples of what's recorded:

  • Player profile loading used to make 3 sequential Supabase queries (progression, run summary, achievements). They were switched to Promise.all -- no dependency between them.
  • End-of-fight reward distribution (accessories + consumables) was sequential. Parallelized as well.
  • Session token creation for buttons was done 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 matters.


The Discord bot without a persistent server

Victory

A frequently misunderstood point: a Discord bot does not necessarily require a persistent WebSocket connection. Discord offers an alternative: Interactions Endpoint URL. You provide an HTTPS URL to Discord, and Discord sends you a POST for each 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 }); // ping Discord
  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 for 50-200ms on a Vercel function or a Cloudflare Worker, responds, and that's it. No persistent connection to maintain, no server to keep running. The entire Discord bot is hosted on the Vercel free tier.

Ed25519 verification (verifyKey from discord-interactions) is mandatory -- Discord sends a signature in the headers that 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",
  // ...
});

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

Special attack


Deployability on two platforms

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

// 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(); }

The main difference: static assets. On Vercel, they are 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). The getAssetBytes function in assetLoader.ts handles both paths by trying the filesystem first, then fetch.

The WASM modules (@cf-wasm/photon/edge-light, @cf-wasm/resvg) have separate builds for each runtime. The edge-light flag in the package name designates the Cloudflare Workers-compatible build, which does not allow new WebAssembly.Module() at runtime -- the WASM must be pre-compiled.


Progression: XP, levels, affinity

A boss, 650 HP

Meta-progression relies on Supabase free tier. The schema includes 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
}

These factors are applied 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 (gained by collecting drops tied to a character) multiplies their stats independently.

Heal

The drop system uses loot tables weighted by difficulty:

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 straightforward:

// 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 this test passes, processGame doesn't leak. The render test (renderImage.spec.ts) instead checks execution time under a practical threshold.

There is also a bench.ts script for profiling 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 are not set to 1. No overhead in production.


What it costs to run

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

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


The 3 things worth remembering

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

  2. The WASM cache with explicit eviction: PhotonImage objects allocate outside the JavaScript heap and will never be GC'd without .free(). Hooking freePhoton into the LRU eviction is RAII in JavaScript. It's subtle in the code, but without it the worker would leak in production.

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


Repo: fox3000foxy/konosuba-rpg

Source-available custom 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. Pas un embed texte. 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. Sans appel explicite à .free(), cette mémoire ne se libère jamais. 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. Pas de 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 级缓存、无服务器 bot。

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

我维护这个项目已经有一段时间了,但静下心来重读自己的代码总是很有启发。konosuba-rpg 是一个 Discord 回合制 RPG,每次操作都实时生成一张 WebP 图片。不是文本 embed。而是一张真正的合成图像,包含精灵、血条、战斗信息----全部都有。

技术栈:TypeScript、Hono、Vercel、Cloudflare Workers、Supabase。完全免费托管。而且这个 Discord bot 无需持久化服务器。这篇文章将解释这一切是如何组合在一起的。

游戏初始状态


基础设计:URL 即游戏状态

最令人印象深刻的一点是:服务端完全没有保存游戏状态。一场战斗的完整状态全部包含在 URL 中。

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

seed 之后的每个片段都是一个已执行的操作。服务器收到这个 URL,从头开始,按顺序重放所有操作,然后返回该时刻的战斗图片。没有会话,没有与用户相关的内存状态。

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

随机数生成器是一个将 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;
      // swap 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 字符限制问题

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;
}

很简单,但当玩家连续攻击 10 次时,aaaaaaaaaa(10 字符)会变成 a10(3 字符)。UI 中的"攻击 x4"和"攻击 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
  // 将 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 模式,原因将在下文说明。


图片渲染管线

对史莱姆的战斗开始

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

管线组织为 3 个合成层:

背景(棋盘 + 边框)
    +
角色层(玩家精灵 + 怪物,固定位置)
    +
UI 覆盖层(血条、消息、角色图标,通过 Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP 输出

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

角色层:精灵按计算出的坐标定位。死亡的玩家被排除在外(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 覆盖层:这是最重的部分。界面的 JSX(血条、文字、图标)以 React-like 方式通过 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 上的 ??= 模式:边缘 worker 中的 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 是一个 WASM 对象,其内存在 WASM 线性端分配,不受 JavaScript GC 管理。如果不显式调用 .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(死亡)。因为 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) 在大图片上可能导致栈溢出,因为参数是通过调用栈传递的。按 32KB 分块可以避免这个问题。结果会被缓存----同一张图片的 base64 转换在每个 worker 实例中只执行一次。


STRIPPER.md----await 串行审计

仓库中有一个 STRIPPER.md 文件,记录了 await 并行化的审计情况。以下是一些记录的例子:

  • 玩家资料加载曾串行执行 3 次 Supabase 查询(进度、run 摘要、成就)。它们之间没有依赖关系,已改为 Promise.all。
  • 战斗结束奖励发放(饰品 + 消耗品)是串行的。同样已并行化。
  • 按钮的会话 token 创建是逐组进行的。独立的组现在并行创建。
// progressionService.ts -- 之前(串行)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

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

没什么革命性的,但在 serverless 环境中,每毫秒响应时间都计费(或影响冷启动),这就很重要了。


无持久服务器的 Discord bot

胜利

一个常被误解的点:Discord bot 不一定需要持久的 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 }); // 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 bot 托管在 Vercel 免费层上。

Ed25519 验证(来自 discord-interactions 的 verifyKey)是必须的----Discord 在 headers 中发送一个签名,你必须验证它,否则 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 密钥注入 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 binding(CF 静态资源)访问,并回退到 HTTPS mirror(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 必须预编译。


成长系统:经验值、等级、好感度

一个 Boss,650 HP

元成长系统基于 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/等级。好感度(通过拾取与角色相关的掉落物获得)独立乘算其属性。

治疗

掉落系统使用按难度加权的战利品表:

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`); }
}

如果 DEV_MODE 和 RENDER_PERF 不是 1,createPerfLogger 返回 no-op。生产环境零开销。


运行成本

  • Vercel 免费层:100GB 带宽,每月 100 万次 serverless 调用。图片渲染算一次调用。
  • Cloudflare Workers 免费层:每天 10 万次请求,每次请求 10ms CPU 时间(渲染在 Workers 上可能超过此限制,因此以 Vercel 为主)。
  • Supabase 免费层:500MB 数据库,5GB 带宽。足以支持数千名玩家。

整个后端在达到显著规模之前以零成本运行。唯一的瓶颈是 Cloudflare Workers 的 CPU 限制----图片渲染因 WASM 而 CPU 密集,因此策略是 Vercel 为主,Workers 作为故障转移 CDN。


值得记住的 3 件事

  1. URL 即游戏状态不仅仅是一个巧妙的技巧----这是 Discord 施加的限制(按钮有 100 字符限制)所迫出来的架构,进而产生了无状态设计,包含 RLE 压缩和会话 token 作为回退。限制决定了设计。

  2. 带显式驱逐的 WASM 缓存:PhotonImage 在 JavaScript 堆之外分配内存,不调用 .free() 永远不会被 GC。将 freePhoton 绑定到 LRU 的驱逐上,就是 JavaScript 中的 RAII。这在代码中并不显眼,但没有它,worker 在生产中会内存泄漏。

  3. 无需 WebSocket 的无服务器 Discord bot:这不如 WebSocket 网关方法知名,但对于处理无状态操作(每个交互相互独立)的 bot,Interactions Endpoint 严格更优----无需重连、无需心跳、无需维护进程。Discord 在其基础设施端管理可用性。


仓库:fox3000foxy/konosuba-rpg

源代码可用自定义许可证----禁止再分发,可自由使用。

konosuba-rpgのコードを週末に読んでみた結果

Discord用ターン制RPG。各アクションがWebP画像をリアルタイム生成:URLをゲーム状態として使用、決定論的RNG、WASMパイプライン、5層キャッシュ、サーバーレスボット。

konosuba-rpgのコードを週末に読んでみた結果

このプロジェクトをしばらくメンテナンスしてきたが、自分のコードを落ち着いて読み返すのはいつだって勉強になる。konosuba-rpgはDiscord用のターン制RPGで、各アクションごとにWebP画像をリアルタイム生成する。テキスト埋め込みではない。スプライト、HPバー、戦闘メッセージ----すべてを含んだ本物の画像が合成される。

スタックは: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

乱数生成器は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はボタンの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;
}

シンプルだが、プレイヤーが攻撃を10連打するとaaaaaaaaaa(10文字)がa10(3文字)になる。UIの「4回攻撃」「10回攻撃」ボタンはまさにこのためのものだ----戦闘を加速しつつペイロードも圧縮する。

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のチェックにより、プレイヤーがゲームを進めた後に古い状態を再実行するのを防ぐ----うっかり「巻き戻し」を防ぐさりげない保護機構だ。

2つのインメモリMap(tokenToSession、latestTurnByBattle)は画像キャッシュと同じglobalThis as unknown as GameSessionGlobalsパターンを使用しており、理由は後述する。


画像レンダリングパイプライン

スライムとの戦闘開始

/konosuba-rpg/:lang/*ルートはJSONを返さない。リクエストに応じて生成されたWebP画像を返す。

パイプラインは3つの合成レイヤーで構成される:

背景(ボード+フレーム)
    +
キャラクターレイヤー(プレイヤースプライト+モブ、固定位置)
    +
UIオーバーレイ(HPバー、メッセージ、キャラクターアイコン、Satori → SVG → PNG経由)
    ↓
Photon.watermark() × 2
    ↓
WebP出力

背景:2枚の固定画像(ボードとフレーム)、ファイルシステムから読み込んで1回合成される。

キャラクターレイヤー:計算された座標にスプライトが配置される。死亡したプレイヤーは除外される(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オーバーレイ:これが重い部分だ。インターフェースのJSX(HPバー、テキスト、アイコン)がReactライクにSatoriで記述され、SVGにレンダリングされ、@cf-wasm/resvgでPNGに変換され、最終合成のためにPhotonに取り込まれる。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上の??=パターン:エッジワーカーの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はWASMオブジェクトであり、JavaScriptのGCの外部にある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(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変換はワーカーインスタンスごとに1回だけ実行される。


STRIPPER.md----逐次awaitの監査

リポジトリにはSTRIPPER.mdというファイルがあり、awaitの並列化に関する監査が文書化されている。記録されている例をいくつか:

  • プレイヤープロフィールの読み込みでは、3つのSupabaseクエリ(進行度、ランの概要、実績)が直列に実行されていた。これらはPromise.allに変更された----相互に依存関係はない。
  • 戦闘終了時の報酬配布(アクセサリー+消耗品)は逐次的だった。同様に並列化された。
  • ボタン用のセッショントークン作成はグループごとに逐次行われていた。独立したグループは現在並列で作成されている。
// progressionService.ts -- 変更前(逐次)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

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

革新的なものではないが、応答時間のミリ秒単位が課金される(またはコールドスタートに影響する)サーバーレスコンテキストでは重要だ。


永続サーバー不要のDiscordボット

勝利

しばしば誤解される点:Discordボットは必ずしも永続的なWebSocket接続を必要としない。DiscordにはInteractions Endpoint URLという代替手段がある。HTTPSのURLをDiscordに提供すると、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 ping
  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のfree tier上でホストされている。

discord-interactionsからのverifyKeyによるEd25519署名検証は必須である----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に意図的と文書化されている。めぐみんの特殊攻撃(爆裂魔法)はDiscord側でアニメーションを持ち----メッセージがまず中間ビジュアルで更新され、3秒後に結果で変更される。Vercel関数が意図的に必要以上に長く実行される唯一のケースだ。

特殊攻撃


2つのプラットフォームへのデプロイ可能性

同じコードベースがVercel(Node.js)とCloudflare Workers(V8 isolates)で修正なしで動作する:

// worker.ts -- Cloudflareエントリーポイント
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // CFのsecretを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 free tierに依存している。スキーマには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
}

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ポイントで星1つ、最大5つ星
  return 1.2 ** stars; // 指数関数的成長
}

これらの係数は各processGameの開始時にキャラクターのステータスに適用される。カズマはプレイヤーの全体レベルに従い、他の3人はそれぞれ独自の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まで
};

テスト

3つのスイート:単体テスト、パフォーマンステスト、リークテスト。

リークテストは特に直接的なものだ:

// 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`); }
}

createPerfLoggerはDEV_MODEとRENDER_PERFが1でない場合はno-opを返す。本番環境ではオーバーヘッドは一切ない。


運用コスト

  • Vercel free tier:月間100GBの帯域幅、100万回のサーバーレス呼び出し。画像レンダリングは1回の呼び出しとしてカウントされる。
  • Cloudflare Workers free tier:日10万リクエスト、リクエストあたり10ms CPU時間(Workerではレンダリングがこれを超える可能性があるため、Vercelをプライマリとしている)。
  • Supabase free tier: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 코드를 읽고 알게 된 것들

턴제 Discord RPG로, 각 액션마다 WebP 이미지를 실시간 생성합니다: URL을 게임 상태로 사용, 결정론적 RNG, WASM 파이프라인, 5단계 캐시, 서버리스 봇.

주말 동안 konosuba-rpg 코드를 읽고 알게 된 것들

한동안 이 프로젝트를 유지해왔지만, 자신의 코드를 차분히 다시 읽는 것은 항상 유익합니다. konosuba-rpg는 턴제 Discord RPG로, 각 액션이 실시간으로 WebP 이미지를 생성합니다. 텍스트 embed가 아닙니다. 스프라이트, HP바, 전투 메시지 등 모든 것이 포함된 실제 합성 이미지입니다.

스택: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. 완전 무료 호스팅입니다. Discord 봇은 영구 서버 없이 작동합니다. 이 글은 이 모든 것이 어떻게 함께 작동하는지 설명합니다.

게임 초기 상태


기본 설계: URL을 게임 상태로 사용

가장 먼저 눈에 띄는 점: 게임플레이를 위한 서버 측 상태가 전혀 없습니다. 전투의 전체 상태가 URL에 담겨 있습니다.

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

seed 이후의 각 세그먼트는 수행된 액션입니다. 서버는 이 URL을 받아 처음부터 다시 시작하여 모든 액션을 순서대로 재생하고, 그 순간의 전투 이미지를 반환합니다. 세션도, 사용자별 RAM 상태도 없습니다.

Discord는 인터랙티브 버튼으로 작동합니다 -- 플레이어가 "공격"을 누르면 Discord가 버튼의 custom_id를 서버로 전송합니다. 이 custom_id에는 새 액션이 추가된 압축된 전투 URL이 포함되어 있습니다. 서버는 모든 것을 처음부터 다시 계산하고 업데이트된 이미지를 반환합니다.

// 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];
}

함수 밖에 미리 컴파일된 Set은 세부 사항이지만, 모듈이 재평가될 수 있는 edge 컨텍스트에서 매 호출마다 구조를 재구성하는 것을 방지합니다.

RNG: 수정된 RC4

난수 생성기는 스트림 암호화 알고리즘인 RC4 구현을 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 { /* ... */ }
}

왜 RC4일까요? 올바른 분포와 합리적인 seed 충돌 저항성을 가진 결정론적 PRNG이기 때문입니다. 동일한 seed = 동일한 숫자 시퀀스 = 매번 동일한 전투입니다. URL을 보존하여 모든 전투를 "재생"할 수 있으며, 두 개의 다른 서버(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;
}

간단하지만, 플레이어가 공격을 10번 연속하면 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 {
  // 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)
}

세션은 7일의 TTL과 10분마다 자동 정리(pruning)가 있습니다. turnVersion 검사는 플레이어가 게임을 진행했을 때 오래된 상태를 재생하는 것을 방지합니다 -- 의도치 않은 "되돌리기"에 대한 미묘한 보호 장치입니다.

두 메모리 내 Map(tokenToSession, latestTurnByBattle)은 이미지 캐시와 동일한 globalThis as unknown as GameSessionGlobals 패턴을 사용하며, 그 이유는 아래에서 살펴보겠습니다.


이미지 렌더링 파이프라인

슬라임과의 전투 시작

/konosuba-rpg/:lang/* 라우트는 JSON을 반환하지 않습니다. 요청 시 생성된 WebP 이미지를 반환합니다.

파이프라인은 3개의 합성 레이어로 구성됩니다:

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: 두 개의 고정 이미지(보드판과 프레임)로, 파일시스템에서 로드되어 한 번 합성됩니다.

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: 무거운 부분입니다. 인터페이스(HP바, 텍스트, 아이콘)의 JSX는 Satori로 React-like하게 기술되고, SVG로 렌더링된 후, @cf-wasm/resvg로 PNG로 변환되어 최종 합성을 위해 Photon으로 가져옵니다. Satori + resvg는 edge-light 플래그로 Cloudflare Workers용으로 특별히 컴파일된 두 WASM 모듈입니다.

방어 액션

전투 진행 중

포옹 액션


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

5개의 별도 캐시 레벨이 있습니다. 각각은 파이프라인의 다른 세분화 수준을 대상으로 합니다.

// 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);

globalThis의 ??= 패턴: edge worker의 JavaScript 모듈은 특정 설정에서 요청 간에 재평가될 수 있습니다. ??=로 globalThis에 캐시를 저장하면 재생성되지 않고 이러한 재평가에서 살아남을 수 있습니다.

WASM 제거(Eviction)

Photon 이미지 캐시(photonCache, layerCache, uiPhotonCache)는 제거 콜백을 사용합니다:

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

new LRUCache(40, freePhoton)

Photon.PhotonImage는 JavaScript GC 외부의 WASM 선형 메모리에 할당된 WASM 객체입니다. 명시적인 .free() 호출 없이는 이 메모리가 해제되지 않습니다. LRU 제거가 자동으로 .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 layer의 키는 정확한 HP 값을 인코딩하지 않습니다 -- 1(생존) 또는 0(사망)만 있습니다. 40 HP의 플레이어 스프라이트와 15 HP의 플레이어 스프라이트는 동일하기 때문입니다. 따라서 아무도 쓰러지지 않는 한 캐시 히트는 모든 피해에 대해 유지됩니다.

반면 UI 키는 정확한 HP(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; // entier 32-bit signé
  }
  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 변환은 worker 인스턴스당 한 번만 수행됩니다.


STRIPPER.md -- 순차적 await 감사

레포지토리에 await 병렬화 감사를 문서화한 STRIPPER.md 파일이 있습니다. 기록된 몇 가지 예시:

  • 플레이어 프로필 로딩이 3개의 Supabase 요청을 직렬로 수행했습니다(진행 상황, 실행 요약, 업적). 의존성이 없어 Promise.all로 변경되었습니다.
  • 전투 종료 보상(장비 + 소모품) 분배가 순차적이었습니다. 마찬가지로 병렬화되었습니다.
  • 버튼용 세션 토큰 생성이 그룹별로 이루어졌습니다. 이제 독립적인 그룹은 병렬로 생성됩니다.
// progressionService.ts -- avant (séquentiel)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

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

혁신적인 것은 아니지만, 응답 시간의 모든 밀리초가 비용으로 청구되거나(콜드 스타트에 기여하는) 서버리스 컨텍스트에서는 중요합니다.


영구 서버 없는 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 }); // 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 secondes
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); // 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(); }

주요 차이점: 정적 에셋입니다. Vercel에서는 파일시스템(/var/task//articles/assets/)에서 읽습니다. Cloudflare Workers에서는 HTTPS 미러(fox3000foxy.com/konosuba-rpg/assets)로 폴백되는 ASSETS 바인딩(CF 정적 에셋)을 통해 전달됩니다. 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; // 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 시작 시 캐릭터 스탯에 적용됩니다. 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  },
    ],
  },
  // ...jusqu'à 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); // max 20MB de croissance heap
});

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(); // zero overhead si désactivé
  perf.mark(`${label}:start`);
  try { return await work(); }
  finally { perf.done(`${label}:done`); }
}

createPerfLogger는 DEV_MODE와 RENDER_PERF가 1로 설정되지 않으면 no-op을 반환합니다. 프로덕션에서 오버헤드가 없습니다.


운영 비용

  • Vercel free tier: 월 100GB 대역폭, 100만 회 서버리스 호출. 이미지 렌더링도 한 번의 호출로 계산됩니다.
  • Cloudflare Workers free tier: 일 10만 건 요청, 요청당 10ms CPU 시간 (렌더링이 Workers에서 이를 초과할 수 있어 Vercel을 기본으로 사용).
  • Supabase free tier: 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입니다. 코드에서 눈에 띄지 않지만, 이것 없이는 프로덕션에서 worker가 메모리 누수됩니다.

  3. WebSocket 없는 서버리스 Discord 봇: WebSocket 게이트웨이 방식보다 덜 알려져 있지만, 무상태 처리를 하는 봇(각 상호작용이 독립적)의 경우 Interactions Endpoint가 엄격히 우수합니다 -- 재연결, 하트비트, 유지해야 할 프로세스가 없습니다. Discord가 인프라 측에서 가용성을 관리합니다.


레포지토리: fox3000foxy/konosuba-rpg

라이선스: 소스 공개 커스텀 -- 재배포 불가, 자유롭게 사용 가능.

Bir hafta sonumu konosuba-rpg'nin kodunu okuyarak geçirdim ve işte bulduklarım

Her eylemin anında WebP görüntüsü oluşturduğu sıra tabanlı bir Discord RPG'si:

Bir hafta sonumu konosuba-rpg'nin kodunu okuyarak geçirdim ve işte bulduklarım

Bu projeyi bir süredir ben sürdürüyorum, ama kendi kodunu sakin kafayla yeniden okumak her zaman öğreticidir. konosuba-rpg, her eylemin anında WebP görüntüsü oluşturduğu sıra tabanlı bir Discord RPG'si. Bir metin embed'i değil. Sprite'ları, can çubukları, savaş mesajları -- her şeyiyle gerçek bir görüntü.

Stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Tamamen ücretsiz barındırma. Ve Discord botu kalıcı bir sunucu olmadan çalışıyor. Bu yazı her şeyin nasıl bir arada durduğunu açıklıyor.

Oyunun başlangıç durumu


Temel tasarım: oyun durumu olarak URL

İlk dikkat çeken şey: oynanış için sunucu tarafında hiçbir durum yok. Bir savaşın tüm durumu URL'nin içinde.

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

Seed'den sonraki her segment oynanan bir eylemdir. Sunucu bu URL'yi alır, baştan başlar, tüm eylemleri sırayla yeniden oynar ve o anki savaşın görüntüsünü döndürür. Oturum yok, kullanıcıya bağlı RAM'de durum yok.

Discord etkileşimli düğmelerle çalışır -- oyuncu "Saldır"a bastığında, Discord düğmenin custom_id'sini sunucuya gönderir. Bu custom_id, yeni eylem eklenmiş savaşın sıkıştırılmış URL'sini içerir. Sunucu her şeyi sıfırdan yeniden hesaplar ve güncellenmiş görüntüyü 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. segment, 8096 değere hash'lenir
  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 küçük bir detay, ancak modüllerin yeniden değerlendirilebildiği bir edge bağlamında yapının her çağrıda yeniden oluşturulmasını önler.

RNG: Değiştirilmiş RC4

Rastgele üreteç, bir PRNG'ye dönüştürülmüş bir RC4 (stream şifreleme algoritması) uygulamasıdır.

export class Random {
  private S: number[]; // 256 girişlik 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]'yi takas et
      [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ü iyi bir dağılıma ve makul seed çarpışma direncine sahip deterministik bir PRNG'dir. Aynı seed = aynı sayı dizisi = her seferinde aynı savaş. Bu, herhangi bir savaşı URL'sini koruyarak "yeniden oynatmayı" sağlar ve iki farklı sunucunun (Vercel + Cloudflare) aynı URL için tam olarak aynı sonucu üretmesini garanti eder.


Discord 100 karakter sınırı sorunu

Discord, düğmelerin custom_id'si için 100 karakter sınırı koyar. Birkaç düzine eylemden sonra, bir savaş URL'si bu sınırı rahatça aşar.

Buna yanıt veren iki mekanizma var.

1. Eylemlerin RLE sıkıştırması

Eylemler tek bir karakterle kodlanır (a=saldırı, d=savunma, h=sarılmak...) 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;
}

Basit, ancak oyuncu üst üste 10 Saldırı yaptığında aaaaaaaaaa (10 karakter) a10'a (3 karakter) dönüşür. UI'daki "x4 Saldır" ve "x10 Saldır" düğmeleri tam da bunun için vardır -- savaşı hızlandırırken payload'u iyi sıkıştırır.

2. Sıkıştırma yeterli olmadığında oturum token'ları

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

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

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Payload'ları battle_key'e göre grupla, Supabase'e toplu ekle
  // custom_id'yi "gs.{token}:{userId}" ile değiştir
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Gerekli değilse lookup yapma
  }
  // Önce bellekten, sonra Supabase'den lookup
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Sahiplik, TTL (7 gün) ve turn_version kontrolü (eski durumu yeniden oynatmayı önler)
}

Oturumların TTL'si 7 gündür ve her 10 dakikada bir otomatik temizlik yapılır. turnVersion kontrolü, oyuncu ilerlemişse eski bir durumun yeniden oynatılmasını engeller -- kazara "geri gitmeye" karşı gizli bir koruma.

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


Görüntü işleme hattı

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

/konosuba-rpg/:lang/* rotası JSON döndürmez. İsteğe bağlı oluşturulmuş bir WebP görüntüsü döndürür.

İşleme hattı 3 bileşik katman halinde düzenlenmiştir:

Background (tahta + çerçeve)
    +
Characters layer (oyuncu sprite'ları + mob, sabit konumlar)
    +
UI overlay (can çubukları, mesajlar, Satori ile karakter simgeleri → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP çıktısı

Background : iki sabit görüntü (tahta ve çerçeve), dosya sisteminden yüklenir ve bir kez birleştirilir.

Characters layer : sprite'lar hesaplanmış koordinatlara göre konumlandırılır. Ölü oyuncular hariç tutulur (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Düşman sprite'ları özel bir flipX ile yatay olarak yansıtılır -- harici bir bağımlılık yerine 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ım. Arayüzün JSX'i (can çubukları, metinler, simgeler) React benzeri Satori ile tanımlanır, SVG'ye dönüştürülür, @cf-wasm/resvg ile PNG'ye çevrilir ve son birleştirme için Photon'a aktarılır. Satori + resvg, Cloudflare Workers için edge-light flag'i ile özel olarak derlenmiş iki WASM modülüdür.

Savunma Eylemi

Devam eden savaş

Sarılmak Eylemi


Önbellek sistemi -- en çok üzerinde çalışılan kısım

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

// renderImage.ts -- hepsi globalThis üzerinde
G.__imageCache  ??= {} as Record; // ham varlıklar
G.__base64Cache ??= {} as Record;       // varlıkların base64'ü (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 üzerinde ??= deseni: edge worker'lardaki JavaScript modülleri, bazı yapılandırmalarda istekler arasında yeniden değerlendirilebilir. Önbellekleri ??= ile globalThis üzerinde saklamak, yeniden oluşturulmadan bu yeniden değerlendirmelerden kurtulmalarını sağlar.

WASM tahliyesi

Photon görüntü ö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 bırakıldı */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage, JavaScript GC'sinin dışında, WASM doğrusal belleği tarafında tahsis edilmiş belleği olan bir WASM nesnesidir. Açık .free() çağrısı olmadan bu bellek asla serbest kalmaz. LRU tahliyesi .free()'i otomatik olarak tetikler -- bu JavaScript'e taşınmış RAII'dir.

Önbellek anahtarları bilerek 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}`;
}

Karakter katmanı anahtarı, HP'nin tam değerini kodlamaz -- sadece 1 (canlı) veya 0 (ölü). Çünkü 40 HP'lik bir oyuncunun sprite'ı ile 15 HP'lik bir oyuncunun sprite'ı aynıdır. Bu nedenle önbellek isabeti, kimse ölmediği sürece herhangi bir hasardan sağ çıkar.

UI anahtarı ise tam HP'yi (can çubuğu her vuruşta değişir) ve mesajların bir hash'ini 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ıya zorlar, bu da float64 dönüşümlerini önler ve kararlı bir polinom hash'i verir. Bunun için harici bir bağımlılık yoktur.

Stack taşması olmadan 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), büyük görüntülerde stack taşmasına neden olabilir çünkü argümanlar çağrı yığınından geçirilir. 32KB'lik parçalama bunu önler. Sonuç önbelleğe alınır -- aynı görüntünün base64 dönüşümü, worker örneği başına yalnızca bir kez yapılır.


STRIPPER.md -- sıralı await denetimi

Repoda, await'leri paralelleştirme denetimini belgeleyen bir STRIPPER.md dosyası var. Kaydedilenlerden birkaç örnek:

  • Oyuncu profili yüklemesi, Supabase'e 3 sıralı sorgu yapıyordu (ilerleme, koşu özeti, başarımlar). Aralarında bağımlılık olmadığı için Promise.all'a dönüştürüldü.
  • Savaş sonu ödül dağıtımı (aksesuarlar + sarf malzemeleri) sıralıydı. Aynı şekilde paralelleştirildi.
  • Düğmeler için oturum token'ı 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(...),
]);

Devrim niteliğinde bir şey değil, ancak her milisaniyelik yanıt süresinin faturalandırıldığı (veya cold start'a katkıda bulunduğu) sunucusuz bir bağlamda bu önemlidir.


Kalıcı sunucu olmadan Discord botu

Zafer

Sıkça yanlış anlaşılan bir nokta: bir Discord botu mutlaka kalıcı bir WebSocket bağlantısı gerektirmez. Discord bir alternatif sunar: Interactions Endpoint URL. Discord'a bir HTTPS URL'si sağlarsınız ve Discord her etkileşim için (slash komutu, düğme, otomatik tamamlama) size bir POST gönderir.

// 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 bir POST gönderir, işleyici bir Vercel fonksiyonu veya Cloudflare Worker üzerinde 50-200ms çalışır, yanıt verir ve biter. Kalıcı bağlantı yok, açık tutulacak sunucu yok. Botun tamamı Vercel free tier'da barındırılır.

Ed25519 doğrulaması (discord-interactions'dan verifyKey) zorunludur -- Discord, başlıklarda doğrulamanız gereken bir imza gönderir, aksi takdirde endpoint'i reddeder.

Özel animasyon -- tek kasıtlı 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 kasıtlı gecikme, STRIPPER.md'de kasıtlı olarak belgelenmiştir. Megumin'in özel saldırısı (Patlama) Discord tarafında bir animasyona sahiptir -- mesaj önce ara bir görselle güncellenir, ardından 3 saniye sonra sonuçla değiştirilir. Bu, bir Vercel fonksiyonunun gereğinden uzun çalıştığı tek durumdur.

Özel saldırı


İki platformda dağıtılabilirlik

Aynı kod tabanı, değişiklik yapılmadan Vercel (Node.js) ve Cloudflare Workers (V8 isolates) üzerinde çalışır:

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

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

Temel fark: statik varlıklar. Vercel'de dosya sisteminden okunur (/var/task//articles/assets/). Cloudflare Workers'ta, bir HTTPS mirror'a (fox3000foxy.com/konosuba-rpg/assets) geri dönüşlü ASSETS binding'inden (CF statik varlıkları) geçerler. assetLoader.ts içindeki getAssetBytes, önce dosya sistemini, ardından fetch'i deneyerek her iki yolu da yönetir.

WASM'ler (@cf-wasm/photon/edge-light, @cf-wasm/resvg) her çalışma zamanı için ayrı derlemelere sahiptir. Paket adındaki edge-light flag'i, çalışma zamanında new WebAssembly.Module()'e izin vermeyen Cloudflare Workers uyumlu derlemeyi belirtir -- WASM önceden derlenmiş olmalıdır.


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

Bir patron, 650 HP

Meta-ilerleme Supabase free tier'a dayanır. Şema; players (genel XP, seviye, altın), character_progress (Darkness, Aqua, Megumin için karakter başına XP/seviye/yakınlık), runs (savaş geçmişi), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions tablolarını 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 stat
}

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 faktörler, her processGame başlangıcında karakter istatistiklerine uygulanır. Kazuma, oyuncunun genel seviyesini takip eder; diğer üçünün her birinin kendi XP/seviyesi vardır. Yakınlık (bir karakterle ilgili drop'ları toplayarak kazanılır) istatistiklerini bağımsız olarak çarpar.

İyileştirme

Drop sistemi, zorluğa göre ağırlıklandırılmış 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  },
    ],
  },
  // ...Legendary'e kadar
};

Testler

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

Sızıntı testi özellikle doğrudandır:

// 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, öncesi ve sonrası zorunlu GC, heap delta < 20MB. Bu test geçerse, processGame sızdırmıyordur. Render testi (renderImage.spec.ts) ise çalışma süresini pratik bir eşiğin altında kontrol eder.

Ayrıca tüm hattı profillemek için bir bench.ts betiği vardır:

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

RENDER_PERF=1 ile, her servisteki withPerf sarmalayıcısı zamanlamaları kaydeder:

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

createPerfLogger, DEV_MODE ve RENDER_PERF 1 değilse no-op'lar döndürür. Üretimde hiçbir ek yük yoktur.


Çalıştırmanın maliyeti

  • Vercel free tier : ayda 100GB bant genişliği, 1M sunucusuz çağrı. Görüntü oluşturma bir çağrı olarak sayılır.
  • Cloudflare Workers free tier : günde 100K istek, istek başına 10ms CPU süresi (render Workers'ta bunu aşabilir, bu nedenle Vercel birincil).
  • Supabase free tier : 500MB veritabanı, 5GB bant genişliği. Binlerce oyuncu için yeterlidir.

Tüm backend, önemli bir hacme kadar sıfır maliyetle çalışır. Tek sürtüşme noktası, Cloudflare Workers'ın CPU sınırıdır -- görüntü oluşturma, WASM nedeniyle CPU yoğunlukludur, bu nedenle Vercel'in birincil, Workers'ın ise CDR yedeklemesi stratejisi.


Hatırlanmaya değer 3 şey

  1. Oyun durumu olarak URL sadece hoş bir numara değil -- Discord tarafından dayatılan bir kısıtlamadır (düğmelerin 100 karakter sınırı vardır) ve RLE sıkıştırma + yedek olarak oturum token'ı ile durumsuz bir mimariyi zorunlu kılmıştır. Kısıtlama tasarımı belirlemiştir.

  2. Açık tahliyeli WASM önbelleği: PhotonImage'ler JavaScript heap'inin dışında bellek ayırır ve .free() olmadan asla GC edilmez. freePhoton'u LRU tahliyesine bağlamak, JavaScript'te RAII'dir. Kodda göze çarpmaz, ancak onsuz worker üretimde sızdırırdı.

  3. WebSocket olmadan sunucusuz Discord botu: WebSocket ağ geçidi yaklaşımından daha az bilinir, ancak durumsuz işleme yapan bir bot için (her etkileşim bağımsızdır), Interactions Endpoint kesinlikle üstündür -- yeniden bağlanma yok, heartbeat yok, sürdürülecek süreç yok. Discord, kullanılabilirliği kendi altyapıları tarafında yönetir.


Repo : fox3000foxy/konosuba-rpg

Kaynak kodu lisansı özel -- yeniden dağıtım yok, kullanımı ücretsiz.

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

Un RPG a turni Discord dove ogni azione genera un'immagine WebP

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

Mantengo questo progetto da un po', ma rileggere il proprio codice a mente fresca è sempre istruttivo. konosuba-rpg è un RPG a turni Discord dove ogni azione genera un'immagine WebP al volo. Non un embed testuale. Una vera immagine composta, con gli sprite, le barre della vita, i messaggi di combattimento -- tutto.

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

Stato iniziale del gioco


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/it/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, riesegue tutte le azioni in ordine, e restituisce un'immagine del combattimento in 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 compressa del combattimento con la nuova azione aggiunta. Il server ricalcola tutto da zero e restituisce l'immagine aggiornata.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Precompilato fuori dalla funzione -- non ricreato a ogni chiamata

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, hash su 8096 valori
  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 a flusso) riadattata come PRNG.

export class Random {
  private S: number[]; // tabella di 256 entry
  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 { /* ... */ }
}

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


Il problema del limite dei 100 caratteri di Discord

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

Due meccanismi rispondono a questo.

1. Compressione RLE delle azioni

Le azioni sono codificate con un singolo carattere (a=attack, d=defend, h=hug...) e compresse con 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;
}

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

2. Session token quando la compressione non basta più

Se il payload compresso rimane troppo lungo, viene memorizzato in database con un token corto:

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

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Raggruppa i payload per battle_key, inserisce in batch in Supabase
  // Sostituisce il custom_id con "gs.{token}:{userId}"
}

export async function decodeGameplayPayloadWithStatus(encodedPayload: string, userID: string) {
  if (!encodedPayload.startsWith(TOKEN_PREFIX)) {
    return { payload: encodedPayload }; // Nessun lookup se non necessario
  }
  // Lookup prima in memoria, poi Supabase se assente
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Verifica ownership, TTL (7 giorni), e turn_version (evita di riprodurre uno stato vecchio)
}

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

Le due Map in memoria (tokenToSession, latestTurnByBattle) usano lo stesso pattern globalThis as unknown as GameSessionGlobals delle cache d'immagine, per le stesse ragioni che vedremo più avanti.


La pipeline di rendering dell'immagine

Inizio del combattimento contro uno Slime

La route /konosuba-rpg/:lang/* non restituisce JSON. Restituisce 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 (la plancia 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 personalizzato -- un ciclo pixel per pixel invece di una dipendenza esterna.

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 della vita, testi, icone) è descritto in React-like con Satori, renderizzato in SVG, convertito in PNG da @cf-wasm/resvg, poi importato in Photon per la composizione finale. Satori + resvg sono due moduli WASM compilati specificamente per Cloudflare Workers con il flag edge-light.

Azione Difesa

Combattimento in corso

Azione Abbraccio


Il sistema di cache -- la parte più elaborata

Ci sono 5 livelli di cache distinti. Ognuno ha come target 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 in alcune configurazioni. Memorizzare le cache su globalThis con ??= garantisce che sopravvivano a queste rivalutazioni senza essere ricreate.

L'evizione WASM

Le cache d'immagine Photon (photonCache, layerCache, uiPhotonCache) usano un callback di evizione:

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 nel linear memory WASM, fuori dal GC di JavaScript. Senza chiamata esplicita a .free(), questa memoria non viene mai liberata. L'evizione del LRU triggera .free() automaticamente -- è RAII portato in JavaScript.

Le chiavi di cache sono intenzionalmente 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 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 cache hit sopravvive quindi a qualsiasi danno finché nessuno cade.

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

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, evitando le conversioni float64 e dando un hash polinomiale stabile. Nessuna dipendenza esterna per questo.

La conversione base64 senza 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ò causare uno stack overflow sulle immagini grandi perché gli argomenti sono passati sulla call stack. Il chunking a 32KB lo evita. Il risultato è messo in cache -- la conversione base64 di una stessa immagine è fatta una sola volta per istanza del 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 è registrato:

  • Il caricamento del profilo giocatore faceva 3 richieste Supabase in serie (progressione, riepilogo run, achievement). Sono state messe 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 ora creati in parallelo.
// progressionService.ts -- prima (sequenziale)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

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

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


Il bot Discord senza server persistente

Vittoria

Punto spesso frainteso: un bot Discord non richiede necessariamente una connessione WebSocket persistente. Discord offre 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
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, il handler gira 50-200ms su una funzione Vercel o un Cloudflare Worker, risponde, e finisce qui. Nessuna connessione permanente da mantenere, nessun server da tenere acceso. L'intero bot Discord è ospitato sul free tier di Vercel.

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

L'animazione speciale -- l'unico await intenzionale

// handleSpecialButton.ts
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 (Esplosione) ha un'animazione lato Discord -- il messaggio viene prima aggiornato con un'immagine intermedia, poi modificato 3 secondi dopo con il risultato. È l'unico caso in cui una funzione Vercel gira volutamente più a lungo del necessario.

Attacco speciale


Distribuibile su due piattaforme

La stessa codebase gira su Vercel (Node.js) e su Cloudflare Workers (V8 isolates) senza modifiche:

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // inietta i segreti 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, vengono letti dal filesystem (/var/task//articles/assets/). Su Cloudflare Workers, passano attraverso 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 prima il filesystem, 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 designa la build compatibile con Cloudflare Workers, che non permette new WebAssembly.Module() a runtime -- il WASM deve essere pre-compilato.


La progressione: XP, livelli, affinità

Un boss, 650 HP

La meta-progressione si basa su Supabase free tier. Lo schema comprende 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
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% statistiche 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 statistiche dei personaggi all'inizio di ogni processGame. Kazuma segue il livello globale del giocatore, gli altri tre hanno ciascuno il proprio XP/livello. L'affinità (ottenuta recuperando drop legati a un personaggio) moltiplica le sue statistiche indipendentemente.

Cura

Il sistema di drop utilizza tabelle di loot 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 leaks.

Il leak test è particolarmente diretto:

// 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 di crescita heap
});

1200 iterazioni di processGame, GC forzato prima e dopo, delta heap < 20MB. Se questo test passa, processGame non perde memoria. Il test di render (renderImage.spec.ts) verifica piuttosto 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:

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 impostati a 1. Nessun overhead in produzione.


Quanto costa farlo funzionare

  • Vercel free tier: 100GB di banda, 1M di invocazioni serverless al mese. Il render dell'immagine conta come un'invocazione.
  • Cloudflare Workers free tier: 100K richieste/giorno, 10ms CPU time per richiesta (il render può superarlo sui Workers, da qui Vercel come primario).
  • Supabase free tier: 500MB di database, 5GB di banda. Sufficiente per migliaia di giocatori.

L'intero backend funziona a costo zero fino a volumi significativi. L'unico punto d'attrito è il limite CPU di Cloudflare Workers -- il render dell'immagine è CPU-intensive a causa di WASM, da qui la strategia di Vercel come primario e Workers come CDN di failover.


Le 3 cose che meritano di essere ricordate

  1. L'URL come stato di gioco non è solo un trucco carino -- è 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 evizione esplicita: i PhotonImage allocano fuori dallo heap JavaScript e non saranno mai GC'd senza .free(). Collegare freePhoton all'evizione del LRU è RAII in JavaScript. È discreto nel codice, ma senza di esso il worker perderebbe memoria in produzione.

  3. Un bot Discord serverless senza WebSocket: è meno conosciuto dell'approccio WebSocket gateway, ma per un bot che fa elaborazione stateless (ogni interazione è indipendente), l'Interactions Endpoint è strettamente superiore -- nessuna riconnessione, nessun heartbeat, nessun processo da mantenere. Discord gestisce la disponibilità dalla propria infrastruttura.


Repo: fox3000foxy/konosuba-rpg

Licenza source-available personalizzata -- nessuna ridistribuzione, free to use.

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

Ein Discord-Runden-RPG, bei dem jede Aktion ein WebP-Bild auf

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

Ich betreibe dieses Projekt seit einer Weile, aber den eigenen Code in Ruhe noch einmal durchzugehen, ist immer lehrreich. konosuba-rpg ist ein Discord-Runden-RPG, bei dem jede Aktion ein WebP-Bild auf Anfrage erzeugt. Kein Text-Embed. Ein echtes zusammengesetztes Bild, mit Sprites, Lebensbalken, Kampfnachrichten – alles.

Der Stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Vollständig kostenloses Hosting. Und der Discord-Bot läuft ohne persistenten Server. Dieser Beitrag erklärt, wie alles zusammenhält.

Anfangszustand des Spiels


Das grundlegende Design: die URL als Spielzustand

Das Erste, was auffällt: Es gibt keinen serverseitigen Zustand für das Gameplay. Der vollständige Zustand eines Kampfes steckt in der URL.

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

Jedes Segment nach dem Seed ist eine gespielte Aktion. Der Server erhält diese URL, startet von vorne, spielt alle Aktionen in der Reihenfolge ab und gibt ein Bild des Kampfes zu diesem Zeitpunkt zurück. Keine Session, kein benutzerbezogener Zustand im RAM.

Discord funktioniert über interaktive Buttons – wenn der Spieler auf "Angreifen" drückt, sendet Discord die custom_id des Buttons an den Server. Diese custom_id enthält die komprimierte URL des Kampfes mit der neu hinzugefügten Aktion. Der Server berechnet alles von Grund auf neu und gibt das aktualisierte Bild zurück.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Vor der Funktion vorkompiliert – wird 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, auf 8096 Werte gehasht
  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 vorkompilierte Set außerhalb der Funktion ist ein Detail, aber es vermeidet, die Struktur bei jedem Aufruf in einem Edge-Kontext neu aufzubauen, wo Module neu evaluiert werden können.

Der RNG: modifiziertes RC4

Der Zufallsgenerator ist eine als PRNG zweckentfremdete RC4-Implementierung (Stromchiffre-Algorithmus).

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;
      // swap S[i] und 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 { /* ... */ }
}

Warum RC4? Weil es ein deterministischer PRNG mit einer ordentlichen Verteilung und einer vernünftigen Resistenz gegen Seed-Kollisionen ist. Gleicher Seed = gleiche Zahlenfolge = gleicher Kampf jedes Mal. Das ermöglicht es, jeden Kampf über seine URL „wiederzuspielen", und garantiert, dass zwei verschiedene Server (Vercel + Cloudflare) für dieselbe URL exakt dasselbe Ergebnis liefern.


Das Problem der 100-Zeichen-Grenze von Discord

Discord erzwingt eine Grenze von 100 Zeichen für die custom_id von Buttons. Nach ein paar Dutzend Aktionen überschreitet eine Kampf-URL locker diese Grenze.

Zwei Mechanismen begegnen dem.

1. RLE-Kompression der Aktionen

Die Aktionen werden mit einem einzelnen Zeichen kodiert (a=attack, d=defend, h=hug...) und per Lauflängenkodierung 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;
}

Einfach, aber wenn der Spieler Angriff x10 spammt, wird aus aaaaaaaaaa (10 Zeichen) a10 (3 Zeichen). Die Buttons "Angreifen x4" und "Angreifen x10" in der UI existieren genau dafür – den Kampf beschleunigen und gleichzeitig die Nutzlast gut komprimieren.

2. Session-Tokens, wenn die Kompression nicht mehr ausreicht

Wenn die komprimierte Nutzlast immer noch zu lang ist, wird sie mit einem kurzen Token in der Datenbank gespeichert:

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

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Gruppiert die Nutzlasten nach battle_key, fügt sie 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 nicht nötig
  }
  // Lookup zuerst im Speicher, dann Supabase falls nicht vorhanden
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Prüft Ownership, TTL (7 Tage) und turn_version (verhindert die Wiederverwendung eines alten Zustands)
}

Die Sessions haben ein TTL von 7 Tagen und werden automatisch alle 10 Minuten bereinigt. Die turnVersion-Prüfung verhindert, dass ein veralteter Zustand erneut abgespielt wird, wenn der Spieler im Spiel vorangekommen ist – ein diskreter Schutz gegen versehentliches „Zurückgehen".

Die beiden Maps im Speicher (tokenToSession, latestTurnByBattle) verwenden dasselbe globalThis as unknown as GameSessionGlobals-Muster wie die Bild-Caches, aus denselben Gründen, die weiter unten erläutert werden.


Die Bild-Rendering-Pipeline

Kampfbeginn gegen einen Schleim

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

Die Pipeline ist in 3 zusammengesetzte Schichten organisiert:

Hintergrund (Spielfeld + Rahmen)
    +
Figurenschicht (Spieler-Sprites + Mob, feste Positionen)
    +
UI-Overlay (Lebensbalken, Nachrichten, Charakter-Icons via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP-Ausgabe

Hintergrund: zwei feste Bilder (das Spielfeld und der Rahmen), die einmal vom Dateisystem geladen und zusammengesetzt werden.

Figurenschicht: Die Sprites werden anhand berechneter Koordinaten positioniert. Tote Spieler werden ausgeschlossen (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Gegner-Sprites werden mit einem benutzerdefinierten flipX horizontal gespiegelt – eine 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 JSX der Oberfläche (Lebensbalken, Texte, Icons) wird React-ähnlich mit Satori beschrieben, in SVG gerendert, von @cf-wasm/resvg in PNG konvertiert und dann in Photon für die endgültige Komposition importiert. Satori + resvg sind zwei WASM-Module, die speziell für Cloudflare Workers mit dem Flag edge-light kompiliert wurden.

Aktion Verteidigung

Laufender Kampf

Aktion Umarmung


Das Cachesystem – der am meisten ausgearbeitete Teil

Es gibt 5 verschiedene Cache-Ebenen. Jede zielt auf eine andere Granularität der Pipeline ab.

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

Das ??=-Muster auf globalThis: JavaScript-Module in Edge-Workern können bei bestimmten Konfigurationen zwischen Anfragen neu evaluiert werden. Das Speichern der Caches auf globalThis mit ??= stellt sicher, dass sie diese Neubewertungen überleben, ohne neu erstellt zu werden.

Die WASM-Eviction

Die Photon-Bild-Caches (photonCache, layerCache, uiPhotonCache) verwenden einen Eviction-Callback:

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

new LRUCache(40, freePhoton)

Photon.PhotonImage ist ein WASM-Objekt mit Speicher, der auf der linearen WASM-Seite allokiert ist, außerhalb des JavaScript-GC. Ohne expliziten Aufruf von .free() wird dieser Speicher nie freigegeben. Die LRU-Eviction triggert .free() automatisch – das ist RAII nach JavaScript portiert.

Die Cache-Schlüssel sind bewusst verlustbehaftet

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 Schlüssel der Figurenschicht kodiert nicht den genauen HP-Wert – nur 1 (lebendig) oder 0 (tot). Denn das Sprite eines Spielers mit 40 HP und eines mit 15 HP ist identisch. Ein Cache-Treffer überlebt daher jeden Schaden, solange niemand fällt.

Der UI-Schlüssel kodiert dagegen 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; // vorzeichenbehaftete 32-Bit-Ganzzahl
  }
  return hash.toString(16);
}

Math.imul erzwingt die Multiplikation als 32-Bit-Ganzzahl, was float64-Konvertierungen vermeidet und einen stabilen Polynom-Hash ergibt. Keine externe Abhängigkeit dafür.

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 verursachen, weil die Argumente über den Call-Stack übergeben werden. Das Aufteilen in 32-KB-Blöcke vermeidet das. Das Ergebnis wird zwischengespeichert – die base64-Konvertierung desselben Bildes wird nur einmal pro Worker-Instanz durchgeführt.


STRIPPER.md – Audit von sequenziellen awaits

Es gibt eine Datei STRIPPER.md im Repo, die ein Audit zur Parallelisierung von awaits dokumentiert. Einige Beispiele dessen, was dort festgehalten wurde:

  • Das Laden des Spielerprofils machte 3 sequenzielle Supabase-Abfragen (Fortschritt, Run-Zusammenfassung, Erfolge). Sie wurden auf Promise.all umgestellt – es gibt keine Abhängigkeiten zwischen ihnen.
  • Die Verteilung der Kampfbelohnungen (Accessoires + Verbrauchsgegenstände) war sequenziell. Ebenfalls parallelisiert.
  • Die Erstellung der Session-Tokens für die Buttons erfolgte 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 serverlosen Kontext, wo jede Millisekunde Antwortzeit abgerechnet wird (oder zum Cold Start beiträgt), zählt es.


Der Discord-Bot ohne persistenten Server

Sieg

Ein oft missverstandener Punkt: Ein Discord-Bot benötigt nicht zwingend eine persistente WebSocket-Verbindung. Discord bietet eine Alternative: die Interactions Endpoint URL. Du stellst Discord eine HTTPS-URL zur Verfügung, und Discord sendet 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 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord sendet einen POST, der Handler läuft 50–200 ms auf einer Vercel-Funktion oder einem Cloudflare Worker, antwortet, und fertig. Keine dauerhafte Verbindung, kein Server, der eingeschaltet bleiben muss. Der gesamte Discord-Bot wird auf dem Vercel Free Tier gehostet.

Die Ed25519-Überprüfung (verifyKey von discord-interactions) ist obligatorisch – Discord sendet eine Signatur in den Headern, die du validieren musst, sonst wird der Endpunkt abgelehnt.

Die Spezialanimation – der einzige absichtliche 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 bewusste 3-Sekunden-Verzögerung ist in STRIPPER.md als beabsichtigt dokumentiert. Megumins Spezialangriff (Explosion) hat eine Animation auf Discord-Seite – die Nachricht wird zuerst mit einem Zwischenbild aktualisiert und 3 Sekunden später mit dem Ergebnis geändert. Dies ist der einzige Fall, in dem eine Vercel-Funktion bewusst länger als nötig läuft.

Spezialangriff


Die Bereitstellbarkeit auf zwei Plattformen

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

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

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

Der Hauptunterschied: die statischen Assets. Auf Vercel werden sie vom 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 und dann fetch versucht.

Die WASM-Bibliotheken (@cf-wasm/photon/edge-light, @cf-wasm/resvg) haben separate Builds für jede Laufzeit. Das Flag edge-light im Paketnamen bezeichnet den Build, der mit Cloudflare Workers kompatibel ist, wo new WebAssembly.Module() zur Laufzeit nicht erlaubt ist – das WASM muss vorcompiliert sein.


Der Fortschritt: XP, Level, Affinität

Ein Boss, 650 HP

Die Meta-Progression basiert auf dem Supabase Free Tier. Das Schema umfasst eine Tabelle players (globales XP, Level, Gold), character_progress (XP/Level/Affinität pro Charakter für Darkness, Aqua, Megumin), runs (Kampfhistorie), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Das Fortschrittsmodell ist einfach:

// 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, maximal 5 Sterne
  return 1.2 ** stars; // exponentielle Progression
}

Diese Faktoren werden zu Beginn jedes processGame auf die Stats der Charaktere angewendet. Kazuma folgt dem globalen Spielerlevel, die anderen drei haben jeweils ihr eigenes XP/Level. Die Affinität (erhalten durch das Einsammeln von charakterspezifischen Drops) multipliziert seine Stats unabhängig.

Heilung

Das Drop-System verwendet 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 Test-Suites: Unit-, Performance- und Leak-Tests.

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, GC erzwungen vorher und nachher, Heap-Delta < 20 MB. Wenn dieser Test bestanden wird, leakt processGame nicht. Der Rendertest (renderImage.spec.ts) prüft eher die Ausführungszeit unter einer praktischen Schwelle.

Es gibt auch ein Skript bench.ts zum Profiling der gesamten Pipeline:

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

Mit RENDER_PERF=1 protokolliert der Wrapper withPerf in jedem Dienst 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 gesetzt sind. Kein Overhead in der Produktion.


Was der Betrieb kostet

  • Vercel Free Tier: 100 GB Bandbreite, 1 Mio. serverlose Aufrufe pro Monat. Das Rendern eines Bildes zählt als ein Aufruf.
  • Cloudflare Workers Free Tier: 100K Anfragen/Tag, 10 ms CPU-Zeit pro Anfrage (das Rendern kann dies auf den Workers überschreiten, daher Vercel als Primär).
  • Supabase Free Tier: 500 MB Datenbank, 5 GB Bandbreite. Ausreichend für Tausende von Spielern.

Das gesamte Backend läuft bis zu einem signifikanten Volumen kostenlos. Der einzige Reibungspunkt ist die CPU-Grenze von Cloudflare Workers – das Bildrendering ist CPU-intensiv aufgrund von WASM, daher die Strategie mit Vercel als Primär und Workers als Failover-CDN.


Die 3 Dinge, die man sich merken sollte

  1. Die URL als Spielzustand ist nicht nur ein netter Trick – es ist eine von Discord auferlegte Einschränkung (Buttons haben ein 100-Zeichen-Limit), die zu einer zustandslosen Architektur mit RLE-Kompression und Session-Token als Fallback gezwungen hat. Die Einschränkung hat das Design bestimmt.

  2. Der WASM-Cache mit expliziter Eviction: PhotonImage allokiert außerhalb des JavaScript-Heaps und wird ohne .free() niemals vom GC erfasst. freePhoton an die LRU-Eviction zu hängen, ist RAII in JavaScript. Es ist unscheinbar im Code, aber ohne dies würde der Worker in der Produktion leaken.

  3. Ein serverloser Discord-Bot ohne WebSocket: Weniger bekannt als der WebSocket-Gateway-Ansatz, aber für einen Bot, der zustandslose Verarbeitung macht (jede Interaktion ist unabhängig), ist der Interactions Endpoint strikt überlegen – keine Wiederverbindung, kein Heartbeat, kein zu wartender Prozess. Discord verwaltet die Verfügbarkeit auf ihrer Infrastruktur.


Repo: fox3000foxy/konosuba-rpg

Source-available custom licence – no redistribution, free to use.

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

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

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

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

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

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


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

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

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

Каждый сегмент после seed -- это сыгранное действие. Сервер получает этот 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-контексте, где модули могут быть переоценены.

ГСЧ: модифицированный 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;
      // swap 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 символов

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

Два механизма решают эту проблему.

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 }; // Без lookup, если не нужно
  }
  // Сначала lookup в памяти, затем Supabase, если отсутствует
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Проверяет ownership, TTL (7 дней) и turn_version (предотвращает повторное воспроизведение старого состояния)
}

Сессии имеют TTL 7 дней и автоматическую очистку каждые 10 минут. Проверка turnVersion предотвращает повторное воспроизведение устаревшего состояния, если игрок продвинулся в партии -- незаметная защита от случайного «возврата назад».

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


Конвейер рендеринга изображений

Начало боя со Слимом

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

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

Background (доска + рамка)
    +
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-подобном стиле с помощью 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-модули в 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, вне GC JavaScript. Без явного вызова .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}`;
}

Ключ слоя персонажей не кодирует точное значение 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) может вызвать переполнение стека на больших изображениях, потому что аргументы передаются через стек вызовов. Разбивка на чанки по 32КБ предотвращает это. Результат кешируется -- конвертация base64 одного и того же изображения выполняется только один раз на инстанс воркера.


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

В репозитории есть файл 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-URL Discord, и Discord отправляет POST на каждый запрос взаимодействия (slash-команда, кнопка, автозаполнение).

// 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–200 мс на функции Vercel или Cloudflare Worker, отвечает, и всё. Никакого постоянного соединения, никакого сервера, который нужно держать включённым. Весь бот Discord размещён на бесплатном тарифе Vercel.

Проверка Ed25519 (verifyKey из discord-interactions) обязательна -- 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 (Взрыв) имеет анимацию на стороне 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 они проходят через binding 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, уровень, золото), 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); // макс. 20 МБ роста кучи
});

1200 итераций processGame, принудительный GC до и после, дельта кучи < 20 МБ. Если этот тест проходит, 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 free tier: 100 ГБ трафика, 1M serverless-вызовов в месяц. Рендер изображения считается как один вызов.
  • Cloudflare Workers free tier: 100K запросов/день, 10 мс CPU-времени на запрос (рендер может превышать это на Workers, отсюда Vercel в качестве основного).
  • Supabase free tier: 500 МБ базы данных, 5 ГБ трафика. Достаточно для тысяч игроков.

Весь бэкенд работает с нулевой стоимостью до достижения значительного объёма. Единственная точка трения -- лимит CPU Cloudflare Workers: рендер изображений требует много CPU из-за WASM, отсюда стратегия с Vercel как основным и Workers как CDN для отказоустойчивости.


3 вещи, которые стоит запомнить

  1. URL как состояние игры -- это не просто хитрая уловка; это ограничение, навязанное Discord (кнопки имеют лимит в 100 символов), которое привело к stateless-архитектуре с RLE-сжатием и токенами сессий как запасным вариантом. Ограничение продиктовало дизайн.

  2. WASM-кеш с явным вытеснением: PhotonImage выделяют память вне кучи JavaScript и никогда не будут собраны GC без .free(). Подключение freePhoton к вытеснению из LRU -- это RAII в JavaScript. Это незаметно в коде, но без этого воркер бы утекал в продакшене.

  3. Бот 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 es lo que encontré

Un RPG por turnos de Discord donde cada acción genera una imagen WebP

Pasé un fin de semana leyendo el código de konosuba-rpg y esto es lo que encontré

Mantengo este proyecto desde hace un tiempo, pero releer el propio código con calma siempre es instructivo. konosuba-rpg es un RPG por turnos de Discord donde cada acción genera una imagen WebP sobre la marcha. No un embed de texto. Una imagen real compuesta, con los sprites, las barras de vida, los mensajes de combate -- todo.

La stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hospedaje completamente gratuito. Y el bot de Discord funciona sin servidor persistente. Este post explica cómo todo se mantiene unido.

Estado inicial del juego


El diseño base: la URL como estado del juego

Lo primero que sorprende: no hay ningún estado del lado del servidor para el gameplay. El estado completo de un combate cabe en la URL.

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

Cada segmento después del seed es una acción jugada. El servidor recibe esta URL, vuelve al inicio, reproduce todas las acciones en orden, y devuelve una imagen del combate en ese instante preciso. Sin sesión, sin estado en RAM vinculado a un usuario.

Discord funciona mediante botones interactivos -- cuando el jugador pulsa "Atacar", Discord envía al servidor el custom_id del botón. Este 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 la función -- no se recrea 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 a 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 ser reevaluados.

El RNG: RC4 modificado

El generador aleatorio es una implementación RC4 (algoritmo de cifrado de flujo) desviada como 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 colisiones de seed. Misma seed = misma secuencia de números = mismo combate cada vez. Esto permite "reproducir" cualquier combate conservando su URL, y garantiza que dos servidores diferentes (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. Después de unas decenas de acciones, una URL de combate supera ampliamente ese límite.

Dos mecanismos responden a esto.

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 mediante 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 spammea Ataque x10 pasa de aaaaaaaaaa (10 chars) a a10 (3 chars). Los botones "Atacar x4" y "Atacar x10" en la UI existen precisamente para eso -- acelerar el combate mientras se comprime bien el payload.

2. Tokens de sesión cuando la compresión no es suficiente

Si el payload comprimido sigue siendo demasiado largo, se almacena en base de datos 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 es necesario
  }
  // Lookup en memoria primero, luego Supabase si está ausente
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Verifica ownership, TTL (7 días), y turn_version (evita reproducir un estado antiguo)
}

Las sesiones tienen un TTL de 7 días y un pruning automático cada 10 minutos. La verificación turnVersion impide reproducir un estado obsoleto si el jugador ha avanzado en la partida -- una protección discreta contra el "retroceso" accidental.

Los dos 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 renderizado 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 capas compuestas:

Background (board + frame)
    +
Characters layer (sprites jugadores + mob, posiciones fijas)
    +
UI overlay (barras HP, mensajes, iconos persos via 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 reflejan horizontalmente con un flipX personalizado -- un bucle píxel por píxel en lugar 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, se renderiza a SVG, se convierte a PNG mediante @cf-wasm/resvg, y luego se importa 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 diferente del pipeline.

// renderImage.ts -- todos en globalThis
G.__imageCache  ??= {} as Record; // assets brutos
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 ser reevaluados entre peticiones en ciertas configuraciones. Almacenar los cachés en globalThis con ??= garantiza que sobrevivan a esas reevaluaciones sin ser recreados.

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 de WASM, fuera del GC de JavaScript. Sin una llamada explícita a .free(), esa memoria nunca se libera. La evicción del LRU dispara .free() automáticamente -- es RAII implementado en JavaScript.

Las claves de caché son 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}`;
}

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 con 40 HP y un jugador con 15 HP es idéntico. Un acierto de caché sobrevive por tanto a cualquier daño mientras nadie caiga.

La clave UI por el contrario codifica los HP exactos (la barra de vida cambia con 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 signado
  }
  return hash.toString(16);
}

Math.imul fuerza la multiplicación a entero de 32 bits, lo que evita las conversiones float64 y da un hash polinomial estable. Sin dependencia externa para esto.

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 octetos
  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 imágenes grandes porque los argumentos se pasan en la call stack. El chunking de 32KB lo evita. El resultado se almacena en caché -- la conversión base64 de una misma imagen se hace solo una vez por instancia de worker.


STRIPPER.md -- auditoría de 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 que allí se registra:

  • La carga del perfil del jugador hacía 3 consultas Supabase en serie (progresión, resumen de run, logros). Se pasaron a Promise.all -- no hay dependencia entre ellas.
  • La distribución de recompensas de fin de combate (accesorios + consumibles) era secuencial. Paralelizada igualmente.
  • La creación de 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 de Discord sin servidor persistente

Victoria

Punto a menudo malentendido: un bot de Discord no requiere necesariamente una conexión WebSocket persistente. Discord ofrece una alternativa: las Interactions Endpoint URL. Proporcionas 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 se ejecuta 50-200ms en una función Vercel o un Cloudflare Worker, responde, y se acabó. Sin conexión permanente que mantener, sin servidor que mantener encendido. La totalidad del bot de Discord está alojada en el free tier de Vercel.

La verificación Ed25519 (verifyKey desde discord-interactions) es obligatoria -- Discord envía una firma en los headers que debes validar, de lo contrario 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",
  // ...
});

Este retardo voluntario de 3 segundos está documentado en STRIPPER.md como intencional. El ataque especial de Megumin (Explosión) tiene una animación del lado de Discord -- el mensaje se actualiza primero con un visual intermedio, y luego se modifica 3 segundos después con el resultado. Es el único caso donde una función de Vercel se ejecuta voluntariamente más tiempo del necesario.

Ataque especial


Despliegue en dos plataformas

El mismo codebase funciona 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 de 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 de CF) con fallback hacia un mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). El getAssetBytes en assetLoader.ts gestiona ambos caminos intentando el filesystem primero, 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 con Cloudflare Workers, que no permite new WebAssembly.Module() en runtime -- el WASM debe estar precompilado.


La progresión: XP, niveles, afinidad

Un jefe, 650 HP

La meta-progresión se apoya en Supabase free tier. El esquema incluye una tabla players (XP global, nivel, gold), character_progress (XP/nivel/afinidad por personaje para Darkness, Aqua, Megumin), runs (historial de 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
}

Estos factores se aplican a las stats de los personajes 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 al recoger drops vinculados a un personaje) multiplica sus stats independientemente.

Curación

El sistema de drops usa tablas de botín 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, rendimiento, 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); // max 20MB de crecimiento heap
});

1200 iteraciones de processGame, GC forzado antes y después, delta heap < 20MB. Si este test pasa, processGame no tiene fugas. El test de renderizado (renderImage.spec.ts) verifica 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 registra 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. Sin overhead en producción.


Lo que cuesta hacerlo funcionar

  • Vercel free tier: 100GB de ancho de banda, 1M de invocaciones serverless al mes. El renderizado de imagen cuenta como una invocación.
  • Cloudflare Workers free tier: 100K peticiones/día, 10ms CPU time por petición (el renderizado puede superar eso en Workers, de ahí Vercel como primario).
  • Supabase free tier: 500MB de base de datos, 5GB de ancho de banda. Suficiente para miles de jugadores.

El conjunto del backend funciona a costo cero hasta un volumen significativo. El único punto de fricción es el límite de CPU de Cloudflare Workers -- el renderizado de imagen consume mucha CPU debido a WASM, de ahí la estrategia de Vercel como primario y Workers como CDN de failover.


Las 3 cosas que merece la pena recordar

  1. La URL como estado del juego no es solo un truco ingenioso -- 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 de JavaScript y nunca serán GC'd sin .free(). Conectar freePhoton a la evicción del LRU es RAII en JavaScript. Es discreto en el código, pero sin esto el worker tendría fugas en producción.

  3. Un bot de 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 desde su infra.


Repo: fox3000foxy/konosuba-rpg

Licencia source-available personalizada -- 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 giliran Discord di mana setiap tindakan menghasilkan gambar WebP

Saya menghabiskan akhir pekan membaca kode konosuba-rpg dan inilah yang saya temukan

Saya sudah memelihara proyek ini sejak lama, tetapi membaca ulang kode sendiri dengan pikiran tenang selalu memberi pelajaran berharga. konosuba-rpg adalah RPG giliran Discord di mana setiap tindakan menghasilkan gambar WebP secara instan. Bukan embed teks. Gambar asli yang dikomposisi, dengan sprite, bar HP, pesan pertarungan -- semuanya.

Stack-nya: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hosting gratis sepenuhnya. Dan bot Discord berjalan tanpa server persisten. Post ini menjelaskan bagaimana semuanya bekerja bersama.

Status awal permainan


Desain dasar: URL sebagai status permainan

Hal pertama yang mencolok: tidak ada status sisi server untuk gameplay. Status lengkap pertarungan ada di dalam URL.

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

Setiap segmen setelah seed adalah tindakan yang dimainkan. Server menerima URL ini, kembali dari awal, memutar ulang semua tindakan secara berurutan, dan mengembalikan gambar pertarungan pada momen tersebut. Tanpa session, tanpa status di RAM yang terkait dengan pengguna.

Discord bekerja dengan tombol interaktif -- ketika pemain menekan "Serang", Discord mengirim ke server custom_id tombol tersebut. custom_id ini berisi URL pertarungan yang sudah dikompresi dengan tindakan baru yang ditambahkan. Server menghitung ulang semuanya dari awal dan mengembalikan gambar yang diperbarui.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Dikompilasi di luar fungsi -- tidak dibuat ulang 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 di luar fungsi adalah detail kecil, tetapi ini menghindari pembangunan ulang struktur setiap kali pemanggilan dalam konteks edge di mana modul bisa dievaluasi ulang.

RNG: RC4 yang dimodifikasi

Generator acaknya adalah implementasi RC4 (algoritma enkripsi stream) yang dialihfungsikan menjadi 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;
      // swap 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 baik dan resistensi tabrakan seed yang memadai. Seed sama = urutan angka sama = pertarungan sama setiap kali. Ini memungkinkan "memutar ulang" pertarungan apa pun dengan menyimpan URL-nya, dan menjamin bahwa dua server berbeda (Vercel + Cloudflare) menghasilkan hasil yang persis sama untuk URL yang sama.


Masalah batas 100 karakter Discord

Discord memberlakukan batas 100 karakter pada custom_id tombol. Setelah beberapa puluh tindakan, URL pertarungan dengan mudah melebihi batas ini.

Dua mekanisme mengatasi hal ini.

1. Kompresi RLE tindakan

Tindakan dienkode dengan satu karakter (a=attack, d=defend, h=hug...) dan dikompresi dengan 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, tetapi ketika pemain spam Serang x10, aaaaaaaaaa (10 char) berubah menjadi a10 (3 char). Tombol "Serang x4" dan "Serang x10" di UI ada tepat untuk ini -- mempercepat pertarungan sambil mengompresi payload dengan baik.

2. Token session saat kompresi tidak cukup

Jika payload yang dikompresi masih terlalu panjang, payload disimpan di database dengan token pendek:

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

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Mengelompokkan payload berdasarkan battle_key, menyisipkan secara 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 }; // Tidak perlu lookup jika tidak diperlukan
  }
  // Lookup di memori dulu, lalu Supabase jika tidak ada
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Memeriksa kepemilikan, TTL (7 hari), dan turn_version (mencegah memutar ulang status lama)
}

Session memiliki TTL 7 hari dan pruning otomatis setiap 10 menit. Pemeriksaan turnVersion mencegah pemutaran ulang status yang sudah kedaluwarsa jika pemain sudah maju dalam permainan -- perlindungan halus terhadap "mundur" yang tidak disengaja.

Kedua Map di memori (tokenToSession, latestTurnByBattle) menggunakan pola globalThis as unknown as GameSessionGlobals yang sama dengan cache gambar, untuk alasan yang sama yang akan kita lihat nanti.


Pipeline rendering gambar

Awal pertarungan melawan Slime

Rute /konosuba-rpg/:lang/* tidak mengembalikan JSON. Rute ini mengembalikan gambar WebP yang dihasilkan sesuai permintaan.

Pipeline diatur dalam 3 layer yang dikomposisikan:

Background (board + frame)
    +
Characters layer (sprite pemain + mob, posisi tetap)
    +
UI overlay (bar HP, pesan, ikon karakter via Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: dua gambar tetap (papan dan bingkai), dimuat dari filesystem dan dikomposisi sekali.

Characters layer: sprite diposisikan berdasarkan koordinat yang dihitung. Pemain yang mati dikecualikan (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Sprite musuh dicerminkan horizontal dengan flipX khusus -- loop pixel per pixel 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 yang berat. JSX antarmuka (bar HP, teks, ikon) dideskripsikan dalam React-like dengan Satori, di-render ke SVG, dikonversi ke 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

Pertarungan berlangsung

Aksi Pelukan


Sistem cache -- bagian yang paling rumit

Ada 5 level cache yang berbeda. Masing-masing menargetkan granularitas pipeline yang berbeda.

// renderImage.ts -- semuanya 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 ??= pada globalThis: modul JavaScript di worker edge dapat dievaluasi ulang antar permintaan pada konfigurasi tertentu. Menyimpan cache di globalThis dengan ??= memastikan cache bertahan dari evaluasi ulang ini tanpa dibuat ulang.

Eviction WASM

Cache gambar Photon (photonCache, layerCache, uiPhotonCache) menggunakan callback eviction:

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 linier WASM, di luar GC JavaScript. Tanpa panggilan eksplisit ke .free(), memori ini tidak akan pernah dibebaskan. Eviction LRU memicu .free() secara otomatis -- ini RAII yang diimplementasikan dalam 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 untuk characters layer tidak mengenkode nilai HP yang tepat -- hanya 1 (hidup) atau 0 (mati). Karena sprite pemain dengan 40 HP dan pemain dengan 15 HP identik. Cache hit bertahan terhadap kerusakan apa pun selama tidak ada yang tumbang.

Kunci UI sebaliknya mengenkode HP yang tepat (bar HP berubah 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 signed
  }
  return hash.toString(16);
}

Math.imul memaksa perkalian dalam integer 32-bit, yang menghindari konversi float64 dan memberikan hash polinomial yang stabil. Tanpa dependensi eksternal untuk ini.

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 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) dapat menyebabkan stack overflow pada gambar besar karena argumen dilewatkan di call stack. Chunking 32KB menghindari hal ini. Hasilnya di-cache -- konversi base64 dari gambar yang sama hanya dilakukan sekali per instance worker.


STRIPPER.md -- audit await sekuensial

Ada file STRIPPER.md di repo yang mendokumentasikan audit paralelisasi await. Beberapa contoh yang tercatat:

  • Pemuatan profil pemain melakukan 3 permintaan Supabase secara serial (progresi, ringkasan run, pencapaian). Semuanya diubah menjadi Promise.all -- tidak ada dependensi di antara mereka.
  • Distribusi hadiah akhir pertarungan (aksesoris + consumable) tadinya sekuensial. Diparalelkan juga.
  • Pembuatan token session untuk tombol dilakukan grup per grup. Grup independen sekarang dibuat secara paralel.
// progressionService.ts -- sebelum (sekuensial)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

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

Tidak ada yang revolusioner, tetapi dalam konteks serverless di mana setiap milidetik waktu respons dikenakan biaya (atau berkontribusi pada cold start), ini berarti.


Bot Discord tanpa server persisten

Kemenangan

Hal yang sering disalahpahami: bot Discord tidak selalu memerlukan koneksi WebSocket persisten. Discord menawarkan alternatif: Interactions Endpoint URL. Anda menyediakan URL HTTPS ke Discord, dan Discord mengirimkan 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, merespons, dan selesai. Tidak perlu koneksi permanen, tidak perlu server menyala terus. Seluruh bot Discord dihosting di free tier Vercel.

Verifikasi Ed25519 (verifyKey dari discord-interactions) wajib -- Discord mengirimkan tanda tangan di header yang harus Anda validasi, jika tidak endpoint akan ditolak.

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 3 detik yang disengaja ini didokumentasikan di STRIPPER.md sebagai hal yang disengaja. Serangan spesial Megumin (Explosion) memiliki animasi di sisi Discord -- pesan pertama diperbarui dengan visual antara, lalu dimodifikasi 3 detik kemudian dengan hasil akhir. Ini satu-satunya kasus di mana fungsi Vercel sengaja berjalan lebih lama dari yang diperlukan.

Serangan spesial


Deployabilitas di dua platform

Codebase yang sama berjalan di Vercel (Node.js) dan Cloudflare Workers (V8 isolates) tanpa modifikasi:

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // menyuntikkan rahasia 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, aset dibaca dari filesystem (/var/task//articles/assets/). Di Cloudflare Workers, aset melalui binding ASSETS (aset statis CF) dengan fallback ke mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). getAssetBytes di assetLoader.ts menangani kedua jalur dengan mencoba filesystem dulu, lalu fetch.

WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) memiliki build terpisah untuk setiap runtime. Flag edge-light di nama paket menunjukkan build yang kompatibel dengan Cloudflare Workers, yang tidak mengizinkan new WebAssembly.Module() saat runtime -- WASM harus dikompilasi sebelumnya.


Progresi: XP, level, afinitas

Sebuah boss, 650 HP

Meta-progresi bertumpu pada Supabase free tier. Skema mencakup tabel players (XP global, level, gold), character_progress (XP/level/afinitas per karakter untuk Darkness, Aqua, Megumin), runs (riwayat pertarungan), 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, maks 5 bintang
  return 1.2 ** stars; // progresi eksponensial
}

Faktor-faktor ini diterapkan pada stat karakter di awal setiap processGame. Kazuma mengikuti level global pemain, tiga lainnya memiliki XP/level masing-masing. Afinitas (diperoleh dengan mengumpulkan drop terkait karakter) mengalikan stat karakter secara independen.

Penyembuhan

Sistem drop menggunakan tabel jarahan yang ditimbang berdasarkan tingkat 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
};

Pengujian

Tiga suite: unit, perf, dan leaks.

Test leak sangat 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. Jika test ini lolos, processGame tidak bocor. Test render (renderImage.spec.ts) lebih memeriksa waktu eksekusi di bawah ambang praktis.

Ada juga script 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 service mencatat timing:

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

createPerfLogger mengembalikan no-op jika DEV_MODE dan RENDER_PERF tidak diset ke 1. Tanpa overhead di production.


Biaya menjalankannya

  • Vercel free tier: 100GB bandwidth, 1M invokasi serverless per bulan. Render gambar dihitung sebagai satu invokasi.
  • Cloudflare Workers free tier: 100K permintaan/hari, 10ms CPU time per permintaan (render bisa melebihi ini di Workers, makanya Vercel sebagai primary).
  • Supabase free tier: 500MB database, 5GB bandwidth. Cukup untuk ribuan pemain.

Seluruh backend berjalan dengan biaya nol hingga volume yang signifikan. Satu-satunya titik gesekan adalah batas CPU Cloudflare Workers -- render gambar intensif CPU karena WASM, makanya strategi Vercel sebagai primary dan Workers sebagai CDN failover.


3 hal yang patut diingat

  1. URL sebagai status permainan bukan sekadar trik keren -- ini adalah kendala yang dipaksakan oleh Discord (tombol memiliki batas 100 karakter) yang memaksa arsitektur stateless dengan kompresi RLE + token session sebagai fallback. Kendala tersebut mendikte desain.

  2. Cache WASM dengan eviction eksplisit: PhotonImage mengalokasi di luar heap JavaScript dan tidak akan pernah di-GC tanpa .free(). Menghubungkan freePhoton ke eviction LRU adalah RAII dalam JavaScript. Ini hal yang kecil dalam kode, tetapi tanpanya worker akan bocor di production.

  3. Bot Discord serverless tanpa WebSocket: ini kurang dikenal dibanding pendekatan WebSocket gateway, tetapi untuk bot yang melakukan pemrosesan stateless (setiap interaksi independen), Interactions Endpoint secara ketat lebih unggul -- tidak perlu koneksi ulang, tidak perlu heartbeat, tidak perlu proses yang dijaga. Discord menangani ketersediaan dari sisi infrastruktur mereka.


Repo: fox3000foxy/konosuba-rpg

Lisensi source-available custom -- tidak untuk redistribusi, free to use.

मैंने एक सप्ताहांत konosuba-rpg का कोड पढ़ा और यहाँ बताया कि मुझे क्या मिला

एक Discord टर्न-बेस्ड RPG जहाँ हर क्रिया तुरंत एक WebP इमेज जनरेट करती है:

मैंने एक सप्ताहांत konosuba-rpg का कोड पढ़ा और यहाँ बताया कि मुझे क्या मिला

मैं इस प्रोजेक्ट को कुछ समय से मेंटेन कर रहा हूँ, लेकिन अपने कोड को शांति से दोबारा पढ़ना हमेशा शिक्षाप्रद होता है। konosuba-rpg एक Discord टर्न-बेस्ड RPG है जहाँ हर क्रिया तुरंत एक WebP इमेज जनरेट करती है। कोई टेक्स्ट एम्बेड नहीं। एक वास्तविक इमेज जो स्प्राइट्स, हेल्थ बार, कॉम्बैट मैसेज -- सब कुछ से बनी होती है।

स्टैक: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase। पूरी तरह से मुफ्त होस्टिंग। और Discord बॉट बिना किसी स्थायी सर्वर के चलता है। यह पोस्ट बताता है कि यह सब एक साथ कैसे काम करता है।

État initial du jeu


बेसिक डिज़ाइन: URL गेम स्टेट के रूप में

पहली चीज़ जो ध्यान खींचती है: गेमप्ले के लिए सर्वर साइड पर कोई स्टेट नहीं है। एक पूरे मुकाबले की स्थिति URL में समाई होती है।

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

सीड के बाद प्रत्येक सेगमेंट एक खेली गई चाल है। सर्वर यह URL प्राप्त करता है, शुरुआत से शुरू करता है, सभी चालों को क्रम से दोबारा खेलता है, और उस पल के मुकाबले की एक इमेज लौटाता है। कोई सेशन नहीं, किसी उपयोगकर्ता से जुड़ा कोई RAM स्टेट नहीं।

Discord इंटरैक्टिव बटन के माध्यम से काम करता है -- जब खिलाड़ी "Attack" दबाता है, 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

रैंडम जनरेटर 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;
      // swap 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...) में एन्कोड किया जाता है और 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;
}

सरल, लेकिन जब खिलाड़ी Attack x10 स्पैम करता है तो aaaaaaaaaa (10 कैरेक्टर) a10 (3 कैरेक्टर) में बदल जाता है। UI में "Attack x4" और "Attack 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 जाँच खिलाड़ी द्वारा आगे बढ़ने पर पुरानी स्थिति को रीप्ले करने से रोकती है -- आकस्मिक "पीछे जाने" के खिलाफ एक सूक्ष्म सुरक्षा।

मेमोरी में दोनों Maps (tokenToSession, latestTurnByBattle) उसी globalThis as unknown as GameSessionGlobals पैटर्न का उपयोग करती हैं जैसे इमेज कैश करते हैं, उन्हीं कारणों से जो आगे बताए जाएँगे।


इमेज रेंडर पाइपलाइन

डेब्यू डु कॉम्बैट कॉन्ट्रे उन स्लाइम

रूट /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 में रेंडर किया जाता है, @cf-wasm/resvg द्वारा PNG में परिवर्तित किया जाता है, फिर अंतिम रचना के लिए 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 मॉड्यूल कुछ कॉन्फ़िगरेशन पर अनुरोधों के बीच पुनर्मूल्यांकित हो सकते हैं। ??= के साथ 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 लीनियर मेमोरी के बाहर आवंटित मेमोरी होती है, जो JavaScript GC के दायरे से बाहर है। बिना .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 (मृत)। क्योंकि 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) बड़ी इमेज पर स्टैक ओवरफ़्लो का कारण बन सकता है क्योंकि आर्ग्युमेंट कॉल स्टैक पर पास होते हैं। 32KB चंकिंग इससे बचाती है। परिणाम कैश किया जाता है -- एक ही इमेज का base64 रूपांतरण प्रति वर्कर इंस्टेंस केवल एक बार किया जाता है।


STRIPPER.md -- अनुक्रमिक awaits का ऑडिट

रिपो में एक फ़ाइल STRIPPER.md है जो await के समानांतरीकरण ऑडिट का दस्तावेज़ीकरण करती है। कुछ उदाहरण जो इसमें दर्ज हैं:

  • खिलाड़ी प्रोफ़ाइल लोड करना तीन Supabase क्वेरी (प्रोग्रेस, रन सारांश, उपलब्धियाँ) श्रृंखला में कर रहा था। उन्हें Promise.all में बदल दिया गया -- उनके बीच कोई निर्भरता नहीं।
  • मुकाबले के अंत में पुरस्कार वितरण (एक्सेसरीज़ + उपभोग्य वस्तुएँ) अनुक्रमिक था। उसी तरह समानांतर किया गया।
  • बटन के लिए सेशन टोकन का निर्माण समूह दर समूह हो रहा था। स्वतंत्र समूह अब समानांतर में बनाए जाते हैं।
// progressionService.ts -- पहले (अनुक्रमिक)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// बाद में
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

इसमें कोई क्रांतिकारी बात नहीं, लेकिन सर्वरलेस कॉन्टेक्स्ट में जहाँ प्रतिक्रिया समय की हर मिलीसेकंड बिल योग्य है (या कोल्ड स्टार्ट में योगदान करती है), यह मायने रखता है।


बिना स्थायी सर्वर के 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 ping
  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 स्टैटिक एसेट्स) से HTTTPS मिरर (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, लेवल, अफ़िनिटी

एक बॉस, 650 HP

मेटा-प्रोग्रेस 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/लेवल है। अफ़िनिटी (एक कैरेक्टर से संबंधित ड्रॉप प्राप्त करके अर्जित) स्वतंत्र रूप से उनके स्टैट्स को गुणा करती है।

हील

ड्रॉप सिस्टम कठिनाई-भारित लूट टेबल का उपयोग करता है:

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 heap वृद्धि
});

processGame के 1200 पुनरावृत्तियाँ, पहले और बाद में फोर्स्ड GC, delta 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 नो-ऑप्स लौटाता है यदि DEV_MODE और RENDER_PERF 1 पर नहीं हैं। प्रोडक्शन में कोई ओवरहेड नहीं।


इसे चलाने में कितना खर्च आता है

  • Vercel फ्री टियर: 100GB बैंडविड्थ, प्रति माह 1M सर्वरलेस इनवोकेशन। इमेज रेंडर एक इनवोकेशन माना जाता है।
  • Cloudflare Workers फ्री टियर: 100K अनुरोध/दिन, प्रति अनुरोध 10ms CPU समय (रेंडर Workers पर इसे पार कर सकता है, इसलिए Vercel प्राथमिक है)।
  • Supabase फ्री टियर: 500MB डेटाबेस, 5GB बैंडविड्थ। हज़ारों खिलाड़ियों के लिए पर्याप्त।

पूरा बैकएंड एक महत्वपूर्ण वॉल्यूम तक शून्य लागत पर चलता है। एकमात्र घर्षण बिंदु Cloudflare Workers की CPU सीमा है -- WASM के कारण इमेज रेंडर CPU-इंटेंसिव है, इसलिए Vercel को प्राथमिक और Workers को फ़ेलओवर CDN के रूप में रखने की रणनीति है।


3 चीज़ें जो याद रखने लायक हैं

  1. URL गेम स्टेट के रूप में केवल एक अच्छी ट्रिक नहीं है -- यह Discord द्वारा लगाई गई एक बाध्यता है (बटन की 100 कैरेक्टर सीमा) जिसने RLE संपीड़न + फ़ॉलबैक के रूप में सेशन टोकन के साथ एक स्टेटलेस आर्किटेक्चर को मजबूर किया। बाध्यता ने डिज़ाइन को निर्धारित किया।

  2. स्पष्ट एविक्शन के साथ WASM कैश: PhotonImage JavaScript heap के बाहर आवंटित होते हैं और .free() के बिना कभी GC'd नहीं होंगे। LRU के एविक्शन पर freePhoton को जोड़ना JavaScript में RAII है। यह कोड में सूक्ष्म है, लेकिन इसके बिना वर्कर प्रोडक्शन में लीक करेगा।

  3. WebSocket के बिना एक सर्वरलेस Discord बॉट: यह WebSocket गेटवे दृष्टिकोण से कम ज्ञात है, लेकिन स्टेटलेस प्रोसेसिंग करने वाले बॉट के लिए (प्रत्येक इंटरैक्शन स्वतंत्र है), Interactions Endpoint सख्ती से बेहतर है -- कोई पुनःकनेक्शन नहीं, कोई हार्टबीट नहीं, कोई बनाए रखने वाली प्रक्रिया नहीं। Discord अपने इन्फ्रा की ओर से उपलब्धता का प्रबंधन करता है।


Repo : fox3000foxy/konosuba-rpg

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

قضيت عطلة نهاية الأسبوع أقرأ كود konosuba-rpg وهذا ما وجدته

لعبة تقمّص أدوار (RPG) بنظام الأدوار على Discord حيث كل حركة تولّد صورة WebP فوراً: URL كحالة اللعبة، RNG حتمي، خط أنابيب WASM، 5 مستويات تخزين مؤقت، بوت بدون خادم.

قضيت عطلة نهاية الأسبوع أقرأ كود konosuba-rpg وهذا ما وجدته

أنا المسؤول عن هذا المشروع منذ فترة، لكن إعادة قراءة الكود الخاص بك بروية هي دائماً تجربة تعليمية. konosuba-rpg هي لعبة تقمّص أدوار (RPG) بنظام الأدوار على Discord حيث كل حركة تولّد صورة WebP فوراً. ليس نص embed. بل صورة حقيقية مركبة، مع sprites وأشرطة الحياة ورسائل القتال -- كل شيء.

المكدس: TypeScript، Hono، Vercel، Cloudflare Workers، Supabase. استضافة مجانية بالكامل. وبوت Discord يعمل بدون خادم دائم. هذا المقال يشرح كيف يعمل كل هذا معاً.

État initial du jeu


التصميم الأساسي: URL كحالة اللعبة

أول ما يلفت الانتباه: لا توجد أية حالة على جانب الخادم لأسلوب اللعب. الحالة الكاملة لأي معركة موجودة في الـ URL.

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

كل مقطع بعد الـ seed هو حركة تم تنفيذها. الخادم يستقبل هذا الـ URL، يعيد من البداية، يعيد تشغيل كل الحركات بالترتيب، ويعيد صورة المعركة في تلك اللحظة بالضبط. لا جلسة، ولا حالة في الذاكرة مرتبطة بأي مستخدم.

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 = المقطع السادس، يُهاش على 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 حيث قد تُعاد تقييم modules.

RNG: RC4 معدّل

مولد الأرقام العشوائي (RNG) هو تطبيق 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;
      // swap 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.


مشكلة حد 100 حرف في Discord

Discord يفرض حد 100 حرف على custom_id للأزرار. بعد بضع عشرات من الحركات، أي URL معركة يتجاوز هذا الحد بسهولة.

آليتان تتعاملان مع هذا.

1. ضغط RLE للحركات

الحركات تُرمّز بحرف واحد (a=هجوم, d=دفاع, h=عناق...) وتُضغط عبر تشفير طول التشغيل (RLE):

// 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. رموز الجلسة (Session tokens) عندما لا يكفي الضغط

إذا بقيت الحمولة المضغوطة طويلة جداً، تُخزّن في قاعدة البيانات مع رمز قصير:

// 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 طبقات مركّبة:

خلفية (board + frame)
    +
طبقة الشخصيات (sprites لاعبين + مخلوق، مواضع ثابتة)
    +
تراكب واجهة المستخدم (أشرطة 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-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: modules JavaScript في edge workers قد تُعاد تقييمها بين الطلبات في بعض الإعدادات. تخزين الـ caches على globalThis مع ??= يضمن بقاءها بعد هذه التقييمات دون إعادة إنشائها.

إخلاء WASM (Eviction)

caches صور Photon (photonCache, layerCache, uiPhotonCache) تستخدم رد اتصال (callback) للإخلاء:

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 (ميت). لأن صورة اللاعب عند 40 HP واللاعب عند 15 HP متطابقة. لذا فإن hit في التخزين المؤقت ينجو من أي ضرر طالما لم يسقط أحد.

مفتاح واجهة المستخدم بالمقابل يرمّز 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 ويعطي معدل تعدد حدودي (polynomial hash) ثابت. لا اعتماد خارجي لهذا.

تحويل 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 بايت
  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. التقسيم إلى أجزاء 32 كيلوبايت يتجنب هذا. النتيجة تُخزّن مؤقتاً -- تحويل base64 لنفس الصورة يتم مرة واحدة فقط لكل نسخة worker.


STRIPPER.md -- تدقيق await المتسلسلة

هناك ملف STRIPPER.md في المستودع يوثق تدقيقاً لموازاة await. بعض الأمثلة مما تم تسجيله:

  • تحميل ملف اللاعب الشخصي كان يقوم بـ 3 استعلامات Supabase متسلسلة (التقدّم، ملخص الجولة، الإنجازات). تم تحويلها إلى Promise.all -- لا تبعية بينها.
  • توزيع مكافآت نهاية المعركة (إكسسوارات + مواد استهلاكية) كان متسلسلاً. تمت موازاته أيضاً.
  • إنشاء رموز الجلسة للأزرار كان يتم مجموعة بمجموعة. المجموعات المستقلة تُنشأ الآن بالتوازي.
// progressionService.ts -- قبل (تسلسلي)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// بعد
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

لا شيء ثورياً، لكن في سياق serverless حيث كل ملي ثانية من زمن الاستجابة تُفوتر (أو تساهم في cold start)، هذا مهم.


بوت Discord بدون خادم دائم

نصر

نقطة يساء فهمها غالباً: بوت Discord لا يحتاج بالضرورة اتصال WebSocket دائم. Discord يقدم بديلاً: Interactions Endpoint URL. تقدم رابط HTTPS لـ Discord، و 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 }); // ping Discord
  if (interaction.type === 2) return handleSlashCommand(...);
  if (interaction.type === 3) return handleButtonInteraction(...);
  if (interaction.type === 4) return handleAutocomplete(...);
}

Discord يرسل POST، المعالج يعمل من 50 إلى 200 ملي ثانية على دالة Vercel أو Cloudflare Worker، يرد، وينتهي الأمر. لا اتصال دائم للحفاظ عليه، ولا خادم لإبقائه قيد التشغيل. بوت Discord بأكمله مستضاف على الخطة المجانية لـ Vercel.

التحقق Ed25519 (verifyKey من discord-interactions) إلزامي -- Discord يرسل توقيعاً في الترويسات (headers) يجب عليك التحقق منه، وإلا سيرفض النقطة الطرفية (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 (انفجار) له حركة على جانب 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(); }

الفرق الرئيسي: الأصول الثابتة (static assets). على Vercel، تُقرأ من نظام الملفات (/var/task//articles/assets/). على Cloudflare Workers، تمر عبر binding 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

التقدّم الكلي (meta-progression) يعتمد على الطبقة المجانية لـ 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
};

الاختبارات

ثلاث مجموعات: وحدة (unit)، أداء (perf)، وتسرب (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 قسري قبل وبعد، فرق 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-ops إذا لم يكن DEV_MODE و RENDER_PERF على 1. لا أثر إضافي في الإنتاج.


تكلفة التشغيل

  • Vercel free tier: 100GB نطاق ترددي، مليون استدعاء serverless شهرياً. توليد الصورة يُحتسب كاستدعاء واحد.
  • Cloudflare Workers free tier: 100 ألف طلب/يوم، 10ms وقت وحدة معالجة مركزية لكل طلب (التوليد قد يتجاوز هذا على Workers، لذلك Vercel هو الأساسي).
  • Supabase free tier: 500MB قاعدة بيانات، 5GB نطاق ترددي. كافٍ لآلاف اللاعبين.

النهاية الخلفية بأكملها تعمل بتكلفة صفرية حتى حجم استخدام كبير. نقطة الاحتكاك الوحيدة هي حد وحدة المعالجة المركزية لـ Cloudflare Workers -- توليد الصور يستهلك وحدة معالجة مركزية بكثافة بسبب WASM، ومن هنا استراتيجية Vercel كأساسي و Workers كـ CDN للتعويض (failover).


3 أشياء تستحق التذكر

  1. الـ URL كحالة اللعبة ليست مجرد خدعة جميلة -- إنها قيد فرضه Discord (الأزرار لها حد 100 حرف) الذي أجبر على بنية عديمة الحالة (stateless) مع ضغط RLE + رمز جلسة كحل بديل. القيد هو ما صمّم التصميم.

  2. ذاكرة WASM المؤقتة مع إخلاء صريح: كائنات PhotonImage تخصّص ذاكرة خارج كومة JavaScript ولن تُجمّع أبداً (GC) بدون .free(). ربط freePhoton بإخلاء LRU هو RAII في JavaScript. غير ظاهر في الكود، لكن بدونه كان الـ worker سيُسرب (leak) في الإنتاج.

  3. بوت Discord serverless بدون WebSocket: أقل شهرة من نهج WebSocket gateway، لكن بالنسبة لبوت يقوم بمعالجة عديمة الحالة (كل تفاعل مستقل)، فإن Interactions Endpoint هو الأفضل قطعياً -- لا إعادة اتصال، لا heartbeat، لا عملية يجب الحفاظ عليها. Discord يدير التوفر على بنيته التحتية.


المستودع: fox3000foxy/konosuba-rpg

رخصة وصول-مصدر مخصصة -- لا إعادة توزيع، مجانية الاستخدام.

Tôi đã dành một cuối tuần để đọc mã nguồn konosuba-rpg và đây là những gì tôi tìm thấy

Một RPG theo lượt trên Discord nơi mỗi hành động tạo ra một hình ảnh WebP ngay lập tức: URL như trạng thái trò chơi, RNG xác định, pipeline WASM, bộ nhớ đệm 5 tầng, bot serverless.

Tôi đã dành một cuối tuần để đọc mã nguồn 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 rồi, nhưng đọc lại code của chính mình một cách bình tĩnh luôn mang lại bài học bổ ích. konosuba-rpg là một RPG theo lượt trên Discord nơi mỗi hành động tạo ra một hình ảnh WebP ngay lập tức. Không phải embed văn bản. Một hình ảnh thực sự được ghép, với sprite, thanh máu, tin nhắn chiến đấu -- tất cả.

Stack: TypeScript, Hono, Vercel, Cloudflare Workers, Supabase. Hosting hoàn toàn miễn phí. Và bot Discord hoạt động mà không cần máy chủ liên tục. Bài viết này giải thích cách tất cả vận hành cùng nhau.

Trạng thái ban đầu của trò chơi


Thiết kế cơ bản: URL như trạng thái trò chơi

Điều đầu tiên đập vào mắt: không có trạng thái phía máy chủ cho gameplay. Trạng thái hoàn chỉnh của một trận chiến nằm gọn trong URL.

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

Mỗi phân đoạn sau seed là một hành động đã thực hiện. Máy chủ nhận URL này, bắt đầu lại từ đầu, phát lại tất cả các hành động theo thứ tự, và trả về một hình ảnh trận chiến tại thời điểm đó. Không session, không trạng thái trong RAM gắn với người dùng.

Discord hoạt động qua các 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 nén của trận chiến với hành động mới được thêm vào. Máy chủ tính toán lại mọi thứ từ đầu và trả về hình ảnh đã cập nhật.

// processUrl.ts
const VALID_MOVES_SET = new Set(["ATK", "DEF", "HUG", "HEA", "SPE", "USE"]);
// Được biên dịch sẵn bên 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 = phân đ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 được biên dịch sẵn bên ngoài hàm là một chi tiết nhỏ, nhưng nó tránh việc xây dựng lại cấu trúc mỗi lần gọi trong bối cảnh edge nơi các module có thể được đánh giá lại.

RNG: RC4 biến thể

Bộ sinh ngẫu nhiên là một triển khai RC4 (thuật toán mã hóa dòng) được biến thành PRNG.

export class Random {
  private S: number[]; // bảng 256 phần tử
  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] 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 { /* ... */ }
}

Tại sao RC4? Bởi vì nó là một PRNG xác định với phân phối tốt và khả năng chống va chạm seed hợp lý. Cùng seed = cùng dãy số = cùng trận chiến mỗi lần. Điều này cho phép "phát lại" bất kỳ trận chiến nào bằng cách giữ URL của nó, và đảm bảo rằng hai máy chủ khác nhau (Vercel + Cloudflare) tạo ra kết quả hoàn toàn giống hệt nhau cho cùng một URL.


Vấn đề giới hạn 100 ký tự của Discord

Discord áp đặt giới hạn 100 ký tự trên custom_id của các nút. Sau vài chục hành động, URL trận chiến vượt quá giới hạn này một cách dễ dàng.

Hai cơ chế giải quyết vấn đề này.

1. Nén RLE các hành động

Các hành động được mã hóa bằng một ký tự đơn (a=attack, d=defend, h=hug...) và được nén bằng 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;
}

Đơn giản, nhưng khi người chơi spam Tấn công x10 thì từ aaaaaaaaaa (10 ký tự) thành 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 chính vì điều này -- tăng tốc trận chiến đồng thời nén payload tốt hơn.

2. Session tokens khi nén không đủ

Nếu payload nén vẫn quá dài, nó được lưu vào cơ sở dữ liệu với một token ngắn:

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

export async function encodeGameplayButtons(buttons: RawButton[]): Promise {
  // Nhóm các payload theo battle_key, chèn batch vào Supabase
  // Thay thế 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 tra cứu nếu không cần
  }
  // Tra cứu trong bộ nhớ trước, sau đó Supabase nếu không có
  const cached = tokenToSession.get(token) || await loadTokenRowByToken(token);
  // Kiểm tra quyền sở hữu, TTL (7 ngày), và turn_version (tránh phát lại trạng thái cũ)
}

Các session có TTL 7 ngày và tự động dọn dẹp mỗi 10 phút. Kiểm tra turnVersion ngăn việc phát lại trạng thái đã lỗi thời nếu người chơi đã tiến triển -- một lớp bảo vệ tinh tế chống "quay lại" vô tình.

Hai Map trong bộ nhớ (tokenToSession, latestTurnByBattle) sử dụng cùng pattern globalThis as unknown as GameSessionGlobals như bộ nhớ đệm hình ảnh, vì những lý do tương tự sẽ được đề cập bên dưới.


Pipeline render hình ảnh

Bắt đầu trận chiến với Slime

Route /konosuba-rpg/:lang/* không trả về JSON. Nó trả về một hình ảnh WebP được tạo ra theo yêu cầu.

Pipeline được tổ chức thành 3 lớp ghép:

Background (board + frame)
    +
Characters layer (sprite người chơi + quái, vị trí cố định)
    +
UI overlay (thanh HP, tin nhắn, biểu tượng nhân vật qua Satori → SVG → PNG)
    ↓
Photon.watermark() × 2
    ↓
WebP output

Background: hai hình ảnh tĩnh (bảng và khung), được tải từ filesystem và ghép một lần.

Characters layer: các sprite được đặt theo tọa độ đã tính toán. Người chơi đã chết bị loại trừ (activeSlots = slots.filter(s => playerHp[s.i] > 0)). Sprite kẻ thù được lật ngang bằng flipX tùy chỉnh -- một vòng lặp từng pixel thay vì phụ thuộc bên 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);
}

UI overlay: đây là phần nặng nhất. JSX của giao diện (thanh máu, văn bản, biểu tượng) được mô tả kiểu React với Satori, render thành SVG, chuyển đổi sang PNG bằng @cf-wasm/resvg, sau đó import vào Photon để ghép cuối cùng. Satori + resvg là hai module WASM được biên dịch riêng cho Cloudflare Workers với flag edge-light.

Hành động Phòng thủ

Chiến đấu đang diễn ra

Hành động Ôm


Hệ thống bộ nhớ đệm -- phần được đầu tư nhiều nhất

Có 5 tầng bộ nhớ đệm riêng biệt. Mỗi tầng nhắm vào một mức độ chi tiết khác nhau của pipeline.

// renderImage.ts -- tất cả trên globalThis
G.__imageCache  ??= {} as Record; // tài nguyên thô
G.__base64Cache ??= {} as Record;       // base64 của tài nguyên (cho Satori)
G.__fontCache   ??= {} as Record; // phông chữ
G.__photonCache    ??= new LRUCache(40, freePhoton);
G.__layerCache     ??= new LRUCache(12, freePhoton);
G.__uiPhotonCache  ??= new LRUCache(30, freePhoton);
G.__renderOutputCache ??= new LRUCache(120);

Pattern ??= trên globalThis: các module JavaScript trong worker edge có thể được đánh giá lại giữa các yêu cầu trên một số cấu hình. Lưu bộ nhớ đệm trên globalThis với ??= đảm bảo chúng tồn tại qua các lần đánh giá lại mà không bị tạo mới.

Eviction WASM

Bộ nhớ đệm hình ảnh Photon (photonCache, layerCache, uiPhotonCache) sử dụng callback eviction:

function freePhoton(_key: string, img: Photon.PhotonImage): void {
  try { img.free(); } catch { /* đã giải phóng */ }
}

new LRUCache(40, freePhoton)

Photon.PhotonImage là một đối tượng WASM với bộ nhớ được cấp phát ở phía tuyến tính WASM, nằm ngoài GC JavaScript. Nếu không gọi .free() một cách tường minh, bộ nhớ này không bao giờ được giải phóng. Eviction LRU kích hoạt .free() tự động -- đó là RAII trong JavaScript.

Khóa cache được cố tình làm mất mát

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 của characters layer không mã hóa giá trị HP chính xác -- chỉ 1 (còn sống) hoặc 0 (đã chết). Bởi vì sprite của người chơi 40 HP và người chơi 15 HP là giống hệt nhau. Một cache hit do đó tồn tại qua bất kỳ sát thương nào miễn là không ai ngã xuống.

Ngược lại, khóa UI mã hóa HP chính xác (thanh máu thay đổi mỗi đòn) và hash của 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à tạo ra hash đa thức ổn định. Không cần phụ thuộc bên ngoài cho việc này.

Chuyển đổi base64 không bị 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) có thể gây stack overflow trên các hình ảnh lớn vì các tham số được truyền qua call stack. Chunking 32KB tránh được điều này. Kết quả được lưu vào bộ nhớ đệm -- việc chuyển đổi base64 của cùng một hình ảnh chỉ được thực hiện một lần cho mỗi instance worker.


STRIPPER.md -- kiểm toán các await tuần tự

Có một file STRIPPER.md trong repo ghi lại quá trình kiểm toán song song hóa các await. Một vài ví dụ được ghi lại:

  • Tải hồ sơ người chơi từng thực hiện 3 truy vấn Supabase nối tiếp (tiến trình, tóm tắt run, thành tích). Chúng đã được chuyển thành Promise.all -- không có phụ thuộc lẫn nhau.
  • Phân phối phần thưởng cuối trận (phụ kiện + vật phẩm tiêu hao) từng là tuần tự. Đã được song song hóa tương tự.
  • Tạo token session cho các nút từng được thực hiện theo nhóm. Các nhóm độc lập giờ được tạo song song.
// progressionService.ts -- trước đây (tuần tự)
await grantAccessoryDropRewards(...);
await grantConsumableDropRewards(...);

// sau đó
await Promise.all([
  grantAccessoryDropRewards(...),
  grantConsumableDropRewards(...),
]);

Không có gì cách mạng, nhưng trong bối cảnh serverless nơi mỗi mili giây thời gian phản hồi đều bị tính phí (hoặc góp phần vào cold start), điều này có ý nghĩa.


Bot Discord không cần máy chủ liên tục

Chiến thắng

Một điểm thường bị hiểu nhầm: bot Discord không nhất thiết cần kết nối WebSocket liên tục. Discord cung cấp một giải pháp thay thế: Interactions Endpoint URL. Bạn cung cấp một URL HTTPS cho Discord, và Discord gửi 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 một function Vercel hoặc Cloudflare Worker, trả lời, và kết thúc. Không cần kết nối thường trực, không cần máy chủ giữ hoạt động. Toàn bộ bot Discord được host trên free tier Vercel.

Xác thực Ed25519 (verifyKey từ discord-interactions) là bắt buộc -- Discord gửi chữ ký trong headers mà bạn phải xác thực, nếu không endpoint sẽ bị từ chối.

Animation đặc biệt -- await có chủ đích 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",
  // ...
});

Sự chậm trễ có chủ đích 3 giây này được ghi lại trong STRIPPER.md là có chủ ý. Đòn tấn công đặc biệt của Megumin (Explosion) có animation phía Discord -- tin nhắn đầu tiên được cập nhật với hình ảnh trung gian, sau đó được sửa đổi 3 giây sau với kết quả. Đây là trường hợp duy nhất một function Vercel cố tình chạy lâu hơn mức cần thiết.

Đòn tấn công đặc biệt


Khả năng triển khai trên hai nền tảng

Cùng một codebase chạy trên Vercel (Node.js) và Cloudflare Workers (V8 isolates) mà không cần sửa đổi:

// worker.ts -- entrypoint Cloudflare
export default {
  fetch(request: Request, env: WorkerBindings): Promise {
    syncBindingsToProcessEnv(env); // tiêm secrets CF vào process.env
    return app.fetch(request, env, ctx);
  }
};

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

Sự khác biệt chính: tài nguyên tĩnh. Trên Vercel, chúng được đọc từ filesystem (/var/task//articles/assets/). Trên Cloudflare Workers, chúng đi qua binding ASSETS (tài nguyên tĩnh CF) với fallback tới mirror HTTPS (fox3000foxy.com/konosuba-rpg/assets). getAssetBytes trong assetLoader.ts xử lý cả hai đường dẫn bằng cách thử filesystem trước, sau đó fetch.

Các WASM (@cf-wasm/photon/edge-light, @cf-wasm/resvg) có bản dựng riêng cho từng runtime. Flag edge-light trong tên package chỉ định bản dựng tương thích Cloudflare Workers, không cho phép new WebAssembly.Module() tại runtime -- WASM phải được biên dịch trước.


Hệ thống tiến triển: XP, cấp độ, thân thiết

Một boss, 650 HP

Meta-progression dựa trên Supabase free tier. Schema bao gồm bảng players (XP toàn cục, cấp độ, vàng), character_progress (XP/cấp độ/thân thiết cho từng nhân vật Darkness, Aqua, Megumin), runs (lịch sử chiến đấu), inventory_items, daily_quests_progress, achievements_unlocked, game_sessions.

Mô hình tiến triển đơn giản:

// characterService.ts
export function computeLevelFromXp(xp: number): number {
  return Math.floor(xp / 100) + 1; // 100 XP mỗi cấp
}

export function getLevelFactor(level: number): number {
  return 1 + 0.2 * (level - 1); // +20% chỉ số mỗi cấp
}

export function getAffinityFactor(affinity: number): number {
  const stars = Math.floor(affinity / 20); // 20 điểm mỗi sao, tối đa 5 sao
  return 1.2 ** stars; // tiến triển theo cấp số nhân
}

Các hệ số này được áp dụng vào chỉ số của nhân vật khi bắt đầu mỗi processGame. Kazuma theo cấp độ toàn cục của người chơi, ba nhân vật còn lại có XP/cấp độ riêng. Thân thiết (kiếm được bằng cách nhặt drop liên quan đến nhân vật) nhân chỉ số của nó một cách độc lập.

Hồi máu

Hệ thống drop sử 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  },
    ],
  },
  // ...cho tới Legendary
};

Kiểm thử

Ba bộ: unit, perf, và leaks.

Leak test đặc biệt trực tiếp:

// 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 lần lặp processGame, GC cưỡng bức trước và sau, delta heap < 20MB. Nếu test này qua, processGame không bị rò rỉ. Test render (renderImage.spec.ts) kiểm tra thời gian thực thi dưới một ngưỡng thực tế.

Ngoài ra còn có script bench.ts để profile pipeline hoàn chỉnh:

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

Với RENDER_PERF=1, wrapper withPerf trong mỗi service ghi lại thời gian:

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

createPerfLogger trả về no-ops nếu DEV_MODE và RENDER_PERF không được đặt thành 1. Không có overhead trong production.


Chi phí vận hành

  • Vercel free tier: 100GB bandwidth, 1M serverless invocations mỗi tháng. Render hình ảnh được tính là một invocation.
  • Cloudflare Workers free tier: 100K yêu cầu/ngày, 10ms CPU time mỗi yêu cầu (render có thể vượt quá trên Workers, do đó Vercel là primary).
  • Supabase free tier: 500MB database, 5GB bandwidth. Đủ cho hàng ngàn người chơi.

Toàn bộ backend chạy với chi phí bằng không cho đến khi đạt khối lượng đáng kể. Điểm nghẽn duy nhất là giới hạn CPU của Cloudflare Workers -- render hình ảnh tốn CPU do WASM, do đó chiến lược dùng Vercel làm primary và Workers làm CDN dự phòng.


3 điều đáng ghi nhớ

  1. URL như trạng thái trò chơi không chỉ là một mẹo hay -- đó là một ràng buộc từ Discord (các nút có giới hạn 100 ký tự) đã buộc phải có kiến trúc stateless với nén RLE + token session làm dự phòng. Ràng buộc đã định hình thiết kế.

  2. Bộ nhớ đệm WASM với eviction tường minh: các PhotonImage cấp phát bên ngoài heap JavaScript và sẽ không bao giờ được GC thu hồi nếu không có .free(). Gắn freePhoton vào eviction của LRU giống như RAII trong JavaScript. Điều này kín đáo trong code, nhưng nếu không có nó worker sẽ bị rò rỉ trong production.

  3. Bot Discord serverless không cần WebSocket: ít được biết đến hơn so với phương pháp WebSocket gateway, nhưng đối với bot xử lý stateless (mỗi tương tác độc lập), Interactions Endpoint hoàn toàn vượt trội -- không cần kết nối lại, không heartbeat, không cần duy trì tiến trình. Discord quản lý tính khả dụng phía hạ tầng của họ.


Repo : fox3000foxy/konosuba-rpg

Giấy phép source-available tùy chỉnh -- không được phân phối lại, free to use.

ฉันใช้เวลาสุดสัปดาห์อ่านโค้ด 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