SURPRISES

A message at the end of their path.

Hide a personal message or link. Choose the route that opens it, then send the game to someone.

One route opens the surprise

The recipient must follow your exact complete winning route. A different route can win the board without opening your surprise.

From your route to their surprise

  1. Choose a routeCollect every required item and finish at the Wallet.
  2. Hide your messageAdd a note or link. Your browser encrypts it for that route.
  3. Send the gameThe shared link contains the board and encrypted surprise.
  4. Follow the routeMatching your path opens the message at the Wallet.

The work happens while you play

Your browser calculates in the background as you move. When you reach the Wallet, it finishes checking your route before showing the result.

If you finish quickly, some calculation may still remain. The wait depends on your device and the work left to do; on slower devices it can take a minute or more.

What the link reveals

The surprise message or link is encrypted. Anyone who inspects the game link can read the board, title, clue and expiration, see that a surprise exists even when its hint is off, and estimate its content length. They can keep a copy and try to find the route with an automated solver.

TokenHunter does not upload or store your game. The complete game travels inside its link. Your own local backups include readable surprise content and routes, so keep backups private. See Privacy for hosting requests and other details.

An expiry date cannot recall a link

After the date you choose, TokenHunter shows an expired screen. This is soft expiration: a modified program or changed device clock can bypass it. It cannot erase copies or stop offline solving.

Technical details: parameters and exact key derivation

The work profile carried by each surprise

V3 uses Argon2id v1.3. Its memory and pass counts are part of the computation; reducing them changes the derived key. These are TokenHunter’s chosen parameters, not a claim that the complete protocol is an RFC recommendation.

Default parameters for newly created v3 surprises
OperationParameters
Each accepted moveArgon2id · 128 MiB · 15 passes · 1 lane · 32-byte output
Final route derivationArgon2id · 128 MiB · 6 passes · 1 lane · 32-byte output
Encryption keyHKDF-SHA256 · 32 bytes
Surprise encryptionAES-256-GCM · random 12-byte IV · 16-byte authentication tag
Per-surprise seed32 random bytes, included in the shared link

Each surprise carries its exact memory and pass counts in immutable profile names. The app accepts flat 64, 128 or 256 MiB profiles with 3–20 passes and one lane. A release may choose different creation defaults; existing surprises retain their original parameters, including earlier 64 MiB surprises. Lowering a recipient’s settings cannot open a surprise with less work. Actual device memory use can exceed the Argon2 allocation.

HKDF turns the final material into an encryption key; the expensive work comes from Argon2id. AES-GCM provides authenticated encryption. Its authentication tag is checked after the full derivation.

Exact v3 key derivation

This describes the production implementation’s byte encoding. H is SHA-256; UTF8 encodes text; first16 takes the first 16 bytes. T concatenates fields, each prefixed by its byte length as an unsigned 32-bit big-endian integer. Strings inside T become UTF-8 bytes; numbers become four big-endian bytes before being length-prefixed.

moves is a byte array: U = 0, D = 1, L = 2, R = 3. Number moves from 1. cells is the decoded grid in row-major order, using the internal tokens ., W, S, X, c, g, kA/kB/kC, GA/GB/GC, and P. They represent floor, wall, start, Wallet, coin, required gem, keys, gates and portals. Portals pair in row-major order. Optional bonus gems are separate from this grid.

Starting contextPseudocode
D = "THUNT-SURPRISE-v3/"
rules   = "th-rules-1"
profile = surprise.profile  // default: "a2id-128m-t15-p1"
final   = surprise.final    // default: "a2id-128m-t6-p1"

boardHash = H(UTF8(JSON.stringify([
  "THUNT-BOARD-v1", rules, cols, rows, cells
])))
C = H(T(D + "context", rules, profile, final,
        boardHash, seed))
S[0] = H(T(D + "start", C))

