From 78de7e1cd3dc459f041f39d01eb6bd25d799739d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 20 Apr 2026 22:30:17 +0700 Subject: [PATCH] feat(wordle): render board in monospace so markers align with letters Wrap renderGuess / renderBoard output in
 and send replies with
parse_mode: HTML. In Telegram's monospace font each " X " letter cell
is 3 characters wide, which is roughly the width of a single emoji
marker, so colored squares stack cleanly over the letter they score.

No user-controlled content lands inside the 
 (guesses are
validated [a-z]{5}), so no HTML escaping is needed in the grid. The
inline /wordle <word> placeholder is properly
entity-encoded for HTML parse mode.
---
 src/modules/wordle/handlers.js | 17 +++++++++++++----
 src/modules/wordle/render.js   | 34 +++++++++++++++++++++++-----------
 2 files changed, 36 insertions(+), 15 deletions(-)

diff --git a/src/modules/wordle/handlers.js b/src/modules/wordle/handlers.js
index 8512456..1455b92 100644
--- a/src/modules/wordle/handlers.js
+++ b/src/modules/wordle/handlers.js
@@ -24,6 +24,10 @@ import wordsData from "./words-data.js";
 const words = wordsData;
 const wordSet = makeWordSet(words);
 
+// All replies use HTML parse mode so renderGuess/renderBoard 
 blocks
+// render as a monospace code box; emoji markers then align with letter columns.
+const HTML = { parse_mode: "HTML" };
+
 /**
  * @param {import("grammy").Context} ctx
  * @returns {number|null}
@@ -86,8 +90,8 @@ export async function handleWordle(ctx, db) {
       ? `๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}. /wordle_new for another.`
       : game.giveup
         ? `๐Ÿณ๏ธ Gave up. Answer was ${game.target.toUpperCase()}. /wordle_new for another.`
-        : `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/wordle \`.`;
-    return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
+        : `Guess ${game.guesses.length}/${MAX_GUESSES}. Use /wordle <word>.`;
+    return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`, HTML);
   }
 
   if (isFinished(game)) {
@@ -110,15 +114,17 @@ export async function handleWordle(ctx, db) {
     const s = await recordResult(db, subject, true);
     return ctx.reply(
       `${reply}\n\n๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}. /wordle_new for another.`,
+      HTML,
     );
   }
   if (game.guesses.length >= MAX_GUESSES) {
     await recordResult(db, subject, false);
     return ctx.reply(
       `${reply}\n\nโŒ Out of guesses. Answer was ${game.target.toUpperCase()}. /wordle_new to retry.`,
+      HTML,
     );
   }
-  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
+  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, HTML);
 }
 
 /**
@@ -137,7 +143,10 @@ export async function handleNew(ctx, db) {
   }
 
   await startFreshGame(db, subject);
-  return ctx.reply(`${prelude}๐Ÿ†• New round started. Use \`/wordle \` to guess.`);
+  return ctx.reply(
+    `${prelude}๐Ÿ†• New round started. Use /wordle <word> to guess.`,
+    HTML,
+  );
 }
 
 /**
diff --git a/src/modules/wordle/render.js b/src/modules/wordle/render.js
index 4125f62..3678fbf 100644
--- a/src/modules/wordle/render.js
+++ b/src/modules/wordle/render.js
@@ -1,28 +1,40 @@
 /**
- * @file Render wordle comparison results as a Telegram-friendly grid.
+ * @file Render wordle comparison results as a Telegram monospace grid.
+ *
+ * Each guess renders as two lines โ€” colored markers over the upper-case
+ * letters โ€” wrapped in an HTML 
 block so Telegram renders it in
+ * monospace and the emoji column widths line up with the letter columns.
+ *
  *   ๐ŸŸฉ correct ยท ๐ŸŸจ partial ยท โฌœ wrong
  *
- * Each guess shows two lines: the colored markers and the upper-case letters
- * underneath so the player can read the word at a glance.
+ * Output strings are intended to be sent with `parse_mode: "HTML"`. All
+ * game content is validated `[a-z]` so no HTML escaping is needed inside
+ * the grid; only callers embedding user-controlled text around the grid
+ * need to escape it.
  */
 
 const MARKER = { correct: "๐ŸŸฉ", partial: "๐ŸŸจ", wrong: "โฌœ" };
 
-/**
- * Render a single guess row (two lines: markers + letters).
- * @param {ReturnType} results
- */
-export function renderGuess(results) {
+function rowPair(results) {
   const markers = results.map((r) => MARKER[r.result] ?? "โฌœ").join("");
   const letters = results.map((r) => ` ${r.letter.toUpperCase()} `).join("");
   return `${markers}\n${letters}`;
 }
 
 /**
- * Render all prior guesses stacked.
+ * Render a single guess row as an HTML 
 block.
+ * @param {ReturnType} results
+ */
+export function renderGuess(results) {
+  return `
${rowPair(results)}
`; +} + +/** + * Render the full board (all prior guesses, blank-line separated) as a + * single HTML
 block, so all rows share one monospace code box.
  * @param {Array<{word:string, results: any[]}>} guesses
  */
 export function renderBoard(guesses) {
-  if (guesses.length === 0) return "No guesses yet. Reply with `/wordle `.";
-  return guesses.map((g) => renderGuess(g.results)).join("\n\n");
+  if (guesses.length === 0) return "No guesses yet. Reply with /wordle <word>.";
+  return `
${guesses.map((g) => rowPair(g.results)).join("\n\n")}
`; }