A smart contract can't tell whether a submitted score came from a valid game or was simply made up. Dario Dash handles that by proving the run itself.
I have been building Dario Dash, a small endless runner on Dusk. The game runs in the browser and does not require a wallet to play. After a ranked run, the browser can generate a Groth16 proof locally and submit the score to a smart contract.
The contract does not trust the submitted score. It accepts it only after verifying the proof, binding it to the transaction sender and checking that the run seed has not already been used.
The source is available on GitHub.
What actually needs to be proven?
A score by itself says almost nothing. A client could simply submit any number it wants.
For Dario Dash, a valid run includes much more than the final score:
- the player movement and jump timing
- the seed-derived obstacle schedule
- obstacle clearance and collision windows
- item pickups
- damage and game-over conditions
- fireball kills
- transitions between Regular, Super, Fire and Cape forms
- the number of ticks played
- the resulting score
The proof must establish that these rules were followed from the initial state until the claimed final state. It also needs to bind the run to the account submitting it, otherwise somebody could copy another player's proof.
The architecture
The repository is split into a few layers:
- dash_zk contains the deterministic game simulation used by the browser proving path.
- dash_core contains a separate 60 Hz simulation used by the RISC Zero path.
- dash_web exposes the Rust simulation to the browser through WebAssembly.
- zk_browser contains the Circom circuit and the JavaScript proof conversion code.
- contract verifies the proof and maintains the leaderboard on Dusk.
- web contains the playable Vite application.
The important boundary is that the game logic is deterministic and integer-only. Floating point physics would be a mess to reproduce consistently across JavaScript, WebAssembly, the proof circuit and the contract.
The browser and contract also derive the obstacle schedule from the same seed. The complete schedule therefore does not have to be trusted as client-supplied input.
Generating the proof in the browser
The browser path uses a Circom circuit of roughly 421,000 constraints. It covers the complete ranked run, including the movement, collision, item and scoring rules.
When a run finishes:
- The game serializes the witness data and public inputs.
- A Web Worker downloads the circuit WebAssembly and the proving key.
- snarkjs groth16.fullProve generates the proof locally.
- The proof is converted into the compressed arkworks format expected by Dusk's BN254 verification host function.
- The browser submits the score, tick count, seed and proof to the contract.
Proof generation currently takes roughly 10 to 60 seconds depending on the machine. The proving key is around 200 MB, so it is published as an immutable release artifact and cached by the browser.
That is a fairly ugly download for a small game, but it is also an honest representation of the current trade-off. Browser-native proving works, but it is not free.
What the contract verifies
The contract does more than call a generic proof verifier.
It reconstructs the public inputs using the transaction sender, recomputes the obstacle schedule from the seed and verifies the Groth16 proof through Dusk's verify_groth16_bn254 host function.
Only after that does it update the account's best score and proven-run count. Used seeds are rejected to prevent straightforward proof replay.
This means the browser is free to be malicious. It can change the displayed score, patch the JavaScript or construct its own request. None of that matters unless it can produce a proof for a run that satisfies the circuit and matches the public inputs reconstructed by the contract.
The second proving path
The repository also contains a RISC Zero path.
Instead of proving the closed-form Circom simulation directly, a RISC Zero guest replays a recorded input trace using the 60 Hz Rust simulation. The execution proof is then wrapped into Groth16 and converted to the same format the Dusk contract expects.
The two paths have different characteristics:
- Circom is fast enough to prove directly in modern browsers, but the circuit has to model the game rules explicitly.
- RISC Zero proves execution of the Rust guest program, which makes more general logic easier to express, but the native proving workflow is considerably heavier.
Supporting both has been useful because it makes the trade-off concrete rather than theoretical.
What this does not solve
A gameplay proof is not the same thing as a complete anti-cheat system.
The circuit proves that a valid sequence of inputs exists. It does not prove that a human generated those inputs, that the user did not inspect future obstacles or that they were not running an automated player.
Those are separate problems. The useful guarantee here is narrower: the on-chain score must correspond to a valid execution of the published game rules, for the given account and seed.
That is already much stronger than trusting a web client or centralized leaderboard API.
Try it
The game is playable without a wallet:
The full Rust, Circom, WebAssembly, contract and browser implementation is here:
Dario Dash is a proof of concept for the proving architecture. I'm interested in collaborating on other practical ZK use cases, particularly on Dusk. Feedback on the proof boundary and browser proving trade-offs is welcome too.
Top comments (0)