NETWORK
Network
Loading live activity
XP FEED
XP
Loading recent XP awards
Live

Featured Rooms

Most active and highest-stakes games on the network right now
Loading rooms
CLOSED BETA · LIMITED SPOTS

Get Early Access

We're opening Arcade21 to a select group of founding testers. Drop your email — we'll reach out when your spot opens.

FOR DEVELOPERS

Build on Chia Gaming

An ecosystem of P2P games is growing on Chia. Ship yours with the Chia Gaming SDK — state channels, stake escrow, and a live player base from day one.

All Rooms

Complete Room Listing

Every room on every game across the network
Loading
Loading rooms
Closed Beta — Now Live  ·  Founding Cohort Open

Real games. Real stakes. No house.

Peer-to-peer skill matches settled on the Chia blockchain. Zero rake. Provably fair. No middleman.

✓ Free to join ✓ No credit card ✓ Founding badge included
8
Games Live
0%
Platform Rake
On-Chain Verifiable
100%
Non-Custodial
Apache 2.0
Open-Source Protocol
0%
Rake on P2P Games
1
On-Chain Settlement per Game
Game Library

Pick Your Game.

Peer-to-peer skill games on Arcade21 — every match player-vs-player, every result settled on-chain. Find your table.

8
Games Live
Full library

Browse All Games

Filter by genre, players, stake, status. Sort however you want.

0 games
Loading games
Live on Chia

Game Rooms

Real-time peer-to-peer sessions — stakes settled on-chain, zero platform rake.
Find a room. Join the match. Win XCH.

--
Active Rooms
--
XCH Staked
--
Players Online
0% Rake
Platform Fee
Loading rooms
Arcade21 Native
Create a Game Room
Set the match, invite players, and launch a professional room in seconds.
Speed
~3 sec room setup
Mode
Private or Public
Environment
Arcade21 Network
Room Settings
Submits to /announce on this deployment.
Live Room Preview
Room Card
Loading game
Public
Waiting
HostPlayer1
Walletxch1...
Room IDpending
Join Target/play
Quick Actions
Tip: create a private room first, test invite flow, then switch to public once your tournament is ready.
Live Chat · Arcade21 Network

The Lobby

Talk strategy, find a match, or just hang out. Channels for every game and the broader Arcade21 community.

Online Now
🔍
💬
# lobby
Open to everyone
🎮
# looking-for-game
Find your next match
⚙️
# developers
SDK & game dev chat
🃏
# cal-poker
California Poker chat
💣
# potato-bomb
Potato Bomb chat
🔤
# wordlock
Wordlock chat
# battleship
Battleship chat
# rock-paper-scissors
RPS chat
🔴
# connect-4
Connect 4 chat
🔢
# guess-my-number
Guess My Number chat
💬
# lobby
0 online
Type to search GIFs
Live Rooms
View all rooms →
· chia.arcade21games.com
Connecting to lobby
Infrastructure

Network Health

Real-time status of the Arcade21 Game Tracker infrastructure, connected games, and active rooms.

🏠
--
Active Rooms
🎮
--
Registered Games
👥
--
Registered Users
⏱️
--
Uptime
📡 Tracker Info
Loading
🔧 System Status
Loading
Live Rankings

Hall of Champions

Global standings across every Chia peer-to-peer game. Ranked by wins, win rate, and XCH staked. Every match is provably fair — every result recorded on-chain.

--
Total Games
--
Players
--
XCH Staked
--
Top Game
--
Active Rooms
Experience Points
Top XP
Loading XP rankings
Global Rankings
Top Players
Ranked by total wins · Updates every 30s
Loading leaderboard
Breakdown
By Game Type
Loading
Live Feed
Recent Activity
Loading
Build on Arcade21 · Open SDK

Bring your game to life.

A Chia game can settle a real-money match in seconds without a server, a payment processor, or a casino license. Open SDK, live player base, zero infrastructure to run. Ship the next great game on Arcade21.

Imagine a game

Whatever you build, the chain settles it.

Skill duels. Strategy boards. Word puzzles. Social party games. The Chia gaming protocol gives you cryptographic primitives that work for all of them — match logic in your code, settlement on-chain.

Skill Duels
Heads-up matches
Poker. Cards. Dice. Anything that resolves on a hidden commit and a public reveal. The protocol was made for this.
California Poker · Crazy Eights
Strategy
Boards & battles
Place. Hide. Hunt. Every move is signed and witnessed by the chain — perfect for games where information leaks turn the game.
Battleship · Connect 4
Puzzle
Logic & language
Codes. Words. Patterns. The Chia commit-reveal scheme makes guessing games impossible to cheat — even by the host.
Wordlock · Guess My Number
Party / Social
Fast & frantic
Quick rounds, shared timers, lightweight stakes. Chia state channels handle micro-transactions that normal blockchains can't.
Potato Bomb · Rock Paper Scissors
Why builders ship here

All the boring parts solved.

Spend your time on game design and game feel — not on payment infrastructure, KYC pipes, anti-cheat heuristics, or matchmaking servers. The protocol does that work.

01
Live player base, day one.
Your game appears in the Arcade21 lobby alongside California Poker, Battleship, and the rest. Skill-gamers already on the platform see it the moment you publish.
02
Zero infrastructure to run.
No servers. No databases. No payment processor. The Arcade21 tracker handles discovery; Chia handles settlement. You run nothing — you ship code.
03
Trustless by design.
Every move signed with BLS. Every outcome cryptographically committed before play begins. Anti-cheat that holds up in a courtroom — without a court.
04
Open ecosystem, MIT SDK.
Fork a reference game. Modify it. Ship it. Or build from scratch. The full SDK, every reference game, every CHIALISP® validator — open source and yours to extend.
Idea to Live

Four steps. No detour.

From a sketch on a napkin to a real-money match between two players — measured in days, not months.

1
Fork or sketch
Clone a reference game (poker, RPS, Wordlock) or start from the empty-template scaffold. TypeScript on the front, CHIALISP® on the chain.
2
Compile validators
Your game's rules become a Chia smart contract. The CLSP compiler ships with the SDK. One command, one .hex file.
3
Test the harness
Multi-player simulator runs your game in real state channels — both sides, signed messages, on-chain settlement — without leaving your laptop.
4
Submit & go live
Submit through the Game Store. Reviewed within 48h. Your manifest goes live, your game shows up in lobbies, players find you.
What's in the box

Everything you need. Nothing you don't.

TypeScript SDK
Strongly-typed API surface for game state, signed actions, channel lifecycle, and player UX hooks.
CHIALISP® Validators
On-chain rules engine. Reference validators for poker, RPS, Battleship — copy, modify, extend.
State Channels
Open a channel, play at full speed off-chain, settle once on-chain. Sub-cent settlement, microsecond moves.
BLS Signature Flow
Every move cryptographically signed. The protocol verifies the chain of moves and refuses to settle a forged one.
Multi-Player Test Harness
Simulate both sides of a match locally. Replay state-channel logs. Reproduce edge cases without a second machine.
Game Store Distribution
Submit your manifest, your game appears in the Arcade21 lobby. Cross-game leaderboards, win tracking, all included.
"
Most chains weren't built for gaming. Their consensus mechanisms are too slow, too expensive, or too centralized for real-time peer-to-peer gameplay. They can hold assets, but they can't settle the microsecond logic of a poker hand without making every transaction cost more than the stakes.
— Bram Cohen, inventor of BitTorrent / creator of Chia
Chia is the chain we built Arcade21 on. Build with us.
Get started

The protocol is open. Your game is next.

The hard problems are solved. State channels work. CHIALISP® works. The player base is on the platform. The only thing missing is your idea.

10
Reference Games
MIT
SDK License
0%
Tracker Fee
~48h
Review Turnaround
Q3 2026
Target launch
--
On the waitlist
8
Live games
5+
Tournament formats
0%
Platform rake
The opportunity

A new shape of competitive play.

Every game on Arcade21 is one-on-one through a Chia state channel. That looks like a constraint until you realize it makes tournaments simpler, faster, and provably fair. No tables of eight. No house. No custodian holding the pot. Every bracket round is a state-channel match. Every settlement hits the chain.

Trustless Prize pools held by chialisp contract, not the platform.
Verifiable Every match settles on-chain. Bracket progression is public.
Composable Open API. Anyone can run a tournament on the network.
Six things that change

Built for one-on-one.
Scaled to championship play.

State channels are the right primitive for trustless gaming. Tournaments are the right primitive for finding out who is actually best. Arcade21 is the first place both work together at real stakes.

Trustless

On-chain prize pools

Every tournament pool is a chialisp coin. The bracket contract is the only signer that can release it, and only when the final match settles on-chain. Arcade21 holds nothing.

Cross-game

Multi-game championships

A single tournament spans Cal Poker, Battleship, Wordlock, and the rest. Points accumulate across games. The top earners advance to the championship round.

Spectator

Crowd-boosted pools

Anyone can add XCH to a tournament pool coin. The chialisp contract enforces that boosted funds go to the bracket winner. Spectators have skin in the game.

Seasonal

Leagues and championships

Weekly brackets feed monthly leagues feed quarterly invitationals. ELO and tournament earnings travel across seasons. Reputation compounds.

Founders

Founding member exclusives

Tournaments only open to founding members. Smaller pots, exclusive bracket access, permanent badge on the leaderboard. One priority slot per founder per quarter.

How the on-chain part works

One pool. Many channels.
Zero custody.

The chialisp tournament contract is the only authority on the prize pool. It watches the chain for state-channel settlements in the bracket, advances winners automatically, and pays out when the final match resolves.

Bracket flow 8-player example
Player 1 Player 2 Player 3 Player 4 Player 5 Player 6 Player 7 Winner Winner Winner Semi A Semi B CHAMP POOL CHIALISP contract pays winner
Round wins
Semifinals
Champion
01
Pool seeded
Entry fees and sponsor funds lock into a single chialisp tournament coin. The amount is public, on-chain, and uncontrolled by the platform.
02
Bracket seeded
Players are paired by ELO so top seeds do not meet in round one. The bracket structure is committed to the contract before round one begins.
03
Matches settle on-chain
Each round is a normal state-channel match between two players. The tournament contract observes on-chain settlements and advances winners automatically.
04
Winner paid
When the final match settles, the contract spends the pool coin and pays the winner directly. No platform signature required. No payout delay.

If Arcade21 disappeared mid-tournament, every player could still claim their share of the pool from the chialisp contract on-chain. The platform is the discovery layer. The contract is the bank.

Formats in design

Five ways to compete.

Each format is built for a different player. Pick the cadence that fits how you actually play.

Single-elimination bracket

Classic format. 16, 32, 64, 128, or 256 players. Lose once and you are out. Final survivor takes the pool.

Short events, high-stakes finals

Swiss format

Skill-paired matchups over N rounds. Everyone plays the full schedule. Better skill ranking than single-elim. The format chess uses.

Large fields, every player wants matches

Cross-game championship

A single tournament spans Cal Poker, Battleship, Wordlock, and more. Points accumulate. Top earners advance to a championship round.

Well-rounded players

Ladder / king of the hill

Permanent ongoing structure. Climb the rankings by beating players above you. Top of the ladder collects a recurring pool from challenger fees.

High-frequency players

Sponsored open

A brand or game developer seeds the prize pool. Entry is free or low-cost. Players compete for the sponsored pot. Sponsors get exposure to the platform.

Onboarding events, new game launches
Why this is different

No house. No custody. No rake.

Pool custody
A chialisp contract holds the prize coin. Arcade21 is not a signer. The contract is the only authority on payout.
Match settlement
Every match is a normal state-channel game settling on-chain. The bracket contract reads chain state, not platform-side records.
Platform rake
Zero. The only cost is the chia network fee for the channel-open and settle transactions. Typically fractions of a cent.
Open access
Any developer can run their own tournament on the network. The infrastructure is exposed through a public API.
Roadmap

The build path.

Where we are. Where we are headed. Honest targets; we ship when ready, not when convenient.

2025 Q4
State channel foundation

Eight games live on Arcade21. Channel-open and settle in chialisp. Shipped.

2026 Q1-Q2
Matchmaking and cross-game ELO

Real-time room discovery, ELO that travels across games, public leaderboards. Shipped.

2026 Q3
Tournament v1 launches

Single-elimination bracket contract. 16 and 32 player events. Entry-fee prize pools. First open registration.

2026 Q4
Sponsored pools and swiss

External funding flows directly into the pool coin. Swiss format for larger fields. Founding-member-exclusive brackets.

2027 Q1
Cross-game championships and spectator funding

Multi-game point series. Spectators can boost any active pool. End-of-quarter championship invitationals.

2027 Q2+
Permanent ladders and open tournament API

King-of-the-hill structures. Any game developer can run their own brackets on the platform.

Closed beta

Be there when the first bracket opens.

Join the waitlist. Founding members get priority registration, a permanent badge on the leaderboard, and early access to every tournament format.

Common questions

Frequently asked.

When can I play in a tournament?

First brackets open in Q3 2026. The waitlist is open now and founding members get priority registration for every launch event. Subscribe for updates and we will reach out when registration opens.

How can pots be big if matches are one-on-one?

The pot is pooled across the whole tournament, not per match. A 64-player single-elim has 63 matches and the entire entry-fee pool goes to the winner (or top three split, depending on format). Sponsored tournaments add external funding on top of entry fees, pushing pools well past what a single match would ever produce.

Is the prize pool actually trustless?

Yes. The pool is a single chialisp coin co-controlled by the bracket contract. Arcade21 is not a signer. The contract releases the pool only when the final on-chain match settlement is observed. If we shut down mid-tournament, players can claim their share directly from the chain.

Can game developers run their own tournaments?

Yes. The tournament infrastructure is exposed through an open API. A developer can fund a prize pool, set the bracket parameters, and run an event for their own game. Arcade21 handles bracket progression and on-chain settlement.

Will there be free-to-enter tournaments?

Yes. Sponsored opens with free entry and real prizes are part of the launch plan. Pro tier members get extra slots in free-entry events. Founding members get priority registration across the board.

How are brackets seeded?

By ELO when stakes are meaningful, so top players do not meet in round one. Random for casual events and free opens. Open brackets with no skill gate are available for players who want to start fresh.

Cultivation Grant Roadmap

Eight milestones.
Twenty-two months.
One destination.

Arcade21 is a recipient of the Chia Cultivation Grant. Awarded on March 25, 2026 on the merits of our team, product, and vision for contributing peer-to-peer gaming infrastructure to the Chia ecosystem. What follows is the public, milestone-by-milestone path.

Watch development on GitHub
8
Milestones
22
Months
1/ 8
Delivered
Jan '28
Final handoff
The mandate

A public commitment to ship a peer-to-peer gaming platform on Chia, in the open.

Most roadmaps are aspirational. This one is contractual. Every milestone below was negotiated, signed, and committed to in writing. Each one carries acceptance criteria, a delivery date, and a non-dilutive cultivation tranche. We share it publicly because we think the work is more interesting when you can watch it happen.

Arcade21 is built on the Chia gaming framework, which Chia Network distributes under the Apache 2.0 license. Our application code, our tracker, our content, and our brand are ours. The underlying state-channel primitives are Chia's. Together they make trustless one-on-one gaming possible at consumer scale for the first time.

CHIA is a registered trademark of Chia Network Inc. Used with permission. Arcade21 is a recipient of the Chia Cultivation Grant. Chia Network Inc. does not endorse or guarantee Arcade21's products or services.
Milestone 01 Apr 2026 Delivered

Alpha · Foundation Live

The foundation. State-channel gaming primitives shipped, five playable titles in the wild, and the onboarding flow that gets a brand-new player from zero to in-game in under five minutes.

Updated to latest Chia gaming primitives. Our integration tracks upstream as it evolves.
Five playable games launched. California Poker, Krunk Words, Rock Paper Scissors, Connect 4, Battleship. All state-channel native.
User onboarding framework established. Wallet connect through Sage and Goby, room creation, room join, settlement.
Milestone 02 Jul 2026 In Progress

Beta · Polish, Identity, Progress

The upgrade pass. The interface gets the finish it deserves. Players get real identities and progress they can show off. Five hundred beta testers are playing daily and telling us what to build next.

Polished user interface. Every screen rebuilt to production quality. The whole site reads as one piece.
Onboarding, profiles, gamification. Public player pages. XP and rewards. Match history. The pieces that make a tracker a community.
500 beta testers. A real community playing every day, finding edges we never would have on our own.
Milestone 03 Oct 2026 Upcoming

Tournaments · The Category-Defining Feature

The release we have been pointing at since day one. State-channel-native tournaments with brackets, prize pools, and settlement nobody has to take on faith. Sponsors can fund the pot. Players play for free or near-free. The pot is held by chialisp, not by us.

v1 tournament functionality. Brackets, prize pools, automated progression, on-chain settlement.
1,000 monthly active users. The first thousand. The hardest thousand.
15,000 monthly played games. Real volume across the platform. Real signal on what works.
Milestone 04 Jan 2027 Upcoming

Onramps · Fiat In, XCH Out

Crypto-native is fine for the first wave. For the second wave we need to meet people where they are. Card in. XCH ready in wallet. Subscriptions for power users who play every day. The bridge between web2 wallet and web3 play, hidden behind a single button.

Fiat payment integrations. Standard rails. Card to XCH in one flow.
Subscription model. Power-user tier. Recurring revenue. Predictable economics for the platform.
Milestone 05 Apr 2027 Upcoming

Mobile · Arcade21 in Your Pocket

Most gaming is mobile gaming. So is most chia. Full mobile integration means the same matches, the same rooms, the same wallets, on the device you actually carry. The game library expands to match the form factor.

Full mobile integration. Native-feeling experience on iOS and Android. Wallet flows that respect the small screen.
Expanded game library. More titles, mobile-first formats, more reasons to open the app.
Milestone 06 Jul 2027 Upcoming

Scale · The Volume Pass

Aggressive user ramping. The content engine running at full speed. The TL;DR podcast hitting ten thousand listeners weekly. Code Cave webinars and daily X Spaces putting state-channel gaming in front of a much wider audience than it has ever had before.

Aggressive user ramping. Paid acquisition where it works, community where it works better.
100+ weekly content pieces via TL;DR podcast (10K+ listeners). The content engine, weekly cadence, real audience.
Code Cave webinars and daily X Spaces. Open development. Live. Every day.
Milestone 07 Oct 2027 Upcoming

Pro · Premium Surface, Developer Tools

A premium tier for serious players who want the deeper functionality. Developer tooling v1 ships. The GameTemplate toolkit makes it possible for a third-party dev to ship a new chia game in days, not months.

Launch of premium features. Pro-tier capabilities for the top of the engagement curve.
Developer tooling v1, including GameTemplate toolkit. The drop-in scaffold that opens chia gaming to every developer who wants a piece of it.
Milestone 08 Jan 2028 The Horizon

Final · Cultivation Complete

The arc completes. The platform is self-sustaining. The cultivation period closes with Arcade21 running at the scale we said it would, on the timeline we said it would, with the product surface we said it would have.

Completion of final project handoff. The cultivation arc closes formally.
6,000 monthly active users. Six thousand humans returning every month to play.
60,000 monthly played games. Sixty thousand games per month. State-channel scale that nobody else has hit.
The destination

Built in public. Watched in public.

Every release ships from a public repo. Every milestone has acceptance criteria the public can verify. Every match settles on a public blockchain. The whole thing is supposed to be watchable. So watch it.

22
Months · Mar 2026 to Jan 2028
6,000
Monthly active players at finish
60,000
Monthly played games at finish
See the code
Arcade21 is a recipient of the Chia Cultivation Grant. CHIA is a registered trademark of Chia Network Inc. Used with permission. The Chia gaming primitives referenced on this page are distributed by Chia Network Inc. under the Apache License, Version 2.0. Chia Network Inc. does not endorse or guarantee Arcade21's products or services. For Chia Network's information about the Cultivation Grant program, visit chia.net.
The Chia Gaming Protocol

Two players. One state channel. No house, no rake.

Chia Gaming is the open-source protocol that makes trustless peer-to-peer wagers possible without a casino, custodian, or per-move gas. Two wallets fund a shared channel coin. They play entirely off-chain. Only the open and the settle ever touch the blockchain.

Zero Gas Per Move Trustless Wagers CHIALISP Smart Contracts P2P Native Apache 2.0
Scroll to explore ↓
Why this is different

Every real-money online game has one of three problems. All three, usually.

Until now, betting online has always required trust in someone you can't see and don't know. Chia Gaming is the first protocol that removes the middleman entirely.

The house takes a cut

Casinos extract 5-15% rake on every pot. Sportsbooks build vig into every line. Web2 gambling is fundamentally a value-extraction service that runs on top of player money.

Your funds are custodial

When you deposit on a gambling site, your money becomes the site's money on its books. Withdrawals can be delayed, frozen, KYC-gated, or simply refused. If the site dies, your balance dies with it.

The randomness is opaque

Every shuffle, every roll, every "RNG" outcome runs inside a black box on the operator's servers. Players have no way to verify the deal was fair. Even "provably fair" implementations require trusting the operator's commitment scheme.

The architecture

Four primitives. Math instead of trust.

Chia Gaming is built on four core ideas, each of which solves one of the problems above. Together they make peer-to-peer gambling possible without any custodian at all.

State Channel

A shared coin on-chain controlled by two players, locked behind a 2-of-2 multisig. Funds are committed when the channel opens, can only be released by mutual signature or via on-chain dispute, and stay locked until settlement.

on-chain 2-of-2 multisig launcher coin

Potato Protocol

Players take turns holding signing authority (the "potato"). Each pass carries an updated signed state with an incremented sequence number. Both players always hold the latest mutually-signed snapshot. Either can close the channel at any time to the latest state.

off-chain BLS signatures sequence numbers

Referee

A chialisp puzzle that validates game moves on-chain when disputes happen. Each game ships with a chain of validator programs that verify move legality, compute state transitions, and slash cheaters. Provably correct, immutable, auditable.

chialisp validators a-e slash

Calpoker (the reference game)

The reference implementation. A poker variant using commit-reveal randomness so both players contribute to the deal and neither can cheat. Five on-chain validator steps enforce the protocol if the game ever hits the chain.

commit-reveal 5-stage validation reference impl
A full session, end to end

Five steps. Two on-chain.

A complete Chia Gaming session touches the blockchain exactly twice: once to open, once to settle. Everything in between is between you and your opponent.

01
Open
Each player creates a launcher coin that points into a shared channel coin. The chain locks both stakes behind a 2-of-2 multisig.
02
Pass
The first player gets the potato. They sign the initial state and pass to their opponent. Sequence number = 1.
03
Play
Players alternate moves and signed state updates entirely off-chain. Every move increments the sequence number. The blockchain knows none of this.
04
Resolve
The game ends. Both players sign the final outcome. If anyone tries to cheat, the other can post the latest signed state to the chain.
05
Settle
A mutual or dispute-driven on-chain spend closes the channel. Funds release according to the final agreed state. Done.
Side by side

Not a casino. Not Lightning. Its own thing.

Most web3 gambling stacks claim "trustless" while quietly retaining custody, charging gas per move, or relying on oracles for randomness. Chia Gaming does none of those.

Traditional online casino Most web3 gambling L2s Chia Gaming
Rake on the pot 5-15% Variable, often hidden Zero
Custody of funds Operator holds everything Bridge custody + smart contract Player wallets, until settlement
Gas per move None (operator pays) Cents to dollars per move Zero, all moves off-chain
Randomness source Operator's black-box RNG External oracle (chainlink, etc) Commit-reveal between players
Outcome privacy Private but operator sees all Every move public on-chain Off-chain; chain sees only final state
Smart contract risk Operator software bugs Upgradeable contracts, admin keys Chialisp puzzles, immutable, auditable
Identity required KYC + bank verification Wallet + sometimes KYC Wallet only

The protocol is open.
The work is shared.

Whether you want to play, build, or just understand what this thing is, the door is open. Pick the path that fits.

The Chia Gaming framework is developed and maintained by Chia Network Inc. and distributed under the Apache License, Version 2.0. CHIA is a registered trademark of Chia Network Inc. Used with permission. Arcade21 is a recipient of the Chia Cultivation Grant. Chia Network Inc. does not endorse or guarantee Arcade21's products or services.
API Reference

The Arcade21 API.

Public read endpoints for game discovery, room state, leaderboards, and live activity. Plus the tracker protocol that BLS-signed game clients speak. JSON over HTTPS.

