Cut the rope. Feed the monster. Earn real Solana.
A skill-based cut-the-rope puzzle with a fair, on-chain play-to-earn layer on Solana mainnet.
Candy Drop turns a beautifully simple physics puzzle into a fair, on-chain economy. Slice ropes, time the swing, and drop the candy into a hungry little monster — then watch pure skill become real SOL. Earn $CANDY as you play, climb the ranks, knock out daily quests, and claim your slice of a daily Solana prize pool straight to your Phantom wallet. No wagering. No pay-to-win. Just skill, holdings, and payouts you can verify on-chain — all wrapped in hand-drawn art and buttery-smooth Phaser physics.
- Why it's special
- How to play
- Play-to-earn: $CANDY → SOL
- Quests & ranks
- Fairness & fund safety
- Architecture
- Tech stack
- Project structure
- Run it locally
- Deploy (Railway)
- Environment variables
- API reference
- Roadmap
- Cut the rope — swipe across a rope to slice it; the candy is released and physics takes over.
- Time the swing — the candy swings side-to-side. Wait for the right moment, then cut to fling it. You can also drag it to build momentum.
- Feed the monster — land the candy on the little green monster to clear the level. Grab all 3 stars for a perfect run.
Bubbles float the candy up; air pumps blow it sideways. Ten hand-built levels across five themed “boxes” mix these mechanics into escalating puzzles.
There are two things in play, and they're deliberately different:
| What it is | |
|---|---|
| 🍬 $CANDY | The in-game coin you earn by playing. It is the metric that decides your reward. |
| Real Solana — what you actually claim to your wallet. |
The model: every day there is a fixed SOL prize pool, shared across players. Your slice is decided by how much of the $CANDY coin you hold, as a percentage of supply — the more you hold, the bigger your share, up to a 3.5% cap.
your SOL today = daily pool × min(your % of $CANDY supply, 3.5%) ÷ Σ everyone's capped %
- 🐋 Whale-proof — holding more than 3.5% of supply earns no extra, so a few big wallets can't drain the pool.
- 🎮 You must play to earn — only wallets active that day share the pool. Holding alone isn't enough; holdings set the amount, playing sets eligibility.
- 🔄 Pre-launch fallback — until the $CANDY token is minted, the pool is split by skill points instead, so the game already pays out. The moment a
TOKEN_MINTis set, rewards switch to holdings automatically.
Settlement & claiming:
flowchart LR
A["Play and earn $CANDY<br/>(all day)"] --> B["UTC midnight:<br/>day settles"]
B --> C["Your share moves to<br/>Available to claim"]
C --> D["Click Claim →<br/>real SOL to your wallet"]
D --> E["Verifiable on<br/>Solscan"]
Daily quests (reset every UTC day) award bonus $CANDY and show on both the game screen and the dashboard:
| Quest | Goal | Reward |
|---|---|---|
| Warm Up | Clear 1 level | +5 |
| Sweet Tooth | Clear 3 levels | +15 |
| On a Roll | Clear 5 levels | +25 |
| Marathon | Clear all 10 levels | +40 |
| Star Collector | Collect 10 stars | +20 |
| Star Hoarder | Collect 20 stars | +35 |
| Star Master | Collect all 30 stars | +50 |
| Flawless | Get a 3-star clear | +25 |
| Triple Threat | Three 3-star clears | +40 |
Ranks are a badge of lifetime $CANDY and never reset:
🥉 Bronze → 🥈 Silver → 🥇 Gold → 💠 Platinum → 💎 Diamond → 👑 Master
Because real money is at stake, the backend is built to be hard to cheat and impossible to drain by accident:
Anti-cheat (server-authoritative):
- The client never sets its own points — the server awards them.
- Each level pays once per day; replays earn nothing.
- A solve needs a single-use server session older than a minimum solve time → blocks instant bots & replays.
- A hard per-wallet daily cap bounds how much anyone can earn.
- The holdings 3.5% cap stops whales dominating the pool.
Fund safety (chain-reconciled claims):
- The payout tx is signed locally first (signature known up-front), the balance is reserved and persisted before broadcasting, and on a confirmation timeout the chain is queried — a transfer that actually landed is never refunded (no double-pay), and only a provably failed/expired tx is refunded.
- On boot, any claim left mid-flight by a crash is reconciled against the chain, so funds are never stuck.
- Persistence is single-flight with retry; the process flushes on SIGTERM (redeploys), and settlement is durable so a crash can't double-credit.
These properties were adversarially reviewed (multi-agent audit) and the confirmed findings were fixed and re-tested.
flowchart TD
subgraph Browser
G["🎮 Phaser game<br/>(play.html)"]
L["🏠 Landing + Docs"]
D["📊 Dashboard"]
end
subgraph "Node server (one process)"
S["Static host"]
API["/api/* REST"]
R["Rules: server-authoritative<br/>points, quests, ranks"]
LED["Ledger: settlement<br/>+ chain-reconciled claims"]
end
W["👻 Phantom wallet"] -->|sign-in signature| API
G & L & D --> S
G & D -->|fetch| API
API --> R --> store["💾 store.js"]
API --> LED --> store
store --> PG[("🐘 Postgres / JSON file")]
LED --> TRE["🔐 Treasury wallet"]
TRE -->|SOL payout| W
One Node process serves the frontend and the API, so it deploys as a single service. State is held in memory and persisted by store.js to Postgres (or a JSON file locally / on a volume).
| Layer | Tech |
|---|---|
| Game | Phaser 3 + Matter.js physics, procedural textures, WebAudio SFX |
| Frontend | Vanilla JS/CSS, responsive, no build step |
| Backend | Node.js (no framework — a tight http server) |
| Chain | @solana/web3.js, tweetnacl + bs58 for signature auth |
| Data | PostgreSQL (JSONB) with a JSON-file fallback |
| Wallet | Phantom (sign-in message + payouts) |
| Hosting | Railway (Railpack, auto-deploy on push) |
.
├── index.html # landing page
├── play.html # the game
├── dashboard.html # earnings dashboard (rank, quests, holdings, claim)
├── docs.html # in-app documentation
├── package.json # root manifest (Railway build entry)
├── assets/ # original art: spritesheet, backgrounds, logos, icons
├── src/
│ ├── game.js # Phaser scene: ropes, physics, win/lose, juice
│ ├── draw.js # procedural textures + themed backgrounds + sprite wiring
│ ├── levels.js # 10 levels in normalized coords, per-level themes
│ ├── wallet.js # connect, sign-in, report solves, claim
│ ├── quests.js # in-game daily-quest panel
│ ├── dashboard.js # dashboard UI
│ └── landing.{js,css} # marketing site
└── server/
├── server.js # static host + /api router
├── config.js # env loader (.env → process.env)
├── auth.js # nonce + ed25519 signature verify + session tokens
├── rules.js # points, quests, ranks, holdings tiers (anti-farm)
├── ledger.js # daily settlement + chain-reconciled claims
├── solana.js # treasury, payouts, holdings, signature status
└── store.js # durable Postgres/file store (single-flight, crash-safe)
git clone https://github.com/ctrlshifthash/candydrop.git
cd candydrop
# install backend deps
cd server && npm install && cd ..
# configure secrets
cp server/.env.example server/.env # then fill in the values
# run (serves the game AND the API on http://localhost:8080)
node server/server.jsOpen http://localhost:8080. Without a TREASURY_SECRET the whole app still runs (play, points, dashboard) — only the claim button is disabled, so you can develop safely before funding a wallet.
Generate the session/admin secrets:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"- New Project → Deploy from GitHub repo →
ctrlshifthash/candydrop. - Add a PostgreSQL plugin (Railway injects
DATABASE_URL). - Set the environment variables below.
- Railpack auto-detects Node from the root
package.json→ runsnpm install→npm start(node server/server.js). No build command or root directory needed. - Keep 1 replica (the store is single-writer) and enable Teardown so redeploys don't overlap.
✅ Live deployment: https://candydrop-production.up.railway.app
| Variable | Required | Description |
|---|---|---|
TREASURY_SECRET |
for payouts | Treasury wallet secret key (base58). Lives only on the server. |
SESSION_SECRET |
✅ | Long random string signing player session tokens. |
ADMIN_TOKEN |
✅ | Gate for /api/admin/*. |
RPC_URL |
✅ | Solana mainnet RPC (use a paid provider in production). |
DATABASE_URL |
prod | Postgres connection string (${{Postgres.DATABASE_URL}} on Railway). |
REQUIRE_DB |
rec. | true → refuse to boot on non-durable file storage. |
DAILY_POOL_SOL |
✅ | Size of the daily pool, in SOL. |
MIN_CLAIM_SOL |
✅ | Minimum claimable balance (keeps fees sane). |
HOLDING_CAP_PCT |
✅ | Effective holding cap (default 3.5). |
TOKEN_MINT |
optional | $CANDY SPL mint. Empty = points fallback; set = holdings rewards. |
MIN_WALLET_SOL |
optional | Anti-sybil: minimum wallet balance to earn (0 = off). |
PORT |
auto | Injected by Railway — do not set. |
🔒 Secrets live only in
server/.env(git-ignored) or the host's env.server/.env.exampledocuments every variable.
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
GET |
/api/config |
— | Public config (network, treasury, quests, ranks, tiers) |
GET |
/api/stats |
— | Live player count + SOL paid |
GET |
/api/nonce?wallet= |
— | Login challenge to sign |
POST |
/api/login |
— | Verify signature → session token |
POST |
/api/level/start |
🔑 | Open a server-authoritative level session |
POST |
/api/level/complete |
🔑 | Report a solve → server awards $CANDY |
GET |
/api/me |
🔑 | Dashboard payload (rank, holdings, claimable, quests) |
POST |
/api/claim |
🔑 | Claim settled SOL to your wallet |
GET |
/api/leaderboard |
— | Top players by lifetime $CANDY |
GET |
/api/admin/stats |
🛡️ | Treasury balance + aggregate stats |
- Playable game + Phantom connect
- Server-authoritative points, quests, ranks
- Daily settlement + real mainnet claims
- Durable Postgres store + crash-safe claims
- Holdings-tier rewards (3.5% cap)
- $CANDY token launch (flip on holdings mode)
- On-chain leaderboard + seasons
- Mobile apps & community level editor
