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

  1. Wallet Authentication System — passwordless login using your Stellar wallet

  2. WebSocket Infrastructure — persistent, bidirectional sync via Socket.io

  3. 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

Keep Reading

No posts found