JSON over HTTPS Rate-limited Stable read paths
Try it

Interactive explorer.

Pick an endpoint on the left, tweak parameters, hit Run to send a real request against this tracker. Copy as curl, fetch, or axios. No credentials required for public read paths.

Live · Tracker reachable
Loading endpoint catalog
Conventions

Base URL & authentication.

All endpoints live under the apex domain. Most read endpoints are public. Writes (announce, results, profile) require either a BLS signature (tracker protocol) or a Bearer JWT (issued at /api/auth/login).

Base URLhttps://arcade21.games
# Public
GET  /announce                       # list rooms
GET  /scrape                         # tracker stats
GET  /api/players/stats              # aggregate player counts
GET  /api/leaderboard                # top players
GET  /api/waitlist/count             # public signup count

# Authenticated (Bearer JWT in Authorization header)
GET  /api/users/me                   # own profile
POST /announce                       # create room (admin or BLS-signed)
POST /announce/result                # report game result (BLS-signed)
Tracker Protocol

Room discovery & lifecycle.

The bittorrent-inspired tracker protocol that game clients speak. Public reads are unauthenticated; writes are BLS-signed by the announcing client.

01

Discovery

Find rooms to join or scrape platform-wide statistics.

GET /announce Public

List currently active rooms. Supports filters: status, gameType, wagerMin, wagerMax, limit (default 50, max 500).

GET /scrape Public

Aggregate tracker stats: room counts by status, players online, total games played, XCH staked.

GET /api/games Public

Game registry. Returns game IDs, names, status, contract hashes, and metadata for every game listed on the tracker.

02

Lifecycle (BLS-signed)

Game clients announce rooms and report results. All writes require a BLS signature over the payload.

POST /announce BLS or Admin

Create or update a room. Body includes roomId, gameType, player1* fields, and a signature object. New rooms require either a verified BLS signature OR an admin JWT.

POST /announce/result BLS

Report a finished game's outcome. BLS-signed by the game client. Updates room status and stats.

Public Data

Players, leaderboards, & waitlist.

Lightweight public reads suitable for embeds, widgets, and third-party dashboards. All cached server-side. No auth required.

GET /api/players/stats Public

Platform-wide aggregate counts: totalPlayers, activeThisWeek, matchesPlayed. Test accounts are excluded.

GET /api/leaderboard Public

Top players by XP (overall or seasonal). Query params: scope (overall, season, or game-specific), gameType, limit, offset.

GET /api/players/:username Public

Public player profile. Returns username, display name, level, XP, ELO, and (optional) bio + avatar.

GET /api/waitlist/count Public

Current number of waitlist signups. Cached 30 seconds. Suitable for live signup counters on marketing pages.

POST /api/waitlist Public

Submit an email for the waitlist. Body: { email, name? }. Returns position and Founding Member status if ≤ 500.

Authentication

Sign in & tokens.

Email/password + OAuth (Discord, Google, Telegram) flows. Returns a Bearer JWT for subsequent authenticated requests.

POST /api/auth/register Public + Invite Code

Create a new account. Requires invite code while in closed beta. Body: { username, email, password, displayName?, inviteCode, age_confirmed, terms_accepted }.

POST /api/auth/login Public

Email + password login. Returns { user, accessToken, refreshToken }. Access token expires in 30 minutes; refresh tokens last 14 days.

POST /api/auth/refresh Refresh Token

Exchange a refresh token for a fresh access token. Body: { refreshToken }.

POST /api/auth/forgot-password Public

Request a password reset email. Body: { email }. Returns 200 whether or not the email exists (anti-enumeration).

POST /api/auth/reset-password Reset Token

Set a new password using the token from the reset email. Body: { token, password }.

System

Health & status.

Liveness and readiness probes. Suitable for uptime monitoring or status pages.

GET /health Public

Liveness probe. Returns 200 + { status: "ok" } if the tracker is accepting connections.

GET /api/public/site-settings Public

Site-wide flags: registration mode, maintenance status, beta-gate state, feature flags.

Example

Listing live rooms.

A typical read flow. Returns up to 500 rooms across all active states.

RequestGET /announce
curl https://arcade21.games/announce?status=waiting&limit=20
Response200 OK
{
  "tracker id": "b251d3ec97117d5e5c39d5550a690d1181a5c102",
  "interval": 60,
  "min interval": 30,
  "complete": 7,
  "incomplete": 3,
  "limit": 20,
  "rooms": [
    {
      "roomId": "550e8400-e29b-41d4-a716-446655440000",
      "gameType": "calpoker",
      "status": "waiting",
      "player1Name": "alice",
      "wagerAmount": 1000000000000,
      "appBaseUrl": "https://arcade21.games"
    }
  ]
}

Building on the tracker?

Read the developer docs for the full SDK, code samples, and submission flow. Or jump into the Chia Gaming protocol for the lower-level state-channel spec.

Tracker on GitHub
Featured Build Preview

YellowJacket Championship Program

YellowJacket is in active development as Arcade21's signature tournament experience: heads-up Texas Hold'em mapped to golf-style scoring with a high-drama bracket cadence designed for spectator moments and repeat play.

Stroke‑Honey scoring model Qualifier → Major → Main arcs Client-side simulation roots
Development status: promotional preview only. Formats, schedules, and economics are being iterated before launch.
Game Identity

World Series of Golf‑Poker concept: poker hand outcomes map to bounded golf scores, creating cinematic swings without abandoning competitive skill expression.

Competitive Layer

Built for recurring storylines: weekly qualification pressure, major-event checkpoints, and season-long rivalry arcs that reward consistent play.

Arcade21 Fit

Designed to sit natively in the Chia gaming stack with player-owned outcomes, room-based discovery, and promotion through the tournaments rail.

Core Mechanic

Stroke‑Honey explained

Each hand category resolves to a bounded hole score while wagered strokes build a carry-over honey pot. Decisive holes create comeback energy and stronger highlight moments.

Program Structure

From open qualifiers to majors

YellowJacket is framed as a progressive circuit: entry-level qualifiers, escalating major rounds, and flagship championship windows for top performers.

Audience Hook

Built for spectators and creators

Tension spikes at final-table transitions and carry-over pots, giving streamers, casters, and community channels consistent narrative beats.

Step 01

Discover YellowJacket from the tournaments page spotlight.

Step 02

Join pre-season updates and early access windows via waitlist.

Step 03

Enter qualifier events as they are opened in phased rollout.

Step 04

Climb rivalry ladders toward major and championship opportunities.

API Keys
Manage your programmatic access to the tracker API
Loading API keys
API Usage
Include your key in the x-api-key header with each request. Keys are rate-limited based on your tier.
Studio: 120 req/min, up to 5 keys · Enterprise: 600 req/min, up to 20 keys
Arcade21 Membership

Choose Your Loadout.

The protocol is open-source. Playing is free forever. Upgrade to Pro when you want to host your own rooms and earn $Minutes faster.

0% Rake On-Chain Settled No House Custody
Loading XCH price
Billing
Open Protocol • For Everyone
Forever Free
The protocol is open-source — so the arcade should be too. Play, earn, and settle on-chain without paying us a cent. Forever.
$0
No card. No subscription. No catch.
Always free. No expiry.
Join any room — paid or free
Daily freerolls + community events
On-chain wallet, profile, and XP
Spectate every match, live
0% rake — settle peer-to-peer on Chia
Community Fund • Donate XCH
Donate
No tiers. No subscription. Send any amount of XCH directly to the Arcade21 community wallet. Funds keep the lobby running and the devs caffeinated.
Any XCH
You set the amount. Your wallet, your call.
100% community-controlled.
Send XCH to
Loading address
Include this memo
Loading memo

Paste this memo into the transaction memo field in your Chia wallet so we can credit your Supporter badge.

⭐ Glowing "Supporter" badge on your profile
Listed on the community-funded thank-you wall
100% on-chain — verifiable, no middleman
- Zero competitive advantage. By design.
Need details on billing, cancellations, custody, and platform economics?
Player login

Welcome Back

Sign in to access your pricing, launch upgrades, and manage rooms, tournaments, and game publishing from one secure Arcade21 profile.

Closed Beta

Email login is currently the only active sign-in method. Social sign-in options will appear after they are enabled.

Don't have an account? Register Forgot password?
Founding competitor intake

Create Your Arcade21 Account

Register once, unlock paid tiers, and start hosting, publishing, and scaling your competitive footprint. Invite-code onboarding keeps access quality high while we expand.

Closed Beta

Already have an account? Sign In

🎮 Rank & Progression
--
Level
--
Total XP
--
Season XP
--
ELO Rating
⚡ Performance
--
Win Rate
--Games
--Wins
--Losses
For Developers

Ship Your Game. Reach Every Player.

List your Chia P2P game in the open Arcade21 catalog. No app store gatekeepers, no rake on stakes. Just real players, on-chain settlement, and live telemetry on every match you ship.

Arcade21 AI Game Builder
SDK Docs ↗
🟡 Connect Four
❌ Tic-Tac-Toe
♟️ Chess
🚢 Battleship
🔴 Checkers
✂️ Rock Paper Scissors
🚀 Project setup
⚡ SDK lifecycle
♻️ Restore flow
🔐 Chialisp
🐛 Debug
🎮

SDK Dev Assistant

Ask me anything about building games for the Chia Gaming protocol. I generate working TypeScript code, explain the SDK, and guide you from setup to submission.

🚀 Project SetupScaffold, install, first run
📖 SDK ReferenceGame object, events, methods
🎯 Build a GameFull code walkthrough
🚢 Ship & SubmitBuild, package, deploy
Enter to send · Shift+Enter new line
🖥️ Live Preview
🎮
Live Game Preview
Ask the AI to build a game — it will appear here and you can play it immediately
Phase 1 · Setup 0/3
Install Node.js 18+ and npm
Required runtime for the SDK build tools
Ask assistant →
Clone the game template repository
git clone the template to get started
Ask assistant →
Run npm install
npm install — installs SDK workspace and deps
Ask assistant →
Phase 2 · Scaffold 0/3
Generate game scaffold
npm run generate -- --name=yourgame
Ask assistant →
Edit manifest.json
Set id, name, gameType, version, gameUrl
Ask assistant →
Set a unique UUID for the game id
Each game needs a globally unique identifier
Ask assistant →
Phase 3 · Game Logic 0/5
Register Game.on() handlers before Game.loaded()
Game.on('init', fn) and Game.on('finished', fn)
Ask assistant →
Call Game.loaded() last in constructor
Loads manifest + factory.hex — triggers init event
Ask assistant →
Implement onInit: set nextHandler + call Game.ready()
Set who moves first, restore history, then Game.ready()
Ask assistant →
Implement move submission with Game.move()
Encode move → Game.move({ gameId, moveHex }) → update nextHandler
Ask assistant →
Implement win detection + Game.finish()
Detect win/draw, show result, call Game.finish()
Ask assistant →
Phase 4 · Restore Flow 0/2
Handle moveHistory in onInit payload
Replay via Game.restoreFromHistory() to rebuild state
Ask assistant →
Verify UI reconstructs correctly after restore
Test by refreshing mid-game in the test harness
Ask assistant →
Phase 5 · Chialisp 0/3
Implement validator.clsp move validation
Validates each move on-chain — must return new board state
Ask assistant →
Compile Chialisp contracts
npm run compile:clsp — outputs factory.hex
Ask assistant →
Verify factory.hex is generated correctly
Check clsp/ output directory for compiled artifacts
Ask assistant →
Phase 6 · Testing 0/3
Start dev server with npm run dev
Runs game server (5175) + test harness (5173)
Ask assistant →
Test two-player flow in test harness
localhost:5173/test-harness/ — simulates both players
Ask assistant →
Test reconnect + restore scenario
Refresh mid-game — verify state rebuilds correctly
Ask assistant →
Phase 7 · Ship It 0/3
Build and package the game
npm run build && npm run package → dist/game.chiagame
Ask assistant →
Host game at a public URL
Must be publicly accessible for the tracker to load it
Ask assistant →
Submit to Arcade21 tracker
Use the Get Listed page to submit your manifest URL
Ask assistant →
Progress: 0 / 22 steps complete
Developer Console

Your Games. Live Telemetry.

Track room creation, player engagement, ratings, and on-chain stake totals across every game you've shipped to Arcade21.

Loading dashboard
Loading player profile
Community

The Community.

Active players, live matches, trending games, and the founding cohort building Arcade21. This is where the network lives.

Members
Founding
On Waitlist
Online Now
Right now

Online Now

Members active in the last 15 minutes.

Loading
Browse

Discover Players

Find your next rival.

All Founding Paid Tier New This Week
Climbing

Top This Week

Highest ELO across active players.

Hot

Trending Now

What's getting played + signed up for this week.

Games
No matches yet this week
Tournaments
No tournaments live
Hashtags
No tags yet
Live

Activity Feed

Wins, joins, achievements, and tournaments — across the network.

Incoming

The Waitlist

Founding-cohort signups waiting to be invited. Got an invite code? Claim your spot →

on the list
Help Center

Everything you need to know.

Account, gameplay, payments, the Chia Gaming protocol, and the policies that hold it all together.

01

Getting Started

What is Arcade21?

Arcade21 is a peer-to-peer gaming platform built on the Chia blockchain. Real-money wagers settle on-chain through state channels. There is no house holding the pot and no platform rake on winnings.

What you can do here:

  • Play head-to-head games for stakes that settle on-chain
  • Discover live rooms across multiple game titles
  • Earn $Minutes (platform currency) and XP from gameplay
  • Compete on leaderboards and (soon) in tournaments
  • Use decentralized chat (Nostr) that nobody can shut down

Why it matters: Arcade21 is a passive directory. We don't custody funds or take rake from games. All stakes are peer-to-peer and settled on-chain by the Chia Gaming protocol.

How do I create an account?
  1. Click Register in the sidebar.
  2. Choose your sign-in method: Email, Discord, Google, or Telegram.
  3. For email registration, you'll need an invite code while we're in closed beta.
  4. For email/password: pick a strong password (8+ characters).
  5. Verify your email (if using email login).
  6. You're in. Welcome.
What's the difference between Forever Free and Pro?

Forever Free is the open-source default. You can play, earn, and settle every game on-chain at no cost to you. This is the experience for the vast majority of players.

Pro ($19.99/month) is for hosts and power users. You can host your own rooms, earn $Minutes at 1.5x, and unlock advanced profile features. See the Pricing page for the full comparison.

Do I need a crypto wallet?

You can browse Arcade21 without a wallet. To actually play for stakes, you'll need a Chia wallet. We recommend Sage or Goby as both have first-class support for state-channel gaming.

Browser extension wallets unlock the smoothest experience. Mobile and native wallet support is on the roadmap.

02

Playing

How do I join a game?
  1. Go to Game Rooms to see live games.
  2. Filter by game type, status, or stake amount.
  3. Click Join → on any room you want to play.
  4. Confirm your wallet (Goby or Sage recommended).
  5. Chat with your opponent in real-time and play.

Note: You can only join rooms if your wallet has enough XCH to cover the stake. Games are peer-to-peer and final. Make sure you understand the rules before joining.

What games are available?

Five games are playable today: California Poker, Krunk Words, Rock Paper Scissors, Connect 4, and Battleship. All five are state-channel native.

More are coming via the Chia Gaming protocol. See the roadmap for what's shipping next.

How do tournaments work?

Tournaments are launching in October 2026 as part of milestone 3 of the cultivation roadmap. The format is state-channel-native: brackets, prize pools, and settlement that no central party controls. Sponsors can fund the pot directly into the bracket contract. Players play for free or near-free.

See the Tournaments page for the latest details and join the waitlist for early access.

What happens if my opponent disconnects?

State channels handle this gracefully. Either player can post the latest signed game state on-chain. The referee (a chialisp puzzle) reads the dispute, validates the move history, and settles the pot according to the rules. The disconnecting player loses if they were behind on signatures.

Practically: you'll see a timeout countdown when your opponent goes idle. If they don't return, the channel force-closes and you receive your fair share automatically.

03

Payments & Stakes

How do XCH payments work?
  • Stakes: When you play, both players lock XCH into a state channel. The winner gets the full pot, minus a tiny network fee.
  • State Channels: Stakes live in cryptographic state channels — faster and cheaper than direct blockchain transactions, with the same security guarantees.
  • Settlement: When a game ends, the state channel settles on-chain automatically.
  • Your Wallet: You control your private keys. Arcade21 never holds your XCH.
  • Fees: Chia blockchain fees are typically under 0.1 cent. State channel opening and closing each cost one transaction.
Are there platform fees on my winnings?

No platform rake on the pot. Zero. The Arcade21 model is a directory + subscription, not a casino. The only deductions from a stake are the standard Chia network fees, which are typically a fraction of a cent.

This is fundamentally different from traditional online gambling sites that take 5-15% of every pot, or web3 gambling L2s that charge gas per move.

What is $Minutes?

$Minutes is the platform's loyalty currency. You earn it by playing games and engaging with the platform. It accrues to your profile and is intended for unlocking platform perks (priority queueing, advanced features, future merch and collectibles).

Forever Free users earn $Minutes at the standard rate. Pro users earn at 1.5×.

Can I deposit USD or use a credit card?

Not yet. Fiat onramps are coming in January 2027 (milestone 4 of the cultivation roadmap). For now, you'll need XCH in your wallet to play for stakes. The easiest path today is to acquire XCH on a supported exchange and transfer to your Chia wallet.

04

Account

Which wallets are supported?

Sage and Goby are first-class. Both are browser extensions that work in Chrome, Firefox, and most chromium-based browsers.

WalletConnect-based connections are also supported for desktop and mobile wallets that implement the standard. Full native mobile support is on the roadmap.

How do I change my password?
  1. Go to your Profile (top-right of sidebar).
  2. Click SettingsChange password.
  3. Enter your current password and the new one.

If you forgot your password, click Forgot password? on the login page. You'll get a reset link via email.

How do I delete my account?

Email support and we'll process the deletion within 14 days. We delete your account record, profile data, and chat history. We retain a minimal anonymized record for fraud prevention and legal compliance (mostly: that an account with your email previously existed). On-chain settlement history can never be deleted because it lives on the Chia blockchain.

See our Privacy Policy for the full details.

05

Tech & Protocol

What is Chia Gaming?

Chia Gaming is the open-source protocol Arcade21 is built on. Two players fund a shared channel coin on the Chia blockchain, then play games entirely off-chain by exchanging signed messages. The blockchain is only used for opening, settling, or resolving disputes.

Read the Chia Gaming hub for a beginner-friendly walkthrough, or the protocol deep dive for the technical details.

What is a state channel?

A state channel is a shared on-chain coin that two players control together. They lock funds in it once, then sign messages back and forth representing game moves and the latest state. The blockchain only sees the final settlement.

This means you get the security of an on-chain settlement with the speed and cost of an instant message. Most games never touch the chain at all.

How does Nostr chat work?
  • Nostr Protocol: "Notes and Other Stuff Transmitted by Relays" — a decentralized messaging standard that any app can relay.
  • Your keys, your messages: Messages are signed by your Nostr keypair. Only you can post under your identity.
  • No central server: Messages broadcast to multiple public relays. Arcade21 runs a relay, but so do many others (damus.io, nos.lol, primal.net, etc).
  • Censorship resistant: No single entity can delete or block your messages.
Why Chia and not Ethereum/Bitcoin/Solana?

Three things make Chia uniquely suited to peer-to-peer gaming: Chialisp, the smart-contract language built around immutable, auditable puzzles; the coin set model, which makes 2-of-2 multisig channels first-class; and BLS signature aggregation, which makes the cryptographic dance underneath state channels efficient.

Ethereum-style smart contracts can simulate this, but at the cost of gas per move and upgradeable-contract risk. Bitcoin Lightning can't do general game state. Chia is the first chain where the protocol described above is genuinely cheap and clean.

06

Safety & Trust

Is my money safe?

Your XCH stays in your wallet under your private key control until you choose to fund a state channel. Once a channel is open, the funds are held in a 2-of-2 multisig coin that can only be released by mutual signature or via on-chain dispute. Arcade21 has no way to access your funds at any stage.

