Files
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00

15 KiB
Raw Permalink Blame History

Wordle Daily

wordledaily is a Telegram HTML5 game modelled on NYT Wordle and its Discord Activity. Everyone plays the same English five-letter word each day. A new puzzle starts at 07:00 ICT (00:00 UTC), and the puzzle number goes up by one each day.

The same module and BotFather game also run unlimited Wordle: /wordle sends a card that opens the player's own random rounds. Play decides the mode from the card; see Card modes.

The daily game has three commands:

  • /wordledaily sends the game card in any chat, private or group, and in a forum topic it stays in that topic. Pressing Play opens a board page that this bot serves.
  • /wordledaily_subscribe opts the chat, or the forum topic, into a daily push at 07:00 ICT.
  • /wordledaily_unsubscribe opts it out.

A topic means a real forum topic (is_topic_message). Telegram also gives a reply chain in an ordinary supergroup a thread ID; a command sent as such a reply counts for the whole chat, the same scope its plays and group results are stored under.

Anyone in a chat may subscribe or unsubscribe, the same as /lol_subscribe and /thuyvan_subscribe. Channels are refused, because Telegram does not allow games in channels.

The word data and scoring live in internal/modules/wordle/wordlist. The daily engine described here (puzzles, progress, stats, live results, recap and push) lives in internal/modules/util/guessgame and is shared with LoLdle Daily.

Card modes

Both modes use the one BotFather game wordle, so a card does not say which mode it is. /wordle and /wordle_new record each card they send; Play on a recorded card opens unlimited mode, and every other card opens today's puzzle:

  • /wordledaily cards and the 07:00 push,
  • every card sent before unlimited mode existed,
  • inline shares (t.me/<bot>?game=wordle), which have no chat message to record,
  • /wordle cards nobody played for 30 days, whose record expired,
  • forwarded copies of a /wordle card, which the bot cannot trace back to their record.

A failed lookup answers Play with an alert rather than opening the wrong mode. See unlimited Wordle.

Setup

  1. BotFather: create a game for the bot with /newgame and the short name wordle. The share link is then t.me/<bot username>?game=wordle. The module, its commands and its routes keep the name wordledaily; only the BotFather game is wordle.

  2. Public URL: this uses the same GAME_BASE_URL as the noitu game. The page is served at <GAME_BASE_URL>/games/wordledaily/.

  3. Environment: WORDLEDAILY_GAME_SECRET is optional and must be at least 32 bytes. Two keys are derived from it, each with its own label:

    • tiennm99bot/wordledaily/token/v1 signs the Play links.
    • tiennm99bot/wordledaily/answer/v1 orders the daily answers.

    When the secret is unset, the bot token is the root key. Because the labels are separate, a noitu link never opens this game.

  4. Keep wordledaily in MODULES, or leave MODULES empty. A MODULES value that still names the retired wordle module loads wordledaily.

The game is disabled when any of these holds:

  • GAME_BASE_URL is unset or invalid.
  • The secret is shorter than 32 bytes.
  • There is no storage.

A disabled game behaves like this:

  • /wordledaily and /wordledaily_subscribe answer "Wordle Daily isn't configured on this server." /wordledaily_unsubscribe still removes a subscription.
  • /wordle shows the unlimited round's board in the chat instead of a card; the other /wordle commands keep working in the chat.
  • Play shows the same text as an alert.
  • No /games/wordledaily/ route exists and the daily push is not registered.

The puzzle

  • Day boundary: puzzle #1 started at 07:00 ICT on 9 October 2026. Puzzle n runs from epoch + (n−1) days until one day later. Because 07:00 ICT is 00:00 UTC, the 00:00 UTC cron always pushes exactly the puzzle a player then gets.
  • Word lists:
    • wordlist/data/answers.txt holds about 2,230 common English words that a puzzle can be. It was curated for this bot; it is not NYT's list.
    • wordlist/data/words.txt (14,855 words) is what a guess may be.
    • A test checks that the answers are sorted, unique, and all in the dictionary.
  • Choosing the answer: the days are split into cycles as long as the answer list. Each cycle walks one permutation of the list: a Fisher–Yates shuffle seeded with HMAC(answer key, "cycle:<n>") through ChaCha8.
    • No answer repeats within a cycle (about six years).
    • The order can't be guessed without the key.
  • Pinning: the first time a puzzle is needed (a request, or the push at 07:00), its answer is stored as puzzle:<n>. Every later read uses that document, so a new secret or an edited answer list never changes a puzzle that has already started.
  • Rollover mid-game: a guess names the puzzle number the page shows. A guess for a puzzle that is no longer current is refused with new_puzzle, and the page reloads the new board.

