Hey, Bastian here.
In Part 1 I shipped a local, single-player game: Trust / Betray / Run, a 60-second timer, and an Arbiter that remembers you. This week I took that same idea and pushed it into real-time multiplayer with wallet sign-in, WebSockets, and a matchmaking engine that keeps two browsers perfectly in sync.
Below is exactly how I built it—using the same endpoints, schema, and snippets from my build notes.
From Solo to Multiplayer: the three pillars
Wallet Authentication System — passwordless login using your Stellar wallet
WebSocket Infrastructure — persistent, bidirectional sync via Socket.io
Matchmaking Engine — queue → room → one authoritative server timer
1) Wallet as Identity (no passwords)
Traditional auth adds friction and risk. Here your public key is your username. The goal is to keep the surface area small (no password resets, no credential leaks) and align identity with how we’ll eventually do on-chain payouts.
Two small choices worth noting:
I stash the public key in sessionStorage (not cookies) to avoid CSRF baggage and because a socket-based session is already stateful.
The sample endpoint below returns 404 if the player doesn’t exist. Creation happens in a separate onboarding route to keep this lookup endpoint fast and cacheable.

Connecting a wallet in Trustfall by Hoops Finance
Client flow
// Wallet connection flow
const address = await StellarWallet.connect();
// Returns public address: GARMZNGOEPXRUACYVBCRSXQ6XFDU7ZKIK7RSOA5774CIBP7GWD4SIUT4
// Store in session for persistence
sessionStorage.setItem('stellar_wallet', address);
Server verification
On the server side, it checks whether that wallet already exists in the database — if not, it creates a new player profile.
// API: /api/players/by-wallet/[walletId].ts
export default async function handler(req, res) {
const { walletId } = req.query;
// Query MongoDB for existing player
const player = await getPlayerByWalletId(walletId);
if (player) {
return res.status(200).json({ player });
} else {
return res.status(404).json({ error: 'Player not found' });
}
}
Player document (MongoDB)
Fields are minimal but map 1:1 to gameplay:
interface Player {
_id?: ObjectId;
username: string;
email?: string;
wallet_id: string | null; // Stellar public key
vault_score: number;
reputation: number;
faction: string;
is_online: boolean;
socket_id: string | null;
created_at: Date;
updated_at: Date;
}
Why this design works now (and later):
Cryptographic identity (challenge signing when needed)
Zero password surface area
Non-custodial by default
2) WebSockets are the heartbeat
HTTP is ping-pong. Multiplayer is a wire. I use Socket.io for a reliable, persistent channel with automatic fallbacks. A single connection handles auth, queueing, timer updates, and player actions.
Small UX win: the socket id gives each tab a unique fingerprint—handy for single-session enforcement and quick debugging.
Client connect
// Client-side connection
const socket = io('wss://trustfall-s0-demo.hoops.finance');
socket.on('connect', () => {
console.log('Connected with ID:', socket.id);
// Each browser gets unique ID: AGfCezRKFjBqpYOoAAA_
});
Server auth + single session
(If you open a second tab and the first is still live, the second is denied. This keeps ladders/fairness clean.)
// Server-side socket authentication
socket.on('auth', async ({ walletId, playerId }) => {
try {
let player;
if (walletId) {
player = await getPlayerByWalletId(walletId);
if (!player) {
socket.emit('auth:error', { error: 'Player not found' });
return;
}
// Check for duplicate sessions
const existingSocketId = activeWalletSessions.get(walletId);
if (existingSocketId && existingSocketId !== socket.id) {
socket.emit('auth:error', {
error: 'ALREADY_LOGGED_IN',
message: 'You are already logged in another session.'
});
return;
}
// Register new session
activeWalletSessions.set(walletId, socket.id);
}
socket.emit('auth:success', { playerId: player._id.toString() });
} catch (error) {
socket.emit('auth:error', { error: 'Authentication failed' });
}
});
Realtime events
(Keep verbs tight: join, update, action. That makes tracing matches easy.)
// Matchmaking updates
socket.emit('matchmaking:join');
socket.on('matchmaking:waiting', (data) => {
console.log(`${data.queueCount} players in queue`);
});
// Game synchronization
socket.on('game:timer_update', (data) => {
updateTimer(data.timeRemaining); // Both players see same countdown
});
socket.emit('game:action', { roomId: 'ABC123', action: 'trust' });
socket.on('game:opponent_ready', () => {
showMessage("Opponent made a choice!");
});
3) Matchmaking → Room → One Clock
Queue management
The queue is intentionally simple: a Map keyed by playerId. That makes reads O(1) and keeps pairing logic legible. If nobody shows within ~10s, the CPU joins so the queue never feels dead.
Queue management — fast path to pairs; deterministic CPU fallback:
class MatchmakingManager {
private static queue = new Map<string, QueueEntry>();
static async joinQueue(playerId: string, socketId: string): Promise<void> {
// Add player to queue
this.queue.set(playerId, { playerId, socketId, joinedAt: new Date() });
// Try to find a match
await this.findMatch(playerId);
}
static async findMatch(playerId: string): Promise<void> {
const player = this.queue.get(playerId);
if (!player) return;
// Look for another player in queue
for (const [otherId, otherPlayer] of this.queue) {
if (otherId !== playerId) {
// Found a match!
await this.createMatch(playerId, otherId);
return;
}
}
// No match found → CPU fallback after 10s
setTimeout(() => {
this.createCpuMatch(playerId);
}, 10000);
}
}
Game room creation
Every match gets a room and a server-owned clock.
class GameRoomManager {
private static rooms = new Map<string, GameRoom>();
static async createRoom(
player1Id: string,
player2Id: string,
socket1Id: string,
socket2Id: string
): Promise<string> {
const roomId = generateRoomId();
const matchId = await createMatch(player1Id, player2Id);
const room: GameRoom = {
roomId,
matchId,
player1Id: new ObjectId(player1Id),
player2Id: new ObjectId(player2Id),
player1SocketId: socket1Id,
player2SocketId: socket2Id,
player1Action: null,
player2Action: null,
timerStarted: new Date(),
timerExpires: new Date(Date.now() + 60000), // 60 seconds
status: 'waiting'
};
this.rooms.set(roomId, room);
return roomId;
}
}
Authoritative timer (server-owned)
(Clients render the number; they don’t decide it. That kills desync bugs.)
function startGameTimer(roomId: string) {
const GAME_DURATION_SECONDS = 60;
const startTime = Date.now();
// Emit initial timer start
io.to(roomId).emit('game:timer_sync', {
timeRemaining: GAME_DURATION_SECONDS,
serverTime: Date.now()
});
const interval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const timeRemaining = Math.max(0, GAME_DURATION_SECONDS - elapsed);
// Emit timer update every second
io.to(roomId).emit('game:timer_update', {
timeRemaining,
serverTime: Date.now()
});
// Timer expired
if (timeRemaining === 0) {
clearInterval(interval);
GameRoomManager.handleForfeit(roomId);
}
}, 1000);
}
Scoring & Persistence (same philosophy, multiplayer scale)
Two separations that matter:
Vault Score comes from the two-player payout matrix (short-term economics).
Reputation depends on your action only in this iteration (long-term identity). That makes faction drift predictable and easier to tune.
Payout matrix
Once both players lock in their actions, the server calculates results and persists them immediately.
class ScoringSystem {
static calculateVaultScores(a1: Action, a2: Action) {
switch (`${a1}-${a2}`) {
case 'trust-trust': return { score1: 1, score2: 1 };
case 'trust-betray': return { score1: -3, score2: 3 };
case 'betray-trust': return { score1: 3, score2: -3 };
case 'betray-betray': return { score1: -3, score2: -3 };
case 'trust-run': return { score1: -1, score2: 1 };
case 'run-trust': return { score1: 1, score2: -1 };
// ... other combinations
}
}
static calculateReputationChanges(a1: Action, a2: Action) {
const toRep = (a: Action) => a === 'trust' ? 1 : a === 'betray' ? -1 : 0;
return { rep1: toRep(a1), rep2: toRep(a2) };
}
}
Authoritative write
(Do this before clients render the results to avoid “split-brain” UI.)
static async processResults(roomId: string) {
const room = this.rooms.get(roomId);
if (!room || !room.player1Action || !room.player2Action) {
throw new Error('Room not ready for processing');
}
const results = ScoringSystem.calculateCompleteResult(
room.player1Action,
room.player2Action
);
const player1 = await players.findOne({ _id: room.player1Id });
const player2 = await players.findOne({ _id: room.player2Id });
// Clamp & compute new stats (vault/reputation/faction)
const newP1Vault = ScoringSystem.clampVaultScore(player1.vault_score + results.player1ScoreChange);
const newP1Rep = ScoringSystem.clampReputation(player1.reputation + results.player1RepChange);
const newP1Fact = ScoringSystem.getFactionFromReputation(newP1Rep);
const newP2Vault = ScoringSystem.clampVaultScore(player2.vault_score + results.player2ScoreChange);
const newP2Rep = ScoringSystem.clampReputation(player2.reputation + results.player2RepChange);
const newP2Fact = ScoringSystem.getFactionFromReputation(newP2Rep);
await updatePlayerStats(room.player1Id, newP1Vault, newP1Rep, newP1Fact);
await updatePlayerStats(room.player2Id, newP2Vault, newP2Rep, newP2Fact);
await matches.updateOne(
{ _id: room.matchId },
{ $set: {
player1_action: room.player1Action,
player2_action: room.player2Action,
player1_score_change: results.player1ScoreChange,
player2_score_change: results.player2ScoreChange,
player1_rep_change: results.player1RepChange,
player2_rep_change: results.player2RepChange,
status: 'completed'
}
}
);
}
Why clamp?
Vault score can’t dip below 0 and rep stays in 0–100. It’s hard to recover from negative numbers; clamping keeps the game forgiving and the UI clean.
CPU Fallback (Arbiter, but live)
Queues only feel good if they move. If no human appears within ~10s, the Arbiter joins. It uses Tit-for-Tat with forgiveness—opening with Trust, mirroring Betrayal, forgiving Run.
class CPUArbiter {
static getCpuAction(cpuId: string, lastOpponentAction?: string): Action {
// First match: always trust
if (!lastOpponentAction) return 'trust';
// Mirror last action; forgive 'run'
if (lastOpponentAction === 'trust') return 'trust';
if (lastOpponentAction === 'betray') return 'betray';
return 'trust';
}
}
You still get a meaningful match, and the world keeps its rhythm.
Security & Fair Play
Small guardrails that pay off big:
Single session per wallet (reject the second tab if the first is live)
Action whitelist (trust | betray | run) to block garbage inputs
Room ownership checks (only room members can submit moves)
Server authority (compute + persist before clients render)
🎥 Watch Part 2 (Multiplayer teardown)
I recorded a short video showing wallet login, instant queue → room, the shared 60-second timer, and the Arbiter’s “remember you” behavior.
👉 Watch Devlog — Trustfall Season 0: Part 2 (video teardown)
✦ Join the experiment
The demo is now live
Queue up, try to break matchmaking, and tell me how it feels when the clock hits 0.
Read the world-building and factions here: trustfall.hoops.finance
What’s next
UX improvements: smoother transitions, better feedback, tighter animations
Lore + Game in one deployment: story, factions, and matches under the same roof
Scene transitions: more cinematic flow between queue, room, and results
On-chain payouts & collectibles: route rewards via Hoops, mint seasonal keepsakes
Social sharing: post match summaries, faction badges, and streaks with one click.
Move fast. Go break my game.
-Bastian