How to Play
What you're playing
What sets it apart:
1v1 battles where lines you clear send garbage rows to your opponent.XNT-staked matches where both players deposit, winner takes the pot.TETRIS reward token (Token-2022 on X1) paid out at match end, proportional to score.Spectator + replay views — any match is watchable live or scrubbable after.
Modes
| Mode | URL | Description |
|---|---|---|
| Solo | index.html | Single-player. Score submits to the leaderboard if your wallet is connected. |
| Battle (lobby) | battle.html | Open queue + invite codes + stake selector. |
| Match | match.html?code=X | Split-screen 1v1 with live garbage exchange. |
| Watch | watch.html?code=X | Read-only spectator. No wallet required. |
| Replay | replay.html?code=X | Scrub through a finished match. |
| Leaderboards | leaderboard.html | Top by solo score and battle wins. |
Match codes are 5-char Crockford base32 — no ambiguous characters (0/1/I/L/O/U). 33M possible codes.
Controls
| Action | Keyboard | Touch | On-screen |
|---|---|---|---|
| Move left / right | ← → | swipe ← / → | ◀ ▶ |
| Soft drop (1 cell) | ↓ | swipe ↓ | ▼ |
| Rotate | ↑ | tap board | ↻ |
| Hard drop (lock now) | Space | — | ⇓ |
| Hold piece | H | — | ▣ |
| Defensive toggle (battle) | D | — | DEF chip |
| Toggle music | — | — | ♫ |
On-screen buttons exist for touch devices but work in any browser. Keyboard input is debounced 70 ms so a held key doesn't fire faster than the engine ticks.
Solo play
Pieces and the 7-bag
Standard tetrominoes (
NEXT and HELD
NEXT shows the upcoming 4 pieces stacked vertically. Plan two or three moves ahead.HELD stores one piece on demand. Press H to swap the active piece into the slot. Limit: one swap per spawned piece — resets when you lock.
Scoring
| Lines cleared at once | Base points | Notes |
|---|---|---|
| Single | 100 | Self-defense only in battle |
| Double | 300 | Sends 1 garbage row in battle |
| Triple | 500 | Sends 2 garbage rows in battle |
| Tetris (4) | 800 | Sends 4 garbage rows in battle |
Per-lock score:
final = base * current_level + combo * 50
Combo increments on every consecutive line-clearing lock; resets to 0 on a non-clearing lock. Level rises by 1 every 10 lines, capped at 15. Each level shortens the auto-fall interval (800 ms at L1 down to 20 ms at L15).
Audio
All sounds are synthesized in-browser via Web Audio — no audio files to download.
- Music: Korobeiniki melody + bass loop. Persists across reloads via
localStorage.tetrisMusic. - SFX:
move,rotate,drop,clear,tetris,levelup,hold,garbage,gameover.
Battle mode
How to start a match
Open queue. Onbattle.htmlclick FIND MATCH. The server pairs the two longest-waiting wallets; both clients navigate to the samematch.htmlsession — one as p1, the other as p2.Invite. Onbattle.htmlclick CREATE. You get a 5-char code and a shareable link. Send it; whoever opens that URL becomes p2.
Match lifecycle
free: create -> lobby -> playing -> finished / abandoned
staked: create -> awaiting_deposit -> lobby
-> (p2 joins) awaiting_deposit -> playing
-> finished / abandoned
| Status | Meaning |
|---|---|
awaiting_deposit | Stake match awaiting either creator or joiner deposit. |
lobby | Created, waiting for opponent. Staked: p1 deposit confirmed. |
playing | Both players in. Active gameplay. |
finished | Match ended via topout or forfeit. winner field set. |
abandoned | Cancelled, swept, or otherwise terminated without a clean result. |
Each player gets their own piece sequence
Both players use the 7-bag distribution, but their seeds differ — and each player's seed is only sent to that player, never to the opponent or any spectator. Shared-seed mode was tried first and discarded — it created compounding bad luck (both stuck in S/Z droughts together) and let players predict each other's queue.
Garbage rows — the core battle mechanic
When you clear lines, the surplus is sent to your opponent as gray rows with one random hole column. They push the opponent's stack up; if any of their blocks get pushed off the top of the playfield, they top out and lose.
Damage table
| Lines you cleared | Garbage you send | Score |
|---|---|---|
| 1 (single) | 0 | 100 |
| 2 (double) | 1 | 300 |
| 3 (triple) | 2 | 500 |
| 4 (tetris) | 4 | 800 |
Singles are free score but zero damage. The whole game pressures you toward setting up tetrises.
The offset rule
Incoming garbage doesn't apply instantly — it sits in a queue.
You have 3 incoming garbage queued.
You clear a tetris (would send 4).
-> 3 cancel against your own queue (queue now 0).
-> 1 surplus is sent to opponent.
This is what makes Tetris-vs-Tetris feel back-and-forth: a fast counter completely neutralizes an incoming attack and still hits back with leftover damage.
When garbage actually lands
Defensive mode
Tap DEF (or press D) to enter defensive mode. The button glows green; a DEF chip appears next to your handle (and opponents see it too).
While defensive:
- You send
no garbage on clears. - Each successful clear removes
1 existing junk row from the bottom of your board (cap: 1 per clear). - Your incoming queue is
not cancelled by your clears — pending garbage still threatens you.
A JUNK -1 banner flashes on each junk-row removal. If your stack is clean (no junk), defensive clears nothing extra.
When to stay offensive: opponent has a tall stack, your queue is short, and you can land a tetris within a few pieces.
Match end
Topout — your stack gets pushed off the top of the playfield. Opponent wins. Match auto-finishes; result broadcast to everyone watching.Forfeit — reload or close the tab. The browser fires asendBeaconto/forfeitas it unloads; opponent wins.Time limit — if the match was created with a time limit, the server auto-finishes atstarted_at + time_limit_ms; higher score wins, equal = draw.Abandonment — server-side sweep marks a matchabandonedif there's been no activity for 2 minutes, or 30 minutes total. The still-alive (or higher-scoring) player gets the win.
XNT stakes
A staked battle puts XNT (X1 mainnet native token) into escrow from both players. Winner takes the full pot (2× stake).
Custody model
A single server-controlled keypair holds in-flight stakes. Its public key is logged on the server and shown in match snapshots so clients know where to deposit.
Lifecycle
| Step | Free match | Staked match |
|---|---|---|
| 1. Create | Status → lobby | Status → awaiting_deposit |
| 2. Creator deposits | — | Wallet signs transfer(stake) → escrow; server verifies on chain; status → lobby |
| 3. Joiner | Clicks JOIN → playing | Clicks JOIN → awaiting_deposit → deposit → playing |
| 4. Match end | Result broadcast | Result broadcast + escrow signs payout transfer to winner master |
Unit: lamports (1 lamport = 10⁻⁹ XNT). UI accepts decimal XNT and converts.
Per-match cap: 100 XNT.
Why deposits use the master wallet (popup)
The session keypair is derivable from one master signature and lives in browser sessionStorage. That's fine for signing high-rate game events. It is not suitable for moving real money. So deposits use the master wallet — you explicitly approve each fund movement in your wallet's popup.
Orphan deposits
If a client signs a deposit but the /confirm-deposit POST never lands (tab closed, network blip), funds hit escrow with nothing crediting them. A reconciliation sweep runs every 5 minutes:
- Fetches the last ~200 inbound escrow signatures from X1.
- Skips any already credited to a match.
- Records new ones as
pendingin theescrow_orphanstable. - Refunds anything still
pendingafter a 10-minute grace window, back to the original sender.
Safety caps: max 5 refunds per sweep, 20 per rolling hour.
TETRIS reward token
When the server has TETRIS_REWARD_MINT configured, match results distribute a Token-2022 SPL reward (symbol TETRIS, 6 decimals) to both players, proportional to their score (1 TETRIS per point at current K). The amount per slot is recorded on the match row and visible in your wallet panel + the match result overlay.
Spectating
Anyone can open watch.html?code=XXXXX for any match — live or finished. No wallet needed. Read-only view of both boards side-by-side, with score/lines/level for each, plus DOUBLE/TRIPLE/TETRIS banners on clears and garbage-incoming banners. Spectators also hear SFX for both sides.
Wallet & session keys
A wallet has two parts:
Master — your real wallet (Phantom / Backpack / X1 / Manaswap). Used for deposits and one signature derivation.Session — long-lived keypair derived from one signature, used to silently sign per-frame game events.
Derivation
master.signMessage("tetris-v0.1") -> 64-byte signature
session_seed = signature[0..32]
session_keypair = Ed25519.fromSeed(session_seed)
Same master + same message = same session keypair, every device, every browser. Reproducible.
Why two keys
Game actions (score posts, state pushes, line-clear claims) happen many times per second. Showing a wallet popup for each one is unworkable. So we sign once with the master to derive the session key, then use the session for all subsequent silent signing.
Strategy notes
README.md (overview),
GAMEPLAY.md (full protocol),
ARCHITECTURE.md (code structure).