Playing

  • Validation: the server checks every guess and colours it. Two-pass marking handles duplicate letters the NYT way. The page never receives a word list, and it never receives the answer until that player's game is over.
  • Progress: each player's board is saved after every guess, per puzzle. A restart, a reload, or opening any other daily card (see Card modes) shows the same board.
  • One game a day: each player gets one game per day across all chats. A finished puzzle can't be replayed.
  • Stats: each player's stats cover every chat. They are played, win %, current and max streak, and the guess distribution, shown on the end screen.
    • The current streak counts consecutive puzzle wins.
    • A loss or a missed day ends it.
    • A streak whose last win is older than yesterday shows as 0.
  • End screen: it shows the answer, the stats, a countdown to the next puzzle and two buttons:
    • Share uses Telegram's game share.
    • Copy result copies the spoiler-free text, such as Wordle Daily #12 3/6 followed by the emoji grid.
  • Page features: physical and on-screen keyboards, a dark scheme that follows the system, and a high-contrast (orange/blue) toggle that is remembered in localStorage. The board is sized from the height the header and keyboard leave, so the page fits a short phone screen without scrolling. Each tile turns to its colour in turn, and the keyboard colours once the row is revealed. Tiles and keys carry their result as an accessible label, and each guess's result is announced to screen readers.
  • Score: a win reports 7 − guesses with setGameScore (force=false). A 1-guess win scores 6 and a 6-guess win scores 1. A loss is not reported.
    • The score is set on the card the player played from.
    • When they open another card the same day, it is set there too, once per card.
    • Each day's card therefore carries that day's leaderboard.
    • A card is recorded as reported only once Telegram accepts the score. BOT_SCORE_NOT_MODIFIED counts as accepted. Any other failure is logged, and the next time the player opens that card the score is set again.

Groups: mapping the Discord Activity to Telegram

NYT's Discord Activity works like this:

  • Everyone plays the same daily word.
  • A live embed in the channel shows each player's progress.
  • Results are spoiler-free colour grids.
  • A daily message recaps yesterday's scores, crowns the best, and shows the group streak.
  • Reminders of a new puzzle can be muted.

Telegram has different limits:

  • A game message has no caption, so any recap has to be a separate text message.
  • Bots can edit their own text messages.
  • setGameScore in a group also posts Telegram's own "X scored N" service message, which cannot be avoided.
  • A message holds at most 4,096 characters, and groups allow about 20 messages a minute.

The bot maps the Activity onto those limits as follows.

Discord Activity Here
/wordle launches the activity /wordledaily sends the card; Play opens the board
Live channel embed of everyone's progress One live results message per group, topic and day (below)
/share and the Share button Share and Copy result on the end screen
Daily recap of yesterday, crown, group streak The 07:00 ICT push to subscribed chats (below)
Mute daily reminders /wordledaily_unsubscribe
Personal streaks Per-user stats, the same for every chat

Live results message

A live results message exists only in groups and supergroups, never in a private chat or for an inline card. It works like this:

  • Who appears: a player is listed in a group topic's day once they have made a guess from a card in that topic. A player who finished elsewhere and then opens the group's card is listed at once, with their finished grid.

  • Creating it: the first time, the bot sends one silent message as a reply to the card, with AllowSendingWithoutReply. After that it only edits that message.

  • Rate of updates: edits are coalesced, at most one every 5 seconds per message, and the text is always rendered from the store.

  • Errors:

    • A "message is not modified" answer is ignored.
    • If the message was deleted, a new one is sent at most once every 10 minutes. An update that comes sooner is held until then, so the final results still arrive.
    • A failure that may pass (429, a timeout, a 5xx, a store error) is retried up to 5 times, waiting Telegram's retry_after or 5, 10, 20, 40 and 80 seconds. A refused request (bad request, forbidden) is not retried.
  • Content: colour grids only, never letters. The rows are ordered like this:

    1. Winners, by fewer guesses and then earlier finish.
    2. Losses (X/6).
    3. Players still playing, with their partial grid, most guesses first.

    The message stops before Telegram's limit and ends with +N more.

Wordle Daily #12 · live results
🔥 Group streak: 5 days

