Skip to main content
Version: 2.0.0

Hide information from players

Prerequisites

You need a module and a client connected to it. If you don't have them yet, follow the quickstart for your language first.

In this guide, you'll build the hidden information of a card game: each player sees their own hand, but only how many cards the other players hold, and nobody sees the order of the deck. You'll keep every table private, and give each player views that return only what they're allowed to see.

Keep tables private​

Any client can subscribe to any row of a public table, whatever your game shows on screen. Hiding something in your client's UI doesn't hide it: a modified client can read the whole table, including other players' cards.

Private tables are the opposite. Clients can't subscribe to them or query them, and they don't even appear in the client bindings. Only your module's code, and you as the database owner, can read them. Tables are private unless you mark them public.

To give clients the data they need, add views. A view runs on the server for the player who subscribes to it, and returns only the rows and columns that player may see. A modified client can't get anything more out of it.

Best practice

Keep your tables private, and expose what clients need through views. Only make a table public if every row and every column is meant for every player, and will stay that way: it's easy to add a secret column to a public table later and forget that everyone can read it.

How it works​

  • Four private tables hold each player's game, the deck, each player's hand and the cards played.
  • A start_game function shuffles the deck and deals a hand to each player.
  • The draw_card and play_card reducers move cards from the deck to a hand, and from a hand to the table.
  • Three views give each player their own hand, how many cards each player in their game holds, and the cards played in their game.

Define the tables​

Add four tables, all private:

  • game_player records which game each player is in.
  • deck_card holds the cards left in each game's deck. The card with the lowest id is the top of the deck.
  • hand_card holds the cards in each player's hand, with an index on the player holding them.
  • played_card holds the cards played face up in each game.

A card is a number from 0 to 51, which clients turn into a rank and a suit.

import type { Identity } from 'spacetimedb';
import { schema, table, t, SenderError, type InferSchema, type ReducerCtx } from 'spacetimedb/server';

// Cards in each player's starting hand.
const HAND_SIZE = 5;

// Each player's game.
const gamePlayer = table(
  { name: 'game_player' },
  {
    identity: t.identity().primaryKey(),
    gameId: t.u64().index('btree'),
  }
);

// The cards left in each game's deck. The card with the lowest id is on top.
const deckCard = table(
  { name: 'deck_card' },
  {
    id: t.u64().primaryKey().autoInc(),
    gameId: t.u64().index('btree'),
    // 0 to 51: the suit is card / 13, the rank is card % 13.
    card: t.u8(),
  }
);

// The cards in each player's hand.
const handCard = table(
  { name: 'hand_card' },
  {
    id: t.u64().primaryKey().autoInc(),
    holder: t.identity().index('btree'),
    card: t.u8(),
  }
);

// The cards played face up in each game.
const playedCard = table(
  { name: 'played_card' },
  {
    id: t.u64().primaryKey().autoInc(),
    gameId: t.u64().index('btree'),
    card: t.u8(),
    playedBy: t.identity(),
  }
);

const spacetimedb = schema({ gamePlayer, deckCard, handCard, playedCard });
export default spacetimedb;

type Ctx = ReducerCtx<InferSchema<typeof spacetimedb>>;

Deal the cards​

Add a start_game function that shuffles a 52-card deck, deals 5 cards to each player, and stores the rest of the deck in shuffled order.

start_game isn't a reducer, so clients can't call it. Call it from your matchmaking when a match is created, with the match's id and its players. See the matchmaking queue guide.

When a game ends, delete its players' game_player and hand_card rows, and its deck_card and played_card rows. Otherwise its players can't start another game: start_game would fail to add them to game_player again.

/** Shuffles a deck and deals a hand to each player. Call it from your matchmaking. */
function startGame(ctx: Ctx, gameId: bigint, players: Identity[]) {
  const deck = Array.from({ length: 52 }, (_, i) => i);
  // Shuffle the deck.
  for (let i = deck.length - 1; i > 0; i--) {
    const j = ctx.random.integerInRange(0, i);
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }

  for (const player of players) {
    ctx.db.gamePlayer.insert({ identity: player, gameId });
    for (const card of deck.splice(0, HAND_SIZE)) {
      ctx.db.handCard.insert({ id: 0n, holder: player, card });
    }
  }
  // The rest of the deck, in shuffled order.
  for (const card of deck) {
    ctx.db.deckCard.insert({ id: 0n, gameId, card });
  }
}
The shuffle can be predicted

The module's random number generator (ctx.random in TypeScript, ctx.Rng in C#, ctx.rng() in Rust and C++) is seeded from the reducer's timestamp, so it isn't cryptographically secure. A determined cheater who knows roughly when the cards were dealt could try every possible timestamp, find the one that deals the hand they hold, and work out the whole deck. That's fine for casual games, but not for games played for money or ranking.

Draw and play cards​

Add two reducers:

  • draw_card moves the top card of the caller's deck into their hand.
  • play_card moves a card from the caller's hand to the cards played in their game. It rejects cards the caller doesn't hold.

Errors are sent back to the player who called the reducer, so they must not reveal anything about other players. play_card returns the same error whether the card doesn't exist or is in another player's hand.

Other players don't see which reducers you call or their arguments. They only see the changes to the views they subscribe to.

export const drawCard = spacetimedb.reducer(ctx => {
  const me = ctx.db.gamePlayer.identity.find(ctx.sender);
  if (!me) {
    throw new SenderError('not in a game');
  }
  let top;
  for (const c of ctx.db.deckCard.gameId.filter(me.gameId)) {
    if (!top || c.id < top.id) {
      top = c;
    }
  }
  if (!top) {
    throw new SenderError('the deck is empty');
  }
  ctx.db.deckCard.id.delete(top.id);
  ctx.db.handCard.insert({ id: 0n, holder: ctx.sender, card: top.card });
});

