Per-Wallet State on GenLayer — How I Built a Word Game with TreeMap, AI Judging, and a Live…
A practical guide to building Intelligent Contracts with persistent on-chain state per wallet.
Per-Wallet State on GenLayer — How I Built a Word Game with TreeMap, AI Judging, and a Live Leaderboard
A practical guide to building Intelligent Contracts with persistent on-chain state per wallet.
The GenLayer community reviewed my first project — a four-game arcade on Testnet Bradbury — and the feedback was specific:
“It would be stronger with per-player history, scores, and clearer on-chain state for each user/session.”
So I built Word Challenge. One contract. One game. Full persistent state per wallet address.
This is how it works.
What the Game Does
Simple premise. Every day, an admin sets a challenge word on-chain. Players connect their wallet, write one creative sentence using that word, and submit it as a transaction to Testnet Bradbury.
Five randomly selected validators run an LLM independently and judge the sentence on two things: did the player use the word correctly, and how creative was the sentence? They reach consensus. The result — along with the player’s updated score, streak, and submission history — is stored permanently on-chain against their wallet address.
A leaderboard ranks all players by total score.
No backend. No database. No simulation. Everything on-chain.
The Architecture
One contract handles everything:
WordChallenge
├── set_word(word) → admin sets today's challenge word
├── submit_sentence(sentence) → player submits, AI judges, state updates
├── get_today_word() → read current word (free, no gas)
├── get_player_stats(address) → read a wallet's score, streak, history
└── get_leaderboard() → read global ranked list of all players
Three state variables:
today_word: str # current challenge word
player_stats: TreeMap[Address, str] # per-wallet: score, streak, history
leaderboard: TreeMap[Address, str] # per-wallet: total score for ranking
The key innovation from v1 is TreeMap[Address, str]. This is what makes per-wallet state possible. Every wallet address gets its own independent entry. Reading one player's stats doesn't touch another's.
The Scoring Design
Outcome Points Word used correctly 5 points Creativity: low +1 point Creativity: good +3 points Creativity: creative +5 points Maximum per day 10 points
Notice the creativity bands — low, good, creative — rather than a numeric score. This is deliberate and important.
GenLayer’s gl.eq_principle_strict_eq() requires all five validators to return byte-identical output. If you ask five different LLMs to rate creativity from 1 to 10, you'll get five different numbers and the transaction will fail. Ask them to pick one of three bands and they converge. Discrete categories beat continuous scales every time in a distributed AI consensus system.
The Complete Contract
# { "Depends": "py-genlayer:0.1.0" }
from genlayer import *
import json
class WordChallenge(gl.Contract):
today_word: str
player_stats: TreeMap[Address, str]
leaderboard: TreeMap[Address, str]
def __init__(self) -> None:
self.today_word = 'consensus'
@gl.public.view
def get_today_word(self) -> str:
return self.today_word
@gl.public.view
def get_player_stats(self, player: str) -> str:
addr = Address(player)
return self.player_stats.get(addr, '{"score":0,"streak":0,"history":[]}')
@gl.public.view
def get_leaderboard(self) -> str:
entries = []
for addr in self.leaderboard:
raw = self.leaderboard[addr]
dat = json.loads(raw)
entries.append({
"address": str(addr),
"score": dat.get("score", 0)
})
entries.sort(key=lambda x: x["score"], reverse=True)
return json.dumps(entries[:20])
@gl.public.write
def set_word(self, word: str) -> None:
self.today_word = word
@gl.public.write
def submit_sentence(self, sentence: str) -> None:
word = self.today_word
prompt = f'''Today's word: "{word}"
Player's sentence: "{sentence}"
Judge this sentence on two things:
1. Did the player use the word correctly? Answer true or false.
2. How creative is the sentence? Answer with exactly one of: low, good, creative.
Respond ONLY with this JSON, nothing else:
{{"correct": true, "creativity": "good", "feedback": "one sentence of encouragement"}}
It is mandatory that you respond only using the JSON format above.
Do not include markdown fences, explanation, or any other text.'''
def nondet():
res = gl.exec_prompt(prompt)
res = res.replace('```json', '').replace('```', '').strip()
dat = json.loads(res)
creativity = str(dat.get('creativity', 'low')).lower()
if creativity not in ['low', 'good', 'creative']:
creativity = 'low'
return json.dumps({
'correct': bool(dat.get('correct', False)),
'creativity': creativity,
'feedback': str(dat.get('feedback', '')),
}, sort_keys=True)
verdict_json = gl.eq_principle_strict_eq(nondet)
verdict = json.loads(verdict_json)
points = 0
if verdict['correct']:
points += 5
creativity_points = {'low': 1, 'good': 3, 'creative': 5}
points += creativity_points.get(verdict['creativity'], 0)
addr = gl.message.sender_account
raw = self.player_stats.get(addr, '{"score":0,"streak":0,"history":[]}')
stats = json.loads(raw)
stats['score'] = stats.get('score', 0) + points
stats['streak'] = stats.get('streak', 0) + 1
history = stats.get('history', [])
history.insert(0, {
'word': word,
'sentence': sentence,
'verdict': verdict_json,
'points': points,
})
stats['history'] = history[:10]
self.player_stats[addr] = json.dumps(stats)
lb_raw = self.leaderboard.get(addr, '{"score":0}')
lb = json.loads(lb_raw)
lb['score'] = lb.get('score', 0) + points
self.leaderboard[addr] = json.dumps(lb)
Five Things Worth Understanding
1. The runner comment is not optional
Line 1 must be exactly:
# { "Depends": "py-genlayer:0.1.0" }
Nothing above it. No blank line. No spaces before the #. Use the versioned tag — :test is not allowed on Bradbury in production mode.
2. No int in state
int is a forbidden storage type in GenLayer contracts. If you put count: int in your class body, Studio will refuse to load the schema with no helpful error message. Store everything as str. Put numbers inside JSON strings inside your TreeMap values.
3. All state is JSON strings in TreeMap
The pattern throughout this contract is: read from TreeMap → json.loads() → mutate the dict → json.dumps() → write back. It's verbose but it works reliably. TreeMap entries are strings, so you encode your structured data yourself.
4. sort_keys=True is not optional
When building the JSON your nondet() function returns, always use sort_keys=True. Without it, Python's dict serialisation order isn't guaranteed to be identical across validators running different Python versions or implementations. One validator outputs {"correct": true, "creativity": "good"}, another outputs {"creativity": "good", "correct": true} — consensus fails. sort_keys=True eliminates this entire class of bug.
5. Cap the leaderboard
get_leaderboard() iterates the entire leaderboard TreeMap. As more players submit, that gets expensive. The contract trims to the top 20 entries before returning. If you're building something with potentially hundreds of players, think about this earlier rather than later.
The Prompt Design
The AI prompt is doing real work here. A few things that matter:
Explicit output format with an example. Giving the validators a concrete JSON example ({"correct": true, "creativity": "good", "feedback": "..."}) removes ambiguity about field names and value types.
“Respond ONLY with this JSON” — and then repeat it. Validators running different LLMs have different tendencies to add preamble or markdown fences. The strip and replace in nondet() handles most of this, but the prompt reinforcing it reduces the failure rate.
Two questions, not ten. The more you ask the AI to judge, the more surface area for validator disagreement. Two binary questions — correct usage yes/no, creativity in three bands — is the minimum viable judgment for this game.
Connecting the Frontend
Once deployed, the frontend calls the contract via genlayer-js:
import { createClient, createAccount } from 'genlayer-js';
import { testnetAsimov } from 'genlayer-js/chains';
import { TransactionStatus } from 'genlayer-js/types';
const account = createAccount();
const client = createClient({ chain: testnetAsimov, account });
// Submit a sentence (write — costs gas, waits for consensus)
const hash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: 'submit_sentence',
args: [sentence],
value: 0n
});
await client.waitForTransactionReceipt({
hash,
status: TransactionStatus.FINALIZED,
retries: 60,
interval: 5000
});
// Read player stats (free — no gas)
const stats = await client.readContract({
address: CONTRACT_ADDRESS,
functionName: 'get_player_stats',
args: [walletAddress],
stateStatus: 'accepted'
});
After finality, fetch the real validator addresses from the transaction receipt:
const res = await fetch(RPC_URL, {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'gen_getTransactionReceipt',
params: [{ txId: txHash }],
id: 1
})
});
const data = await res.json();
const validators = data.result.consumedValidators;
These are the real addresses of the five validators who judged the sentence. Showing them to the player makes the consensus process tangible — which is the whole point of building on GenLayer.
Build It Yourself
The pattern in this contract — TreeMap[Address, str] for per-wallet state, gl.exec_prompt() for AI judgment, gl.eq_principle_strict_eq() for consensus, discrete output bands to prevent validator disagreement — is reusable for any game or application where you need persistent on-chain state per user.
Forum. Voting system. On-chain reputation. Prediction market. The plumbing is the same.
GenLayer is still early. The tooling has rough edges. But the primitive it offers — AI judgment as a first-class part of consensus — is genuinely new, and worth the friction of building on a testnet that occasionally goes down.
knisaci builds on GenLayer, Base, and Mantle. Follow for more developer write-ups. The Word Challenge contract and frontend are open source — link in bio.
Photo by Andrei Castanha on Unsplash
메타데이터
- post_id
- 13ec4e9ab3ff
- slug
- per-wallet-state-on-genlayer-how-i-built-a-word-game-with-treemap-ai-judging-and-a-live-13ec4e9ab3ff
- url
- https://medium.com/@knisaci14/per-wallet-state-on-genlayer-how-i-built-a-word-game-with-treemap-ai-judging-and-a-live-13ec4e9ab3ff
- canonical_url
- https://medium.com/@knisaci14/per-wallet-state-on-genlayer-how-i-built-a-word-game-with-treemap-ai-judging-and-a-live-13ec4e9ab3ff
- author_url
- https://medium.com/@knisaci14
- status
- ok
- fetched_at
- 2026-06-09 15:37:30