Back to Portfolio Get in touch

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

Soroban Rust Stellar SDK Vanilla JS Cloudflare Workers GitHub Actions
Play the game View source

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.