The JSON array above is serialized without whitespace. The seed is the decoded 32-byte value. Parse each validated profile name to get its memory in MiB and pass count; multiply MiB by 1,024 for Argon2’s KiB parameter. Each Argon2id below uses version 0x13, one lane, a 32-byte output and no optional secret or associated-data inputs. Defaults use 131,072 KiB.

Move and final keyPseudocode
n = number of accepted moves
for i = 1 ... n:
  move = one-byte array containing moves[i - 1]
  input = H(T(D + "step", C, i, S[i - 1], move))
  salt  = first16(H(T(D + "salt", C, i)))
  S[i]  = Argon2id(input, salt, memory = profile.memoryKiB,
                   passes = profile.passes)

pathHash = H(T(D + "path", moves))
finalSalt = H(T(D + "final", C, pathHash))
finalInput = H(T(D + "final-work", C, pathHash, S[n]))
material = Argon2id(finalInput, first16(finalSalt),
                   memory = final.memoryKiB, passes = final.passes)
key = HKDF-SHA256(
  IKM = material, salt = finalSalt,
  info = UTF8(D + "surprise-key"), length = 32
)
aad = T(D + "aad", C)

Before sealing or final decryption, the engine requires the complete route to be a valid win. The encrypted plaintext is UTF-8 JSON with v: 1, at: [walletColumn, walletRow] using zero-based coordinates, and p holding either {kind: "message", text: ...} or {kind: "url", url: ...}. The plaintext’s version is independent of the outer protocol version.

Encrypt with AES-256-GCM using key, a fresh random IV, aad and a 128-bit tag. ct contains ciphertext followed by the tag. seed, iv and ct use unpadded base64url. The public envelope fields are v: 3, rules, profile, final, seed, iv and ct.

That envelope lives in the TH5 board container, with the public presentation fields r: "win", n (show the hint) and optional c (clue). The publisher copies only allowed fields. It does not include the creator’s route, its hash, intermediate states or the derived key. Existing v2 surprises still open using their original derivation. Older v1 surprises cannot gain this protection merely by changing their version number; their creators must lock and share them again.

Your path opens the message

The creator draws a route that collects every required item and reaches the Wallet. A random seed gives that surprise its own starting state. Each accepted move combines the previous state, the direction and the board context, then runs Argon2id to produce the next state.

  1. Board + random seedCreate the starting state
  2. Previous state + next moveArgon2id produces a new state
  3. Complete winning route + final stateFinal Argon2id work, then HKDF
  4. Derived keyAuthenticate and decrypt the surprise

Reaching the same cell through a different route produces a different state. A portal traversal counts as one accepted move, even though it crosses both portal endpoints. Rejected moves do not extend the chain.

There is no intermediate “you found it” check. A legal route produces states whether or not it is the creator’s route. Only the final authenticated decryption reveals a match. A different route that also wins the board does not normally open the surprise.

What an automated solver can do

A bot can be a native Go, Python or Node program. It does not need a browser, mouse movements or the processing animation. It can enumerate valid routes, try likely routes first and calculate different branches in parallel.

Routes that share the same beginning can reuse its computed states for the same surprise. A full search therefore pays for distinct route prefixes, plus the final derivation for each complete candidate checked—not necessarily every move of every route independently. A new random seed changes the states and prevents reuse of an old surprise’s state chain.

Memory hardness raises resource costs; it does not guarantee a particular cracking time or a fixed amount of RAM for every possible attack. Faster hardware, optimized implementations and time–memory tradeoffs matter. Browser timings are not a lower bound on a bot’s speed. Anyone who learns the creator’s route can compute its key without searching.

Open to scrutiny

The implementation is checked with independent native and browser-WASM derivation vectors, complete-route and malformed-input tests, and a native search tool. Those checks are useful evidence of correctness, not an independent cryptographic audit.

Found a shortcut or a mismatch in this description? Contact the developer with a minimal example using a dummy surprise. Please use a made-up example and leave out private routes or real surprise content.

← Back to the game