Vordli
Cyrillic Wordle as a Telegram Mini App — word of the day, a shared leaderboard per chat, three language editions from one codebase.
- Status
- live
- Main language
- TypeScript
- Last commit
- August 8, 2026
Wordle that lives inside Telegram: the 6×5 grid and an on-screen ЙЦУКЕН keyboard open in the messenger, nothing to install. One mode — word of the day: everyone gets the same word, guesses are counted, and the leaderboard belongs to the chat you opened the game from. The chat is the point. It isn’t a person playing an app, it’s a group playing each other.
What it does
- Word of the day rolling over at the edition’s local midnight, six guesses, a leaderboard scoped to the group the game was opened from.
- A live board in the chat itself: an image of everyone who has guessed today, refreshed as the day goes on and reposted lower when enough new players show up.
- A daily recap, plus the answer revealed in a separate channel — as a spoilered image, because a photo caption can’t be spoilered.
- Paid hints and buying your own word for a future day with Telegram Stars, with refunds and a moderation queue in the admin dashboard.
- Inline mode: typing
@botin any chat posts today’s game as a card, even where the bot was never added. - Three editions from one codebase — ru, uk and en, each its own bot and its own deploy.
How it’s built
Node 22 and TypeScript in npm workspaces: server runs Fastify, holding the bot (grammY), the
REST API and the built frontend, while web is vanilla TS on Vite with the Telegram WebApp SDK.
Postgres for storage, migrations applied at server start. It ships as Docker Compose on a VPS,
and CI builds the image on the server itself — no registry.
The server is authoritative: scoring lives in one module, and the answer is never sent to the client while a game is active. Every API request is authenticated by the Telegram initData signature.
The language editions are not forks. Everything language-specific sits in two files:
locales.ts on the server (letter normalization, alphabet, dictionaries, timezone, bot copy)
and i18n.ts on the client (keyboard layout and UI strings). One LOCALE variable at build
time picks the edition. English is the odd one out: it doesn’t take its word from a local pool
but fetches the real NYT answer for the date, and rolls over at midnight New York, so both the
word and the puzzle number match the original.
A synchronous PNG encode froze the server
The board images are drawn on a canvas and encoded to PNG. canvas.toBuffer() does that
synchronously, and it turned out the encode is most of the render: on the VPS a 10-player board
is ~400 ms of which ~320 ms is the encode; a 40-player one ~1340 ms of which ~1060 ms is.
Synchronously, that isn’t “a bit slower”, it’s the whole server stopped. Measured against a 5 ms
interval: five synchronous encodes back to back and the interval fired zero times; five
asynchronous ones and it fired 56.
It surfaced exactly at the rollover, when the daily recap renders for every chat, the channel
reveal goes out, and every player opens the app for the new word in the same second. The fix is
canvas.encode('png') — the same work goes to a libuv thread, the wall-clock cost is unchanged,
and the server keeps answering while it runs.
That wasn’t enough for the live board, which is the one render sitting on the gameplay path, nudged by every guess. The drawing itself still occupied the main thread: 20.6 ms against 7.3 ms through a worker, with an idle floor of 2 ms. So it moved into a worker thread at the price of about 20 ms more wall clock per board. The bill for that: avatars have to cross into the worker as raw bytes, because a decoded image is a native handle that won’t survive a structured clone. And every worker failure falls back to rendering in-process — an optimization that is always allowed to be absent.
Telegram decides the board layout, not design
A player card is landscape, and Telegram fits a photo to the bubble width — so the number of cards per row is what decides how large a card actually arrives on a phone. Hence the ladder: 2 per row up to 4 players, 3 up to 6, 4 up to 24, 5 beyond. Both ends carry weight. In a small chat, four in a row shrink to an unreadable 82 px strip. At the top the constraint is different: Telegram rejects a photo whose dimensions sum past 10000 px, and 40 cards at 5 per row come out to exactly 8 rows — 4308×5240, just inside. Four per row would need ten rows and produce a column.
The flip side is that the cost of a refresh now scales with the number of players in the chat. A 4-card board is 85 KB and 75 ms; a 40-card one is 733 KB and 639 ms — roughly 8× on both. The ceiling on concurrent live chats stopped being a single number: a few large groups now cost what dozens of small ones used to, and a growing chat makes its own refreshes more expensive. The one knob is the live board’s debounce interval, currently 10 seconds.
What’s next
The English edition is finished but switched off until it has its own domain and bot. The Ukrainian dictionaries were generated automatically from hunspell and a subtitle frequency list, and want a manual pass.