export const playCard = spacetimedb.reducer({ cardId: t.u64() }, (ctx, { cardId }) => {
  const me = ctx.db.gamePlayer.identity.find(ctx.sender);
  if (!me) {
    throw new SenderError('not in a game');
  }
  // The same error whether the card doesn't exist or is in another player's hand.
  const card = ctx.db.handCard.id.find(cardId);
  if (!card || !card.holder.isEqual(ctx.sender)) {
    throw new SenderError("you don't have this card");
  }
  ctx.db.handCard.id.delete(card.id);
  ctx.db.playedCard.insert({ id: 0n, gameId: me.gameId, card: card.card, playedBy: ctx.sender });
});

Give each player their own views​

Add three public views. Each one depends on who is asking, so SpacetimeDB computes it separately for each subscribed player:

  • my_hand returns the caller's cards. It's a single filter, so it returns a query instead of rows: SpacetimeDB then keeps it up to date the same way as a subscription query, without re-running the view.
  • played_cards returns the cards played in the caller's game. It's also a query: it finds the caller's game_player row, then the played cards with the same game id.
  • my_table returns every player in the caller's game with the number of cards they hold. It returns a Seat type instead of hand_card rows, so other players' cards never leave the server: a view decides which columns players see, not just which rows.

Because SpacetimeDB computes these views for each player, keep them small: each one reads a few rows through indexes. See Views for how they're evaluated.

export const myHand = spacetimedb.view(
  { name: 'my_hand', public: true },
  t.array(handCard.rowType),
  ctx => ctx.from.handCard.where(c => c.holder.eq(ctx.sender))
);

export const playedCards = spacetimedb.view(
  { name: 'played_cards', public: true },
  t.array(playedCard.rowType),
  ctx =>
    ctx.from.gamePlayer
      .where(p => p.identity.eq(ctx.sender))
      .rightSemijoin(ctx.from.playedCard, (p, c) => p.gameId.eq(c.gameId))
);

// What every player can know about a player: who they are, and how many cards they hold.
const seat = t.row('Seat', {
  identity: t.identity(),
  handSize: t.u32(),
});

export const myTable = spacetimedb.view({ name: 'my_table', public: true }, t.array(seat), ctx => {
  const me = ctx.db.gamePlayer.identity.find(ctx.sender);
  if (!me) {
    return [];
  }
  return Array.from(ctx.db.gamePlayer.gameId.filter(me.gameId), p => ({
    identity: p.identity,
    handSize: Array.from(ctx.db.handCard.holder.filter(p.identity)).length,
  }));
});

Show the game on the client​

On the client, subscribe to the three views. They're all the client can subscribe to: subscribing to a private table such as hand_card fails with "no such table", as if it didn't exist.

The client cache then holds the player's hand, the hand sizes and the played cards. Draw the game from it each frame. The client cache doesn't keep the order the rows were added in, so sort the hand by card and the played cards by id.

import { DbConnection, tables } from './module_bindings';

const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
const SUITS = ['♠', '♥', '♦', '♣'];

// Turns a card, from 0 to 51, into a name such as "Q♥".
function cardName(card: number): string {
  return RANKS[card % 13] + SUITS[Math.floor(card / 13)];
}

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .onConnect(ctx => {
    ctx.subscriptionBuilder().subscribe([tables.myHand, tables.myTable, tables.playedCards]);
  })
  .build();

// Call this each frame to draw the game from the client cache.
function drawGame() {
  const hand = Array.from(conn.db.myHand.iter()).sort((a, b) => a.card - b.card);
  console.log(`Your hand: ${hand.map(c => cardName(c.card)).join(' ')}`);

  for (const seat of conn.db.myTable.iter()) {
    if (!seat.identity.isEqual(conn.identity!)) {
      console.log(`${seat.identity.toHexString().slice(0, 8)} holds ${seat.handSize} cards`);
    }
  }

  const played = Array.from(conn.db.playedCards.iter()).sort((a, b) => (a.id < b.id ? -1 : 1));
  console.log(`Played: ${played.map(c => cardName(c.card)).join(' ')}`);
}

// Call this when the player clicks the deck.
function draw() {
  conn.reducers.drawCard({}).catch(console.error);
}

// Call this when the player clicks a card in their hand.
function play(cardId: bigint) {
  conn.reducers.playCard({ cardId }).catch(console.error);
}

What's next?​

Each player now sees exactly what they're allowed to, and a modified client can't see more:

  • All four tables are private, so clients can't read the deck or anyone's hand directly.
  • my_hand and played_cards return only the caller's cards and the cards played in their game.
  • my_table tells each player how many cards the others hold, without their cards.
  • Errors from play_card don't reveal other players' cards.

To adapt it to your game:

  • Show how many cards are left in the deck. Add it to a view the same way as the hand sizes: count the deck_card rows of the caller's game.
  • Reveal hands at the end of a round. A view can check the state of the game before returning other players' cards, for example only once a showdown has started.
  • Hide other kinds of information. The same pattern hides units under fog of war, or a hidden role in a social deduction game: keep the data in a private table, and return only what the caller may see from a view. To send each player only what's near them, without hiding anything, see Only sync what's near the player.
  • Take turns. See Make a turn-based game with turn timeouts.

To learn more about the features used here, see Access Permissions and Views.

Next, you can reset a leaderboard each season.