Skip to main content
Werewolf·Live demonstration● PLAYABLE·
click frame to playhow it was built ↓

Social deduction at a round table: vote out the werewolves before they pick off the village - with nobody able to see who voted for whom.

Under the hood

The game. Werewolf is N-player social deduction with day/night phases, secret roles, and elimination votes. The client renders a 3D round table with player avatars in Three.js.

Chains. Werewolf splits across two chains: Arbitrum hosts the lobby (createGame / joinGame on a PaimaL2Contract), while the secret voting runs on Midnight. The EVM side is swappable; Midnight is where the privacy guarantees come from.

Running on EffectStream. The EffectStream state machine tracks the EVM lobby and the on-chain round state, and computes the leaderboard. Votes are proved client-side in a WebAssembly worker and submitted through the batcher. A trusted-but-verifiable host node drives the round loop - it provides role witnesses and resolves each phase by decrypting votes - but every step is committed on-chain, so it can't lie: the whole game is auditable at the end.

Leaderboard. Players earn points tracked into the leaderboard endpoint (the Standings panel). No achievements.

The Compact contract

</>View source on GitHub

Source: packages/shared/contracts/midnight/contract-werewolf/src/Werewolf.compact

The headline is secret voting: each vote enters the contract already encrypted, only the host can decrypt it, yet the contract still enforces who may vote (a Merkle proof against the alive set) and that nobody votes twice (a per-round nullifier). Roles are committed up front and revealed only at the end, so the trusted host stays honest by construction.

Admin secret commitment

The host's authority is a hash commitment, not a wallet pubkey. At game creation the admin's secret is committed as adminSecretCommitment; every admin-only circuit pulls the secret back through the wit_getAdminSecret witness and asserts the recomputed commitment matches. Same idea as Go Fish's owner check, scoped per game via the witness signature (gameId).

Compact`adminPunishPlayer` (admin gate)
export witness wit_getAdminSecret(gameId: Uint<32>): Bytes<32>;

export circuit adminPunishPlayer(
_gameId: Uint<32>,
_playerIdx: Uint<32>,
): [] {
const gameId = disclose(_gameId);
const playerIdx = disclose(_playerIdx);
const state = games.lookup(gameId);

const adminSecret = wit_getAdminSecret(gameId);
assert(
Crypto_computeAdminSecretCommitment(adminSecret) == state.adminSecretCommitment,
"Only Admin can punish",
);

const pk = PlayerKey { gameId: gameId, playerIdx: playerIdx };
if (players.member(pk) && players.lookup(pk).isAlive) {
const current = players.lookup(pk);
players.insert(pk, PlayerState { ...current, isAlive: false });
games.insert(gameId, GameState {
...state,
aliveCount: (state.aliveCount - 1) as Uint<32>,
});
}
}

Secret voting (encrypted vote + alive proof + nullifier)

Every day-phase vote enters the contract as an already-encrypted ciphertext built off-chain in the player's witness (wit_getActionData). The contract never learns what was voted - only the host's key decrypts it later. But the contract still enforces two things: (1) the voter is in the current alive set, proven by a Merkle path against aliveTreeRoot without revealing which player they are, and (2) they haven't already voted this round, via a per-round nullifier inserted into a public Set. The ciphertext lands in roundVotes keyed by the nullifier. The result is a verifiable secret ballot.

Compact`voteDay`
// Witness implemented by the player off-chain. ActionData wraps an encrypted
// vote (only the host's key decrypts it), a Merkle path proving the player is
// in the alive set, and the leaf secret used to nullify.
export witness wit_getActionData(gameId: Uint<32>, round: Uint<32>): ActionData;

export ledger voteNullifiers: Set<Bytes<32>>;
export ledger roundVotes: Map<VoteKey, Bytes<3>>;

export circuit voteDay(_gameId: Uint<32>): Bytes<32> {
const gameId = disclose(_gameId);
const state = games.lookup(gameId);
assert(state.phase == Phase.Day, "Not Day phase");

const actionData = wit_getActionData(gameId, state.round);
const encryptedVote = disclose(actionData.encryptedAction);
const merklePath = actionData.merklePath;
const leafSecret = actionData.leafSecret;

// (1) Prove the voter is alive, anonymously
const leaf = Crypto_hash(leafSecret);
assert(
Crypto_verifyMerkleProof(state.aliveTreeRoot, leaf, merklePath),
"Invalid Merkle Proof",
);

// (2) One vote per voter per round - nullifier blocks the second
const nullifier = Crypto_computeNullifierDay(gameId, state.round, leafSecret);
const publicNullifier = disclose(nullifier);
assert(!voteNullifiers.member(publicNullifier), "Double voting detected");
voteNullifiers.insert(publicNullifier);

// Store the ciphertext for the host to decrypt; the contract never sees the vote
roundVotes.insert(
VoteKey { gameId, phase: Phase.Day, round: state.round, nullifier: publicNullifier },
encryptedVote,
);
return publicNullifier;
}

Post-game fairness via master secret reveal

Role assignments are committed to public state at game creation but stay encrypted until the game ends. When the host reveals the master secret, anyone can recompute the salt for each playerIdx and verify the stored roleCommitment matches the claimed role. The host can't retroactively re-assign roles - the commitments were locked in at createGame time.

Compact`verifyFairness`
export circuit verifyFairness(
_gameId: Uint<32>,
_masterSecret: Bytes<32>,
_playerIdx: Uint<32>,
_assignedRole: Uint<8>,
): Boolean {
const gameId = disclose(_gameId);
const masterSecret = disclose(_masterSecret);
const playerIdx = disclose(_playerIdx);
const assignedRole = disclose(_assignedRole);

const state = games.lookup(gameId);

// Step 1: the revealed master secret must hash to the public commitment
const calculatedCommit = Crypto_hash(masterSecret);
if (calculatedCommit != state.masterSecretCommitment) return false;

// Step 2: re-derive this player's salt from the master secret
const saltSeed = std_persistentHash<[Bytes<32>, Bytes<32>, Uint<32>]>(
[masterSecret, pad(32, "salt"), playerIdx],
);

// Step 3: re-derive the role commitment and compare to the stored one
const pk = PlayerKey { gameId: gameId, playerIdx: playerIdx };
if (!players.member(pk)) return false;
const roleCommitment = players.lookup(pk).roleCommitment;
const derivedCommit = Crypto_commitRole(assignedRole, saltSeed);
return derivedCommit == roleCommitment;
}
Standings
loading…
Honors
loading…