This is a submission for the MLH x DEV Writing Challenge
What I Built
The last time Ayush Kumar and I entered the same hackathon, he led the team that beat us. His team took first on our track. Mine took second.
So when MLH's Midnight Hackathon opened in July, we made sure that couldn't happen again. This time he was on our team.
What the four of us built that weekend started with nine lines of someone else's Python. This is how an open-source Among Us clone picks its impostor in multiplayer:
# AI0702/Among-Us-clone, game.py lines 1365-1373 (Unlicense)
if p[0] > self.player_highest_id:
self.player_highest_id = p[0]
if self.player.player_id > self.player_highest_id and self.player.imposter == False:
print("yes")
self.player_highest_id = self.player.player_id
self.player.imposter = True
elif self.player.player_id < self.player_highest_id and self.player.imposter == True:
print("no")
self.player.imposter = False
Read it aloud: every client watches the player IDs coming in. If mine is the highest I've seen, I make myself the impostor and print yes. If a higher one turns up, I quietly demote myself and print no.
Nobody dealt anything. The impostor appointed itself. Whoever holds the biggest number is the impostor, every match, 100% of the time. Know the IDs, and you know the impostor before anyone has left the cafeteria.
The first time you read it, it's funny. The second time, it's a little unsettling, because this isn't a quirk of one hobby project. It's just the one you're allowed to see. Every online social-deduction game has a dealer you can't audit: a server that picks the impostor, records who killed whom, counts the votes, and tells you what the ejected player "was". You trust it because you have no alternative.
That was the inspiration. The question we took into the weekend wasn't "how do we pick the impostor fairly?" (Fisher-Yates answered that in 1938). It was:
How do you build a game server that can still run the match, but can no longer lie about it?
A deal is a claim. A claim is a commitment. A commitment can be opened.
Left: the dealer we inherited. Right: the one we replaced it with, re-run over 100,000 seeds for this post.
Before you read further: this ran in mock mode
The winning demo ran in MN_MODE=mock, not on Midnight testnet. Mock mode is the bridge's default. It swaps the chain for an in-process ledger that enforces exactly the same rules as our Compact contract, and every rejection message in this post came from that code. It uses sha256 instead of persistentCommit, generates no zero-knowledge proofs, and runs in the bridge's own process. The dashboard labels all of that on every page.
We also wrote one rule into our spec on day one and never broke it: the server can still see the secrets. It can no longer forge them. I'd rather you read the rest knowing that than find it in a footnote.
We decided not to build a game
My first commit landed at 21:16 on July 18. By then we'd already made the decision that shaped everything else: we would not make a new game.
That's the obvious move for a Gaming Track: invent something small and chain-native. But a new game built around a blockchain only proves the blockchain can run a new game. It says nothing about the games people already play, on servers they can't see into.
So we took the existing clone, AI0702/Among-Us-clone (Python, pygame, LAN multiplayer over pickle-on-TCP, Unlicense), and gave ourselves one constraint: retrofit fairness without redesigning the game. The game and its characters belong to Innersloth, and the engine belongs to AI0702. We built the part that stops it lying, and we called it Shadow Protocol.
That meant surgery with a very small scalpel:
- The
print("yes")dealer came out. That spot in the code now sayspass. - Hooks went in wherever the game already knew something had happened: the deal, a kill, a vote, an ejection, and each of the four ways a match can end.
-
One new Python file,
midnight_hooks.py, carries those events to a TypeScript bridge that talks to Midnight.
No sprites, screens, menus or network payloads changed for the fairness layer. Every patch carries a # [MIDNIGHT] marker (15 in game.py), so anyone can grep for exactly what we touched.
5 players -> 5 commitments -> every kill, vote and ejection checked against them -> 1 winner -> 5 openings, 5/5 verified.
That's the pitch: anti-cheat as a layer. If it fits onto a hobby pygame clone with one new file, it fits onto your game server.
What it stops
| Threat | Base repo | Shadow Protocol v1 | How |
|---|---|---|---|
| Rigged role assignment | Rigged by design | Checkable | Deal comes from a seed committed before any role exists |
| A non-saboteur kills | Undetectable | Rejected | Kill must prove the killer committed as SABOTEUR |
| An agent claims to be the saboteur | Undetectable | Rejected | The claim must reopen that seat's commitment |
| Double voting | Undetectable | Rejected | One nullifier per seat per meeting |
| Lying about the ejected role | Undetectable | Rejected | Ejection must open the day-one commitment |
| Rewriting history | Trivial | Detectable | Every seat is reopened at game end |
| Operator reading the roles | Yes | Still yes | Documented v1 limit |
That last row stays in on purpose.
Spec first, with a CUT list
Before anyone opened an editor, we wrote a spec, SHADOW_PROTOCOL_PYBRIDGE_BUILD_PROMPT.md, written to be handed straight to AI coding agents. Its first instruction: "Follow the LOCKED SCOPE. Work through phases in order; pass each phase's acceptance tests before continuing."
The most useful part was the refusals. Exactly six on-chain events: deal, kill, vote, ejection, winner, audit. A CUT list: extra roles, a night phase, Lace wallets, a web client, voice chat. An AI agent will cheerfully build all five if you let it. The spec says no in writing, so nobody has to say it at 4am.
Three decisions worth stealing
Never block a frame. Proofs take seconds. A pygame frame takes about 16 milliseconds. Every hook is fire-and-forget into a background queue with 5-second timeouts and capped backoff, and nothing on the chain side can reach the render loop.
Except the deal. If roles went out before the commitments were anchored, the whole point would be gone. So the deal runs on its own thread, and until it lands nobody is the saboteur. There is no silent fallback to print("yes").
Trust nothing twice. A non-saboteur's kill is refused by the bridge with 409 NOT_SABOTEUR before a transaction exists, and the contract would refuse it anyway. A failed proof is never retried, because a failed proof is either a bug or a forgery.
What I learned
- Honesty is a design input, not a disclaimer. Writing "the server can see secrets" into the spec on day one decided what we built: forgery-proof, not secret-proof. That's a smaller claim, and every part of it is true.
- A CUT list is a feature. Six finished events beat twelve half-built ones, and "Completion" was a judging criterion for a reason.
- Fairness bugs are quiet. Our shuffle discards one byte value, 255, and I only understood why while writing this post. Remove that line and the dealer leans toward the same player the old one always picked. (The math is in Partner Technologies.)
Demo
The end of an honest match, in the art Subarna redrew. What makes it honest happens off screen:
The judge-facing /audit dashboard, from a re-run of the same code for this post. Left: mid-meeting, the killer and voters exist nowhere. Right: game over, every commitment reopened, 0 mismatches.
Among Us (clone) — with Shadow Protocol (Midnight integration)
An unofficial clone of the popular multiplayer game 'Among Us', recreated in Python using various supported libraries. Support for upto 5 (or more?) players via LAN. Assets ripped from the from the game.
MLH Hackathon — Midnight Gaming Track: this fork retrofits a zero-knowledge fairness layer ("Shadow Protocol") onto the game with zero UI changes. See SHADOW_PROTOCOL.md for the full integration guide, quickstart, trust model, and test instructions Engine derived from AI0702/Among-Us-clone (Unlicense); all Midnight integration built during the hackathon.
Requirements
- python 3.X
- pygame
- pytmx
- pickle
- select
- socket
- asyncore
- threading
- pyaudio (optional, for voice chat)
How to run
Singleplayer
- To start the game
python main.py - Choose 'Freeplay' from the menu to start playing
Local Multiplayer
- To start the game server for multiplayer support
python server.py - To start the game client
python main.py - Choose 'Local' from the menu
- Enter IP Address…
Verify it in 60 seconds, no keys
-
The rigged dealer is real:
AI0702/Among-Us-clone/game.py#L1365-L1373, pinned to a commit. -
It's gone:
Among-Midnight/game.py#L1433-L1453. The spot sayspass, and the role now comes from the bridge. -
The kill rule is a circuit:
shadow_ledger.compact#L113-L127. -
The hook can't crash the game:
midnight_hooks.py#L157-L172.
Run it (mock mode)
git clone https://github.com/SoumyaEXE/Among-Midnight
cd Among-Midnight/midnight-bridge
npm install && npm test # 14 passing
npm run dev # dashboard at http://127.0.0.1:8088/audit
I ran exactly this on a clean Windows machine for this post: 14 of 14 passing in about 12 seconds. The game itself needs Python 3.11 (asyncore is gone in 3.12), and the README has the rest. One gotcha: .env.example says port 5310, but the bridge and hooks both default to 8088. Skip the .env and they agree.
Partner Technologies
The partner technology is Midnight, specifically its contract language, Compact. We chose it for one thing a server log can never give you: proving a statement about a secret without revealing the secret. In a game about hidden roles, that's the entire problem.
Step one: lock the roles before anyone sees them
At the start of a match, the bridge deals the roles and seals each one into a commitment. Only the commitments go on-chain, and they go before a single role reaches a player.
circuit openCommitment(role: Uint<8>, seat: Uint<8>, salt: Bytes<32>): Bytes<32> {
return persistentCommit<[Field, Field, Bytes<32>]>(
[role as Field, seat as Field, gameSeed], salt);
}
Read it aloud: a seat's commitment is its role, its seat number and this match's seed, sealed with 32 random bytes. Each ingredient blocks a specific cheat. Without the seat, the saboteur's commitment becomes a portable badge you could pin on anyone. Without the gameSeed, last night's opening replays into tonight's match. Without the salt, two possible roles means anyone can brute-force it in two guesses.
Step two: a kill that proves the killer without naming them
This is the moment the project felt worth doing:
export circuit recordKill(victim: Uint<8>): [] {
const v = disclose(victim);
const creds = killerCreds(); // private: [role, seat, salt]
const role = creds[0];
const seat = creds[1];
const salt = creds[2];
assert(gamePhase == 1, "kills only while LIVE");
assert(role == 2, "killer must be SABOTEUR");
checkValidAliveSeatCommitment(role, seat, salt);
assert(v < 5, "bad victim seat");
assert(alive.lookup(v), "victim already dead");
assert(v != seat, "self-kill");
alive.insert(v, false);
killCount.increment(1);
}
Read it aloud: the prover privately holds a role, a seat and a salt. The role must be SABOTEUR, those three must reopen that seat's commitment, and that seat must be alive. Then the victim dies.
Look at what goes through disclose(): only victim. The killer's seat goes into the proof and never comes out. The chain learns that a real saboteur killed seat 0, and nothing about who. That's the whole of Among Us, reduced to one sentence the chain can check.
The obvious workaround, an agent simply saying it's the saboteur, dies on the next line. Its role, seat and salt don't reopen its own commitment, so the ledger answers killer commitment mismatch. You can lie to the crew. You can't lie to your own commitment.
Step three: votes that count once and name nobody
const nullifier = persistentHash<[Bytes<32>, Field, Bytes<32>]>(
[salt, meetingRound as Field, pad(32, "vote")]);
assert(!voteNullifiers.member(disclose(nullifier)), "double vote");
voteNullifiers.insert(disclose(nullifier));
Every vote leaves a fingerprint, H(salt | meetingRound | "vote"). The same seat in the same meeting always leaves the same fingerprint, so a second vote collides. A new meeting changes the round, so the seat can vote again. And because the salt is secret, nobody can trace a fingerprint back to its voter.
Step four: the ejection screen can't lie
"Player X was The Impostor." In every centralized version, that line is just the server's word. Here it's an assertion:
assert(openCommitment(r, s, slt) == roleCommitments.lookup(s),
"ejection reveal does not open commitment");
The server must open the commitment it made before the match started, so the wrong role or salt fails. At game end, auditReveal opens every seat the same way, so anyone can recompute the match. ZK hides what must stay hidden during the match. Openings make everything checkable after it.
Could this just be a signed server log?
It's the test I hold every Web3 project to. No. A signed log proves the server said something. It can't prove a kill came from the committed saboteur without naming the saboteur, because the verifier would need the role, and the role is the secret. It can't enforce one vote per player without revealing who voted. And it can't stop the server from signing a different story tomorrow. The commitments, private witnesses, nullifiers and the SETUP -> LIVE -> MEETING -> OVER phase machine each close one of those gaps. Remove Midnight and the project is back to print("yes") with better logging.
Where Midnight fought back
The contract, bridge, queue, mock ledger and dashboard all came together. What didn't was the last link: a wallet that could fund and sign transactions, wired through the SDK to the deployed contract. On the final day, while Ayush was on the day shift pushing the integration, this comment was the honest state of it:
// midnight-bridge/src/providers.ts
// VERIFY-AGAINST-DOCS (Phase 2 task): the headless deployer wallet. wallet-sdk
// 1.0.0 is a modular facade (shielded/unshielded/dust/hd); copy the exact
// wallet-from-seed helper from the current example-bboard (its testkit wiring)
// and finish buildWallet() below. Everything else here is ready.
"Everything else here is ready" is doing a lot of work. There was no "Midnight plus pygame" tutorial to follow, because Web3 gaming on a privacy chain is close to unexplored. So we did what the spec's cut order told us to. It had one line we treated as law: "Never cut: deal commitments, recordKill role proof, audit dashboard, video, README disclosure." We cut none of those. We put the chain behind one flag, MN_MODE, made mock mode enforce the same rules, and wrote the limitation into the README instead of hoping nobody would ask.
What Midnight made easy was the thinking. When disclose() is a keyword, the privacy review is a grep. Our spec has a test that literally reads "grep generated output: no role disclosure outside eject/audit."
Proof: one match, all the way down
Diagrams are easy to draw and hard to check. So here is one match with the real value at every step: a mock-mode run of the repo's bridge, with the test suite's fixed seed and five-player lobby, played the week I wrote this. Alongside the honest moves, I tried every cheat I could think of.
152003 was The Impostor.
The bridge was not The Impostor. The commitments would have said so.
Cheats 7, 11 and 13 were fired straight at the mock ledger, because the bridge refuses to even build those transactions. I had to go around the bridge just to confirm the contract says no too.The same run as a copyable table (15 steps, 5 cheats)
#
What happened
Actual value
1
Five players join
IDs
4213, 88231, 90111, 152003, 731188
2
Who the old dealer would pick
731188, the highest ID
3
Seed committed
abab…abab (fixed for tests)
4
Fisher-Yates deal
Saboteur is seat 3, player 152003
5
Seat 3's commitment on the ledger
c2dd90ba…d7f86270
6
Cheat: an agent kills
409 NOT_SABOTEUR / killer must be SABOTEUR
7
Cheat: an agent claims to be saboteur
killer commitment mismatch
8
The saboteur kills
public:
{"victimSeat": 0}, no killer anywhere
9
Seat 1 votes for seat 3
nullifier
59f4fcd4…93248158
10
Cheat: seat 1 votes again
409 DUPLICATE / double vote
11
Cheat: eject seat 3 as "AGENT"
ejection reveal does not open commitment
12
Seat 3 ejected honestly
opens to role 2, SABOTEUR
13
Cheat: saboteur declared winner
saboteur was ejected: agents must win
14
Agents win
reason:
ejection
15
Audit
5/5 commitments recompute
My favourite rejection was an accident. Between two runs I hit the bridge's /reset route and dealt again:
503 CHAIN_UNAVAILABLE
detail: initGame callable once, in SETUP
/reset clears the bridge's memory, not the ledger, and the ledger had already been dealt. Mildly annoying, and exactly right. A ledger you can reset is just a log with extra steps.
Check one number yourself. Row 9's nullifier is sha256 over a domain tag, seat 1's salt (published at the audit), the round, and "vote":
import hashlib
salt = bytes.fromhex("87ecf21de7c9b103a65303941b74c3994f514a036e2c6d8a3f8738085d5cb470")
print(hashlib.sha256(
b"shadow-protocol:nullifier:v1" + salt + (0).to_bytes(4, "big") + b"vote"
).hexdigest())
# 59f4fcd4bf16185f29fc80d68edf38a0fb7a5c014797ff8c215976f993248158
It matches the bridge to the last digit. Change the round to 1 and you get 4a077d1a…, a new fingerprint, which is why seat 1 can vote next meeting but not twice in this one.
I ran the repo's own That's 1/5 per seat. It holds because of one small line. The shuffle turns random bytes into seat numbers with I worked out the exact odds without that line. Seat 4 holds the highest player ID. Delete that one line and the new dealer leans, very slightly, toward exactly the player the old dealer always picked. It's a 0.31-point lean instead of 80 points, but a dealer that's only a little rigged is still rigged.Is the dealer actually fair? The 100,000-seed test and the one byte that matters
fisherYates() over 100,000 fresh seeds and counted who drew the saboteur:
seat 0 20.10%
seat 1 19.93%
seat 2 19.95%
seat 3 19.85%
seat 4 20.16%
% bound, and when it needs a number below 5 it first throws away byte 255, since 256 doesn't divide evenly by 5.255 % 5 == 0 gives the first swap a 52/256 chance instead of 51/256, and that swap decides who ends up in seat 4:
seat 0 seat 1 seat 2 seat 3 seat 4
with reject 20.00% 20.00% 20.00% 20.00% 20.00%
without 19.84% 19.84% 20.08% 19.92% 20.31%
"Salts leaked before reveal: 0" isn't a slogan. It's test B8: until the game is over, the audit endpoint must not contain the words role, salt or saboteur anywhere.
Hackathon Experience
The hackathon: MLH's Midnight Hackathon, July 17-19, 2026. It was listed in New York. I was at home in Howrah.
Who I teamed up with. Subarna Maity and Sourish Panda are my school friends. I've done almost every hackathon with Subarna, DEV challenges included, and at some point you stop needing to explain things to each other. Ayush was the new piece. The last time we'd met at a hackathon, it was across the results table: his team first, mine second. There's a particular kind of respect you have for someone who beat you fairly, and the easiest way to act on it is to stop competing with them.
We split the work the way it actually needed doing:
- *I (@soumyadeepdey) * built the first working version: the clone running end to end with the fairness layer wired in.
- Subarna remade the game's art, redrawing every asset with Midnight branding. He also chased the bugs, and Devpost credits him with the LAN multiplayer and testing.
- Ayush owned the Web3 side: the Midnight integration and the contract.
- Sourish made it understandable to everyone else: the documentation, the presentation and the demo video.
The team, the weekend of the build.
The atmosphere was two shifts and one git log. Subarna, Sourish and I took the night and pulled the all-nighter. Ayush took the day. The build never really stopped, but nobody had to be awake for all of it. You can read the handover in the commits: mine starts at 21:16 on July 18, and Ayush's run from 13:01 to 13:23 the next afternoon. One of them is my favourite line in the whole history:
b2a2ef3 13:14 fix: replace Windows backslashes with cross-platform
forward slashes in asset paths
The base game dates from 2022 and loads images with Windows paths like Assets\Images\Meeting\chat.png. On Ayush's Mac, those point nowhere. So nine minutes before the demo guide was committed, someone on the day shift was fixing seventeen image paths in a four-year-old game. That's what a retrofit really looks like. You inherit everything, including the backslashes.
What I'll remember most isn't the results email, and it isn't a proof verifying. It came earlier: the first time it was a game. The clone running, clients joining, and the fairness layer sitting underneath it instead of bolted on beside it. Before that moment, Shadow Protocol was a spec and a contract. After it, it was something you could play, cheat at, and get caught.
We won the Gaming Track: $200 each, for all four of us.
Last time, Ayush and I were on opposite sides of the results. This time we were on the same side.
Emergency meeting: known limitations
Every line here is also in the repo's README.
| Limitation | What it means |
|---|---|
| Mock ledger in the demo | No proofs generated; the ledger runs in the bridge's process, and the dashboard says so |
| Testnet path unfinished |
buildWallet() and contract call sites are marked VERIFY-AGAINST-DOCS
|
| The bridge holds every salt | It could vote for a seat that didn't vote, but an agent can never produce a valid kill |
| Pickle over LAN | Inherited from the base game. Trusted-LAN demo only |
| Task wins | Game-attested; the contract still enforces "saboteur ejected means agents win" |
| Bridge crash mid-match | Pending in-memory events are lost; audit_log.json keeps the rest |
| Night Coin | A 100-coin stake counter in the bridge, not a Midnight token |
| Dashboard vote count | The meeting-opener counts as a vote, so 3 votes show as 4 in the screenshots |
Next: a web client where players hold their own salts through Lace wallets, so the bridge can't see roles at all. Then mental-poker dealing, so no single party knows the deal, the bridge included.
What this unlocks
Among Us is the costume. Underneath is a primitive: any game server with a hidden deal can commit to it on day one and be forced to open it later. A card game commits the shuffled deck. A battle royale commits its loot table before the drop, and "rigged RNG" stops being a Reddit thread and becomes a checkable claim. A loot box commits its odds before the purchase. None of those need a new game. They need one new file next to the old one, and a contract that knows how to say no.
FAQ
How do you prove a player's role without revealing it on Midnight?
Commit to it before the game with persistentCommit([role, seat, gameSeed], salt) and publish only the commitment. Then pass role, seat and salt into a circuit as a private witness. The circuit reopens the commitment and asserts the role, for example assert(role == 2, "killer must be SABOTEUR"). Only values wrapped in disclose() become public; in recordKill, that's just the victim.
What is a nullifier in a zero-knowledge vote?
A fingerprint derived from a voter's secret: the same every time for one voter in one election, and untraceable to the voter. Here it's H(salt | meetingRound | "vote"). The contract stores the fingerprints it has seen, so a second vote in the same meeting hits "double vote".
Can you add zero-knowledge anti-cheat to an existing game server?
Yes, if you keep it out of the game loop. We added one Python file and 15 marked patches to an existing pygame game and changed no network payloads. Every chain call is fire-and-forget. The one blocking step, the deal, runs on its own thread, and the match has no impostor until it's anchored.
Why does the bridge say initGame callable once, in SETUP?
The ledger was already dealt for this match. /reset only clears the bridge's in-memory session, not the ledger. Restart the bridge in mock mode, or deploy a fresh contract on testnet.
Credits
-
AI0702/Among-Us-clone (Unlicense), the engine, including the
print("yes")that started all this. - Innersloth, for Among Us and its characters. This is an unofficial, non-commercial fan project.
-
Midnight Network, for Compact, the proof server and
example-bboard. - MLH, for the Midnight Hackathon (our Devpost).
- Subarna Maity (art, bug fixes, LAN multiplayer), Ayush Kumar (Midnight integration and contract), Sourish Panda (docs, presentation, demo video): two school friends and one former rival.
The original clone printed yes when it made you the impostor. Ours doesn't print anything. It commits, and when the match is over, anyone can check.
Built by a team of four within the hackathon window, spec-first with AI coding agents. This post was drafted with AI assistance and checked against the source. Every number comes from the repo, its 14 tests, or the mock run above, re-verified the week this was published.









Top comments (3)
i got some more memes
@heyitsjem @jess @ben back again with another blog hope guys will like this one!