Alice 3/6
⬜🟨⬜⬜⬜
🟨🟩⬜🟩⬜
🟩🟩🟩🟩🟩
Bob X/6
…
Carol playing 2/6
⬜⬜🟨⬜⬜
⬜🟩🟩⬜⬜

The 07:00 ICT push

The cron wordledaily_daily_push (0 0 * * * UTC) does the following:

  1. Pins today's answer.
  2. Deletes the puzzle:, play: and cday: documents of puzzles older than yesterday's (see Storage).
  3. Claims the day under daily_push:last_date, so a second trigger on the same day sends nothing.
  4. For each subscriber:
    1. In a group, it sends yesterday's recap. The recap opens with yesterday's answer, then the group streak, then the players who finished.
    2. It sends today's card in the same topic.

If the recap fails to send, the card is skipped. The shared subscription fan-out then prunes chats that blocked or removed the bot. Sends are throttled above 30 subscribers.

Wordle Daily #11 — yesterday's results
Answer: CRANE
🔥 Your group is on a 5 day streak!
👑 3/6: Alice, Dan
4/6: Eve
X/6: Bob

What else the push sends depends on the day before:

  • Group streak: it counts consecutive puzzles that at least one member of the group or topic solved; a loss does not keep it going. If nobody solved yesterday's word and a streak of at least 2 days was alive, the streak line reads Nobody solved it. Group streak reset.
  • Nobody played: the recap is just the title and the answer.
  • Private chats: a private chat always gets just the card.

Streak lines appear only from 2 days up.

Nothing else is posted. There is no separate "X is playing" message and no message per guess; the live results message covers both.

HTTP API

Every route lives under /games/wordledaily/:

  • GET /games/wordledaily/ serves the page, and GET app.js and GET app.css serve its assets.
  • The API routes are POST with a JSON body.

Headers, CSP, caching and the 4 KB request limit are the same as noitu's, through internal/modules/util/htmlgame.

Route Body Answer
api/state {"token"} view
api/guess {"token","num","word"} view
api/new {"token","seq"} unlimited cards only; see unlimited Wordle

The token decides the mode. The daily view has these fields:

  • mode (daily), num, date, player, max (6), len (5)
  • guesses: a list of {word, marks}. marks has one letter per position: c correct, p present elsewhere, w wrong.
  • status: playing, won or lost
  • next_at: the unix second the next puzzle starts
  • answer, stats (played, win_pct, cur, max, dist[6]) and share: only once the game is over

Errors are {"error","message"}:

Status Codes
400 bad_request
401 bad_token, expired
403 bad_mode (api/new on a daily card)
409 new_puzzle, new_round (unlimited)
422 length, unknown, finished
429 rate_limited: 60 requests a minute per player, every route together

A Play token expires after 6 hours and is not tied to a day.

Storage

Everything is in the module's collection, under separate key prefixes.

Key Holds
puzzle:<n> the pinned answer of puzzle n
play:<n>:<user> one player's board on puzzle n: guesses with marks, status, finish time, cards reported on, group topics joined
stats:<user> played, wins, current and max streak, guess distribution, last puzzle finished and won
cday:<n>:<chat>:<thread>: the players of a group topic's day, the card the summary replies to, and the summary message ID
cstreak:<chat>:<thread>: the group topic's streak and the last puzzle it counted
subscribers the subscribed chats and topics
daily_push:last_date the last puzzle number pushed
uround:, ustats:, ucard: unlimited mode; see unlimited Wordle

Each player's board and stats are written under a per-player lock. The board is the record and is written first. A stats update is skipped for a puzzle already counted, and when the stats write after a finished board fails, the next state load or guess folds that board into the stats. So the stats always follow the board and never count a game twice. Group documents use versioned writes with retries.

Retention: the 07:00 push deletes puzzle:<n>, play:<n>:* and cday:<n>:* for every n older than yesterday's puzzle; nothing reads them after the recap. stats:, cstreak: and the subscription documents are kept. With the memory store this keeps the process's memory bounded.

Choices made

These follow NYT's behaviour where it is known. Change them here if wanted:

  • The recap shows yesterday's answer first.
  • The group streak counts wins only.
  • The live results message shows partial grids of players still playing, as NYT's live embed does. This reveals some colour hints to members who have not played yet.
  • The answer list was curated for this bot and can be replaced. A puzzle that has already started keeps its stored answer.