Among-Midnight - Shadow Protocol Documentation
MLH Hackathon · Midnight Gaming Track
Table of Contents
- Overview
- Why ZK Fairness?
- Architecture
- Shadow Ledger Contract
- Trust Model & Cryptographic Guarantees
- Quickstart
- HTTP API
- Math Behind the Commitments
- Known Limitations
- Credits
Overview
Among-Midnight is an unofficial Python clone of Among Us augmented with a zero-knowledge anti-cheat layer called Shadow Protocol, built on the Midnight Network.
The base game's multiplayer dealer was literally rigged — the impostor was always the
player with the highest player_id. Shadow Protocol eliminates this by binding the role
deal, every kill, every vote, and every ejection to ZK commitments on-chain.
"The game still runs in real time — it just can no longer lie about it."
Why ZK Fairness?
Every centralised game server has a fundamental trust problem:
- Roles can be forged or pre-determined.
- Kills can be fabricated by non-impostors.
- Votes can be stuffed or replayed.
- Match history can be rewritten after the fact.
Shadow Protocol addresses each of these with on-chain zero-knowledge proofs, making cheating cryptographically impossible — not just against the rules.
Architecture
┌────────────────┐ pickle over TCP :4321 ┌───────────────────────┐
│ pygame clients │◄────────────────────────►│ server.py (relay) │
│ (unchanged UI) │ └───────────────────────┘
└──────┬─────────┘
│ HTTP JSON localhost :5310 (fire-and-forget — midnight_hooks.py)
▼
┌────────────────────────────────┐
│ midnight-bridge (Node 20, TS) │
│ • seat map, roles, salts (RAM)│
│ • tx queue (serialized+retry) │──► proof server (Docker :6300)
│ • ZK proof generation │
│ • contract client │──► Midnight testnet (ShadowLedger)
│ • audit dashboard /audit │
└────────────────────────────────┘
Key Components
| Component | Language | Role |
|---|---|---|
game.py |
Python 3.11 | Game loop + ZK hook call-sites |
midnight_hooks.py |
Python 3.11 | Fire-and-forget queue worker |
server.py |
Python 3.11 | Dumb pickle relay over TCP |
midnight-bridge/ |
TypeScript 5.9 | Bridge: proofs, contract, dashboard |
shadow_ledger.compact |
Compact | On-chain ZK contract (Midnight) |
Shadow Ledger Contract
The core fairness logic lives in
shadow_ledger.compact.
Game Phases
0 SETUP → initGame() → 1 LIVE
1 LIVE → startMeeting() → 2 MEETING
2 MEETING → recordEjection() → 1 LIVE
2 MEETING → endMeetingNoEjection() → 1 LIVE
any → declareWinner() → 3 OVER
Exported Circuits
// ZK showpiece: proves the caller is SABOTEUR without revealing their seat
export circuit recordKill(victim: Uint<8>): []
// One vote per seat per meeting via salted nullifier; voter identity stays private
export circuit recordVote(target: Uint<8>): []
// Ejection must open the day-one commitment — server cannot lie about the role
export circuit recordEjection(seat: Uint<8>, role: Uint<8>, salt: Bytes<32>): []
// Endgame: opens every seat's commitment so anyone can verify the match forever
export circuit auditReveal(seat: Uint<8>, role: Uint<8>, salt: Bytes<32>): []
Commitment Scheme
The commitment binds role + seat + gameSeed so it cannot be replayed for another seat or another match (spec §6.3):
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);
}
Trust Model & Cryptographic Guarantees
| Threat | Base Repo | Shadow Protocol v1 | Mechanism |
|---|---|---|---|
| Rigged role assignment | Rigged by design | Impossible | Fisher–Yates from on-chain gameSeed, audited at game end |
| Fake kill by non-saboteur | Undetectable | Impossible | recordKill requires ZK proof of role == SABOTEUR |
| Vote stuffing / double voting | Undetectable | Impossible | Per-meeting ZK nullifiers in contract state |
| Lying about ejected role | Undetectable | Impossible | Ejection must open the day-one commitment |
| Rewriting match history | Trivial | Impossible | Append-only chain record + full audit reveal |
| Server/bridge reading secrets | Yes | Still yes — v1 limitation | Roadmap: browser clients with Lace wallets |
Quickstart
1. Start the Bridge (mock mode — no chain required)
cd midnight-bridge
npm install
npm run dev # boots on http://127.0.0.1:5310 (MN_MODE=mock)
Open the audit dashboard: http://127.0.0.1:5310/audit
MN_MODE=mockruns an in-process simulated ledger that enforces the same circuit checks as the Compact contract — commitment openings, nullifiers, phase guards. The full game + dashboard work with no chain.
2. Start the Game (Python 3.11)
python3.11 -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
python server.py # LAN relay on :4321
python main.py # one per player; choose 'Local'
3. Real Midnight Testnet (Phase 2)
# Compact toolchain
compact update && compact compile --version # pin version into the contract pragma
# Proof server
docker compose -f proof-server-local.yml up -d
# Compile contract + bindings
cd midnight-bridge && npm run build:chain
Set MN_MODE=testnet in midnight-bridge/.env and fund your deployer address
with tDUST from the faucet. Do this on Day 1 — faucet/latency problems are
the #1 Midnight hackathon killer.
4. Run Tests
cd midnight-bridge && npm test
# 14 passing: B1–B8 + C-series analogues
HTTP API
All endpoints are served by the midnight-bridge on localhost:5310.
| Route | Method | Behaviour |
|---|---|---|
/health |
GET |
Liveness + deps + queue depth + session state |
/deal |
POST |
Synchronous — anchors seed + commitments on-chain, returns seatMap + saboteurPlayerId |
/kill |
POST |
202 enqueue; 409 NOT_SABOTEUR / DEAD_*, 422 self-kill |
/vote |
POST |
202; first vote of a round auto-opens the meeting; 409 DUPLICATE |
/eject |
POST |
202 (ejectedId: null = no ejection); 422 stale round |
/gameover |
POST |
202; queues declareWinner + 5× auditReveal, writes audit_log.json |
/reset |
POST |
Dev only — clears RAM session |
/events |
GET |
SSE stream of audit events (dashboard feed) |
/audit |
GET |
Judge-facing dashboard |
/audit/data |
GET |
Verification payload — reveals: [] until OVER; no role/salt leaks early |
Math Behind the Commitments
Role Assignment — Fisher–Yates Shuffle
The saboteur seat is selected via a deterministic Fisher–Yates shuffle seeded
by the on-chain gameSeed. For \( n \) players, the shuffle runs in \( O(n) \)
and produces a uniformly random permutation.
The probability that any specific player is assigned the Saboteur role is:
$$ P(\text{seat } i = \text{SABOTEUR}) = \frac{1}{n} $$
With \( n = 5 \) (the spec-locked default), each player has a 20% chance of being the Saboteur.
Vote Nullifier
To prevent double-voting, a per-seat, per-meeting nullifier is computed as:
$$ \text{nullifier} = H!\left(\,\text{salt} \;|\; \text{meetingRound} \;|\; \texttt{"vote"}\,\right) $$
where \( H \) is the Midnight persistentHash function. The nullifier is stored
in the on-chain voteNullifiers set. A second vote from the same seat recomputes
the same nullifier and fails the assert(!voteNullifiers.member(...)) check.
What Was Changed in the Game
All patches are tagged # [MIDNIGHT] in source.
| Patch | File | What |
|---|---|---|
| P1 Deal | game.py |
Rigged "highest player_id" dealer deleted; roles come only from bridge /deal |
| P2 Kill | game.py |
Killer's client posts /kill after the existing kill handling |
| P3 Vote | game.py |
Voter's client posts /vote once per meeting |
| P4 Eject | game.py |
Ejected client posts /eject; bridge dedupes by round |
| P5 Game over | game.py |
Each win branch posts /gameover |
| — | midnight_hooks.py |
The only new Python file: fire-and-forget queue worker, 5s timeouts, capped backoff |
No pickle payload shapes, sprites, screens, sounds, or menus were touched.
Known Limitations
- Bridge custody — the bridge holds all salts, so it can generate any seat's proofs. The chain still prevents anything inconsistent with the day-one commitments (an AGENT seat can never produce a valid kill proof).
- Pickle over LAN — inherited from the base repo; trusted-LAN demo only.
- Task-completion wins — attested by the game; the contract cannot see tasks (but it still enforces saboteur ejected ⇒ agents win).
- Bridge crash mid-match — pending events are lost (RAM state);
audit_log.jsonhas everything up to the last write.
Roadmap
- Web client with player-held salts + Lace wallets
- Mental-poker trustless dealing (removes bridge custody)
- Per-seat wallets
Screenshots



Credits
- Innersloth — Among Us game and assets
- Midnight Network — ZK smart-contract platform
- All Midnight integration built during the MLH hackathon
See also: SHADOW_PROTOCOL.md · DEMO_GUIDE.md


Log in or sign up for Devpost to join the conversation.