The biggest practical risks: losing your private key (back it up), getting phished into signing a malicious transaction (verify what you're signing), and the standard volatility risks of holding cryptocurrency.

How do I report a problem?
  • Feedback & bug reports: Email the team — contact details are in the site footer.
  • Moderation: Use the Report button on any chat message or player profile.
  • Security issue: See our Security Policy for responsible disclosure.
  • Legal concerns: Email support (contact info in the site footer).
Is Arcade21 regulated?

Arcade21 is a passive directory of peer-to-peer games on the Chia blockchain. We don't custody funds, take rake, or host a casino. Players are responsible for understanding the gambling laws in their own jurisdiction.

The platform itself is in private closed beta. Public launch will include age verification, regional gating, and the standard guardrails for jurisdictions that require them.

What if I want to stop playing?

Self-exclusion tooling is on the roadmap. In the meantime, you can email support to have your account temporarily suspended. We do not market to players who have asked us to stop.

If you or someone you know has a problem with gambling: in the US, call 1-800-GAMBLER. In Canada, call ConnexOntario at 1-866-531-2600. International resources at begambleaware.org.

Still need help?

Three ways to reach us.

💬

Feedback & bugs

Email the team — contact details are in the site footer. Goes directly to us.

🔒

Security disclosure

Found a vulnerability? Read our responsible disclosure policy for the secure-comms path.

📄

Legal & compliance

Read the Terms, Privacy Policy, and EULA. Email support for anything that needs human review.

Security & Responsible Disclosure

At Arcade21, security is a core value. If you discover a vulnerability, please report it responsibly.

🔒 Security is a Shared Responsibility

We rely on the security research community to help us identify and fix vulnerabilities. If you find a bug, please report it to us directly — not publicly — so we can fix it before bad actors learn about it.

How to Report a Vulnerability

If you believe you've found a security vulnerability in Arcade21, please email:

⚠️ Do not:

  • Publicly disclose the vulnerability on social media, forums, or GitHub issues
  • Exploit the vulnerability for personal gain
  • Access or modify data beyond what's necessary to demonstrate the issue
  • Disrupt service availability or test with data that affects real users

What to Include in Your Report

  1. Description: Clear explanation of the vulnerability and its impact
  2. Steps to Reproduce: Detailed instructions we can use to replicate the issue
  3. Affected Component: Which part of the system is vulnerable (frontend, backend, API, etc.)
  4. Severity Assessment: Your estimate of risk (critical, high, medium, low)
  5. Your Contact Info: Email or other way to reach you for follow-up questions

Response Timeline

  • 48 hours: We'll acknowledge receipt of your report and assign a severity level
  • 7 days: We'll either have a fix ready or provide a detailed plan and ETA
  • 30 days: Critical vulnerabilities will be patched and deployed (or we'll notify you of a later date)

We ask that you do not disclose the vulnerability publicly for at least 30 days from our patch, or 90 days from initial report — whichever comes first — to give users time to update.

Scope

We're interested in vulnerabilities affecting:

  • arcade21.games website and API
  • tracker.koba42.com platform
  • User authentication and account security
  • Game state integrity and fairness
  • Private key or seed phrase exposure
  • XCH transaction validation
  • Data privacy (user data, chat messages)

Out of Scope:

  • Third-party services (Discord, Google, Telegram, etc.)
  • User-specific issues (lost passwords, compromised accounts due to user negligence)
  • Social engineering or phishing
  • Denial-of-service attacks (unless they reveal a structural vulnerability)
  • Minor UX/UI issues not affecting security

Safe Harbor

Provided that you follow this policy, we will not pursue legal action against you for:

  • Accessing or modifying systems to the extent necessary to discover and prove the vulnerability
  • Accessing or exfiltrating data that is necessary to demonstrate the vulnerability
  • Testing with accounts you own or have explicit permission to test
  • Good-faith security research and testing

You must not cause material harm to Arcade21 users or infrastructure, and you must comply with all applicable laws. This safe harbor applies only to authorized security testing performed in good faith.

Questions?

If you have questions about this policy or the disclosure process, email [email protected] and we'll get back to you within 24 hours.

Terms of Service

Last updated: May 18, 2026 · Effective immediately

📌 What Arcade21 Is — Five Key Facts
  1. Software Service & Membership Platform. Arcade21 is a software company offering membership-based access to a P2P gaming discovery platform.
  2. We never handle, hold, or touch your funds. At no point does Arcade21, Koba42 Corp, or any of its officers have access to, custody of, or control over any user funds or digital assets.
  3. We do not escrow money. No funds are deposited with us. No wagers pass through our servers. We are not an intermediary in any financial transaction.
  4. All wagering is strictly peer-to-peer. Game wagers are locked and settled exclusively via Chia on-chain state channels, directly between players' wallets. We have no technical ability to intervene in these transactions.
  5. We are a protocol interface, not a financial operator. Our role is equivalent to a software application that provides a user interface to a public blockchain protocol — we do not operate the protocol, control the network, or hold any assets on anyone's behalf.
⚠️ Important Notice
Arcade21 is a software service and membership platform. It is not a gambling operator, financial intermediary, money transmitter, payment processor, escrow service, or custodian of any kind. All peer-to-peer transactions occur exclusively on the Chia blockchain between users' own wallets.

1. About Arcade21

Koba42 Corp, operating as Arcade21 ("we", "us", "our", "Company"), is a software company operating a membership-based gaming platform and protocol interface at arcade21.games ("Service"). Arcade21 provides software tools, a discovery layer, and membership benefits that allow users to connect with peer-to-peer games running on the Chia blockchain. The Service includes:

  • Membership Platform — Tiered subscription membership providing access to platform features, analytics, priority listings, and community tools
  • Protocol Interface — A software interface for discovering and connecting to peer-to-peer game rooms announced on the Chia network
  • Room Discovery — A directory of game rooms announced by third-party game clients running on the Chia protocol
  • Leaderboard & Statistics — Aggregation of publicly announced match results from the Chia blockchain
  • Developer Tools — SDK references, API access, and integration documentation
  • Community Features — In-platform messaging, profiles, gamification, and social tools

Arcade21 is a software service and membership company. We are not a financial institution, gambling operator, money transmitter, payment processor, escrow provider, or custodian of any kind.

2. No Fund Handling — Absolute Prohibition

This section represents a foundational operating principle of Arcade21:

Arcade21 does not — under any circumstances — handle, hold, receive, transfer, escrow, custody, process, or intermediate any funds, cryptocurrency, or digital assets between users.

Specifically:

  • No user funds are ever deposited with, held by, or routed through Arcade21 or Koba42 Corp
  • No wagers are placed with us, through us, or on our behalf
  • No funds are escrowed, pooled, or managed by us at any point in any transaction
  • Subscription payments are one-directional service fees paid by members to Koba42 Corp for software access — they are not deposits, wagers, or held funds
  • All game wagers are locked and settled exclusively in Chia state channels — cryptographic constructs that exist on the blockchain and are controlled entirely by the players' own wallets and on-chain Chialisp smart contracts
  • We have no technical capability to access, move, freeze, modify, or recover funds in any state channel or user wallet

3. The Service Is Not

For absolute clarity, the Service is not:

  • A gambling platform, casino, sportsbook, or gaming operator
  • A financial services provider, bank, or regulated financial institution
  • A money transmitter, payment processor, or remittance service
  • A custodial wallet or asset management service
  • An escrow service or financial intermediary
  • A party to any wager, bet, or financial transaction between users
  • A game host, execution environment, or on-chain participant
  • A counterparty in any game outcome

All games listed on the Service run entirely on the Chia blockchain through peer-to-peer state channels. Game logic, fund custody, move verification, and settlement occur exclusively on-chain between the players' wallets. Arcade21 is not a participant in, party to, or operator of any game or transaction.

4. Peer-to-Peer Architecture

You understand and acknowledge that:

  • All gameplay occurs directly between players via Chia state channels on the Chia blockchain
  • All XCH transactions are executed and authorized exclusively by your wallet software
  • We never have custody of, access to, or control over your funds at any time, for any reason
  • Game outcomes are determined by on-chain Chialisp smart contracts — not by us or any third party
  • We cannot reverse, modify, pause, cancel, or intervene in any on-chain transaction or state channel
  • Room listings are announced by third-party game clients; we index and display this data but do not verify, guarantee, or endorse any room or its contents
  • The Arcade21 platform is a user interface to a public blockchain protocol, similar in legal character to a software application that interfaces with a decentralized network

5. Membership and Subscriptions

Access: Platform access is currently invite-only (closed beta). You are responsible for maintaining the security of your credentials. Account sharing is prohibited.

Membership fees: Subscription payments are fees paid to Koba42 Corp for access to software features and membership benefits. They are not deposits, not wagers, and not held on your behalf. All payments are on-chain, final, and non-refundable once confirmed on the blockchain.

Subscription periods: Active for 30 days from payment confirmation. We reserve the right to modify tier features or pricing with 30 days' notice.

API keys: Issued to qualifying members for authorized programmatic access. Abuse, excessive load, scraping beyond stated rate limits, or redistribution may result in revocation without refund.

6. Limitation of Liability

TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW:

  • The Service is provided "AS IS" and "AS AVAILABLE" without any warranty, express or implied
  • We are not liable for any losses, damages, or claims arising from peer-to-peer gameplay, wagers, or on-chain transactions of any kind
  • We are not liable for smart contract bugs, state channel failures, blockchain reorganizations, network outages, or wallet software errors
  • We are not liable for the actions, conduct, misrepresentations, or failures of other users, game developers, or third-party game clients
  • We are not liable for any loss of XCH, digital assets, tokens, or funds under any circumstances
  • We are not liable for service interruptions, downtime, or data loss
  • Our total aggregate liability for any and all claims shall not exceed the total subscription fees you paid to us in the twelve (12) months immediately preceding the claim

7. Platform Rewards, Result Confirmation & Disputes

Platform rewards (XP, ELO, $Minutes): Arcade21 awards platform rewards — including experience points (XP), ELO ratings, and $Minutes tokens — based on completed game results reported through the Arcade21 game client software. These rewards are platform features, not financial instruments, prizes, or compensation of any kind.

Dual-report confirmation requirement: Platform rewards are only credited after both players independently report a matching game result through the game client. A single-player report places the result in a pending state — no rewards are awarded until the second player submits a matching report. This requirement exists to protect platform integrity and prevent fraudulent result submissions.

Disputed results: If the two players' reported results disagree on the winner, the result is automatically flagged as disputed. In a disputed result: (a) no platform rewards are awarded to either player; (b) the dispute is logged and reviewed by platform administrators; (c) Arcade21 reserves the right to determine the outcome of platform rewards at its sole discretion based on available evidence. Arcade21's determination of platform rewards in a dispute is final and not subject to appeal.

On-chain outcomes are separate: Platform rewards (XP, ELO, $Minutes) are entirely separate from on-chain XCH wager settlement, which is determined exclusively by Chialisp smart contracts on the Chia blockchain. Arcade21 has no ability to affect on-chain outcomes. A disputed platform result does not affect, reverse, or alter any on-chain wager settlement.

No guaranteed rewards: Platform rewards are offered at Arcade21's discretion and may be modified, adjusted, or discontinued at any time with reasonable notice. Past reward rates do not guarantee future reward rates.

8. Assumption of Risk

By using the Service, you expressly acknowledge that:

  • Cryptocurrency transactions are irreversible once confirmed and carry inherent risk of permanent loss
  • P2P gaming involving real-value wagers carries significant financial risk
  • Smart contracts and blockchain technology may contain unforeseen bugs or vulnerabilities
  • You are solely responsible for evaluating and understanding the risks of any game, wager, or blockchain transaction before participating
  • You are solely responsible for compliance with all laws and regulations applicable in your jurisdiction
  • Access to and use of cryptocurrency gaming platforms may be restricted, regulated, or prohibited in your jurisdiction — it is your responsibility to verify this before using the Service

9. Age Eligibility & Verification

Access to the Service is restricted to adults who meet the minimum legal age for online gaming and wagering in their jurisdiction. By using the Service you represent and warrant that:

  • You are at least 18 years of age, or such higher minimum age as is required by the laws of the jurisdiction from which you are accessing the Service (commonly 19 in many Canadian provinces and 21 in the United States and certain other jurisdictions);
  • You have truthfully responded to any age-verification prompt displayed by the Service;
  • You are not accessing the Service on behalf of, or for the benefit of, any person who does not meet the applicable minimum age.

Arcade21 conducts age verification by self-attestation, supported by jurisdiction detection (IP-based) and an audit log of every attestation. We reserve the right to require additional identity verification at any time, particularly in response to law-enforcement requests or where there is reason to suspect a misrepresentation of age. Providing false information about your age is a material breach of these Terms and grounds for immediate termination, forfeiture of platform rewards, and (where applicable) referral to law enforcement.

10. Geographic Restrictions

The Service is not available in all jurisdictions. We use IP-based geo-detection (via Cloudflare edge headers) to identify and block access from jurisdictions where the listing or facilitation of peer-to-peer real-money gaming carries elevated regulatory risk. Blocked regions currently include, without limitation:

  • OFAC-sanctioned countries: Iran, North Korea, Syria, Cuba, Russia, Belarus, and any other jurisdiction subject to comprehensive United States sanctions;
  • United States — restricted states: Washington and Utah (additional U.S. states may be added or removed as regulatory analysis evolves);
  • Any other jurisdiction we determine, in our sole discretion, to be inconsistent with the lawful operation of a non-custodial discovery service.

You may not access or attempt to access the Service from a blocked jurisdiction, including by use of a VPN, proxy, Tor, or other circumvention technology. Doing so is a material breach of these Terms and may also violate applicable export-control or sanctions law. Even where the Service is technically reachable from your location, it remains your sole responsibility to determine whether your use of the Service is lawful in your jurisdiction; we make no representation that it is.

11. Prohibited Use

You may not use the Service to:

  • Violate any applicable law, regulation, or third-party right
  • Announce fraudulent, misleading, or non-existent game rooms
  • Manipulate leaderboard rankings, game statistics, or platform data
  • Attempt to exploit, hack, attack, or disrupt the Service, its infrastructure, or other users
  • Use automated systems to scrape, load-test, or overload the Service beyond permitted API rate limits
  • Impersonate other users, developers, or representatives of Arcade21
  • Use the platform from a jurisdiction where doing so is prohibited by law

We reserve the right to suspend or terminate accounts in violation of these terms, at our sole discretion and without refund.

12. Intellectual Property

The Service's source code is published open source under the Apache License 2.0. Game titles, brands, and SDK components remain the property of their respective creators and developers.

Chia Network: CHIA is a registered trademark of Chia Network Inc. Used with permission. Arcade21 (Koba42 Corp) is a recipient of the Chia Cultivation Grant, awarded on the merits of the team, product, and vision. Chia Network Inc. does not endorse, sponsor, or guarantee Arcade21's products or services, and is not a party to any user transaction or game outcome. Chia Network Inc. does not control the quality, safety, or operation of the Arcade21 platform. Chia blockchain software is used under the Apache License, Version 2.0 (apache.org/licenses/LICENSE-2.0).

13. Modifications

We may update these Terms at any time. Material changes will be posted on this page with an updated effective date and announced to registered members. Continued use of the Service following notice constitutes acceptance of the revised Terms.

14. Governing Law

These Terms are governed by the laws of the Province of New Brunswick, Canada, without regard to conflict of law principles. Any dispute not resolved by arbitration shall be subject to the exclusive jurisdiction of the courts of New Brunswick, Canada.

15. Contact

Legal inquiries: [email protected]
General: [email protected]

Privacy Policy

Last updated: May 18, 2026 · Effective immediately

Our Approach
We collect the minimum data necessary to operate the Service. We do not sell, rent, or share your personal data with third parties. We never have access to your wallet private keys or funds.

1. Data We Collect

Account Data (if you register):

  • Email address, username, display name
  • Hashed password (bcrypt, never stored in plaintext)
  • Account tier and subscription status

Room Data (announced by game clients):

  • Room IDs, game types, player names, wallet addresses (public on-chain data)
  • Wager amounts, game outcomes, state channel status
  • These are public blockchain data points, not private information

Technical Data (automatic):

  • IP address (for rate limiting and security only, not stored long-term)
  • Browser user agent (for compatibility)
  • Access timestamps

Payment Data:

  • XCH transaction memos (for matching subscription payments)
  • Payment amounts and timestamps
  • We do NOT store wallet private keys, seed phrases, or have any custody of funds

2. Data We Do NOT Collect

  • Wallet private keys or seed phrases
  • Game moves, hands, or strategies
  • Financial data beyond on-chain transaction amounts (which are publicly visible on the Chia blockchain)
  • Location data, device identifiers, or advertising IDs
  • Third-party cookies or cross-site tracking

3. How We Use Your Data

  • Service operation — Display rooms, leaderboards, and statistics
  • Authentication — Verify your identity for account features
  • Subscription management — Match on-chain payments to your account
  • Security — Rate limiting, abuse prevention, IP blocking
  • Analytics — Aggregate, anonymized platform usage (via Grafana, self-hosted)

4. Data Storage and Security

  • Data is stored in a SQLite database on our self-hosted infrastructure
  • Passwords are hashed with bcrypt (never stored in plaintext)
  • All connections are encrypted via TLS (HTTPS)
  • We do not use third-party cloud databases or analytics services
  • JWT tokens expire after 24 hours; refresh tokens after 7 days

5. Blockchain Data

Wallet addresses, transaction amounts, and game outcomes displayed on the Service are public blockchain data visible to anyone running a Chia full node. We aggregate and display this data but do not create it. Removing your data from our Service does not remove it from the Chia blockchain.

6. Third-Party Services

  • Google Fonts — Font delivery (Space Grotesk, JetBrains Mono). Subject to Google's privacy policy.
  • X (Twitter) Web Intents — Social sharing opens x.com in a new window. We do not send data to X; sharing is user-initiated only.

We do not use Google Analytics, Facebook Pixel, or any third-party tracking scripts.

7. Your Rights

You may:

  • Request a copy of your account data
  • Update or correct your profile information
  • Request account deletion (email [email protected])
  • Revoke API keys at any time

Note: Publicly announced room data and leaderboard statistics derived from blockchain activity cannot be selectively removed as they are aggregated from public on-chain records.

8. Data Retention

  • Active rooms: Automatically purged after 10 minutes of inactivity
  • Chat messages: Stored in memory only, cleared on room cleanup or server restart
  • Account data: Retained until account deletion is requested
  • Audit logs: Retained for 90 days
  • Payment records: Retained for accounting and dispute purposes

9. Children

The Service is not intended for users under 18 years of age. We do not knowingly collect data from minors.

10. Contact

Privacy inquiries: [email protected]

End User Agreement

Last updated: May 18, 2026 · Effective immediately

📌 Before You Continue — Read This First
  • Arcade21 is a software service and membership platform — not a gambling site, casino, or financial operator.
  • We never hold, escrow, or transmit your funds. No money ever passes through Arcade21 or Koba42 Corp between players.
  • All wagers occur directly between players' wallets via Chia on-chain state channels. We are not a party to any wager.
  • Your membership fee pays for software access and platform features — not entry into any game or wager pool.
  • By continuing, you confirm you are 18 or older and that using this platform is legal in your jurisdiction.
Agreement Summary
By using Arcade21, you acknowledge it is a software and membership service. You accept full responsibility for your wallet, gaming activity, and compliance with local laws. All on-chain transactions are peer-to-peer, irreversible, and entirely outside our control. We have no ability to refund, reverse, or intervene in any blockchain transaction.

1. Acceptance of Agreement

This End User Agreement ("Agreement") is a legally binding contract between you ("User", "Member", "you") and Koba42 Corp, operating as Arcade21 ("Company", "Arcade21", "we", "us"), a corporation. By creating an account, accessing the platform, or making a subscription payment, you agree to be bound by this Agreement, the Terms of Service, and the Privacy Policy in their entirety.

If you do not agree with any provision of this Agreement, you must not use the Service.

2. Nature of the Service — Software and Membership

Arcade21 is a software company providing a membership-based platform that serves as an interface and discovery layer for peer-to-peer games operating on the Chia blockchain. You understand and expressly acknowledge that:

  • The Service is a software platform and membership club — not a game operator, gambling site, or financial services provider
  • Arcade21 functions as a protocol interface — providing a user-friendly software layer over a public blockchain protocol, similar in character to a browser or wallet application that presents blockchain data without participating in it
  • We do not create, operate, host, execute, or manage any games listed on the platform
  • Games are independently developed and operated by third-party game developers
  • All peer-to-peer transactions and game logic occur on the Chia blockchain, not through Arcade21's servers
  • We have no technical or legal ability to move, freeze, refund, or reverse any XCH transaction or state channel
  • Our role ends at providing software tools and a discovery interface — we are not a participant in any game or financial transaction

3. Absolute No-Custody, No-Escrow Declaration

Arcade21 and Koba42 Corp do not — under any circumstances, in any form, at any time — hold, handle, escrow, custody, receive, transmit, pool, or otherwise intermediate any funds, XCH, cryptocurrency, tokens, or digital assets between users.

You explicitly acknowledge that:

  • No user funds are ever deposited with us, routed through us, or held by us in any capacity
  • No wagers are placed with Arcade21, through Arcade21, or on Arcade21's behalf
  • Game wagers are locked and settled exclusively in Chia state channels — on-chain cryptographic constructs controlled entirely by the players' own wallets and Chialisp smart contracts
  • Membership fees paid to Koba42 Corp are one-directional service payments for software access only — they are not deposits, wagers, game stakes, or funds held on your behalf
  • If you experience a financial loss in a game, that loss occurred between you and your opponent on the Chia blockchain — not through Arcade21 or any service we provide
  • We cannot and will not refund, recover, or compensate for any on-chain financial loss under any circumstances

4. Member Responsibilities

As a member, you represent, warrant, and agree that:

  • You are at least 18 years of age, or the age of majority in your jurisdiction, whichever is greater
  • You are not accessing the Service from a jurisdiction where doing so is prohibited by law
  • You are solely responsible for the security and control of your wallet, private keys, seed phrase, and all associated funds
  • You are solely responsible for evaluating the financial risks of any game, wager, or blockchain transaction before participating
  • You are solely responsible for ensuring your use of the Service is lawful in your jurisdiction, including any laws relating to online gaming, cryptocurrency, or digital assets
  • You understand that XCH and other digital assets carry real monetary value and can be permanently and irrecoverably lost
  • You will not hold the Company liable for any outcome of your participation in any game, wager, or on-chain transaction
  • Game result reporting: You understand and agree that platform rewards (XP, ELO, $Minutes) require both players to independently submit a matching game result through the game client. You agree to submit accurate and honest game result reports. Submitting a false or manipulated result report constitutes a material breach of this Agreement and may result in immediate account termination, forfeiture of all platform rewards, and other remedies available at law
  • Result confirmation timing: You acknowledge that platform rewards are not awarded on a single report and may be delayed pending confirmation from the opposing player. No claim arises against Arcade21 for platform rewards that have not yet been confirmed by both parties
  • Disputed results: In the event of a disputed game result (where the two players' reports disagree), you agree to cooperate with any reasonable platform review process and accept that Arcade21's determination of platform rewards in such disputes is final

5. Third-Party Game Developers

Games listed on the Service are created and maintained by independent third-party developers. Arcade21 does not:

  • Audit, verify, certify, or warrant the security or correctness of any game's smart contracts or software
  • Guarantee that any listed game is free from bugs, exploits, or vulnerabilities
  • Endorse, recommend, vouch for, or assume liability for any specific game or developer
  • Control, modify, or influence game logic, outcomes, or on-chain settlement

The term "provably fair" as used on this platform refers to the inherent cryptographic properties of Chia blockchain state channels and Chialisp smart contracts — it is not a guarantee or warranty made by Arcade21.

6. Membership and Subscription Terms

  • Membership subscriptions are 30-day access periods activated upon confirmation of on-chain payment to Koba42 Corp
  • All subscription payments are final, non-refundable, and non-transferable
  • Membership features and tier benefits are provided "as available" and may be modified with notice
  • We reserve the right to suspend or terminate membership for any violation of this Agreement without refund
  • In the event of permanent platform discontinuation, no refunds will be issued for unused subscription periods
  • Membership grants access to platform software features only — it does not grant any ownership interest, equity, revenue share, or claim on any on-chain assets

7. Indemnification

You agree to defend, indemnify, and hold harmless Koba42 Corp, Arcade21, and their respective officers, directors, employees, and agents from and against any and all claims, liabilities, damages, losses, costs, and expenses (including reasonable legal fees) arising from or related to:

  • Your use of or access to the Service
  • Your participation in any peer-to-peer game, wager, or on-chain transaction
  • Your violation of this Agreement, the Terms of Service, or any applicable law or regulation
  • Any dispute between you and another user, game developer, or third party
  • Any content you submit, post, or transmit through the Service

8. Dispute Resolution

Disputes between users (on-chain outcomes): Arcade21 is not a party to any dispute between users or between a user and a game developer regarding on-chain game outcomes. Game wager settlement is determined exclusively by on-chain Chialisp smart contracts. We have no obligation, ability, or authority to arbitrate, adjudicate, or intervene in any on-chain dispute.

Platform reward disputes (XP, ELO, $Minutes): Disputes regarding platform rewards — including challenges to result confirmation outcomes, disputed game results, or reward calculations — are subject to Arcade21's internal review process. To raise a platform reward dispute, contact [email protected] within 7 days of the disputed result. Arcade21's determination of platform reward disputes is final and binding. Platform reward disputes are explicitly excluded from the arbitration process below, as they do not involve financial instruments or currency of any kind.

Disputes with Arcade21 (all other matters): Any dispute, claim, or controversy arising out of or relating to this Agreement or the Service (other than platform reward disputes addressed above) shall first be addressed through written notice and good-faith negotiation via [email protected]. If not resolved within thirty (30) days, the dispute shall be submitted to binding arbitration administered by the Canadian Arbitration Association under its Commercial Arbitration Rules, with proceedings conducted in English in Moncton, New Brunswick, Canada.

9. Termination

This Agreement remains in effect until terminated by either party. You may terminate by ceasing use and requesting account deletion. We may immediately suspend or terminate your access for any violation of this Agreement. Upon termination:

  • Your account, membership benefits, and API keys will be deactivated
  • No refund is owed for any unused subscription period
  • Your on-chain transactions and blockchain data are entirely unaffected — we have no ability to alter them
  • Sections relating to liability, indemnification, and dispute resolution survive termination

10. Severability

If any provision of this Agreement is found to be invalid, illegal, or unenforceable under applicable law, that provision shall be modified to the minimum extent necessary to make it enforceable, and the remaining provisions shall continue in full force and effect.

11. Entire Agreement

This Agreement, together with the Terms of Service and Privacy Policy, constitutes the entire agreement between you and the Company with respect to the Service, and supersedes all prior agreements, representations, and understandings.

12. Contact

Koba42 Corp (operating as Arcade21)
Legal: [email protected]
General: [email protected]
Web: arcade21.games

`; } function sdkOpenStackBlitz(btn, lang) { const pre = btn.closest('pre'); const code = pre ? (pre.querySelector('code')?.textContent || '') : ''; if (!code) return; // Determine filename const filename = lang === 'ts' || lang === 'typescript' ? 'src/game.ts' : lang === 'json' ? 'manifest.json' : 'src/game.js'; const packageJson = JSON.stringify({ name: 'chia-game-preview', version: '1.0.0', scripts: { dev: 'vite', build: 'tsc && vite build' }, dependencies: {}, devDependencies: { vite: '^5.0.0', typescript: '^5.0.0' } }, null, 2); const readmeMd = `# Chia Game Preview\n\nGenerated by Arcade21 AI Game Builder.\n\nThis game uses the Chia Gaming SDK. Wire up your SDK dependencies to run the full game.\n\nSee: https://tracker.koba42.com`; // Build StackBlitz form and submit const form = document.createElement('form'); form.method = 'POST'; form.action = 'https://stackblitz.com/run'; form.target = '_blank'; form.style.display = 'none'; const fields = { 'project[title]': 'Chia Game Preview — Arcade21', 'project[description]': 'Generated by Arcade21 AI Game Builder', 'project[template]': 'typescript', [`project[files][${filename}]`]: code, 'project[files][package.json]': packageJson, 'project[files][README.md]': readmeMd, }; Object.entries(fields).forEach(([k, v]) => { const input = document.createElement('input'); input.type = 'hidden'; input.name = k; input.value = v; form.appendChild(input); }); document.body.appendChild(form); form.submit(); document.body.removeChild(form); const orig = btn.textContent; btn.textContent = 'Opening'; setTimeout(() => btn.textContent = orig, 2000); } function sdkAppendMessage(role, content, isTyping) { const welcome = document.getElementById('sdkWelcome'); if (welcome) welcome.remove(); const msgs = document.getElementById('sdkMessages'); if (!msgs) return null; const el = document.createElement('div'); el.className = `sdk-msg ${role}`; const initials = role === 'assistant' ? '✨' : (authState.user?.username?.[0] || 'U').toUpperCase(); el.innerHTML = `
${initials}
` + `
${isTyping ? '
' : (role === 'assistant' ? sdkRenderMarkdown(content) : `

${escHtml(content)}

`) }
`; msgs.appendChild(el); msgs.scrollTop = msgs.scrollHeight; return el; } async function sdkSendMessage() { if (_sdkSending) return; const input = document.getElementById('sdkChatInput'); const sendBtn = document.getElementById('sdkSendBtn'); const text = input?.value?.trim(); if (!text) return; _sdkSending = true; input.value = ''; sdkAutoResize(input); if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = '…'; } _sdkMessages.push({ role: 'user', content: text }); sdkAppendMessage('user', text); const typingEl = sdkAppendMessage('assistant', '', true); try { const r = await authFetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: _sdkMessages }), }); if (!r) { if (typingEl) typingEl.remove(); sdkAppendMessage('assistant', '⚠️ Session expired. Please log in again.'); } else { const d = await r.json(); if (typingEl) typingEl.remove(); if (!r.ok) { sdkAppendMessage('assistant', `⚠️ ${d.error || 'Something went wrong.'}`); } else { _sdkMessages.push({ role: 'assistant', content: d.reply }); sdkAppendMessage('assistant', d.reply); // Update usage counter const balEl = document.getElementById('sdkBalance'); const hintEl = document.getElementById('sdkBalanceHint'); const sendBtn2 = document.getElementById('sdkSendBtn'); if (balEl && d.used !== undefined) balEl.textContent = `${d.used}/${d.limit}`; if (hintEl && d.remaining !== undefined) hintEl.textContent = `${d.remaining} messages left this month`; if (sendBtn2 && d.remaining !== undefined) sendBtn2.disabled = (d.remaining <= 0); if (!r.ok && (d.code === 'MONTHLY_LIMIT' || d.code === 'TIER_REQUIRED')) initAIBuilder(); // Auto-preview: HTML block first, fallback to TypeScript/JS via mock harness const htmlMatch = d.reply.match(/```html\n([\s\S]*?)```/); const tsMatch = d.reply.match(/```(?:typescript|ts)\n([\s\S]*?)```/); const jsMatch = d.reply.match(/```(?:javascript|js)\n([\s\S]*?)```/); if (htmlMatch && htmlMatch[1].trim()) { sdkLoadPreviewHtml(htmlMatch[1].trim(), 'Generated Game'); } else if (tsMatch && tsMatch[1].trim()) { sdkLoadPreviewHtml(sdkBuildHarness(tsMatch[1].trim(), 'typescript'), 'Mock Harness Preview'); } else if (jsMatch && jsMatch[1].trim()) { sdkLoadPreviewHtml(sdkBuildHarness(jsMatch[1].trim(), 'javascript'), 'Mock Harness Preview'); } } } } catch(err) { if (typingEl) typingEl.remove(); sdkAppendMessage('assistant', '⚠️ Connection error. Try again.'); } _sdkSending = false; if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '↑'; } if (input) input.focus(); } // ── Legacy stubs (no-ops — page redesigned to chat) ────────────────────────── function setAibPrompt(text) {} // (legacy stub — real initAIBuilder is above) async function aiGenerateGame() { showToast('Use the SDK Assistant chat to get help building your game.', 'info'); } function loadAIBPreview() {} async function loadAIGames() {} // ── Notifications ────────────────────────────────────────────────────────── let _notifSSE = null; let _notifPanelOpen = false; function _updateNotifBadge(count) { const badge = document.getElementById('notifBadge'); const bell = document.getElementById('notifBellWrap'); if (!badge || !bell) return; if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.style.display = 'block'; bell.style.display = 'block'; } else { badge.style.display = 'none'; } } function _renderNotifItem(n) { const typeIcons = { welcome:'🎮', follow:'👋', room_invite:'⚔️', game_result:'🏆', tournament:'🏟️', system:'📡', feedback:'💬' }; const icon = typeIcons[n.type] || '🔔'; const timeAgo = _actTimeAgo(n.created_at * 1000); const unreadStyle = n.read ? '' : 'background:rgba(40,200,220,0.05);border-left:2px solid var(--cyan);'; return `
${icon}
${n.title}
${n.message ? `
${n.message}
` : ''}
${timeAgo}
${!n.read ? '
' : ''}
`; } async function loadNotifications() { if (!authState?.accessToken) return; const bell = document.getElementById('notifBellWrap'); if (bell) bell.style.display = 'block'; try { const r = await apiFetch('/api/notifications'); const { notifications, unreadCount } = await r.json(); _updateNotifBadge(unreadCount); const list = document.getElementById('notifList'); if (list) { list.innerHTML = notifications.length ? notifications.map(_renderNotifItem).join('') : '
No notifications yet
'; } } catch(_) {} } async function handleNotifClick(id, link) { await apiFetch(`/api/notifications/${id}/read`, { method: 'POST' }).catch(() => {}); await loadNotifications(); if (link && link !== '#') { const hash = link.startsWith('#') ? link.slice(1) : link; if (hash) showPage(hash); } toggleNotifPanel(); } async function markAllNotifsRead() { await apiFetch('/api/notifications/read-all', { method: 'POST' }).catch(() => {}); _updateNotifBadge(0); await loadNotifications(); } function toggleNotifPanel() { _notifPanelOpen = !_notifPanelOpen; const panel = document.getElementById('notifPanel'); if (!panel) return; panel.style.display = _notifPanelOpen ? 'block' : 'none'; if (_notifPanelOpen) loadNotifications(); } function initNotifications() { if (!authState?.accessToken) return; if (_notifSSE) { _notifSSE.close(); _notifSSE = null; } loadNotifications(); _notifSSE = new EventSource('/api/notifications/stream?token=' + encodeURIComponent(authState.accessToken)); _notifSSE.onmessage = (e) => { try { const d = JSON.parse(e.data); if (d.type === 'count') _updateNotifBadge(d.count); if (d.type === 'notification') { _updateNotifBadge(d.count); if (_notifPanelOpen) loadNotifications(); } } catch(_) {} }; } // Close notif panel on outside click document.addEventListener('DOMContentLoaded', () => { document.addEventListener('click', (e) => { const wrap = document.getElementById('notifBellWrap'); if (wrap && !wrap.contains(e.target) && _notifPanelOpen) { _notifPanelOpen = false; const panel = document.getElementById('notifPanel'); if (panel) panel.style.display = 'none'; } }); }); // ── Connected Accounts ────────────────────────────────────────────────────── async function loadLinkedAccounts() { const el = document.getElementById('linkedAccountsList'); if (!el || !authState?.accessToken) return; try { const r = await apiFetch('/api/user/linked-accounts'); const d = await r.json(); const providers = [ { key: 'telegram', label: 'Telegram', color: '#229ED9', icon: '', detail: d.telegramUsername ? `@${d.telegramUsername}` : null, connected: d.telegram }, { key: 'discord', label: 'Discord', color: '#5865F2', icon: '', detail: d.discordUsername ? `@${d.discordUsername}` : null, connected: d.discord }, { key: 'google', label: 'Google', color: '#4285F4', icon: '', detail: d.googleEmail, connected: d.google }, ]; el.innerHTML = providers.map(p => `
${p.icon}
${p.label}
${p.connected ? (p.detail || 'Connected') : 'Not connected'}
${p.connected ? `` : `` }
`).join(''); } catch(e) { if (el) el.innerHTML = '
Could not load linked accounts
'; } } async function linkAccount(provider) { try { const r = await apiFetch('/api/user/link-nonce', { method: 'POST' }); const { nonce } = await r.json(); if (!nonce) throw new Error('No nonce'); window.location.href = `/api/auth/${provider}?action=link&nonce=${nonce}`; } catch(e) { const el = document.getElementById('linkedAccountsError'); if (el) el.textContent = 'Failed to start linking. Try again.'; } } async function unlinkAccount(provider) { if (!confirm(`Disconnect ${provider}? You can reconnect it later.`)) return; try { const r = await apiFetch(`/api/user/link/${provider}`, { method: 'DELETE' }); const d = await r.json(); if (d.error) { document.getElementById('linkedAccountsError').textContent = d.error; return; } document.getElementById('linkedAccountsSuccess').textContent = `${provider} disconnected.`; loadLinkedAccounts(); } catch(e) { document.getElementById('linkedAccountsError').textContent = 'Failed to unlink. Try again.'; } } // Handle OAuth linking redirects back to profile (function handleLinkCallbacks() { const params = new URLSearchParams(window.location.search); const linked = ['discord_linked', 'google_linked'].find(k => params.get(k)); const err = ['discord_error', 'google_error'].find(k => params.get(k)); if (linked || err) { history.replaceState({}, '', window.location.pathname + '#profile'); if (linked) { const provider = linked.replace('_linked', ''); setTimeout(() => { const el = document.getElementById('linkedAccountsSuccess'); if (el) el.textContent = `${provider.charAt(0).toUpperCase()+provider.slice(1)} connected successfully!`; loadLinkedAccounts(); }, 300); } } })(); // ── Public User Profile Modal ───────────────────────────────────────────────── async function openUserProfile(username) { const modal = document.getElementById('userProfileModal'); const content = document.getElementById('userProfileContent'); if (!modal || !content) return; modal.style.display = 'flex'; content.innerHTML = '
Loading
'; try { const r = await fetch(`/api/users/${encodeURIComponent(username)}`); if (!r.ok) throw new Error('User not found'); const u = await r.json(); const initials = (u.displayName || u.username || '?').charAt(0).toUpperCase(); const tierColors = { pro: ['#c084fc','rgba(192,132,252,0.15)'], studio: ['#38bdf8','rgba(56,189,248,0.15)'], enterprise: ['#f0dc00','rgba(240,220,0,0.15)'], free: ['var(--text-3)','transparent'] }; const [tc, tbg] = tierColors[u.tier] || tierColors.free; content.innerHTML = `
${initials}
${u.displayName || u.username}
@${u.username}
${u.isFoundingMember ? '⭐ Founding Member' : ''} ${u.tier}
${u.minutesBalance || 0}
$Minutes
${u.joinedAt ? new Date(u.joinedAt*1000).getFullYear() : '—'}
Joined
${u.bio ? `
${u.bio}
` : ''} `; } catch(e) { content.innerHTML = '
User not found
'; } } function closeUserProfile() { const modal = document.getElementById('userProfileModal'); if (modal) modal.style.display = 'none'; } // Close modal on backdrop click document.addEventListener('DOMContentLoaded', () => { const modal = document.getElementById('userProfileModal'); if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) closeUserProfile(); }); }); // ── Live Activity Feed ────────────────────────────────────────────────────── let _activitySSE = null; let _activityOpen = true; const ACT_LABELS = { room_created: (d) => `${d.player} opened a ${d.gameType} room${d.wager ? ` · ${(d.wager/1e12).toFixed(3)} XCH` : ''}`, room_joined: (d) => `${d.player2} joined ${d.gameType}`, game_ended: (d) => d.winner ? `${d.winner} won ${d.gameType}${d.wager ? ` · ${(d.wager/1e12).toFixed(3)} XCH` : ''}` : `${d.gameType} ended in a draw`, user_joined: (d) => `${d.username} joined via ${d.via || 'invite'}`, tournament_created: (d) => `New tournament: ${d.name || 'Unnamed'}`, }; function _actTimeAgo(ts) { const s = Math.floor((Date.now() - ts) / 1000); if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s/60)}m ago`; return `${Math.floor(s/3600)}h ago`; } function _renderActivityItem(event) { const label = ACT_LABELS[event.type]; if (!label) return ''; return `
${event.icon}${label(event.data)}${_actTimeAgo(event.ts)}
`; } // ── Stats panel (hsp) activity render ──────────────────────────────────────── const HSP_TYPE_LABELS = { room_created: 'Room Opened', room_joined: 'Player Joined', game_ended: 'Game Over', user_joined: 'New Member', tournament_created: 'Tournament', }; function _renderHspItem(event) { const label = ACT_LABELS[event.type]; if (!label) return ''; const typeLabel = HSP_TYPE_LABELS[event.type] || event.type; // Strip onclick from desc for the compact panel const desc = label(event.data).replace(/ onclick="[^"]*"/g, ''); return `
${event.icon} ${typeLabel}
${desc}
${_actTimeAgo(event.ts)}
`; } function _prependActivityItem(event) { // Sidebar feed const list = document.getElementById('activityFeedList'); if (list) { const html = _renderActivityItem(event); if (html) { const div = document.createElement('div'); div.innerHTML = html; list.insertBefore(div.firstChild, list.firstChild); while (list.children.length > 15) list.removeChild(list.lastChild); } } // Stats panel feed const hsp = document.getElementById('hspActivity'); if (hsp) { const html = _renderHspItem(event); if (html) { const div = document.createElement('div'); div.innerHTML = html; const first = div.firstChild; first.style.animation = 'actSlideIn 0.3s ease'; hsp.insertBefore(first, hsp.firstChild); while (hsp.children.length > 15) hsp.removeChild(hsp.lastChild); } } } function toggleActivityFeed() { _activityOpen = !_activityOpen; const list = document.getElementById('activityFeedList'); const icon = document.getElementById('activityToggleIcon'); if (list) list.style.display = _activityOpen ? '' : 'none'; if (icon) icon.textContent = _activityOpen ? '▲' : '▼'; } function initActivityFeed() { if (_activitySSE) { _activitySSE.close(); _activitySSE = null; } // Load recent first via REST, then open SSE fetch('/api/activity/recent?limit=15') .then(r => r.json()) .then(({ events }) => { // Sidebar const list = document.getElementById('activityFeedList'); if (list) list.innerHTML = events.map(_renderActivityItem).join(''); // Stats panel — show latest 6 const hsp = document.getElementById('hspActivity'); if (hsp) { const html = events.slice(0, 15).map(_renderHspItem).join(''); hsp.innerHTML = html || '
Network
Waiting for activity
'; } }) .catch(() => {}); _activitySSE = new EventSource('/api/activity/stream'); _activitySSE.onmessage = (e) => { try { const event = JSON.parse(e.data); _prependActivityItem(event); } catch (_) {} }; _activitySSE.onerror = () => { // Auto-reconnect handled by browser }; } // Start feed once DOM is ready document.addEventListener('DOMContentLoaded', () => { setTimeout(initActivityFeed, 500); }); function triggerDiscordAuth(isRegister = false) { if (isRegister) { requireAgeTerms(() => { const inviteCode = prompt('Enter your invite code to register:'); if (!inviteCode) return; window.location.href = `/api/auth/discord?invite_code=${encodeURIComponent(inviteCode.trim().toUpperCase())}&age_confirmed=1&terms_accepted=1`; }); return; } window.location.href = '/api/auth/discord'; } function triggerGoogleAuth(isRegister = false) { if (isRegister) { requireAgeTerms(() => { const inviteCode = prompt('Enter your invite code to register:'); if (!inviteCode) return; window.location.href = `/api/auth/google?invite_code=${encodeURIComponent(inviteCode.trim().toUpperCase())}&age_confirmed=1&terms_accepted=1`; }); return; } window.location.href = '/api/auth/google'; } (function handleGoogleCallback() { const params = new URLSearchParams(window.location.search); const googleCode = params.get('google_code'); const googleError = params.get('google_error'); if (googleError) { const msgs = { access_denied:'Google login cancelled', invite_required:'An invite code is required to register', invalid_invite:'Invalid invite code', invite_expired:'Invite code has expired', invite_used:'Invite code has already been used', registration_closed:'Registration is currently closed', token_failed:'Google auth failed — please try again', server_error:'Server error — please try again' }; history.replaceState({}, '', window.location.pathname + window.location.hash); const errEl = document.getElementById('tgLoginError') || document.getElementById('tgRegisterError'); if (errEl) errEl.textContent = msgs[googleError] || 'Google login failed'; return; } if (googleCode) { history.replaceState({}, '', window.location.pathname); fetch(`/api/auth/google/complete?code=${googleCode}`) .then(r => r.json()) .then(data => { if (data.accessToken) { authState = { user: data.user, accessToken: data.accessToken, refreshToken: data.refreshToken }; saveAuthState(); updateAuthUI(); showPage('rooms'); } }) .catch(() => console.error('Google complete failed')); } })(); function triggerTelegramAuth() { const onRegisterPage = document.getElementById('register-page')?.classList.contains('active'); const doAuth = () => { if (!window.Telegram || !window.Telegram.Login) { const onLoginPage = document.getElementById('login-page')?.classList.contains('active'); const errorEl = document.getElementById(onLoginPage ? 'tgLoginError' : 'tgRegisterError'); if (errorEl) errorEl.textContent = 'Telegram widget not loaded yet — try again in a moment'; return; } window.Telegram.Login.auth({ bot_id: TELEGRAM_BOT_ID, request_access: true }, function(data) { if (data) window.onTelegramAuth(data); }); }; // If on the register page, require age/terms confirmation first if (onRegisterPage) { requireAgeTerms(doAuth); } else { doAuth(); } } window.onTelegramAuth = async function(user) { try { // Determine which page is active to show errors on the right one const onLoginPage = document.getElementById('login-page')?.classList.contains('active'); const onRegisterPage = document.getElementById('register-page')?.classList.contains('active'); const errorEl = onRegisterPage ? document.getElementById('tgRegisterError') : document.getElementById('tgLoginError'); // First try login (existing user) const res = await fetch('/api/auth/telegram', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(user) }); const data = await res.json(); if (res.ok) { // Existing user — login successful authState = { user: data.user, accessToken: data.accessToken, refreshToken: data.refreshToken }; saveAuthState(); updateAuthUI(); showPage('rooms'); return; } // If we get invite_required error — they're new, show invite code prompt if (data.error === 'invite_required') { const inviteCode = prompt('Enter your invite code to join:'); if (!inviteCode) { if (errorEl) errorEl.textContent = 'Invite code required'; return; } const res2 = await fetch('/api/auth/telegram', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...user, invite_code: inviteCode }) }); const data2 = await res2.json(); if (!res2.ok) { if (errorEl) errorEl.textContent = data2.error || 'Registration failed'; return; } authState = { user: data2.user, accessToken: data2.accessToken, refreshToken: data2.refreshToken }; saveAuthState(); updateAuthUI(); showPage('rooms'); return; } // Other error if (errorEl) errorEl.textContent = data.error || 'Authentication failed'; } catch(err) { console.error('Telegram auth error:', err); const errorEl = document.getElementById('tgLoginError') || document.getElementById('tgRegisterError'); if (errorEl) errorEl.textContent = 'Connection error'; } }; // ── Forgot / Reset Password ─────────────────────────────────────────────────── function showForgotPassword(show = true) { const loginForm = document.querySelector('#login-page form:not(#forgotPasswordForm)'); const forgotForm = document.getElementById('forgotPasswordForm'); if (loginForm) loginForm.style.display = show ? 'none' : ''; if (forgotForm) forgotForm.style.display = show ? 'block' : 'none'; } async function doForgotPassword(e) { e.preventDefault(); const email = document.getElementById('forgotEmail').value.trim(); const msgEl = document.getElementById('forgotMsg'); const btn = e.target.querySelector('button[type=submit]'); btn.disabled = true; btn.textContent = 'Sending'; msgEl.style.color = 'var(--text-2)'; msgEl.textContent = ''; try { const r = await fetch('/api/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }); const d = await r.json(); msgEl.style.color = 'var(--cyan)'; msgEl.textContent = d.message || 'Check your inbox for a reset link.'; } catch(_) { msgEl.style.color = '#ff4444'; msgEl.textContent = 'Network error. Try again.'; } btn.disabled = false; btn.textContent = 'Send Reset Link'; } // ── Check URL for reset/verify tokens on page load ─────────────────────────── function checkUrlTokens() { const params = new URLSearchParams(window.location.search); const resetToken = params.get('reset_token'); const verifyToken = params.get('verify_email'); const inviteParam = params.get('invite'); if (resetToken) handleResetToken(resetToken); if (verifyToken) handleVerifyToken(verifyToken); // Phase A1 — invite-link prefill: if ?invite=CODE is in URL, save it for the // registration form and surface a confirmation pill near the invite field. if (inviteParam) { try { sessionStorage.setItem('a21_invite_code', inviteParam.trim().toUpperCase()); } catch(_) {} applyInvitePrefill(inviteParam.trim().toUpperCase()); // Invite links (e.g. the "Claim Your Access" email button) must land the user // directly on the registration page with the code prefilled. initRouting has // already chosen a start page by now, so navigate to register explicitly. // A logged-in user is auto-redirected to rooms inside showPage, so this is safe. if (!(typeof authState !== 'undefined' && authState && authState.user)) { try { showPage('register'); } catch(_) {} } } else { // Re-apply on subsequent renders (e.g. switching to register page) if we have // a previously-captured code in this session try { const saved = sessionStorage.getItem('a21_invite_code'); if (saved) applyInvitePrefill(saved); } catch(_) {} } // Clean URL — keep nothing referral-related visible after prefill if (resetToken || verifyToken || inviteParam) { const keep = new URLSearchParams(window.location.search); keep.delete('reset_token'); keep.delete('verify_email'); keep.delete('invite'); const qs = keep.toString(); window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash); } } function applyInvitePrefill(code) { if (!code) return; const input = document.getElementById('regInviteCode'); if (input) { input.value = code; input.setAttribute('readonly', 'readonly'); input.style.opacity = '0.85'; } // Inject a confirmation pill above the invite field (idempotent) const wrap = input?.parentElement; if (wrap && !document.getElementById('invitePrefillPill')) { const pill = document.createElement('div'); pill.id = 'invitePrefillPill'; pill.style.cssText = 'display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:rgba(58,196,229,0.10);border:1px solid rgba(58,196,229,0.30);border-radius:999px;font-family:var(--ff-display);font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--cyan);margin-bottom:8px'; pill.innerHTML = `✓ Invite code applied — ${code}`; wrap.insertBefore(pill, input); } } async function handleResetToken(token) { // Show a reset password modal const modal = document.createElement('div'); modal.id = 'resetPasswordModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px'; modal.innerHTML = `

Set New Password

Choose a strong password (min 8 chars).

`; document.body.appendChild(modal); } async function doResetPassword(token) { const pw = document.getElementById('resetNewPw').value; const pw2 = document.getElementById('resetConfirmPw').value; const msg = document.getElementById('resetMsg'); if (pw !== pw2) { msg.style.color = '#ff4444'; msg.textContent = 'Passwords do not match.'; return; } if (pw.length < 8) { msg.style.color = '#ff4444'; msg.textContent = 'Password must be at least 8 characters.'; return; } try { const r = await fetch('/api/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password: pw }) }); const d = await r.json(); if (!r.ok) { msg.style.color = '#ff4444'; msg.textContent = d.error || 'Reset failed.'; return; } msg.style.color = 'var(--cyan)'; msg.textContent = '✓ Password updated! You can now log in.'; setTimeout(() => { document.getElementById('resetPasswordModal')?.remove(); showPage('login'); }, 2000); } catch(_) { msg.style.color = '#ff4444'; msg.textContent = 'Network error.'; } } async function resendVerificationEmail() { const btn = document.getElementById('resendVerifyBtn'); if (btn) { btn.disabled = true; btn.textContent = 'Sending'; } try { const r = await apiFetch('/api/auth/send-verification', { method: 'POST' }); const d = await r.json(); showToast(d.message || 'Verification email sent!', 'success'); } catch(_) { showToast('Failed to send. Try again.', 'error'); } if (btn) { btn.disabled = false; btn.textContent = 'Resend Email'; } } async function handleVerifyToken(token) { try { const r = await fetch('/api/auth/verify-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }); const d = await r.json(); if (d.success) { if (authState.user) { // Optimistic update for instant feedback, then reconcile with authoritative // server state so the verified flag + banner are correct on every page. authState.user.email_verified = 1; saveAuthState(); updateAuthUI(); if (typeof refreshUserProfile === 'function') refreshUserProfile(); showToast('✅ Email verified!', 'success'); } else { // Link opened while logged out — the account IS verified server-side; tell them to sign in. showToast('✅ Email verified — sign in to finish.', 'success'); } } else { showToast(d.error || 'Verification failed.', 'error'); } } catch(_) { showToast('Verification request failed.', 'error'); } } // ── Toast helper (lightweight) ──────────────────────────────────────────────── function showToast(message, type = 'info') { const toast = document.createElement('div'); const color = type === 'success' ? 'var(--cyan)' : type === 'error' ? '#ff4444' : 'var(--text-2)'; toast.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:99999;background:var(--card);border:1px solid ${color};border-radius:10px;padding:14px 20px;font-size:14px;color:${color};font-weight:600;box-shadow:0 8px 32px rgba(0,0,0,0.4);animation:fadeSlideUp 0.3s ease;max-width:320px`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 4000); } async function doLogin(e) { e.preventDefault(); const email = document.getElementById('loginEmail').value.trim(); const password = document.getElementById('loginPassword').value; const errorEl = document.getElementById('loginError'); errorEl.textContent = ''; try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await res.json(); if (!res.ok) { errorEl.textContent = data.error || 'Login failed'; return; } authState = { user: data.user, accessToken: data.accessToken, refreshToken: data.refreshToken }; saveAuthState(); updateAuthUI(); showPage('rooms'); } catch(err) { errorEl.textContent = 'Connection error'; } } // ── Age/Terms modal for OAuth flows ─────────────────────────────────────── let _pendingOAuthFn = null; function requireAgeTerms(callback) { // If already confirmed this session, proceed immediately if (sessionStorage.getItem('ageTermsConfirmed') === '1') { callback(); return; } _pendingOAuthFn = callback; const modal = document.getElementById('ageTermsModal'); if (modal) { document.getElementById('oauthAgeConfirm').checked = false; document.getElementById('oauthTermsAccept').checked = false; document.getElementById('ageTermsError').textContent = ''; modal.style.display = 'flex'; } else { callback(); // fallback — modal not found, don't block } } function closeAgeTermsModal() { const modal = document.getElementById('ageTermsModal'); if (modal) modal.style.display = 'none'; _pendingOAuthFn = null; } function confirmAgeTermsAndProceed() { const age = document.getElementById('oauthAgeConfirm').checked; const terms = document.getElementById('oauthTermsAccept').checked; const errEl = document.getElementById('ageTermsError'); if (!age) { errEl.textContent = 'You must confirm you are 18 or older.'; return; } if (!terms) { errEl.textContent = 'You must accept the Terms of Service and EULA.'; return; } sessionStorage.setItem('ageTermsConfirmed', '1'); const modal = document.getElementById('ageTermsModal'); if (modal) modal.style.display = 'none'; if (_pendingOAuthFn) { const fn = _pendingOAuthFn; _pendingOAuthFn = null; fn(); } } // ── Email registration ──────────────────────────────────────────────────── async function doRegister(e) { e.preventDefault(); const errorEl = document.getElementById('registerError'); errorEl.textContent = ''; // Honeypot check — bots fill in the hidden field, humans don't const honeypot = document.getElementById('regHoneypot'); if (honeypot && honeypot.value.length > 0) { // Silently fail — don't tell the bot it was caught await new Promise(r => setTimeout(r, 1500)); errorEl.textContent = 'Registration failed. Please try again.'; return; } // Age + Terms validation if (!document.getElementById('regAgeConfirm').checked) { errorEl.textContent = 'You must confirm you are 18 years of age or older.'; return; } if (!document.getElementById('regTermsAccept').checked) { errorEl.textContent = 'You must accept the Terms of Service and EULA to continue.'; return; } const body = { username: document.getElementById('regUsername').value.trim(), email: document.getElementById('regEmail').value.trim(), password: document.getElementById('regPassword').value, displayName: document.getElementById('regDisplayName').value.trim(), inviteCode: document.getElementById('regInviteCode').value.trim().toUpperCase(), age_confirmed: true, terms_accepted: true }; if (body.password.length < 8) { errorEl.textContent = 'Password must be at least 8 characters'; return; } if (body.password !== document.getElementById('regPasswordConfirm').value) { errorEl.textContent = 'Passwords do not match'; return; } try { const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { errorEl.textContent = data.error || 'Registration failed'; return; } authState = { user: data.user, accessToken: data.accessToken, refreshToken: data.refreshToken }; saveAuthState(); updateAuthUI(); showPage('rooms'); } catch(err) { errorEl.textContent = 'Connection error'; } } async function doLogout() { if (authState.refreshToken) { fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: authState.refreshToken }) }).catch(() => {}); } clearAuth(); showPage('rooms'); } async function doUpdateProfile(e) { e.preventDefault(); const errorEl = document.getElementById('profileError'); const successEl = document.getElementById('profileSuccess'); errorEl.textContent = ''; successEl.textContent = ''; const body = { display_name: document.getElementById('profileDisplayName').value.trim() || undefined, bio: document.getElementById('profileBio')?.value.trim() ?? undefined, wallet_address: document.getElementById('profileWallet').value.trim() || undefined, }; try { // Bio + display name → /api/profile const profileBody = {}; if (body.display_name) profileBody.display_name = body.display_name; if (body.bio !== undefined) profileBody.bio = body.bio; const r1 = await apiFetch('/api/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileBody) }); const d1 = await r1.json(); if (!r1.ok) { errorEl.textContent = d1.error || 'Update failed'; return; } // Wallet address → /api/auth/me if (body.wallet_address !== undefined) { const r2 = await apiFetch('/api/auth/me', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ walletAddress: body.wallet_address, displayName: body.display_name }) }); const d2 = await r2.json(); if (!r2.ok) { errorEl.textContent = d2.error || 'Wallet update failed'; return; } } // Refresh auth state const meRes = await apiFetch('/api/auth/me'); if (meRes.ok) { authState.user = await meRes.json(); saveAuthState(); updateAuthUI(); } successEl.textContent = '✓ Profile updated!'; setTimeout(() => { if (successEl) successEl.textContent = ''; }, 3000); } catch(err) { errorEl.textContent = 'Connection error'; } } function populateProfile() { if (!authState.user) { showPage('login'); return; } const u = authState.user; document.getElementById('profileAvatar').textContent = (u.display_name || u.username || '?')[0].toUpperCase(); document.getElementById('profileUsername').textContent = u.display_name || u.username; document.getElementById('profileRole').textContent = u.role; // Single consolidated tier style map — used for both badge and subscription card const tierColors = { free: { bg: 'rgba(86,86,128,0.1)', color: '#9898b8', border: 'rgba(86,86,128,0.2)', cardBg: 'rgba(86,86,128,0.06)', cardBorder: 'rgba(86,86,128,0.2)' }, pro: { bg: 'rgba(153,69,255,0.1)', color: '#9945ff', border: 'rgba(153,69,255,0.2)', cardBg: 'rgba(153,69,255,0.06)', cardBorder: 'rgba(153,69,255,0.25)' }, studio: { bg: 'rgba(0,180,255,0.1)', color: '#00b4ff', border: 'rgba(0,180,255,0.2)', cardBg: 'rgba(0,180,255,0.06)', cardBorder: 'rgba(0,180,255,0.25)' }, enterprise: { bg: 'rgba(255,215,0,0.1)', color: '#ffd700', border: 'rgba(255,215,0,0.2)', cardBg: 'rgba(255,215,0,0.06)', cardBorder: 'rgba(255,215,0,0.25)' } }; const tc = tierColors[u.tier] || tierColors.free; const tierEl = document.getElementById('profileTier'); tierEl.textContent = u.tier; tierEl.style.cssText = `font-size:10px;font-weight:700;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:0.5px;background:${tc.bg};color:${tc.color};border:1px solid ${tc.border}`; document.getElementById('profileDisplayName').value = u.display_name || ''; document.getElementById('profileEmail').value = u.email || ''; document.getElementById('profileWallet').value = u.wallet_address || ''; // Show/hide email verification banner (centralized — also runs via updateAuthUI) syncEmailVerifyBanner(u); const bioEl = document.getElementById('profileBio'); if (bioEl) { bioEl.value = u.bio || ''; const counter = document.getElementById('bioCharCount'); if (counter) counter.textContent = (u.bio || '').length; bioEl.addEventListener('input', () => { if (counter) counter.textContent = bioEl.value.length; }, { once: false }); } // ── Rich subscription + $Minutes card ── const tierNames = { free: 'Explorer', pro: 'Pro', studio: 'Studio', enterprise: 'Enterprise' }; const minutesMult = { free: '1×', pro: '1.5×', studio: '2×', enterprise: '3×' }; const tierColor = tc.color; const tierBorder = tc.cardBorder; const tierBg = tc.cardBg; let expiryHtml = ''; if (u.tier !== 'free' && u.tier_expires_at) { const expDate = new Date(u.tier_expires_at * 1000); const daysLeft = Math.ceil((expDate - Date.now()) / 86400000); const expColor = daysLeft <= 7 ? '#ff6b35' : daysLeft <= 14 ? '#ffb400' : 'var(--text-3)'; expiryHtml = `
${daysLeft > 0 ? `Renews in ${daysLeft} day${daysLeft !== 1 ? 's' : ''} · ${expDate.toLocaleDateString()}` : `Expired ${expDate.toLocaleDateString()}`}
`; } let upgradeHtml = ''; if (u.tier === 'free') { upgradeHtml = `
Upgrade to unlock room creation, analytics & more $Minutes
`; } else { upgradeHtml = `
`; } const minBalance = u.minutes_balance || 0; const subCard = document.getElementById('profileSubCard'); if (subCard) { subCard.innerHTML = `
Your Plan
${tierNames[u.tier] || 'Explorer'} ${u.tier}
${expiryHtml}
$Minutes
${minBalance.toLocaleString()}
Earn rate: ${minutesMult[u.tier] || '1×'}
${u.tier !== 'free' ? '✓' : '—'}
Create Rooms
${u.tier !== 'free' ? '✓' : '—'}
Analytics
${['studio','enterprise'].includes(u.tier) ? '✓' : '—'}
Publish Games
${upgradeHtml} `; } const created = u.created_at ? new Date(u.created_at * 1000).toLocaleDateString() : 'Unknown'; const lastLogin = u.last_login_at ? new Date(u.last_login_at * 1000).toLocaleDateString() : 'Never'; document.getElementById('profileMeta').innerHTML = `Member since: ${created}
Last login: ${lastLogin}`; // Load payment history const payEl = document.getElementById('profilePayments'); if (payEl) { authFetch('/api/subscribe/history').then(r=>r.json()).then(data => { if (!data.payments || data.payments.length === 0) { payEl.innerHTML = 'No payments yet.'; return; } payEl.innerHTML = data.payments.map(p => { const date = new Date(p.created_at * 1000).toLocaleDateString(); const statusColors = { confirmed: 'var(--green)', pending: 'var(--orange)', expired: 'var(--text-4)', refunded: 'var(--blue)' }; const color = statusColors[p.status] || 'var(--text-3)'; return `
${p.tier.toUpperCase()} · ${p.amount_xch} XCH
${date}${p.status}
`; }).join(''); }).catch(() => { payEl.innerHTML = 'Failed to load.'; }); } } /* ── AUTH INPUT FOCUS STYLES ── */ (function() { const style = document.createElement('style'); style.textContent = '#loginEmail:focus,#loginPassword:focus,#regInviteCode:focus,#regUsername:focus,#regDisplayName:focus,#regEmail:focus,#regPassword:focus,#regPasswordConfirm:focus,#profileDisplayName:focus,#profileWallet:focus{border-color:rgba(171,201,255,.32)!important;box-shadow:0 0 0 2px rgba(74,217,255,.15),0 0 14px rgba(40,200,220,.12)!important}'; document.head.appendChild(style); })(); /* ── AUTH INIT ── */ document.addEventListener('DOMContentLoaded', () => { loadAuthState(); checkUrlTokens(); // Fetch site settings: announcement banner + room creation mode fetch('/api/public/site-settings').then(r=>r.ok?r.json():null).then(d=>{ if(!d) return; window._siteSettings = d; const configuredPlayBase = String(d.play_app_base_url || '').trim(); if (configuredPlayBase) { try { const u = new URL(configuredPlayBase); if (u.protocol === 'http:' || u.protocol === 'https:') { window.PLAY_APP_BASE = configuredPlayBase.replace(/\/+$/, ''); } } catch(_) {} } const text = d.announcement_banner?.trim(); if(text){ document.getElementById('siteBannerText').textContent = text; document.getElementById('siteBanner').style.display = 'block'; } }).catch(()=>{}); }); // ── Live XCH Price + Pricing Display ── let _xchUsd = null; let _tierPricing = null; let _billingMode = 'monthly'; async function fetchLiveXchPrice() { try { const r = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=chia&vs_currencies=usd', { cache: 'no-store' }); const d = await r.json(); _xchUsd = d?.chia?.usd || null; } catch {} return _xchUsd; } function xchToUsd(xch) { if (!_xchUsd) return ''; return `≈ $${(_xchUsd * xch).toFixed(2)} USD`; } function setBillingMode(mode = 'monthly') { _billingMode = (mode === 'annual') ? 'annual' : 'monthly'; const monthlyBtn = document.getElementById('billingMonthlyBtn'); const annualBtn = document.getElementById('billingAnnualBtn'); if (monthlyBtn && annualBtn) { if (_billingMode === 'annual') { monthlyBtn.style.background = 'transparent'; monthlyBtn.style.color = 'var(--text-3)'; monthlyBtn.style.borderColor = 'var(--border-dim)'; annualBtn.style.background = 'rgba(0,255,136,0.14)'; annualBtn.style.color = 'var(--green)'; annualBtn.style.borderColor = 'rgba(0,255,136,0.4)'; } else { monthlyBtn.style.background = 'var(--elevated)'; monthlyBtn.style.color = 'var(--text-1)'; monthlyBtn.style.borderColor = 'var(--border-dim)'; annualBtn.style.background = 'rgba(0,255,136,0.08)'; annualBtn.style.color = 'var(--green)'; annualBtn.style.borderColor = 'rgba(0,255,136,0.35)'; } } const usdMonthly = { pro: 19.99, studio: 49.99, enterprise: 99.99 }; const xchMonthlyFallback = { pro: 7.55, studio: 18.87, enterprise: 37.73 }; const discount = _billingMode === 'annual' ? 0.8 : 1.0; ['pro','studio','enterprise'].forEach((tier) => { const amountEl = document.getElementById(`${tier}Amount`); const xchEl = document.getElementById(`${tier}Xch`); const periodEl = document.getElementById(`${tier}Period`); const monthlyXch = Number(_tierPricing?.[tier]?.xch || xchMonthlyFallback[tier] || 0); const shownXch = monthlyXch * discount; const shownUsd = (usdMonthly[tier] * discount).toFixed(2); if (amountEl) amountEl.textContent = `$${shownUsd}`; if (xchEl) xchEl.textContent = `≈ ${shownXch.toFixed(2)} XCH / ${_billingMode === 'annual' ? 'month (billed yearly)' : 'month'}`; if (periodEl) periodEl.textContent = _billingMode === 'annual' ? 'Paid in XCH. Billed yearly at 20% discount.' : 'Paid in XCH. Cancel anytime.'; }); // Update Supporter card XCH equivalent try { const supXch = document.getElementById('supporterXch'); if (supXch && _tierPricing?.pro?.xch && usdMonthly.pro) { const xchUsd = (usdMonthly.pro / _tierPricing.pro.xch); if (xchUsd > 0) supXch.textContent = '≈ ' + (5 / xchUsd).toFixed(2) + ' XCH / month'; } } catch(_){} } async function loadXchPrice() { const label = document.getElementById('xchPriceLabel'); try { const [pricingResp] = await Promise.all([ fetch(`${API_BASE}/api/subscribe/pricing`), fetchLiveXchPrice() ]); const data = await pricingResp.json(); _tierPricing = data.tiers || null; // Keep display in XCH terms by default; billing toggle handles annual scenario. if (_tierPricing) setBillingMode(_billingMode); // Update $Minutes package USD estimates document.querySelectorAll('[data-minutes-xch]').forEach(el => { if (_xchUsd) { const xch = parseFloat(el.dataset.minutesXch); el.textContent = `≈ $${(_xchUsd * xch).toFixed(2)}`; } }); if (data.test_mode) { if (label) label.innerHTML = `⚠️ TEST MODE — Beta pricing active`; } else { const xchPriceStr = _xchUsd ? `  ·  1 XCH = $${_xchUsd.toFixed(2)} USD` : ''; if (label) label.innerHTML = `Prices fixed in XCH  ·  Pro: ${data.tiers.pro.xch} XCH  ·  Studio: ${data.tiers.studio.xch} XCH  ·  Enterprise: ${data.tiers.enterprise.xch} XCH${xchPriceStr}`; } } catch(e) { if (label) label.textContent = 'All subscriptions paid in XCH · Cancel anytime'; } } /* ══ WALLET MANAGER ══ */ const WC_PROJECT_ID = 'aeeae16367676ccd4840f1179ff08820'; const WALLET_STORAGE_KEY = 'chia-tracker-wallet'; const walletState = { type: null, // 'goby' | 'sage' address: null, connected: false, goby: null, // window.chia ref sageClient: null, sageSession: null }; function isGobyInstalled() { return !!(window.chia && window.chia.isGoby); } function shortenAddress(addr) { if (!addr) return ''; return addr.slice(0, 10) + '...' + addr.slice(-6); } function saveWalletState() { if (walletState.connected) { localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify({ type: walletState.type, address: walletState.address })); } else { localStorage.removeItem(WALLET_STORAGE_KEY); } updateWalletUI(); } function updateWalletUI() { // Update wallet dot on avatar const dot = document.getElementById('walletDot'); if (dot) { dot.className = walletState.connected ? 'wallet-dot connected' : 'wallet-dot'; } // Update dropdown wallet section if open renderDropdownWallet(); } async function connectGoby() { if (!isGobyInstalled()) { alert('Goby wallet not detected. Install it from goby.app'); return false; } try { await window.chia.request({ method: 'connect', params: { eager: false } }); const accounts = await window.chia.request({ method: 'accounts' }); if (!accounts || accounts.length === 0) throw new Error('No accounts'); // Convert puzzle hash to xch address const puzzleHash = accounts[0]; // Simple bech32m would need a lib — use wallet's own method or just store puzzle hash walletState.type = 'goby'; walletState.address = puzzleHash; walletState.connected = true; walletState.goby = window.chia; saveWalletState(); fetchOnChainMinutesBalance(); // async, updates pill when ready return true; } catch(e) { console.error('Goby connect error:', e); if (e.code !== 4001 && e.code !== 4002) alert('Failed to connect Goby wallet.'); return false; } } async function initSageClient() { if (walletState.sageClient) return walletState.sageClient; // WalletConnect UMD attaches to several different global names depending // on the build. Try all the known shapes. This bundle exports under // window["@walletconnect/sign-client"] (bracket notation, npm-package name). const wcGlobal = window['@walletconnect/sign-client'] || window.WalletConnectSignClient || null; const SignClient = (wcGlobal && (wcGlobal.SignClient || wcGlobal.default || wcGlobal)) || window.SignClient || null; if (!SignClient || typeof SignClient.init !== 'function') { console.error('WalletConnect SignClient not loaded. Inspected:', { bracket: !!window['@walletconnect/sign-client'], pascal: !!window.WalletConnectSignClient, direct: !!window.SignClient, resolvedShape: wcGlobal && Object.keys(wcGlobal).slice(0, 10), }); return null; } try { const client = await SignClient.init({ projectId: WC_PROJECT_ID, metadata: { name: 'Arcade21 Game Tracker', description: 'P2P Gaming Discovery on Chia', url: window.location.origin, icons: [window.location.origin + '/images/arcade21-logo.png'] } }); walletState.sageClient = client; // Check existing sessions const sessions = client.session.getAll(); if (sessions.length > 0) { walletState.sageSession = sessions[sessions.length - 1]; } // Listen for session delete client.on('session_delete', ({ topic }) => { if (walletState.sageSession && walletState.sageSession.topic === topic) { walletState.sageSession = null; walletState.connected = false; walletState.address = null; walletState.type = null; saveWalletState(); } }); return client; } catch(e) { console.error('Sage init error:', e); return null; } } async function connectSage() { const client = await initSageClient(); if (!client) { alert('Failed to initialize WalletConnect. Please try again.'); return false; } // If existing session, try to reuse if (walletState.sageSession) { try { const addr = await getSageAddress(client, walletState.sageSession); if (addr) { walletState.type = 'sage'; walletState.address = addr; walletState.connected = true; saveWalletState(); return true; } } catch(e) { walletState.sageSession = null; } } // New connection — show QR try { const { uri, approval } = await client.connect({ requiredNamespaces: { chia: { methods: ['chip0002_connect', 'chip0002_getAssetBalance', 'chip0002_getAssetCoins', 'chip0002_signCoinSpends', 'chip0002_sendTransaction', 'chip0002_signMessage'], chains: ['chia:mainnet'], events: ['accountChanged', 'chainChanged'] } } }); // Show QR modal showSageQRModal(uri); // Wait for approval const session = await approval(); walletState.sageSession = session; walletState.sageClient = client; const addr = await getSageAddress(client, session); walletState.type = 'sage'; walletState.address = addr || 'Connected'; walletState.connected = true; saveWalletState(); hideSageQRModal(); fetchOnChainMinutesBalance(); // async, updates pill when ready return true; } catch(e) { console.error('Sage connect error:', e); hideSageQRModal(); return false; } } async function getSageAddress(client, session) { try { const result = await client.request({ topic: session.topic, chainId: 'chia:mainnet', request: { method: 'chip0002_connect', params: {} } }); // Result should have puzzleHash or address if (result && result.puzzleHash) return result.puzzleHash; if (result && result.address) return result.address; if (typeof result === 'string') return result; return null; } catch(e) { console.error('getSageAddress error:', e); return null; } } // Convert a UTF-8 string to hex (for Goby memos) function utf8ToHex(str) { return '0x' + Array.from(new TextEncoder().encode(str)).map(b => b.toString(16).padStart(2, '0')).join(''); } async function sendXchWithWallet(toAddress, amountMojos, memo) { if (!walletState.connected) { showWalletModal(); return { success: false, error: 'No wallet connected' }; } // Encode memo as hex for CHIP-0002 const memoHex = memo ? [utf8ToHex(memo)] : []; if (walletState.type === 'goby') { try { if (window.A21_DEBUG) console.log('[WALLET] Goby transfer:', { to: toAddress, amount: String(amountMojos), memos: memoHex, assetId: '' }); const result = await window.chia.request({ method: 'transfer', params: { to: toAddress, amount: String(amountMojos), memos: memoHex, assetId: '' } }); if (window.A21_DEBUG) console.log('[WALLET] Goby transfer result:', result); return { success: true, txId: result?.id || result }; } catch(e) { console.error('[WALLET] Goby transfer error:', e, 'code:', e?.code, 'message:', e?.message, 'data:', e?.data); if (e.code === 4001) return { success: false, error: 'Transaction cancelled by user' }; return { success: false, error: e.message || 'Transaction rejected' }; } } if (walletState.type === 'sage') { if (!walletState.sageClient || !walletState.sageSession) { return { success: false, error: 'Sage session expired. Reconnect wallet.' }; } try { const result = await walletState.sageClient.request({ topic: walletState.sageSession.topic, chainId: 'chia:mainnet', request: { method: 'chip0002_transfer', params: { to: toAddress, amount: String(amountMojos), memos: memoHex, assetId: '' } } }); return { success: true, txId: result }; } catch(e) { if (e.code === 4001) return { success: false, error: 'Transaction cancelled by user' }; return { success: false, error: e.message || 'Transaction rejected' }; } } return { success: false, error: 'Unknown wallet type' }; } async function disconnectWallet() { if (walletState.type === 'sage' && walletState.sageClient && walletState.sageSession) { try { await walletState.sageClient.disconnect({ topic: walletState.sageSession.topic, reason: { code: 6000, message: 'User disconnected' } }); } catch(e) { /* ignore */ } } walletState.type = null; walletState.address = null; walletState.connected = false; walletState.sageSession = null; localStorage.removeItem(WALLET_STORAGE_KEY); updateWalletUI(); } function showWalletModal() { let overlay = document.getElementById('walletModalOverlay'); if (overlay) overlay.remove(); overlay = document.createElement('div'); overlay.id = 'walletModalOverlay'; overlay.className = 'wallet-modal'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const gobyAvailable = isGobyInstalled(); overlay.innerHTML = `
${walletState.connected ? `
Wallet Connected
Manage your wallet connection
${walletState.type === 'goby' ? 'Goby' : 'Sage'} Wallet
${shortenAddress(walletState.address)}
` : `
Connect Wallet
Connect a Chia wallet to make payments and interact with the blockchain
Goby
Browser extension wallet
${gobyAvailable ? '● Detected' : 'Not Installed'}
Sage
Desktop wallet via WalletConnect
QR Code
`}
`; document.body.appendChild(overlay); } async function handleGobyConnect() { const overlay = document.getElementById('walletModalOverlay'); if (!isGobyInstalled()) { window.open('https://www.goby.app/', '_blank'); return; } const inner = overlay?.querySelector('.wallet-modal-inner'); if (inner) inner.innerHTML = '
Connecting to Goby
Approve the connection in your Goby extension
'; const ok = await connectGoby(); if (ok) { overlay?.remove(); } else if (overlay) { showWalletModal(); } } async function handleSageConnect() { const overlay = document.getElementById('walletModalOverlay'); const inner = overlay?.querySelector('.wallet-modal-inner'); if (inner) inner.innerHTML = '
Initializing WalletConnect
Please wait
'; const ok = await connectSage(); if (ok) { overlay?.remove(); } else if (overlay) { showWalletModal(); } } function showSageQRModal(uri) { const overlay = document.getElementById('walletModalOverlay'); const inner = overlay?.querySelector('.wallet-modal-inner'); if (!inner) return; inner.innerHTML = `
Scan with Sage Wallet
Open Sage → Settings → WalletConnect → Scan QR Code
Waiting for connection
`; // Generate QR code using a simple canvas-based QR generateQR(document.getElementById('sageQRCode'), uri); } function hideSageQRModal() { const overlay = document.getElementById('walletModalOverlay'); if (overlay) overlay.remove(); } // Simple QR code generator (uses an external service as fallback) function generateQR(container, text) { if (!container) return; const img = document.createElement('img'); img.src = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(text)}&bgcolor=FFFFFF&color=000000&margin=0`; img.width = 220; img.height = 220; img.style.borderRadius = '8px'; container.appendChild(img); } // Restore wallet state on page load (function restoreWallet() { try { const saved = localStorage.getItem(WALLET_STORAGE_KEY); if (saved) { const { type, address } = JSON.parse(saved); walletState.type = type; walletState.address = address; walletState.connected = true; // Try eager reconnect if (type === 'goby' && isGobyInstalled()) { walletState.goby = window.chia; window.chia.request({ method: 'connect', params: { eager: true } }) .then(() => window.chia.request({ method: 'accounts' })) .then(accounts => { if (accounts?.length) walletState.address = accounts[0]; updateWalletUI(); fetchOnChainMinutesBalance(); }) .catch(() => { /* still show saved state */ }); } if (type === 'sage') { initSageClient().then(client => { if (client && walletState.sageSession) { getSageAddress(client, walletState.sageSession) .then(addr => { if (addr) walletState.address = addr; updateWalletUI(); }); } }); } } updateWalletUI(); } catch(e) { /* ignore */ } })(); /* ══ CONTENT & ECOSYSTEM (Phase 7) ══ */ let _subStep = 0; const _subData = {}; function loadSubmitPage() { const area = document.getElementById('submitFormArea'); if (!area) return; if (!authState.user) { area.innerHTML = `
🎮

