DEV Community

Cover image for I built a daily regex game. Scoring regex fairly was the hard part
Ijaz Ur Rahim
Ijaz Ur Rahim

Posted on

I built a daily regex game. Scoring regex fairly was the hard part

I like regex golf: the challenge of writing the shortest pattern that does a job. So I built a daily game around it, Regex Hunter.

Every day there's one hunt, the same for everyone. You get a few words to catch and a few to spare, and you write one pattern that matches every target and none of the decoys. Then you try to make it shorter, against a par.

Here's an example board:

CATCH   nono  xyx  hahaha  abab  lolol
SPARE   ababb  abba  ab  aaa  abcab
Enter fullscreen mode Exit fullscreen mode

^(.)(.)\1 catches all five targets, but it also hits ababb and aaa. Close, not a solve. That back-and-forth, where the board lights up as you type and shows you exactly what your pattern hits, is most of the fun.

This post is about the less visible part: what it takes to score regex fairly.

Length is the score, so length has to be honest

The score is your pattern's length. That sounds trivial until you decide what "length" means.

  • Characters, not bytes or UTF-16 units. In JavaScript, '๐Ÿ˜€'.length is 2. A pattern with an emoji shouldn't cost double, so the client counts code points with [...pattern].length, and the server uses Python's len(), which already counts code points.
  • Flags cost a character. Code golf has always charged for /i. Without that, a case-insensitive hunt is free to trivialise. Each of i, m and s that you add beyond the hunt's own flags costs one character. g costs nothing because it changes nothing: a "does it match?" test ignores it.
// Flags that change what a pattern matches; 'g' is a no-op for .test().
const SCORED_FLAGS = 'ims';

export function scoredLength(pattern, flags, base) {
  const extra = new Set([...flags].filter(
    (f) => SCORED_FLAGS.includes(f) && !base.includes(f),
  )).size;
  return [...pattern].length + extra;
}
Enter fullscreen mode Exit fullscreen mode

The server has the same function in Python, and a test keeps the two in step.

The browser judges live, the server judges for real

The board needs instant feedback, so the browser runs your pattern with JavaScript's RegExp on every keystroke. A leaderboard can't trust the browser, though, so every submitted solve is checked again on the server.

That server check has two jobs the browser doesn't:

  1. Cap the pattern. 64 characters at most. Nobody golfing needs more, and it bounds the work.
  2. Survive catastrophic backtracking. Someone will submit (a+)+$ against a long string. The server uses Python's regex module, which supports a per-match timeout, and gives every word 25 ms:
MATCH_TIMEOUT = 0.025  # seconds, catastrophic-backtracking guard

for word in monsters:
    if compiled.search(word, timeout=MATCH_TIMEOUT) is None:
        return False, "Pattern does not catch all monsters."
for word in innocents:
    if compiled.search(word, timeout=MATCH_TIMEOUT) is not None:
        return False, "Pattern wrongly catches an innocent."
Enter fullscreen mode Exit fullscreen mode

A timeout counts as a failed solve, not a server error, so a hostile pattern costs one request and nothing more.

The bug I found while writing this post

While pulling the snippets above, I reread the browser's solve check:

const regex = new RegExp(pattern, flags);
const caught = hunt.monsters.every((w) => regex.test(w));
Enter fullscreen mode Exit fullscreen mode

Spot it? If flags contains g (or y), RegExp.prototype.test is stateful. After a match it sets regex.lastIndex to where the match ended, and the next call starts searching from there, even on a different string. So after ca matches cat (ending at index 2), the check on car starts at index 2, finds nothing, and the board says your perfectly good pattern misses a target.

No live hunt uses g today, so no one was hit by it, but the hunt editor allows it. The fix is one line: start every word from zero.

const test = (w) => { regex.lastIndex = 0; return regex.test(w); };
Enter fullscreen mode Exit fullscreen mode

I added a test that fails without it. It's a good reminder that a g regex is an iterator wearing a boolean's clothes.

Par: what counts as "good"?

A score needs a reference point. Each hunt has a par: the length of a good, not heroic, solution. Points are 100 ร— par รท your length, capped at 150, so matching par earns 100 and beating it earns more, up to a ceiling. The cap matters because otherwise one absurd 3-character trick on an easy hunt outweighs a week of solid play.

Community hunts add a wrinkle: anyone can write one and set its par. To stop a generous par from inflating scores, a community hunt's par is capped at 1.5ร— the shortest solve anyone has found.

Fairness beyond the pattern

A few smaller decisions turned out to matter as much as the regex engine:

  • Archive runs never touch your streak. Replaying last Tuesday's hunt is practice, not a do-over.
  • Rankings by concept. One overall number hides a lot, so there are separate boards for anchors, character classes, quantifiers, backreferences and lookarounds. You can be top 5% at classes and still learning lookarounds.
  • Multiplayer has three modes. In Shortest, fewest characters wins. In Fastest, the quickest valid solve wins. In Reverse, you see only the board and no description, and golf blind.

Everything else in it

Scoring is the core, but the daily hunt is only one way in:

  • Daily hunt: one shared hunt a day, with streaks, stars, a leaderboard and a share card for your result.
  • Archive: replay any past daily on its own leaderboard.
  • Practice packs: 16 short ladders, each drilling one technique, from shorthand classes and anchors to backreferences, validation and multiline work.
  • Academy: nine interactive tracks from your first literal to golf-grade patterns. Finishing a track earns a certificate anyone can verify on the site.
  • Guide: a complete regex reference where every example is live and editable.
  • Playground: test any pattern against your own text.
  • Community hunts: write your own hunt in the Studio, share it with a short link, and discuss solutions in threaded comments with upvotes.
  • Hunting Spree: real-time multiplayer races in Shortest, Fastest or Reverse mode, public or private.
  • Teams: a shared hunt library and members-only sprees.
  • Rankings: an overall board plus one per regex concept.
  • Avatars: build your own from parts. A few parts are earned, not picked: finish an Academy track, win a spree, or keep a 30-day streak.
  • Two languages: English and Chinese.

Try it

The daily is free and needs no account: regexhunter.com.

If you've found a shorter pattern than you think anyone could, I'd love to see it in the comments, along with what the scoring should do about a regex trick you think is unfair.

Top comments (0)