DEV Community

Cover image for We built a Web3 quest game where the progress bar is a city
Denshin Team
Denshin Team

Posted on AI-assisted

We built a Web3 quest game where the progress bar is a city

Most Web3 quest platforms look the same: a list of tasks, a checkbox per task, a points counter. You swap, you tick, you forget. We wanted progress you can actually see, so in Denshin the progress bar is a city.

Denshin went live on September 10 with its first season, Genesis, and launches on Product Hunt on September 22. Here is how it's built, and a few decisions that turned out to matter.

The idea in one paragraph

The map is one district of 100 quarters, and four of them are open at the start. Each quest belongs to a quarter: complete it and the quarter opens. The Genesis season has 33 quests across Base, Soneium and Sonic, and 23 of them are verified straight from the chain: holding gas, holding a stablecoin, swapping on a named DEX, supplying to a lending market, providing liquidity. There is no token and there are no NFTs. The prize pool is $100 USDT for the top 50 of the season, small on purpose: the game has to be worth playing on its own.

Drawing the city with PixiJS

The UI is Vue 3 and the map is PixiJS. The map is a stack of containers, bottom to top:

baseFill → roadWear → grid → roadMarkings → roadEffects (traffic)
→ neonGlow → buildings → players → aircraft → clouds → fog
Enter fullscreen mode Exit fullscreen mode

Two kinds of renderers draw into it:

  • Stateless renderers (road markings, wear, neon glow, buildings) create their display objects once and are done.
  • Animated systems (traffic, pedestrians, aircraft, clouds, fog) register a ticker callback and hold state. Every one of them has an init(layer, ticker) / destroy(ticker) pair, and forgetting the second half is the fastest way to leak.

Every procedural detail, from the stains on the asphalt to which cars drive which lanes, comes from a seeded PRNG (mulberry32) with one seed per renderer. The city looks the same for every player and on every reload, which also makes visual bugs reproducible.

Two performance lessons:

  1. Bake on desktop, not on phones. On desktop the quarter art is baked into one world-sized RenderTexture, which makes panning cheap. On phones that one texture runs the GPU out of memory, so mobile keeps live sprites and a lighter 512px WebP set.
  2. Atlas the small stuff. Road markings and vehicles ship as committed spritesheets instead of hundreds of separate PNGs.

Verifying quests from the chain

No screenshots, no "paste your tx hash". Every on-chain quest check asks the chain one of three questions:

Check Question
Native balance Do you hold at least X of the gas token on chain N?
Token balance Do you hold at least X of a specific ERC-20?
Swap Did you swap at least X on an approved venue since you started the quest?

Three rules make this hard to game:

  • Accepted-at block. Action checks only look at blocks after the moment you pressed Start mission. Otherwise "did you swap on Base" would pay for any swap in the wallet's history.
  • One transaction, one reward, ever. An accepted tx hash is consumed: stored once, behind a uniqueness constraint on the hash itself. It can't close two tasks, the same task twice, or a task on another account.
  • The server picks the hash. The hash comes from the verifier's own read of the chain, never from the browser, so a client can't nominate one it likes.

Swaps are matched against a registry of known pools and routers per network, so "a swap on Kyo" means Kyo, not any contract that happens to emit a Swap event.

Putting the daily check-in on-chain

Until this week the Daily Check was an off-chain button. Now it is a transaction: sync() on a tiny contract called DenshinSync, deployed at the same address on Soneium, Base and Sonic.

function sync() external returns (uint32 streak) {
    uint32 day = today(); // block.timestamp / 1 days
    Record memory record = _records[msg.sender];
    if (record.lastDay >= day) revert AlreadySynced(msg.sender, day);

    streak = record.lastDay + 1 == day ? record.streak + 1 : 1;
    // ...store lastDay, streak, longest, total
    emit Synced(msg.sender, day, streak, total);
}
Enter fullscreen mode Exit fullscreen mode

It holds no funds, has no owner and no admin functions, and cannot be upgraded. The day is the block's UTC day, not the player's clock. The streak that pays points is computed off-chain from Synced events, so a player can sync on Base one day and on Sonic the next without breaking it.

The identical address on three chains comes from a deterministic CREATE2 deployment. A side effect we didn't expect to care about: changing even a comment in the source changes the bytecode metadata, and with it the address.

For the player it costs gas only, a fraction of a cent. The contract is 0xCCFFb22048d9E1fDd04F5F3B9681cC0C95a0f654, source verified on each chain's explorer.

Stack

  • Client: Vue 3, PixiJS, viem/wagmi, Reown AppKit
  • Server: NestJS, PostgreSQL, Redis, BullMQ
  • Contract: Solidity, Hardhat

Try it

The map and the quests are visible without a wallet: https://app.denshin.io. Docs: https://docs.denshin.io.

We launch on Product Hunt on September 22. If you have thoughts on the map, the verification rules or the on-chain check-in, that is the best place to leave them: https://www.producthunt.com/products/denshin

We're a small team, so honest feedback is the most useful thing you can give us.

Top comments (0)