Ready to ship your game?

Sign in with a Studio or Pro account to submit your Chia P2P game to the Arcade21 catalog. New here? Pricing details are on the Pricing page.

`; return; } _subStep = 0; renderSubmitStep(); loadUserSubmissions(); } function renderSubmitStep() { const area = document.getElementById('submitFormArea'); const steps = ['Basics', 'Details', 'Links', 'Review']; const stepsHtml = steps.map((s, i) => { const cls = i < _subStep ? 'done' : i === _subStep ? 'active' : ''; const line = i < steps.length - 1 ? `
` : ''; return `
${i < _subStep ? '✓' : i + 1}
${s}
${line}`; }).join(''); let content = ''; if (_subStep === 0) { const cats = ['Strategy','Card','Casino','Puzzle','Word','Skill','Action','Trivia','Other']; const selectedCat = _subData.game_type || ''; content = `
${stepsHtml}
🎯 The Basics
Tell us about your game. What's it called, what category does it fit, and what should players expect?
${(_subData.game_name||'').length}/60
Please enter a game name (at least 2 characters)
Helps players find your game in the catalog and powers featured-category curation.
Please choose a category
${(_subData.description||'').length}/500
Description must be at least 10 characters
Semantic version of your game (e.g. 1.0, 2.1.3)
`; } else if (_subStep === 1) { content = `
${stepsHtml}
📖 Game Details
Help players learn how to play. Clear instructions make for happier players and better reviews.
${(_subData.instructions||'').length}/1000
Tip: Number your steps and keep it simple. Players should understand the game in 30 seconds.
MIT is recommended for maximum compatibility with the Chia gaming ecosystem
`; } else if (_subStep === 2) { content = `
${stepsHtml}
🔗 Links & Author
Connect your game to its source code, packages, and your developer identity. All fields are optional but help build trust.
URL to your .chiagame manifest JSON. Built with the Chia Gaming SDK? This is auto-generated.
Please enter a valid URL (must start with https://)
Direct download link to your compiled game package
Please enter a valid URL (must start with https://)
`; } else if (_subStep === 3) { content = `
${stepsHtml}
✅ Review & Submit
Double-check everything looks good, then confirm the two attestations below to submit.
Game Name
${escHtml(_subData.game_name)}
${_subData.game_type?`
Category
${escHtml(_subData.game_type)}
`:''}
Description
${escHtml((_subData.description||'').substring(0,80))}${(_subData.description||'').length>80?'…':''}
Version
${escHtml(_subData.version||'1.0')}
${_subData.instructions?`
Instructions
${escHtml(_subData.instructions.substring(0,80))}${_subData.instructions.length>80?'…':''}
`:''}
License
${escHtml(_subData.license||'MIT')}
Author
${escHtml(_subData.author_name||'Not specified')}
${_subData.manifest_url?`
Manifest
${escHtml(_subData.manifest_url)}
`:''} ${_subData.package_url?`
Package
${escHtml(_subData.package_url)}
`:''}
⚠ Required Attestations
What happens next?
Our reviewers check submissions for security, fair-play mechanics, and catalog fit. Most games are reviewed within 48 hours. You'll see status updates on this page and in your developer console.
`; } else if (_subStep === 4) { content = `
🎉

Submission Received

Your game is in review. You'll see status changes here and on your developer console.

`; } area.innerHTML = content; } function updateCharCount(el, max) { const id = el.id === 'subGameName' ? 'cc-name' : el.id === 'subDesc' ? 'cc-desc' : 'cc-inst'; const counter = document.getElementById(id); if (counter) counter.textContent = el.value.length; } function submitStepNext() { // Validate current step if (_subStep === 0) { const name = document.getElementById('subGameName').value.trim(); const desc = document.getElementById('subDesc').value.trim(); const cat = document.getElementById('subCategory')?.value || ''; let valid = true; if (name.length < 2) { document.getElementById('sf-name').classList.add('error'); valid = false; } else { document.getElementById('sf-name').classList.remove('error'); } if (!cat) { document.getElementById('sf-cat')?.classList.add('error'); valid = false; } else { document.getElementById('sf-cat')?.classList.remove('error'); } if (desc.length < 10) { document.getElementById('sf-desc').classList.add('error'); valid = false; } else { document.getElementById('sf-desc').classList.remove('error'); } if (!valid) return; _subData.game_name = name; _subData.game_type = cat; _subData.description = desc; _subData.version = document.getElementById('subVersion').value.trim() || '1.0'; } else if (_subStep === 1) { _subData.instructions = document.getElementById('subInstructions').value.trim(); _subData.license = document.getElementById('subLicense').value; } else if (_subStep === 2) { const manifest = document.getElementById('subManifestUrl').value.trim(); const pkg = document.getElementById('subPackageUrl').value.trim(); let valid = true; if (manifest && !manifest.startsWith('https://')) { document.getElementById('sf-manifest').classList.add('error'); valid = false; } else { document.getElementById('sf-manifest')?.classList.remove('error'); } if (pkg && !pkg.startsWith('https://')) { document.getElementById('sf-package').classList.add('error'); valid = false; } else { document.getElementById('sf-package')?.classList.remove('error'); } if (!valid) return; _subData.author_name = document.getElementById('subAuthorName').value.trim(); _subData.author_url = document.getElementById('subAuthorUrl').value.trim(); _subData.manifest_url = manifest; _subData.package_url = pkg; } _subStep++; renderSubmitStep(); window.scrollTo({ top: 0, behavior: 'smooth' }); } function submitStepBack() { // Save current step data before going back if (_subStep === 1) { _subData.instructions = document.getElementById('subInstructions')?.value?.trim() || _subData.instructions; _subData.license = document.getElementById('subLicense')?.value || _subData.license; } else if (_subStep === 2) { _subData.author_name = document.getElementById('subAuthorName')?.value?.trim() || _subData.author_name; _subData.author_url = document.getElementById('subAuthorUrl')?.value?.trim() || _subData.author_url; _subData.manifest_url = document.getElementById('subManifestUrl')?.value?.trim() || _subData.manifest_url; _subData.package_url = document.getElementById('subPackageUrl')?.value?.trim() || _subData.package_url; } _subStep--; renderSubmitStep(); } async function submitGame() { const btn = document.getElementById('submitFinalBtn'); const msg = document.getElementById('submitMsg'); // Attestations gate const rights = document.getElementById('attestRights')?.checked; const fair = document.getElementById('attestFair')?.checked; const errEl = document.getElementById('attestErr'); if (!rights || !fair) { if (errEl) errEl.style.display = 'block'; return; } if (errEl) errEl.style.display = 'none'; if (btn) btn.disabled = true; if (msg) { msg.textContent = 'Submitting'; msg.style.color = 'var(--text-3)'; } try { const r = await authFetch(`${API_BASE}/api/submissions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_subData) }); const data = await r.json(); if (data.success) { _subStep = 4; renderSubmitStep(); loadUserSubmissions(); } else { if (msg) { msg.textContent = data.error || 'Submission failed.'; msg.style.color = '#ff5050'; } if (btn) btn.disabled = false; } } catch(e) { if (msg) { msg.textContent = 'Failed to submit. Please try again.'; msg.style.color = '#ff5050'; } if (btn) btn.disabled = false; } } async function loadUserSubmissions() { const container = document.getElementById('userSubmissionsList'); const wrapper = document.getElementById('userSubmissionsArea'); if (!container || !authState.user) return; try { const r = await authFetch(`${API_BASE}/api/submissions`); const data = await r.json(); if (!data.submissions?.length) { if (wrapper) wrapper.style.display = 'none'; return; } if (wrapper) wrapper.style.display = 'block'; container.innerHTML = data.submissions.map(s => `
${escHtml(s.game_name)} v${escHtml(s.version||'1.0')}
${s.status.replace('_',' ')}
${escHtml(s.description||'').substring(0,100)}${(s.description||'').length>100?'...':''}
Submitted ${getTimeAgo(s.created_at)}${s.reviewer_notes?` · Reviewer: ${escHtml(s.reviewer_notes)}`:''}
`).join(''); } catch(e) { container.innerHTML = '
Failed to load submissions
'; } } // Game Reviews (in game modal) async function loadGameReviews(gameId) { const container = document.getElementById('gameReviewsArea'); if (!container) return; try { const r = await fetch(`${API_BASE}/api/games/${encodeURIComponent(gameId)}/reviews`, { headers: authState.accessToken ? { 'Authorization': `Bearer ${authState.accessToken}` } : {} }); const data = await r.json(); let html = ''; // Review form (if logged in) if (authState.user) { const existing = data.user_review; html += `
${existing ? 'Update Your Review' : 'Leave a Review'}
${[1,2,3,4,5].map(i => ``).join('')}
`; } // Average rating if (data.review_count > 0) { html += `
${[1,2,3,4,5].map(i => ``).join('')} ${data.avg_rating} / 5 (${data.review_count} review${data.review_count !== 1 ? 's' : ''})
`; } // Reviews list if (data.reviews.length) { html += data.reviews.map(r => `
${(r.display_name || r.username)[0].toUpperCase()}
${escHtml(r.display_name || r.username)} ${[1,2,3,4,5].map(i => ``).join('')}
${r.review_text ? `
${escHtml(r.review_text)}
` : ''}
${getTimeAgo(r.created_at)}
`).join(''); } else if (!authState.user) { html += '
No reviews yet. Log in to be the first!
'; } container.innerHTML = html; window._currentReviewRating = data.user_review?.rating || 0; } catch(e) { container.innerHTML = '
Failed to load reviews
'; } } window._currentReviewRating = 0; function setStarRating(val) { window._currentReviewRating = val; document.querySelectorAll('#starInput span').forEach((s, i) => s.classList.toggle('active', i < val)); } async function submitReview(gameId) { const msg = document.getElementById('reviewMsg'); if (!window._currentReviewRating) { msg.textContent = 'Select a rating'; msg.style.color = '#ff5050'; return; } try { const r = await authFetch(`${API_BASE}/api/games/${encodeURIComponent(gameId)}/reviews`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rating: window._currentReviewRating, review_text: document.getElementById('reviewText')?.value?.trim() || '' }) }); const data = await r.json(); if (data.success) { msg.textContent = `Saved! Avg: ${data.avg_rating}/5`; msg.style.color = 'var(--green)'; setTimeout(() => loadGameReviews(gameId), 1000); } else { msg.textContent = data.error || 'Failed'; msg.style.color = '#ff5050'; } } catch(e) { msg.textContent = 'Error'; msg.style.color = '#ff5050'; } } // Developer Dashboard async function loadDevDashboard(_retry = 0) { const container = document.getElementById('devDashContent'); if (!container) return; if (!authState.user) { // Bootstrap race: on direct hash navigation, this can run before the // DOMContentLoaded auth init has populated authState.user from localStorage. // If we have saved auth, wait briefly and retry instead of flashing the // "please log in" message at a user who is, in fact, logged in. let hasSavedAuth = false; try { hasSavedAuth = !!localStorage.getItem('chia-tracker-auth'); } catch(_) {} if (hasSavedAuth && _retry < 8) { container.innerHTML = '
Loading dashboard
'; setTimeout(() => loadDevDashboard(_retry + 1), 200); return; } container.innerHTML = '
Please log in with a Studio tier or above account to access the developer console.
'; return; } try { const r = await authFetch(`${API_BASE}/api/developer/dashboard`); const data = await r.json(); if (data.error) { const isAdmin = authState.user?.role === 'admin' || authState.user?.role === 'superadmin'; const friendly = data.error === 'Insufficient tier' ? (isAdmin ? 'Backend tier-check is rejecting your admin role. Restart the API server so the latest middleware bypass is loaded.' : 'The Developer Console is available on Pro tier and above. Visit Pricing to upgrade.') : data.error; container.innerHTML = `
${friendly}
`; return; } const s = data.summary; // First-run / zero-data state — show an attractive promo instead of // a wall of zeros. Triggers when the developer has never submitted. if (!s.total_submissions && !data.games.length && !data.submissions.length) { container.innerHTML = `
Welcome to the Console

Your dashboard lights up the moment you ship.

Submit your first Chia P2P game and this page fills with live telemetry — rooms created, matches played, unique players, ratings, and on-chain stake totals across every match.

Real-time telemetry
Per-match analytics from every room
Player ratings
Five-star reviews from every player
On-chain proof
Cryptographic stake settlement
Need an SDK or templates? Open developer docs →
`; // Inject the firstrun styles once if (!document.getElementById('dd2FirstRunStyles')) { const st = document.createElement('style'); st.id = 'dd2FirstRunStyles'; st.textContent = ` #devdash-page .dd2-firstrun{position:relative;overflow:hidden;background:linear-gradient(160deg,rgba(11,21,32,0.95),rgba(7,16,26,0.92));border:1px solid rgba(58,196,229,0.18);border-radius:20px;padding:56px 48px;text-align:center;box-shadow:0 30px 80px -30px rgba(0,0,0,0.7)} #devdash-page .dd2-firstrun-glow{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse 600px 280px at 20% 0%,rgba(58,196,229,0.18),transparent 70%),radial-gradient(ellipse 600px 280px at 80% 100%,rgba(236,53,148,0.16),transparent 70%),radial-gradient(ellipse 400px 200px at 50% 50%,rgba(249,229,0,0.06),transparent 70%)} #devdash-page .dd2-firstrun > *{position:relative;z-index:1} #devdash-page .dd2-firstrun-eyebrow{display:inline-block;font-family:var(--ff-display);font-size:10px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--cyan);padding:5px 14px;border:1px solid rgba(58,196,229,0.30);border-radius:999px;margin-bottom:18px;background:rgba(58,196,229,0.06)} #devdash-page .dd2-firstrun-title{font-family:var(--ff-display);font-size:clamp(28px,3.6vw,42px);font-weight:900;line-height:1.1;letter-spacing:-0.8px;color:#fff;margin:0 0 14px;max-width:680px;margin-left:auto;margin-right:auto} #devdash-page .dd2-firstrun-accent{background:linear-gradient(135deg,var(--cyan),var(--yellow),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;filter:drop-shadow(0 0 28px rgba(58,196,229,0.25))} #devdash-page .dd2-firstrun-sub{font-family:var(--ff);font-size:15px;color:rgba(255,255,255,0.6);line-height:1.65;max-width:580px;margin:0 auto 32px} #devdash-page .dd2-firstrun-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:32px} #devdash-page .dd2-firstrun-feat{background:rgba(4,8,14,0.45);border:1px solid rgba(58,196,229,0.10);border-radius:12px;padding:18px 16px;text-align:left} #devdash-page .dd2-firstrun-feat-icon{font-size:22px;margin-bottom:8px;line-height:1} #devdash-page .dd2-firstrun-feat-title{font-family:var(--ff-display);font-size:13px;font-weight:800;color:#fff;letter-spacing:-0.2px;margin-bottom:4px} #devdash-page .dd2-firstrun-feat-sub{font-family:var(--ff);font-size:12px;color:rgba(255,255,255,0.45);line-height:1.4} #devdash-page .dd2-firstrun-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:18px} #devdash-page .dd2-cta-ghost{font-family:var(--ff-display);font-weight:700;font-size:12px;letter-spacing:1.5px;text-transform:uppercase;padding:13px 22px;border-radius:8px;border:1px solid rgba(58,196,229,0.30);background:rgba(11,21,32,0.6);color:#fff;cursor:pointer;transition:border-color .18s ease, background .18s ease} #devdash-page .dd2-cta-ghost:hover{border-color:var(--cyan);background:rgba(58,196,229,0.06)} #devdash-page .dd2-firstrun-foot{font-family:var(--ff);font-size:13px;color:rgba(255,255,255,0.45)} @media(max-width:768px){#devdash-page .dd2-firstrun{padding:40px 24px}#devdash-page .dd2-firstrun-stats{grid-template-columns:1fr;gap:10px}} `; document.head.appendChild(st); } return; } container.innerHTML = `
${s.total_submissions}
Submissions
${s.approved}
Approved
${s.pending}
Pending Review
${s.rejected}
Rejected
Live Games
Your Approved Games
${data.games.length ? `
${data.games.map(g => `
${escHtml(g.game_name)}
v${escHtml(g.version)}
${g.stats.rooms_created}
Rooms
${g.stats.total_matches}
Matches
${g.stats.unique_players}
Players
${g.rating.count > 0 ? g.rating.avg + '/5' : '—'}
Rating
${(g.stats.total_wager_mojos / 1e12).toFixed(2)}
XCH Staked
`).join('')}
` : `
🎮
No approved games yet
Ship your first game to get it listed on Arcade21. Once approved, this dashboard fills with live telemetry from every match.
`} ${data.submissions.length ? `
History
All Submissions
${data.submissions.map(sub => `
${escHtml(sub.game_name)}
v${escHtml(sub.version||'1.0')} · Submitted ${getTimeAgo(sub.created_at)}${sub.reviewer_notes ? ' · ' + escHtml(sub.reviewer_notes) : ''}
${sub.status.replace('_',' ')}
`).join('')}
` : ''} `; } catch(e) { console.error('[DEV DASH]', e); container.innerHTML = '
Failed to load dashboard. Please try refreshing.
'; } } /* ══════════════════════════════════════════════════════════════════ COMMUNITY HUB (Phase A) — /players page ClickUp: https://app.clickup.com/t/86ba0wfgx Backend: routes/community.js · GET /api/community/snapshot ══════════════════════════════════════════════════════════════════ */ const CH = { snapshot: null, feedTab: 'global', // global | following | you discoverFilter: 'all', // all | founding | pro | new searchTimer: null }; function chEsc(s){ return esc(s == null ? '' : String(s)); } function chInitial(name){ return ((name || '?')[0] || '?').toUpperCase(); } function chAvatarHtml(user, sizeClass){ if (!user) return `
?
`; const display = user.display_name || user.username || ''; const initial = chInitial(display); if (user.avatar_url) { return `
`; } return `
${chEsc(initial)}
`; } function chAgo(unixSec){ if (!unixSec) return ''; const s = Math.max(1, Math.floor(Date.now()/1000) - Number(unixSec)); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s/60)}m ago`; if (s < 86400) return `${Math.floor(s/3600)}h ago`; if (s < 7*86400) return `${Math.floor(s/86400)}d ago`; return new Date(unixSec * 1000).toLocaleDateString('en-US',{month:'short',day:'numeric'}); } function chViewProfile(username){ if (typeof viewPlayer === 'function') viewPlayer(username); } /* ── Master loader ──────────────────────────────────────────── */ async function chLoadCommunity(){ try { const offset = 0; // authFetch attaches the access token AND transparently refreshes it on a 401 // (the snapshot now 401s on an expired/invalid token instead of silently // returning anonymous data). This is what keeps follow-state accurate after // the 15-min access token rolls over mid-session — otherwise iFollow comes // back empty and every Discover card wrongly shows "+ Follow". const r = await authFetch(`${API_BASE}/api/community/snapshot?filter=${CH.discoverFilter}&offset=${offset}&limit=18`, { cache: 'no-store' }); if (!r.ok) throw new Error('snapshot failed'); const data = await r.json(); CH.snapshot = data; chRenderHeroStats(data); chRenderOnline(data.onlineNow); chRenderTop(data.topThisWeek); chRenderTrending(data.trending); chRenderFeed(); chRenderWaitlist(data.waitlistGhost, data.totals); chRenderDiscover(data.discoverPlayers); } catch (err) { console.warn('[CH] load failed:', err); } } /* ── Hero stats ─────────────────────────────────────────────── */ function chRenderHeroStats(d){ const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = (val||0).toLocaleString(); }; setText('chStatUsers', d.totals?.users); setText('chStatFounding', d.totals?.founding); setText('chStatWaitlist', d.totals?.waitlist); setText('chStatOnline', (d.onlineNow || []).length); } /* ── Online Now ─────────────────────────────────────────────── */ function chRenderOnline(users){ const rail = document.getElementById('chOnlineRail'); if (!rail) return; if (!users || !users.length) { rail.innerHTML = '
No one online right now — be the first.
'; return; } rail.innerHTML = users.map(u => { const display = u.display_name || u.username || ''; const avBg = u.avatar_url ? `background-image:url('${chEsc(u.avatar_url)}')` : ''; const title = `${display} (@${u.username || ''})`; return `
${u.avatar_url ? '' : chInitial(display)}
`; }).join(''); } /* ── Top This Week ─────────────────────────────────────────── */ function chRenderTop(users){ const grid = document.getElementById('chTopGrid'); if (!grid) return; if (!users || !users.length) { grid.innerHTML = '
No ranked matches yet this week. Start a room — be the first to climb.
'; return; } grid.innerHTML = users.map((u, i) => { const display = u.display_name || u.username || ''; const avBg = u.avatar_url ? `background-image:url('${chEsc(u.avatar_url)}')` : ''; return `
#${i+1}
${u.avatar_url ? '' : chInitial(display)}
${chEsc(display)}
@${chEsc(u.username || '')}
${(u.top_elo||1000).toLocaleString()} ELO${(u.total_games||0).toLocaleString()} games
`; }).join(''); } /* ── Trending Now ─────────────────────────────────────────── */ function chRenderTrending(t){ if (!t) return; const gamesEl = document.getElementById('chTrendGames'); if (gamesEl) { const games = t.games || []; if (!games.length) gamesEl.innerHTML = '
No matches yet this week
'; else gamesEl.innerHTML = games.map(g => `
${chEsc(g.game || 'Unknown')}${g.event_count} matches
` ).join(''); } const tourEl = document.getElementById('chTrendTournaments'); if (tourEl) { const tours = t.tournaments || []; if (!tours.length) tourEl.innerHTML = '
No tournaments live
'; else tourEl.innerHTML = tours.map(t => `
${chEsc(t.name)}${t.player_count} signed
` ).join(''); } const tagEl = document.getElementById('chTrendHashtags'); if (tagEl) { const tags = t.hashtags || []; if (!tags.length) tagEl.innerHTML = '
No tags yet
'; else tagEl.innerHTML = tags.map(h => `${chEsc(h.tag)} ${h.count}` ).join(''); } } /* ── Activity Feed ─────────────────────────────────────────── */ function chSetFeedTab(tab){ CH.feedTab = tab; document.querySelectorAll('#players-page .ch-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); chRenderFeed(); } function chFeedItemText(item){ const ev = item.event_type || ''; const d = item.details || {}; const u = item.user; const userTag = u ? `${chEsc(u.display_name || u.username || '')}` : 'Someone'; switch(ev){ case 'match_win': return `${userTag} won ${d.opponent ? 'vs ' + chEsc(d.opponent) : ''} ${d.game ? 'in ' + chEsc(d.game) + '' : ''}`; case 'match_loss': return `${userTag} played ${d.opponent ? 'vs ' + chEsc(d.opponent) : ''} ${d.game ? 'in ' + chEsc(d.game) + '' : ''}`; case 'room_created': return `${userTag} opened a room${d.game ? ' in ' + chEsc(d.game) + '' : ''}`; case 'achievement': return `${userTag} unlocked ${chEsc(d.name || 'an achievement')}`; case 'tier_upgrade': return `${userTag} upgraded to ${chEsc(d.tier || 'a paid tier')}`; case 'tournament_join': return `${userTag} joined ${chEsc(d.tournament || 'a tournament')}`; case 'follow': return `${userTag} followed ${chEsc(d.target_username || 'someone')}`; default: return `${userTag} ${chEsc(ev || 'did something')}`; } } function chRenderFeed(){ const feedEl = document.getElementById('chFeed'); if (!feedEl || !CH.snapshot) return; const all = CH.snapshot.activityFeed || []; const me = authState?.user?.id; const iFollow = new Set((CH.snapshot.iFollow || []).map(Number)); let items; if (CH.feedTab === 'following') { items = all.filter(a => a.user && (a.user.id === me || iFollow.has(Number(a.user.id)))); if (!items.length && !authState?.user) { feedEl.innerHTML = '
Sign in to see what people you follow are up to.
'; return; } } else if (CH.feedTab === 'you') { if (!authState?.user) { feedEl.innerHTML = '
Sign in to see your own activity here.
'; return; } items = all.filter(a => a.user && a.user.id === me); } else { items = all; } if (!items.length) { feedEl.innerHTML = '
Nothing here yet.
'; return; } feedEl.innerHTML = items.slice(0, 30).map(item => { const u = item.user; const avBg = u && u.avatar_url ? `background-image:url('${chEsc(u.avatar_url)}')` : ''; const initial = u ? chInitial(u.display_name || u.username || '') : '?'; const likedClass = item.i_liked ? ' liked' : ''; return `
${u && u.avatar_url ? '' : chEsc(initial)}
${chFeedItemText(item)}
${chEsc(chAgo(item.created_at))}
`; }).join(''); } async function chToggleLike(activityId, btn){ if (!authState?.user) { showPage('login'); return; } // Optimistic update const countEl = btn.querySelector('.ch-like-count'); const heartEl = btn.querySelector('.heart'); const isLiked = btn.classList.contains('liked'); btn.classList.toggle('liked'); if (heartEl) heartEl.textContent = isLiked ? '♡' : '♥'; if (countEl) countEl.textContent = String(Math.max(0, (parseInt(countEl.textContent,10)||0) + (isLiked ? -1 : 1))); try { const r = await authFetch(`${API_BASE}/api/community/like`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ activityId }) }); if (!r.ok) throw new Error('like failed'); const data = await r.json(); if (countEl) countEl.textContent = String(data.likes); btn.classList.toggle('liked', !!data.liked); if (heartEl) heartEl.textContent = data.liked ? '♥' : '♡'; } catch(err) { // Revert optimistic update btn.classList.toggle('liked'); if (heartEl) heartEl.textContent = btn.classList.contains('liked') ? '♥' : '♡'; if (countEl) countEl.textContent = String(Math.max(0, (parseInt(countEl.textContent,10)||0) + (isLiked ? 1 : -1))); } } /* ── Waitlist Ghost Rail ───────────────────────────────────── */ function chRenderWaitlist(rail, totals){ const railEl = document.getElementById('chWaitlistRail'); const countEl = document.getElementById('chWaitlistCount'); if (countEl) countEl.textContent = (totals?.waitlist || 0).toLocaleString(); if (!railEl) return; if (!rail || !rail.length) { railEl.innerHTML = '
No waitlist signups yet
'; return; } railEl.innerHTML = rail.slice(0, 30).map(w => `
${chEsc(w.initial)}
` ).join(''); } /* ── Discover Players ──────────────────────────────────────── */ function chSetDiscoverFilter(filter){ CH.discoverFilter = filter; document.querySelectorAll('#players-page .ch-discover-tab').forEach(b => b.classList.toggle('active', b.dataset.filter === filter)); chReloadDiscover(); } async function chReloadDiscover(){ const grid = document.getElementById('chDiscoverGrid'); if (!grid) return; grid.innerHTML = '
'.repeat(6); try { // authFetch so the filtered re-fetch is authenticated too (it used to be // anonymous, which dropped follow-state). Keep CH.snapshot.iFollow in sync // from the authed response so chRenderDiscover marks already-followed players. const r = await authFetch(`${API_BASE}/api/community/snapshot?filter=${CH.discoverFilter}&offset=0&limit=18`, { cache: 'no-store' }); const data = await r.json(); if (CH.snapshot) CH.snapshot.iFollow = data.iFollow || CH.snapshot.iFollow || []; else CH.snapshot = data; chRenderDiscover(data.discoverPlayers || []); } catch(_) { grid.innerHTML = '
Could not load players
'; } } function chRenderDiscover(players){ const grid = document.getElementById('chDiscoverGrid'); if (!grid) return; if (!players || !players.length) { grid.innerHTML = '
No matching players
'; return; } const meId = authState?.user?.id; const meUsername = authState?.user?.username; const iFollow = new Set((CH.snapshot?.iFollow || []).map(Number)); grid.innerHTML = players.map(u => { const display = u.display_name || u.username || ''; const avBg = u.avatar_url ? `background-image:url('${chEsc(u.avatar_url)}')` : ''; const initial = chInitial(display); const pills = []; if (u.is_founding_member) pills.push('Founding ⭐'); if (u.tier && u.tier !== 'free') pills.push(`${chEsc(u.tier)}`); // Follow button — only on OTHER users + only when authenticated const isMe = meId && Number(u.id) === Number(meId); const isFollowing = iFollow.has(Number(u.id)); let followBtn = ''; if (!isMe) { if (authState?.user) { followBtn = ``; } else { followBtn = ``; } } else { followBtn = `You`; } return `
${u.avatar_url ? '' : chEsc(initial)}
${chEsc(display)}
@${chEsc(u.username || '')}
${pills.length ? `
${pills.join('')}
` : ''}
${u.top_elo ? `${u.top_elo.toLocaleString()} ELO` : ''}${u.total_games ? `${u.total_games} games` : ''}${u.followers || 0} followers
${followBtn}
`; }).join(''); } async function chToggleFollow(btn){ if (!authState?.user) { showPage('login'); return; } const username = btn.dataset.username; const userId = Number(btn.dataset.userid); if (!username || !userId) return; const wasFollowing = btn.classList.contains('following'); // Optimistic flip btn.classList.toggle('following'); btn.textContent = wasFollowing ? '+ Follow' : '✓ Following'; btn.disabled = true; // Update follower count inline (optimistic) const countSpan = document.querySelector(`[data-followers-of="${userId}"] b`); if (countSpan) { const curr = parseInt(countSpan.textContent, 10) || 0; countSpan.textContent = String(Math.max(0, curr + (wasFollowing ? -1 : 1))); } try { const method = wasFollowing ? 'DELETE' : 'POST'; const r = await authFetch(`${API_BASE}/api/players/${encodeURIComponent(username)}/follow`, { method }); if (!r.ok) throw new Error('follow failed'); const data = await r.json(); // Confirm with server-returned count if (countSpan && typeof data.follower_count === 'number') countSpan.textContent = String(data.follower_count); // Update in-memory iFollow set so Following feed tab + future re-renders are in sync if (CH.snapshot) { const set = new Set((CH.snapshot.iFollow || []).map(Number)); if (wasFollowing) set.delete(userId); else set.add(userId); CH.snapshot.iFollow = [...set]; } } catch(err) { // Revert optimistic update btn.classList.toggle('following'); btn.textContent = wasFollowing ? '✓ Following' : '+ Follow'; if (countSpan) { const curr = parseInt(countSpan.textContent, 10) || 0; countSpan.textContent = String(Math.max(0, curr + (wasFollowing ? 1 : -1))); } } finally { btn.disabled = false; } } /* ── Search ────────────────────────────────────────────────── */ function chDebouncedSearch(){ clearTimeout(CH.searchTimer); CH.searchTimer = setTimeout(chDoSearch, 250); } async function chDoSearch(){ const q = document.getElementById('chSearchInput')?.value?.trim(); const container = document.getElementById('chSearchResults'); if (!container) return; if (!q || q.length < 2) { container.innerHTML = ''; return; } try { const r = await fetch(`${API_BASE}/api/players/search?q=${encodeURIComponent(q)}&limit=10`); const data = await r.json(); if (!data.players?.length) { container.innerHTML = '
No players found for "' + chEsc(q) + '"
'; return; } container.innerHTML = '
' + data.players.map(p => { const display = p.display_name || p.username || ''; const avBg = p.avatar_url ? `background-image:url('${chEsc(p.avatar_url)}')` : ''; return `
${p.avatar_url ? '' : chEsc(chInitial(display))}
${chEsc(display)}
@${chEsc(p.username)} · ${p.follower_count || 0} followers · ${chEsc(p.tier)}
`; }).join('') + '
'; } catch(_) { container.innerHTML = '
Search failed
'; } } /* Wire discover-filter chip clicks */ document.addEventListener('click', (e) => { const chip = e.target.closest && e.target.closest('#players-page .ch-discover-tab'); if (!chip) return; chSetDiscoverFilter(chip.dataset.filter); }); /* ══════════════════════════════════════════════════════════════════ END COMMUNITY HUB ══════════════════════════════════════════════════════════════════ */ /* ══ PLAYER PROFILES & SOCIAL ══ */ let _playerSearchTimer = null; function debouncePlayerSearch() { clearTimeout(_playerSearchTimer); _playerSearchTimer = setTimeout(doPlayerSearch, 300); } async function doPlayerSearch() { const q = document.getElementById('playerSearchInput')?.value?.trim(); const container = document.getElementById('playerSearchResults'); if (!container) return; if (!q || q.length < 2) { container.innerHTML = ''; return; } try { const r = await fetch(`${API_BASE}/api/players/search?q=${encodeURIComponent(q)}&limit=10`); const data = await r.json(); if (!data.players?.length) { container.innerHTML = '
No players found
'; return; } container.innerHTML = data.players.map(p => `
${(p.display_name || p.username)[0].toUpperCase()}
${escHtml(p.display_name || p.username)}
@${escHtml(p.username)} · ${p.follower_count || 0} followers · ${p.tier}
${p.tier}
`).join(''); } catch(e) { container.innerHTML = '
Search failed
'; } } function viewPlayer(username) { window._viewPlayerUsername = username; showPage('player'); } async function loadPlayerProfile(username) { const container = document.getElementById('playerContent'); if (!container) return; if (!username) { container.innerHTML = '
Player not found
'; return; } container.innerHTML = '
Loading
'; try { const r = await fetch(`${API_BASE}/api/players/${encodeURIComponent(username)}`, { headers: authState.accessToken ? { 'Authorization': `Bearer ${authState.accessToken}` } : {} }); if (!r.ok) { container.innerHTML = '
Player not found
'; return; } const data = await r.json(); const p = data.profile; const initial = (p.display_name || p.username)[0].toUpperCase(); const memberDate = new Date(p.member_since * 1000).toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); const isMe = authState.user && authState.user.username === username; container.innerHTML = `
${p.avatar_url ? `` : initial}
@${escHtml(p.username)}
${p.display_name ? `
${escHtml(p.display_name)}
` : ''} ${p.bio ? `
${escHtml(p.bio)}
` : ''}
${p.tier} ${p.is_founding_member ? '' : ''} ${p.tier === 'pro' ? 'PRO' : ''} ${p.tier === 'studio' ? 'STUDIO' : ''} ${p.tier === 'enterprise' ? 'ENTERPRISE' : ''} ${data.badges.map(b => { const m = b.category ? b : badgeMeta(b.badge_type); return `${m.icon||'◆'} ${m.label||b.badge_type}`; }).join('')}
${p.follower_count}
Followers
${p.following_count}
Following
${p.total_matches}
Matches
${memberDate}
Joined
${!isMe && authState.user ? `
` : ''} ${isMe ? `
` : ''}
${p.total_matches}
Total Matches
${p.wins}
Wins
${p.losses}
Losses
${p.win_rate}%
Win Rate
${data.badges.length ? `
🏅 Badges ${data.badges.length}
${data.badges.map(b => { const m = b.category ? b : badgeMeta(b.badge_type); const dateStr = b.awarded_at ? new Date(b.awarded_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short' }) : ''; return `
${m.icon||'◆'}
${m.label||b.badge_type}
${dateStr ? `
${dateStr}
` : ''}
`; }).join('')}
` : ''}
📋 Recent Activity
${data.recentActivity.length ? data.recentActivity.map(a => renderActivityItem(a, p)).join('') : '
No activity yet
'}
`; } catch(e) { console.error('[PLAYER]', e); container.innerHTML = '
Failed to load profile
'; } } const BADGE_META = { // onboarding just_showing_up: { icon: '🕹️', label: 'Just Showing Up', category: 'onboarding', lore: 'Every legend starts with showing up.' }, fully_loaded: { icon: '⚡', label: 'Fully Loaded', category: 'onboarding', lore: 'Name, face, story. You are real now.' }, // combat first_blood: { icon: '⚔️', label: 'First Blood', category: 'combat', lore: 'The first win is the sweetest.' }, hat_trick: { icon: '🎩', label: 'Hat Trick', category: 'combat', lore: 'Three in a row. The crowd noticed.' }, on_fire: { icon: '🔥', label: 'On Fire', category: 'combat', lore: 'Five straight. You are not running hot -- you are the flame.' }, unstoppable: { icon: '💥', label: 'Unstoppable', category: 'combat', lore: 'Ten in a row. You are a force of nature.' }, // grind veteran: { icon: '🛡️', label: 'Veteran', category: 'grind', lore: '100 games in. Most people never make it this far.' }, grinder: { icon: '⚙️', label: 'Grinder', category: 'grind', lore: '500 games. This is not a hobby anymore.' }, jack_of_all_games: { icon: '🃏', label: 'Jack of All Games', category: 'grind', lore: 'You have played everything the arcade has to offer.' }, // social social_butterfly: { icon: '🦋', label: 'Social Butterfly', category: 'social', lore: 'You brought someone in. The club grows because of you.' }, recruiter: { icon: '🫂', label: 'Recruiter', category: 'social', lore: 'Five players walk in because of you.' }, // seasonal season_champion: { icon: '🏆', label: 'Season Champion', category: 'seasonal', lore: 'Top 3. When the season closed, your name was at the top.' }, season_elite: { icon: '⭐', label: 'Season Elite', category: 'seasonal', lore: 'Top 10. The leaderboard knows your name.' }, season_contender: { icon: '🎖️', label: 'Season Contender', category: 'seasonal', lore: 'Top 50 at season end. Not everyone makes the cut.' }, // special og_founding_tester: { icon: '👑', label: 'OG Founding Beta Tester', category: 'special', lore: 'You were here before the doors opened. One of the first 500.' }, // admin custom: { icon: '✦', label: 'Custom', category: 'admin', lore: 'A special recognition from the Arcade21 team.' }, // legacy slugs (backward compat) early_adopter: { icon: '🌟', label: 'Early Adopter', category: 'special', lore: '' }, top10: { icon: '🏆', label: 'Top 10', category: 'seasonal', lore: '' }, first_win: { icon: '⚔️', label: 'First Win', category: 'combat', lore: '' }, streak_5: { icon: '🔥', label: '5-Win Streak', category: 'combat', lore: '' }, whale: { icon: '🐋', label: 'High Roller', category: 'special', lore: '' }, }; function badgeMeta(type) { if (type && type.startsWith('level_')) { const n = parseInt(type.split('_')[1], 10); const icon = n >= 19 ? '🌌' : n >= 17 ? '👑' : n >= 14 ? '💎' : n >= 10 ? '⬡' : n >= 6 ? '◇' : '◆'; return { icon, label: `Level ${n}`, category: 'level', lore: `Reached Level ${n}.` }; } return BADGE_META[type] || { icon: '◆', label: type.replace(/_/g, ' '), category: '', lore: '' }; } function badgeLabel(type) { const m = badgeMeta(type); return `${m.icon} ${m.label}`; } function renderActivityItem(a, fallbackUser) { const user = a.username || fallbackUser?.username || 'Unknown'; const initial = (a.display_name || user)[0].toUpperCase(); const timeAgo = getTimeAgo(a.created_at); let text = ''; const details = a.details ? (typeof a.details === 'string' ? (() => { try { return JSON.parse(a.details); } catch(e) { return {}; } })() : a.details) : {}; const uLink = `${escHtml(user)}`; const targetLink = details.target_username ? `@${escHtml(details.target_username)}` : 'a player'; switch(a.event_type) { case 'match_win': text = `${uLink} won a match${details.game ? ' in ' + escHtml(details.game) : ''}`; break; case 'match_loss': text = `${uLink} played a match${details.game ? ' in ' + escHtml(details.game) : ''}`; break; case 'room_created': text = `${uLink} created a game room`; break; case 'achievement': text = `${uLink} earned: ${details.name || 'achievement'}`; break; case 'tier_upgrade': text = `${uLink} upgraded to ${details.tier || 'premium'}`; break; case 'tournament_join': text = `${uLink} joined a tournament`; break; case 'follow': text = `${uLink} followed ${targetLink}`; break; default: text = `${uLink} did something`; } return `
${initial}
${text}
${timeAgo}
`; } function getTimeAgo(epoch) { const diff = Math.floor(Date.now() / 1000) - epoch; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; } async function toggleFollow(username, isCurrentlyFollowing) { const btn = document.getElementById('followBtn'); if (!btn) return; try { const r = await authFetch(`${API_BASE}/api/players/${encodeURIComponent(username)}/follow`, { method: isCurrentlyFollowing ? 'DELETE' : 'POST' }); const data = await r.json(); if (data.success) { btn.className = `player-follow-btn ${isCurrentlyFollowing ? 'follow' : 'unfollow'}`; btn.textContent = isCurrentlyFollowing ? 'Follow' : 'Following'; btn.onclick = () => toggleFollow(username, !isCurrentlyFollowing); // Update follower count in UI loadPlayerProfile(username); } } catch(e) { console.error('Follow error:', e); } } async function loadPlayerStats() { try { const r = await fetch(`${API_BASE}/api/players/stats`); const d = await r.json(); const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; }; el('pStatTotal', d.totalPlayers != null ? d.totalPlayers.toLocaleString() : '--'); el('pStatActive', d.activeThisWeek != null ? d.activeThisWeek.toLocaleString() : '--'); el('pStatMatches', d.matchesPlayed != null ? d.matchesPlayed.toLocaleString() : '--'); } catch(e) { /* leave -- if fetch fails */ } } async function loadGlobalFeed() { const container = document.getElementById('globalActivityFeed'); if (!container) return; try { const r = await fetch(`${API_BASE}/api/feed/global?limit=20`); const data = await r.json(); if (!data.feed?.length) { container.innerHTML = '
No activity yet. Play some games!
'; return; } container.innerHTML = data.feed.map(a => renderActivityItem(a)).join(''); } catch(e) { container.innerHTML = '
Failed to load feed
'; } } /* ══ SUBSCRIPTION PAYMENT FLOW ══ */ let _payPollTimer = null; let _payExpiresAt = 0; let _payCountdownTimer = null; // Supporter tier — manual XCH donation flow // ── DONATE CARD (community-wallet address + per-user memo) ────────────── // Swap ARCADE21_DONATE_XCH_ADDRESS below for the real receive address when // the dedicated donation wallet is provisioned. Memos are per-user so we // can attribute incoming transactions and award the Supporter badge. window.ARCADE21_DONATE_XCH_ADDRESS = window.ARCADE21_DONATE_XCH_ADDRESS || 'xch1donate-address-not-yet-configured-replace-me'; function _a21UserDonateMemo(){ try{ const u = (typeof authState !== 'undefined' && authState && authState.user) ? authState.user : null; if (u && u.id) return 'A21-SUP-' + String(u.id).padStart(6,'0'); if (u && u.userId) return 'A21-SUP-' + String(u.userId).padStart(6,'0'); if (u && u.username) return 'A21-SUP-' + String(u.username).toLowerCase().replace(/[^a-z0-9]/g,'').slice(0,16); }catch(_){} return 'A21-SUP-ANON (sign in for credit)'; } function renderDonateCard(){ const addrEl = document.getElementById('donateAddr'); const memoEl = document.getElementById('donateMemo'); const help = document.getElementById('donateMemoHelp'); if (!addrEl || !memoEl) return; addrEl.textContent = window.ARCADE21_DONATE_XCH_ADDRESS; memoEl.textContent = _a21UserDonateMemo(); // Tweak help text for anon users. try{ const u = (typeof authState !== 'undefined' && authState && authState.user) ? authState.user : null; if (!u && help){ help.innerHTML = 'No account yet? Sign up first so we can credit your donation with a Supporter badge.'; } }catch(_){} } function copyDonateField(which){ const el = document.getElementById(which === 'addr' ? 'donateAddr' : 'donateMemo'); const toast = document.getElementById('donateCopiedToast'); if (!el) return; const txt = el.textContent || ''; const done = () => { if (toast){ toast.textContent = (which === 'addr' ? 'Address' : 'Memo') + ' copied to clipboard'; toast.style.opacity = '1'; clearTimeout(window._a21DonToastT); window._a21DonToastT = setTimeout(()=>{ toast.style.opacity = '0'; }, 2200); } }; if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(txt).then(done).catch(()=>{ // Fallback const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); done(); }catch(_){} document.body.removeChild(ta); }); } else { const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); done(); }catch(_){} document.body.removeChild(ta); } } // Hook into existing showPage flow so the card refreshes whenever Pricing opens. (function(){ const _origShow = (typeof showPage === 'function') ? showPage : null; if (!_origShow) return; window.showPage = function(pageId){ const r = _origShow.apply(this, arguments); if (pageId === 'pricing') { try{ renderDonateCard(); }catch(_){} } return r; }; })(); // First paint (in case pricing is the initial route). document.addEventListener('DOMContentLoaded', () => { try{ renderDonateCard(); }catch(_){} }); function openSupporterDonation() { // Read live XCH spot for $5 conversion if available let xchEquiv = '~1.9 XCH'; try { const supXch = document.getElementById('supporterXch'); if (supXch && supXch.textContent) { const m = supXch.textContent.match(/([\d.]+)\s*XCH/i); if (m) xchEquiv = '~' + m[1] + ' XCH'; } } catch(_){} // Build modal let existing = document.getElementById('supporterDonateModal'); if (existing) { existing.remove(); } const wallet = 'xch1lhpevvkxngwuhjnu8xs4ycd67fxagq6jar3tz2l03f2ccqg7x02sce3x74'; const modal = document.createElement('div'); modal.id = 'supporterDonateModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px;animation:fadeIn .2s ease-out'; modal.innerHTML = `
Supporter · The Tip Jar

Thanks for keeping us alive

Send ${xchEquiv} (about $5 USD) to our wallet. Use the memo below so we can credit your account and apply your Supporter badge.

Send to (XCH wallet)
${wallet}
Include this memo
supporter:${(authState && authState.user && authState.user.username) ? authState.user.username : '[your-username]'}
Once received we'll apply your Supporter badge and post a thank-you on the next monthly recap. Donations are non-refundable and do not grant tier perks beyond cosmetic recognition.
Email us when sent →
`; document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); } // Compute Supporter $5 in XCH from the live spot price banner function updateSupporterXch() { try { const banner = document.getElementById('xchPriceLabel'); const sup = document.getElementById('supporterXch'); if (!banner || !sup) return; const m = (banner.textContent || '').match(/\$([\d.]+)/); if (!m) return; const xchUsd = parseFloat(m[1]); if (!xchUsd || xchUsd <= 0) return; const xch = (5 / xchUsd).toFixed(2); sup.textContent = '≈ ' + xch + ' XCH / month'; } catch(_){} } function startSubscription(tier, btnEl) { if (!authState.user) { showPage('login'); return; } const btn = btnEl || (typeof event !== 'undefined' ? event.target : null); const origText = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'Redirecting to Stripe'; btn.disabled = true; } authFetch('/api/subscribe/checkout/stripe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tier }) }) .then(async (r) => { const contentType = (r.headers.get('content-type') || '').toLowerCase(); const raw = await r.text(); let data = null; if (contentType.includes('application/json')) { try { data = JSON.parse(raw); } catch (_) { data = null; } } else { try { data = JSON.parse(raw); } catch (_) { data = null; } } if (!r.ok) { throw new Error((data && data.error) || (r.status === 401 || r.status === 403 ? 'Your session is not authorized. Please sign in again and retry.' : `Stripe checkout failed (HTTP ${r.status}).`)); } if (!data || !data.checkout_url) { if (/ { window.location.href = data.checkout_url; }) .catch(err => { if (btn) { btn.textContent = origText; btn.disabled = false; } alert((err && err.message) ? err.message : 'Failed to start Stripe checkout. Please try again.'); console.error('[STRIPE CHECKOUT]', err); }); } function showPaymentModal(data, tier) { const tierNames = { pro: 'Pro', studio: 'Studio', enterprise: 'Enterprise' }; const tierXch = { pro: 7.55, studio: 18.87, enterprise: 37.73 }; const tierUsd = { pro: xchToUsd(7.55) || '≈ $19.99 USD', studio: xchToUsd(18.87) || '≈ $49.99 USD', enterprise: xchToUsd(37.73) || '≈ $99.99 USD' }; let overlay = document.getElementById('payModalOverlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'payModalOverlay'; overlay.className = 'pay-modal-overlay'; document.body.appendChild(overlay); } overlay.innerHTML = `
${tierNames[tier] || tier} Subscription
Complete Your Payment
${data.amount_xch} XCH
≈ ${tierUsd[tier] || ''} USD / month
Send To Address
${data.address}
Memo (Required)
${data.memo}
Exact Amount
${data.amount_xch} XCH
⚠️ Send the exact amount with the exact memo in a single transaction. Your tier will activate automatically once the transaction confirms on-chain (usually 1-2 minutes).
Expires in ${Math.floor(data.expires_in / 60)}:${String(data.expires_in % 60).padStart(2, '0')}
⏳ Waiting for payment
`; overlay.classList.add('active'); overlay.addEventListener('click', (e) => { if (e.target === overlay) closePaymentModal(); }); // Render wallet section const walletSection = document.getElementById('payWalletSection'); if (walletSection) { if (walletState.connected) { const wName = walletState.type === 'goby' ? 'Goby' : 'Sage'; const wIcon = walletState.type === 'goby' ? '/images/goby.png' : '/images/sage.png'; walletSection.innerHTML = `
or send manually below
`; document.getElementById('payWithWalletBtn').onclick = function() { payWithConnectedWallet(data.address, data.amount_mojos, data.memo); }; } else { walletSection.innerHTML = `
or send manually below
`; document.getElementById('connectWalletPayBtn').onclick = function() { showWalletModal(); }; } } // Start polling for confirmation _payExpiresAt = Date.now() + (data.expires_in * 1000); startPaymentPolling(); startPaymentCountdown(); } async function payWithConnectedWallet(toAddress, amountMojos, memo) { const btn = document.getElementById('payWithWalletBtn'); if (btn) { btn.disabled = true; btn.innerHTML = 'Sending transaction'; } try { const result = await sendXchWithWallet(toAddress, amountMojos, memo); if (result.success) { const status = document.getElementById('payStatus'); if (status) { status.className = 'pay-modal-status checking'; status.textContent = '✅ Transaction sent! Waiting for on-chain confirmation'; } if (btn) { btn.innerHTML = '✅ Transaction Sent'; btn.style.opacity = '0.6'; } } else { alert('Transaction failed: ' + (result.error || 'Unknown error')); if (btn) { btn.disabled = false; btn.innerHTML = ` Retry Payment`; } } } catch(e) { alert('Transaction error: ' + e.message); if (btn) { btn.disabled = false; btn.innerHTML = ` Retry Payment`; } } } function copyPayField(id) { const el = document.getElementById(id); if (!el) return; // Get just the text, not the button text const text = el.childNodes[0].textContent.trim(); navigator.clipboard.writeText(text).then(() => { const btn = el.querySelector('.pay-modal-copy'); if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); } }); } function startPaymentPolling() { if (_payPollTimer) clearInterval(_payPollTimer); _payPollTimer = setTimeout(function poll() { if (Date.now() > _payExpiresAt) { const st = document.getElementById('payStatus'); if (st) { st.className = 'pay-modal-status expired'; st.textContent = '⏰ Payment request expired. Close and try again.'; } return; } authFetch('/api/subscribe/status') .then(r => r.json()) .then(data => { if (!data.hasPending) { // Payment confirmed! (no more pending = it was confirmed) const st = document.getElementById('payStatus'); if (st) { st.className = 'pay-modal-status confirmed'; st.innerHTML = '✅ Payment confirmed! Your tier has been upgraded.'; } // Refresh auth state authFetch('/api/auth/me').then(r=>r.json()).then(me => { if (me.user) { authState.user = me.user; updateAuthUI(); } }); return; // stop polling } _payPollTimer = setTimeout(poll, 10000); // check every 10s }) .catch(() => { _payPollTimer = setTimeout(poll, 15000); }); }, 5000); // first check after 5s } function startPaymentCountdown() { if (_payCountdownTimer) clearInterval(_payCountdownTimer); _payCountdownTimer = setInterval(() => { const remaining = Math.max(0, Math.floor((_payExpiresAt - Date.now()) / 1000)); const el = document.getElementById('payTimer'); if (!el) { clearInterval(_payCountdownTimer); return; } if (remaining <= 0) { el.innerHTML = 'Payment request expired'; clearInterval(_payCountdownTimer); return; } const m = Math.floor(remaining / 60); const s = String(remaining % 60).padStart(2, '0'); el.innerHTML = `Expires in ${m}:${s}`; }, 1000); } function closePaymentModal() { const overlay = document.getElementById('payModalOverlay'); if (overlay) overlay.classList.remove('active'); if (_payPollTimer) { clearTimeout(_payPollTimer); _payPollTimer = null; } if (_payCountdownTimer) { clearInterval(_payCountdownTimer); _payCountdownTimer = null; } } /* ══ TOURNAMENTS ══ */ async function loadTournaments() { const el = document.getElementById('tournList'); // Hype-page mode: #tournList may be absent. Always run the reveal // animation so .tn-reveal sections become visible, then bail early. if (!el) { // Live waitlist counts (hero stats bar + any legacy element) (async () => { try { const r = await fetch('/api/waitlist/count', { cache: 'no-store' }); if (!r.ok) return; const { count } = await r.json(); const wl1 = document.getElementById('tWaitlistCount'); const wl2 = document.getElementById('thStatWaitlist'); const fmt = Number.isFinite(count) ? count.toLocaleString() : '--'; if (wl1) wl1.textContent = fmt; if (wl2) wl2.textContent = fmt; } catch (_) { /* non-fatal */ } })(); initTournamentRevealAnimations(); initTournamentsHeroCarousel(); return; } const createBar = document.getElementById('tournCreateBar'); if (createBar) createBar.style.display = 'none'; el.innerHTML = '
Loading tournaments
'; try { const r = await fetch(`${API_BASE}/api/tournaments`); if (!r.ok) throw new Error('Failed to fetch tournaments'); const data = await r.json(); const tournaments = data.tournaments || []; const tierRank = { free: 0, pro: 1, studio: 2, enterprise: 3 }; if (authState.user && (tierRank[authState.user.tier] || 0) >= 2 && createBar) { createBar.style.display = 'flex'; } const totalEl = document.getElementById('tStatTotal'); const openEl = document.getElementById('tStatOpen'); const playersEl = document.getElementById('tStatPlayers'); if (totalEl) totalEl.textContent = tournaments.length; if (openEl) openEl.textContent = tournaments.filter(t => t.status === 'registration').length; if (playersEl) playersEl.textContent = tournaments.reduce((sum, t) => sum + (t.playerCount || 0), 0); renderTournamentFeaturedLive(tournaments); if (tournaments.length === 0) { el.innerHTML = `
🏟️
No Tournaments Yet
Tournaments are in active development. This directory will populate as public tournament features are released.
`; initTournamentRevealAnimations(); return; } el.innerHTML = '
' + tournaments.map((t, index) => { const statusClass = t.status === 'registration' ? 'registration' : t.status === 'active' ? 'active' : 'completed'; const statusLabel = t.status === 'registration' ? 'Open' : t.status === 'active' ? 'Live' : 'Completed'; const gameLabel = t.game_type ? escHtml(t.game_type) : 'Any Game'; const canRegister = t.status === 'registration' && authState.user; const formatLabel = escHtml((t.format || 'single_elim').replace(/_/g, ' ').replace(/\w/g, c => c.toUpperCase())); const icon = t.status === 'active' ? '⚔️' : t.status === 'completed' ? '🏅' : '🏆'; const playerPct = t.max_players ? Math.min(100, Math.round((t.playerCount || 0) / t.max_players * 100)) : 0; return `
${icon}
${escHtml(t.name)}
${statusLabel}
${t.description ? `
${escHtml(t.description)}
` : ''}
🎮 ${gameLabel} 👥 ${t.playerCount || 0}/${t.max_players} 📋 ${formatLabel} ${t.entry_fee_mojos > 0 ? `💰 ${(t.entry_fee_mojos / 1e12).toFixed(2)} XCH` : '🎟️ Free Entry'}
${t.max_players ? `
${playerPct}% filled
` : ''}
${canRegister ? `` : ''}
`; }).join('') + '
'; initTournamentRevealAnimations(); } catch (err) { el.innerHTML = '
Failed to load tournaments.
'; } } function renderTournamentFeaturedLive(tournaments) { const el = document.getElementById('tnFeaturedLive'); if (!el) return; if (!tournaments.length) { el.style.display = 'none'; el.innerHTML = ''; return; } const featured = tournaments.find(t => t.status === 'registration') || tournaments[0]; const status = featured.status === 'registration' ? 'Registration Open' : featured.status === 'active' ? 'Live Now' : 'Completed'; const entry = featured.entry_fee_mojos > 0 ? `${(featured.entry_fee_mojos / 1e12).toFixed(2)} XCH` : 'Free Entry'; const gameType = escHtml(featured.game_type || 'Any Game'); const formatLabel = escHtml((featured.format || 'single_elim').replace(/_/g, ' ').replace(/\w/g, c => c.toUpperCase())); el.style.display = 'block'; el.innerHTML = `

${escHtml(featured.name)}

${featured.description ? escHtml(featured.description) : 'Tournament event loaded from live API data. Join and compete in a player-first bracket flow.'}

🎮 ${gameType} 📋 ${formatLabel} 👥 ${featured.playerCount || 0}/${featured.max_players || '—'} 💰 ${entry} 🚦 ${status}
`; } const TOURNAMENT_CAMPAIGN_VARIANTS = [ { headline: 'Arcade21 tournaments are in active development.', body: 'No launch date is announced yet. This page reflects direction and design, not a live tournament schedule.' }, { headline: 'Community tournaments are the goal.', body: 'We are building toward social, creator-friendly competition while keeping messaging honest and promise-light.' }, { headline: 'Beautiful placeholder. Real progress behind it.', body: 'Today: concept and atmosphere. Next: tournament systems when they are truly ready.' } ]; let tournamentCampaignIndex = 0; function renderTournamentCampaignVariant() { const h = document.getElementById('tnCampaignHeadline'); const b = document.getElementById('tnCampaignBody'); if (!h || !b) return; const current = TOURNAMENT_CAMPAIGN_VARIANTS[tournamentCampaignIndex]; h.textContent = current.headline; b.textContent = current.body; } function cycleTournamentCampaignVariant(direction = 1) { const len = TOURNAMENT_CAMPAIGN_VARIANTS.length; tournamentCampaignIndex = (tournamentCampaignIndex + direction + len) % len; renderTournamentCampaignVariant(); } function copyTournamentCampaignVariant() { const current = TOURNAMENT_CAMPAIGN_VARIANTS[tournamentCampaignIndex]; const payload = `${current.headline} ${current.body}`; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(payload); } } function initTournamentRevealAnimations() { const reveals = document.querySelectorAll('#tournaments-page .tn-reveal, #yellowjacket-page .tn-reveal'); if (!reveals.length) return; // Use immediate visibility so sections never stay hidden inside nested scroll containers. reveals.forEach(el => el.classList.add('is-visible')); document.querySelectorAll('#tournaments-page .tn-bracket-svg').forEach(svg => svg.classList.add('draw')); renderTournamentCampaignVariant(); } /* ────────────────────────────────────────────────────────────────── Roadmap page: stagger-reveal milestones on scroll, animate the gradient spine to fill up to the current milestone (in-progress). Today (2026-05-17) → Alpha delivered, Beta in-progress. Spine fills past Alpha node, stops at Beta node. ────────────────────────────────────────────────────────────────── */ function initRoadmapAnimations() { const page = document.getElementById('roadmap-page'); if (!page || page.__rmInited) { // Re-reveal in case page hidden/shown page && page.querySelectorAll('.rm-milestone').forEach(m => m.classList.add('is-visible')); return; } page.__rmInited = true; const milestones = page.querySelectorAll('.rm-milestone'); // Animate the spine fill based on current progress. // delivered=1, in-progress=2, upcoming=3-7, horizon=8. // We show the spine reaching the bottom of the in-progress (Beta) node. const total = milestones.length || 8; const progressMilestone = 2; // Beta is the active in-progress one // Use the position of the in-progress milestone as the fill percentage. // Pad to 5% minimum so the spine is visible at the top. const fillPct = Math.max(5, Math.min(100, (progressMilestone / total) * 100)); const spineFill = page.querySelector('.rm-timeline-spine-fill'); if ('IntersectionObserver' in window) { const io = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('is-visible'); io.unobserve(entry.target); } }); }, { threshold: 0.15, rootMargin: '0px 0px -80px 0px' }); milestones.forEach((m, i) => { // small stagger so they don't all snap in at once if all visible at load setTimeout(() => io.observe(m), i * 60); }); } else { // Fallback: just show them milestones.forEach(m => m.classList.add('is-visible')); } // Animate the spine fill after a short delay so it draws as you scroll in if (spineFill) { requestAnimationFrame(() => { setTimeout(() => { spineFill.style.setProperty('--rm-progress', fillPct + '%'); spineFill.style.height = fillPct + '%'; }, 600); }); } } function initTournamentsHeroCarousel() { const root = document.getElementById('thCarousel'); if (!root || root.__thInited) return; root.__thInited = true; const slides = root.querySelectorAll('.tn-hero-slide'); const dotsContainer = document.getElementById('thDots'); const dots = dotsContainer ? dotsContainer.querySelectorAll('.tn-hero-dot') : []; if (slides.length === 0) return; let idx = 0; let timer = null; const ROTATE_MS = 7000; function show(n) { idx = ((n % slides.length) + slides.length) % slides.length; slides.forEach((s, i) => s.classList.toggle('active', i === idx)); dots.forEach((d, i) => d.classList.toggle('active', i === idx)); } function start() { stop(); timer = setInterval(() => show(idx + 1), ROTATE_MS); } function stop() { if (timer) { clearInterval(timer); timer = null; } } dots.forEach(d => { d.addEventListener('click', () => { const target = parseInt(d.dataset.idx, 10) || 0; show(target); start(); // reset the timer when manually clicked }); }); // Pause auto-rotate while the cursor is hovering the hero const heroEl = root.closest('.tn-hero'); if (heroEl) { heroEl.addEventListener('mouseenter', stop); heroEl.addEventListener('mouseleave', start); } // Respect user motion preference const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!reduce) start(); } function showCreateTournament() { const form = document.getElementById('tournCreateForm'); form.style.display = form.style.display === 'none' ? 'block' : 'none'; } async function createTournament() { if (!authState.user) { showPage('login'); return; } const name = document.getElementById('tournName').value.trim(); if (!name) { alert('Tournament name required.'); return; } try { const r = await authFetch(`${API_BASE}/api/tournaments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, gameType: document.getElementById('tournGameType').value || null, maxPlayers: parseInt(document.getElementById('tournMaxPlayers').value) || 16, format: document.getElementById('tournFormat').value, description: document.getElementById('tournDesc').value.trim() || null }) }); const data = await r.json(); if (data.error) { alert(data.error); return; } document.getElementById('tournCreateForm').style.display = 'none'; loadTournaments(); } catch (err) { alert('Failed to create tournament.'); } } async function registerTournament(id) { if (!authState.user) { showPage('login'); return; } try { const r = await authFetch(`${API_BASE}/api/tournaments/${id}/register`, { method: 'POST' }); const data = await r.json(); if (data.error) { alert(data.error); return; } alert('Registered! You are seed #' + data.seed); loadTournaments(); } catch (err) { alert('Failed to register.'); } } async function viewTournament(id) { try { const r = await fetch(`${API_BASE}/api/tournaments/${id}`); const data = await r.json(); const t = data.tournament; const players = data.players || []; const matches = data.matches || []; const el = document.getElementById('tournList'); let html = `
`; html += `
${escHtml(t.name)}
${t.status}
${t.description ? `
${escHtml(t.description)}
` : ''}
Game: ${escHtml(t.game_type || 'Any')} Players: ${players.length}/${t.max_players} Format: ${escHtml((t.format || 'single_elim').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase()))}
`; // Players html += '
Registered Players
'; if (players.length === 0) { html += '
No players registered yet.
'; } else { html += '
' + players.map((p, i) => { const tierBadge = p.tier && p.tier !== 'free' ? `${p.tier}` : ''; return `
#${i + 1} ${escHtml(p.display_name || p.username)}${tierBadge}
`; }).join('') + '
'; } // Matches if (matches.length > 0) { html += '
Bracket
'; html += '
' + matches.map(m => { const p1 = m.player1_display || m.player1_name || 'TBD'; const p2 = m.player2_display || m.player2_name || 'TBD'; const winner = m.winner_name ? `Winner: ${escHtml(m.winner_name)}` : 'Pending'; const statusColor = m.status === 'completed' ? 'var(--green)' : 'var(--text-3)'; return `
Round ${m.round} Match ${m.match_number}: ${escHtml(p1)} vs ${escHtml(p2)} ${winner}
`; }).join('') + '
'; } el.innerHTML = html; } catch (err) { alert('Failed to load tournament details.'); } } /* ══ PROFILE STATS + MATCH HISTORY ══ */ async function loadProfileStats() { try { const r = await authFetch(`${API_BASE}/api/user/stats`); const s = await r.json(); const ge = document.getElementById('psGames'); if (ge) ge.textContent = s.totalGames || 0; const we = document.getElementById('psWins'); if (we) we.textContent = s.wins || 0; const le = document.getElementById('psLosses'); if (le) le.textContent = s.losses || 0; const wr = document.getElementById('psWinRate'); if (wr) wr.textContent = s.winRate ? s.winRate + '%' : '0%'; } catch (err) { /* silent */ } } async function loadMatchHistory() { const el = document.getElementById('profileMatches'); if (!el) return; try { const r = await authFetch(`${API_BASE}/api/user/history?limit=20`); const data = await r.json(); const matches = data.matches || []; // Show export button for Pro+ const tierRank = { free: 0, pro: 1, studio: 2, enterprise: 3 }; if (authState.user && (tierRank[authState.user.tier] || 0) >= 1) { const btn = document.getElementById('btnExportHistory'); if (btn) btn.style.display = 'inline-block'; } if (matches.length === 0) { el.innerHTML = 'No matches recorded yet. Play some games!'; return; } el.innerHTML = matches.map(m => { const date = new Date(m.played_at * 1000).toLocaleDateString(); const resultColors = { win: 'var(--green)', loss: '#ff4444', draw: 'var(--blue)', forfeit: 'var(--orange)', unknown: 'var(--text-4)' }; const color = resultColors[m.result] || 'var(--text-3)'; const wagerXch = m.wager_mojos > 0 ? (m.wager_mojos / 1e12).toFixed(4) + ' XCH' : 'Free'; return `
${escHtml(m.game_type || 'Unknown')} · ${wagerXch}
${date}${m.result}
`; }).join(''); } catch (err) { el.innerHTML = 'Failed to load.'; } } function exportMatchHistory() { window.open(`${API_BASE}/api/user/history?format=csv`, '_blank'); } async function loadApiKeysPage() { const el = document.getElementById('apiKeysList'); if (!el) return; try { const r = await authFetch(`${API_BASE}/api/user/api-keys`); const data = await r.json(); if (data.error) { el.innerHTML = `
${escHtml(data.error)}
`; return; } const keys = data.keys || []; if (keys.length === 0) { el.innerHTML = '
🔑
No API Keys Yet
Generate a key to integrate your game or service with the tracker API.
'; return; } el.innerHTML = '
' + keys.map(k => { const created = new Date(k.created_at * 1000).toLocaleDateString(); const lastUsed = k.last_used_at ? new Date(k.last_used_at * 1000).toLocaleDateString() : 'Never'; return `
${escHtml(k.key_prefix)}...
${escHtml(k.name)} · Created ${created} · Last used: ${lastUsed}
`; }).join('') + '
'; } catch (err) { el.innerHTML = '
Failed to load API keys.
'; } } async function loadApiKeys() { const section = document.getElementById('profileApiKeys'); if (!section) return; const tierRank = { free: 0, pro: 1, studio: 2, enterprise: 3 }; if (!authState.user || (tierRank[authState.user.tier] || 0) < 2) { section.style.display = 'none'; return; } section.style.display = 'block'; const el = document.getElementById('apiKeysList'); try { const r = await authFetch(`${API_BASE}/api/user/api-keys`); const data = await r.json(); const keys = data.keys || []; if (keys.length === 0) { el.innerHTML = 'No API keys yet. Create one to integrate with the tracker API.'; return; } el.innerHTML = keys.map(k => { const created = new Date(k.created_at * 1000).toLocaleDateString(); const lastUsed = k.last_used_at ? new Date(k.last_used_at * 1000).toLocaleDateString() : 'Never'; return `
${escHtml(k.key_prefix)}... · ${escHtml(k.name)}
Last used: ${lastUsed}
`; }).join(''); } catch (err) { el.innerHTML = 'Failed to load.'; } } async function createApiKey() { const name = prompt('API key name (e.g. "My Game Bot"):'); if (!name) return; try { const r = await authFetch(`${API_BASE}/api/user/api-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); const data = await r.json(); if (data.error) { alert(data.error); return; } alert('API Key created! Save this now (shown once only):\n\n' + data.key); loadApiKeys(); } catch (err) { alert('Failed to create API key.'); } } async function revokeApiKey(id) { if (!confirm('Revoke this API key? This cannot be undone.')) return; try { await authFetch(`${API_BASE}/api/user/api-keys/${id}`, { method: 'DELETE' }); loadApiKeys(); } catch (err) { alert('Failed to revoke.'); } }
Send Feedback
Bug reports, suggestions, or questions — all welcome