Case Study
Word Scramble: putting a game leaderboard on-chain without making players learn crypto
A retro, mid-century-themed word puzzle where solving a scramble writes your score to a Soroban smart contract on Stellar, and hitting a milestone mints an achievement badge automatically. The interesting engineering isn't the game — it's everything I had to remove so that a player who has never heard of Stellar can still end up with a verifiable on-chain score.
Role
Solo Developer
Timeline
Jun – Aug 2026
Context
Stellar Builders
Contracts
3 on Mainnet
The problem
A leaderboard is the oldest feature in games, and normally it's a database table. Putting it on-chain buys one specific thing: the scores are verifiable and nobody — including me — can quietly edit them. That's a real property worth having.
The cost is brutal, though. To write a score on-chain the traditional way, a player needs a wallet, needs that wallet funded, needs to understand what a transaction fee is, and needs to approve a signing prompt. That's four chances to lose someone before they've played a single round. Most blockchain games quietly accept this and end up with an audience of people who already own crypto.
I wanted the on-chain guarantee without the on-chain onboarding. The whole project became an exercise in deleting steps between "I solved a word" and "my score is provably mine."
Two contracts that talk to each other
The on-chain layer is deliberately split. A leaderboard contract owns scores; a reward contract owns badges. They're separate because they have different authorization rules and different reasons to change.
LeaderboardContract
Holds a top-100 table. submit_score only
overwrites an entry when you actually beat your previous best, so the contract is
naturally idempotent — replaying a transaction can't inflate anyone's score.
Every player-facing write calls require_auth()
on the player, so you can only ever write your own row.
RewardContract
Mints BRONZE / SILVER / GOLD / LEGEND badges at 100, 300, 500 and 1000 points. Minting is idempotent too — the same badge is never issued twice.
Its init authorizes exactly one caller: the
leaderboard contract. No player, and no admin, can mint a badge directly.
The part I like most is that badges cost the player nothing extra.
submit_score performs an inter-contract call into
the reward contract in the same transaction, so a milestone badge appears as a side effect of
saving your score. There's no second prompt, no second fee, and no way to end up with a score
saved but the badge missing — if the transaction succeeds, both happened.
The frontend is deliberately boring by comparison: vanilla HTML, CSS and JavaScript with no
framework and no build step, talking to the contracts through a single 575-line
stellar.js integration layer. A game that lives or
dies on load time didn't need a bundler.
Removing the wallet-funding barrier
On testnet this problem hides: Friendbot funds any new account for free, so I never felt the friction. Mainnet has no Friendbot. A brand-new player would arrive at a game that demanded they go buy XLM before they could save a score — which is where they'd leave.
So mainnet runs behind a Cloudflare Worker that does two jobs: it sponsors account creation (covering the reserve for players who don't have an activated account), and it fee-bumps the score submission so the player never pays a transaction fee. The player still signs — they keep custody, and the contract still verifies it's their own score — but they never need to hold a balance.
A bug worth remembering: sponsored accounts initially started with a zero balance, and Freighter refuses to sign a transaction from an account showing nothing. The sponsorship was technically correct and completely unusable. The wallet's own safety check was the thing standing between the player and the feature — a reminder that "the transaction is valid" and "the user can actually send it" are different claims.
What the security review caught
Before deploying to mainnet I wrote a self-conducted review of all three contracts. It isn't a third-party audit and I don't present it as one, but forcing myself to write down every state-changing entry point and who is allowed to call it found two real access-control gaps that I'd otherwise have shipped.
The one that mattered
set_reward_contract — the function that
wires the leaderboard to the badge minter — took no authorization at all. On testnet
that's harmless. On mainnet, anyone could have called it and repointed the leaderboard at a
contract they controlled. It was found while re-wiring for the mainnet deploy and fixed
before launch, so the deployed contract ID reflects the corrected version.
The workspace also compiles with overflow-checks = true
and panic = "abort", so an arithmetic overflow
halts the transaction rather than silently wrapping a score. Contract behaviour is covered by
a unit test suite that runs in CI on every push, alongside a check that every
contract.call(...) in the frontend matches a real
exported function in the Rust source — a class of bug that otherwise only surfaces at
runtime, in front of a player.
Where it stands
3
Contracts live
50+
Testnet users
100
Leaderboard cap
0
XLM needed
The leaderboard contract was redeployed once, in July 2026, to raise the cap from 10 to 100 entries ahead of scaling past 50 testnet users. Scores already earned on the old contract were carried forward through a one-time, admin-authenticated migration rather than being wiped — people had earned those, and resetting them to make my deploy easier would have been the wrong trade.
What I'd do differently
Design for mainnet constraints from day one
Friendbot made testnet frictionless and hid the single biggest UX problem in the product. The fee-sponsorship worker — probably the most important feature in the whole build — only got written because mainnet forced it. If I'd developed against mainnet constraints earlier, the architecture would have accounted for sponsorship from the start instead of gaining it late.
Get the leaderboard sizing right before deploying
A cap of 10 was fine for a demo and wrong within weeks. On-chain, that mistake isn't a migration script — it's a redeploy, a new contract ID, and a data migration for scores real people earned. Immutability is the feature, which means the cost of a careless default is much higher than it is behind a database.
Write the authorization table first, not last
Both access-control gaps were found by tabulating every entry point against who may call it — an exercise that took an afternoon and would have taken minutes if I'd done it while writing the contracts. Enumerating the callers is a design step, not a review step.
Want something like this built?
I build full-stack and on-chain products end to end — contracts, frontend, deployment, and the unglamorous work that makes them usable by people who aren't developers.