#loom video https://www.loom.com/share/45e4181c7cf24fb2bb4f886a9568a738
AmongSol is a real-time multiplayer social deduction game built for developers. Players join a shared code room, collaboratively write and fix Rust or Solana/Anchor programs, run live tests, and try to identify the hidden impostor sabotaging the codebase before time runs out. Stakes are locked on-chain at the start of every round and distributed automatically to winners through a Solana smart contract.
- Overview
- Screenshots
- Solana Integration
- Gameplay
- Project Structure
- Backend Architecture
- Challenge System
- WebSocket Protocol
- Requirements
- Running Locally
- Deployment
- Known Limitations
- Troubleshooting
AmongSol puts between two and four developers in a shared code editor. One of them is secretly assigned the impostor role. The rest are engineers whose job is to make all tests pass. The impostor's job is to quietly introduce bugs, stall collaborators, and escape detection until the round ends.
At the start of every game, each player stakes a fixed amount of SOL into an on-chain escrow vault managed by the AmongSol Anchor program. The vault only releases funds after the game concludes and the host signs a settlement transaction. Winners receive the full pot split according to the outcome — impostor takes everything on a win, civilians split the pot equally when they vote the impostor out.
The game is built on a Rust/Axum WebSocket backend, a Next.js frontend with a Monaco-based shared code editor, and an Anchor smart contract deployed to Solana Devnet.
Home Screen
Create Room
Lobby
Game View
Solana is the financial backbone of every AmongSol game. Rather than keeping stakes in a database or trusting the server with funds, AmongSol uses a purpose-built Anchor smart contract to custody all player stakes on-chain for the duration of the round. No one can access the vault until the game ends and the settlement instruction is invoked.
| Property | Value |
|---|---|
| Program ID | J1EHYJokNcbDGKYk3W8wkpmfAUogjD1NGq1jonPx2CnA |
| Network | Solana Devnet |
| RPC Endpoint | https://api.devnet.solana.com |
| Framework | Anchor |
| Default Stake | 0.1 SOL per player |
The staking program lives at programs/amongsol_staking/ and exposes three primary instructions:
initialize_game
Called by the host when a room is created. This instruction derives a Game Escrow PDA using the room ID as a seed and records the host public key, the agreed stake amount in lamports, and a settled flag initialized to false. No funds move at this step.
deposit_stake
Called once per player when they confirm their stake from the lobby. The instruction verifies the depositing wallet matches the expected stake amount and transfers lamports from the player's account into the Vault PDA. The Vault PDA is a system-owned account derived from the Game Escrow address. This instruction can only be called while the game has not been settled.
settle_game
Called by the host at the end of the game after winner determination. This instruction verifies the caller is the host recorded at initialization, marks the escrow as settled, and transfers lamports out of the Vault PDA to winner addresses. The transfer amounts are provided as instruction arguments by the frontend and are computed off-chain based on the game result.
Game Escrow PDA
seeds : ["game", room_id_bytes]
├── host : Pubkey
├── stake_lamports: u64
├── settled : bool
└── vault_bump : u8
Vault PDA
seeds : ["vault", game_escrow_pubkey]
└── lamports: u64 (sum of all player deposits)
The Game Escrow account stores metadata. The Vault is a bare system account that holds the actual SOL. Separating metadata from the lamport custody makes the settlement math simpler and avoids rent-exemption edge cases.
The full on-chain lifecycle of a single game round:
1. Host calls initialize_game(room_id, stake_lamports)
-> Game Escrow PDA is created on-chain
-> Vault PDA address is derived but not yet funded
2. Each player calls deposit_stake(game_escrow)
-> Player wallet transfers stake_lamports to Vault PDA
-> Backend receives ConfirmStake WebSocket message with tx signature
-> Backend verifies signature and marks player as staked
3. Game plays out entirely off-chain (WebSocket server)
-> Tests run, meetings are called, votes are cast
-> Backend determines winner
4. Results screen: host calls settle_game(game_escrow, winner_addresses, amounts)
-> Vault lamports are distributed to winners
-> Escrow.settled is set to true
-> Further deposits blocked
No admin key holds funds at any point. The host wallet is the only authority for the settlement instruction, and its identity is recorded at initialization time, so a malicious backend cannot redirect winnings.
The frontend handles all wallet and transaction logic in frontend/lib/solana.ts. It supports three wallet adapters out of the box:
- Phantom
- Solflare
- Brave Wallet
Key operations performed by the client library:
PDA derivation — Both the Game Escrow and Vault PDAs are derived client-side using @solana/web3.js PublicKey.findProgramAddressSync with the same seeds used on-chain. This lets the frontend construct transactions without any server round-trip.
Anchor discriminator construction — Instructions are built manually using Anchor's 8-byte discriminator (first 8 bytes of sha256("global:<instruction_name>")), which allows the frontend to call the program without a generated IDL client if needed.
Transaction submission — All transactions are signed by the user's wallet and sent to the configured RPC endpoint. The returned transaction signature is sent to the backend via WebSocket as a ConfirmStake or post-settlement confirmation message.
// Example: deriving the Game Escrow PDA client-side
const [gameEscrowPda] = PublicKey.findProgramAddressSync(
[Buffer.from("game"), Buffer.from(roomId)],
PROGRAM_ID
);
// Example: deriving the Vault PDA from the escrow address
const [vaultPda] = PublicKey.findProgramAddressSync(
[Buffer.from("vault"), gameEscrowPda.toBuffer()],
PROGRAM_ID
);Payout distribution is determined by who gets voted out:
| Outcome | Distribution |
|---|---|
| Ejected player is the impostor | Full vault transferred to the impostor |
| Ejected player is not the impostor | Vault split equally among all non-impostor players |
| Round ends with no ejection | Impostor wins; full vault transferred to impostor |
The payout amounts are computed on the backend after the game ends and passed as arguments to the settle_game instruction. The on-chain program does not independently compute winner logic — it trusts the host-signed instruction and enforces only that the host is the signer and the vault has not already been settled.
To redeploy the staking program to Devnet:
cd programs/amongsol_staking
# Build the program
anchor build
# Deploy to Devnet
anchor deploy --provider.cluster devnetAfter a fresh deploy, the program ID will change. Update all of the following (the local files and the hosted dashboards are separate — missing one leaves part of the stack pointing at the dead program):
declare_id!macro inprograms/amongsol_staking/src/lib.rs(or runanchor keys sync)[programs.localnet]/[programs.devnet]inAnchor.tomlNEXT_PUBLIC_AMONGSOL_STAKING_PROGRAM_IDinfrontend/.env.localAMONGSOL_STAKING_PROGRAM_IDinbackend/.envNEXT_PUBLIC_AMONGSOL_STAKING_PROGRAM_IDin the Vercel dashboard, then redeploy (build-time variable — see Deployment)AMONGSOL_STAKING_PROGRAM_IDin the Railway dashboard, then restart
The program's upgrade authority is the wallet that first deployed it (
~/.config/solana/id.json). Keep that keypair backed up — without it the program can never be upgraded again and you must deploy a brand-new program ID.
To verify the deployment:
solana program show <new_program_id> --url devnetFrontend (frontend/.env.local):
NEXT_PUBLIC_AMONGSOL_STAKING_PROGRAM_ID=J1EHYJokNcbDGKYk3W8wkpmfAUogjD1NGq1jonPx2CnA
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com
NEXT_PUBLIC_BACKEND_URL=https://amongsol-production.up.railway.app
Backend (backend/.env):
DATABASE_URL=postgresql://user:password@host:5432/database
AMONGSOL_STAKING_PROGRAM_ID=J1EHYJokNcbDGKYk3W8wkpmfAUogjD1NGq1jonPx2CnA
- A player opens the home screen and creates a new room, selecting a challenge map (Rust or Anchor).
- Other players join via a room code.
- Each player connects a Solana wallet (Phantom, Solflare, or Brave Wallet) from the lobby and deposits the required stake into the on-chain vault.
- The host starts the round once all four players have confirmed their stakes.
- The game assigns roles privately: one player is the impostor, the rest are engineers.
- Players see the challenge in a shared Monaco editor. Each player controls a distinct function. Engineers try to make tests pass; the impostor tries to introduce or preserve failures without getting caught.
- Any player can run
cargo testat any time. Results are broadcast to all players. - Any player can call an emergency meeting to discuss suspicious edits. The edit history is shared during the meeting.
- Players vote to eject a suspect. Simple majority carries.
- The game ends when the impostor is ejected, all tests pass with the impostor still in the room, or the 3-minute timer expires.
- The results screen shows the winner, the impostor's identity, and the payout amounts.
- The host signs the settlement transaction to release the vault to winners.
Each round lasts 3 minutes. A 30-second code lock warning fires before editing is disabled at the end of the round. The timer pauses during emergency meetings and resumes when voting concludes.
Voting requires a simple majority: floor(players / 2) + 1 votes to eject. If no majority is reached, the meeting ends with no ejection and the round continues.
amongsol/
├── frontend/ # Next.js 16 application
│ ├── app/
│ │ ├── page.tsx # Home screen
│ │ ├── create/ # Room creation and map selection
│ │ ├── join/ # Join room by code
│ │ ├── lobby/ # Waiting room and stake confirmation
│ │ ├── game/ # Shared Monaco code editor
│ │ ├── meeting/ # Emergency meeting discussion
│ │ ├── vote/ # Voting phase
│ │ └── results/ # Game outcome and payout summary
│ ├── components/ # Reusable React components
│ ├── lib/
│ │ ├── socket.ts # WebSocket client
│ │ ├── solana.ts # Wallet adapter and transaction builder
│ │ └── backend.ts # HTTP helpers
│ └── package.json
│
├── backend/ # Axum + WebSocket server (Rust)
│ ├── src/
│ │ ├── main.rs # Server entry point
│ │ ├── game/
│ │ │ ├── manager.rs # Room registry (DashMap)
│ │ │ ├── session.rs # Per-room game state machine
│ │ │ ├── roles.rs # Impostor assignment
│ │ │ ├── timer.rs # Round countdown
│ │ │ └── challenges.rs # Challenge loader
│ │ ├── ws/ # WebSocket message handlers
│ │ └── compiler/ # cargo test runner
│ └── Cargo.toml
│
├── programs/
│ └── amongsol_staking/ # Anchor smart contract
│ ├── src/lib.rs # Program instructions
│ ├── Cargo.toml
│ └── Anchor.toml
│
└── challenges/
├── rust/ # Pure Rust challenge crates
│ ├── transfer/
│ ├── withdraw/
│ └── initialize/
└── anchor/ # Solana/Anchor challenge crates
└── escrow_release/
The game manager maintains two concurrent hash maps using DashMap:
sessionsmaps aGameIdto aGameSession, storing all runtime state for an active room.conn_to_gamemaps aConnectionIdto aGameId, used to route incoming WebSocket messages to the correct session without a linear scan.
Each room transitions through a fixed set of states:
Lobby
|
v
Playing <---------+
| |
v |
CodeLocked |
| |
v |
Meeting |
| |
v |
Voting -----------+
|
v
Ended
Meetings and voting can loop back into Playing if no ejection reaches majority. Code is locked when the timer drops below 30 seconds and cannot be unlocked.
The backend connects to a PostgreSQL database via sqlx. On startup it runs a CREATE TABLE IF NOT EXISTS migration to ensure the game_results table exists. Game results (winner role, impostor identity, player list, payout amounts) are persisted at end of round for audit purposes.
Challenges are self-contained Cargo crates stored under challenges/<map>/<challenge_id>/. Each crate has a set of functions with stub implementations and a test suite targeting those functions.
When a round starts:
- The backend reads the challenge directory for the selected map.
- The stub source is sent to all players as their starting code via the
GameStartedWebSocket message. - When a player runs tests, the backend copies the challenge crate to a temporary directory, replaces the target function bodies with the current player code from session state, and spawns
cargo testas a subprocess. - Standard output and exit code are captured and broadcast to all players as a
TestResultsmessage.
Supported maps:
rust— Pure Rust functions covering common programming tasks.anchor— Solana/Anchor instruction handlers targeting program correctness.
Adding a new challenge requires creating a new directory under the appropriate map folder with a valid Cargo.toml and at least one #[test] that can fail when the stub is unimplemented.
All real-time game state is communicated over a single persistent WebSocket connection per player.
| Message | Payload | Description |
|---|---|---|
JoinGame |
room_code, wallet_address | Enter an existing room |
ConfirmStake |
tx_signature | Notify backend of successful stake deposit |
StartGame |
— | Host initiates the round |
EditCode |
function_id, content | Broadcast a code edit |
RunTests |
— | Trigger cargo test execution |
CallMeeting |
— | Initiate an emergency meeting |
StartVoting |
— | Transition meeting to voting phase |
CastVote |
target_player_id | Submit a vote for ejection |
| Message | Payload | Description |
|---|---|---|
GameJoined |
full session state | Sent on successful join to sync state |
PlayerJoined |
player info | Broadcast when a new player enters the lobby |
PlayerLeft |
player_id | Broadcast on disconnection |
StakeUpdated |
player_id, staked | Reflects on-chain stake confirmation |
GameStarted |
challenge functions | Delivers starting stubs to all players |
RoleAssigned |
role | Private message; tells player their role |
TestResults |
passed, output | Broadcast test execution result |
PlayerEditing |
player_id, function_id | Collaborative cursor presence |
TimerTick |
seconds_remaining | Sent every second during Playing state |
CodeLocked |
— | Signals 30-second lock has engaged |
MeetingCalled |
edit_history | Broadcasts full edit log for discussion |
VotingStarted |
— | Transitions all clients to voting phase |
VoteUpdate |
vote_counts | Running vote tally |
GameOver |
winner_role, impostor_id, payouts | Final result broadcast |
| Dependency | Minimum Version |
|---|---|
| Node.js | 18 |
| Rust toolchain | 1.75 |
| Anchor CLI | 0.29 |
| Solana CLI | 1.18 |
| PostgreSQL | 14 |
| Solana wallet extension | Phantom, Solflare, or Brave Wallet |
Start a PostgreSQL instance and note the connection string. The backend will create the required table automatically.
cd backendCreate backend/.env:
DATABASE_URL=postgresql://user:password@localhost:5432/amongsol
AMONGSOL_STAKING_PROGRAM_ID=J1EHYJokNcbDGKYk3W8wkpmfAUogjD1NGq1jonPx2CnA
Start the server:
cargo runThe backend listens on:
- HTTP and WebSocket:
http://localhost:8080 - WebSocket endpoint:
ws://localhost:8080/ws
cd frontendCreate frontend/.env.local:
NEXT_PUBLIC_AMONGSOL_STAKING_PROGRAM_ID=J1EHYJokNcbDGKYk3W8wkpmfAUogjD1NGq1jonPx2CnA
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
Install dependencies and start the dev server:
npm install
npm run devOpen http://localhost:3000 in a browser.
To test the staking flow locally, use Solana Devnet wallets with airdropped SOL:
# Airdrop devnet SOL to a test wallet
solana airdrop 2 <wallet_address> --url devnetEach player needs at least 0.1 SOL plus a small buffer for transaction fees (~0.001 SOL per transaction).
- Push the repository to GitHub.
- Import the project in Vercel.
- Set the Root Directory to
frontend. - Add all environment variables from
frontend/.env.local. - Deploy.
Important — after redeploying the staking program (new program ID): the Vercel dashboard environment variables are separate from your local
frontend/.env.local. You must updateNEXT_PUBLIC_AMONGSOL_STAKING_PROGRAM_IDin Vercel → Settings → Environment Variables to the new program ID, then trigger a redeploy.NEXT_PUBLIC_*variables are baked in at build time, so an env change does not take effect until the project is rebuilt. If the dashboard still points at the old program ID, the live site will keep using the dead program and settlement will fail.
- Create a new Railway project from the GitHub repository.
- Set the Root Directory to
backend. - Add environment variables:
DATABASE_URL,AMONGSOL_STAKING_PROGRAM_ID, andSOLANA_RPC_URL. - Under Settings, go to Networking and expose port 8080 publicly.
- Deploy.
Important — after redeploying the staking program: update
AMONGSOL_STAKING_PROGRAM_IDin the Railway dashboard to the new program ID and restart/redeploy the service. The backend uses it (andSOLANA_RPC_URL) to verify stake transactions, so a stale value will reject every stake. Railway reads env vars at startup, so a restart is enough — no rebuild required.
Provision a serverless PostgreSQL instance on Neon. Copy the connection string and set it as DATABASE_URL in both the Railway backend environment and locally. The backend handles schema creation on startup.
- Room size is 2 to 4 players.
- Round duration is fixed at 3 minutes with no configurable override.
- Game results persistence requires a live PostgreSQL connection; in-memory sessions are lost on backend restart.
- Anchor challenges require
solana-programand related crates available in the compile environment; cold builds on the backend may be slow.
| Symptom | Resolution |
|---|---|
| Frontend cannot create or join rooms | Verify the backend is running and NEXT_PUBLIC_BACKEND_URL is set correctly |
| Backend fails on startup | Check that DATABASE_URL points to a reachable PostgreSQL instance |
| Stale room state in the browser | Clear browser local storage for the site and reconnect |
| Wallet not detected | Install Phantom or Solflare; if using Brave, disable the built-in Brave Wallet |
| WebSocket connection fails | Confirm backend port 8080 is publicly accessible and not blocked by a firewall |
| Stake deposit transaction fails | Ensure the wallet has at least 0.1 SOL plus ~0.001 SOL for fees on Devnet |
| cargo test hangs in the browser | The backend challenge runner has a timeout; check backend logs for the subprocess exit reason |
| Monaco editor renders blank | Verify the game route is receiving the GameStarted message; check WebSocket connection state in browser devtools |
No license has been defined for this repository yet.

