Game Patterns

Games are where Adama really sings. The state machine model, durable execution, and per-player privacy were practically designed for multiplayer game development (because, well, they kind of were). This chapter covers patterns specifically for building games -- from simple turn-based affairs to more complex real-time experiences.

Turn-Based Game Structure

Basic Turn Loop

Problem: Implement a turn-based game where players take actions in sequence.

private principal player1;
private principal player2;
public principal currentPlayer;
public int turnNumber = 0;
public bool gameOver = false;

@construct {
  player1 = @no_one;
  player2 = @no_one;
}

message Move {
  int position;
}

channel<Move> move;

#waitingForPlayers {
  // Wait until we have two players
  if (player1 != @no_one && player2 != @no_one) {
    currentPlayer = player1;
    turnNumber = 1;
    transition #playerTurn;
  }
}

#playerTurn {
  // Request move from current player
  future<Move> f = move.fetch(currentPlayer);
  Move m = f.await();

  // Process the move
  applyMove(m);
  turnNumber++;

  // Check for game end
  if (checkGameOver()) {
    transition #gameEnd;
  } else {
    // Switch players
    currentPlayer = (currentPlayer == player1) ? player2 : player1;
    transition #playerTurn;
  }
}

#gameEnd {
  gameOver = true;
  // Game complete - document stays open for review
}

procedure applyMove(Move m) {
  // Game-specific move logic
}

function checkGameOver() -> bool {
  // Game-specific end condition
  return false;
}

The beauty of this is that the state machine handles the entire game flow. The fetch/await pattern blocks on a specific player's input, so the runtime just suspends until that player acts. No polling, no callbacks, no event soup.

Multi-Player Turn Order

Problem: Support more than two players with configurable turn order.

record Player {
  public int id;
  private principal who;
  public string name;
  public int turnOrder;
  public bool eliminated;

  index who;
}

table<Player> _players;

public int currentTurnOrder = 0;
public int roundNumber = 1;

public formula currentPlayer = (iterate _players
  where turnOrder == currentTurnOrder && !eliminated
  limit 1);

public formula activePlayers = iterate _players where !eliminated order by turnOrder;
public formula activePlayerCount = (iterate _players where !eliminated).size();

procedure advanceTurn() {
  int maxOrder = 0;
  foreach (p in iterate _players where !eliminated) {
    if (p.turnOrder > maxOrder) {
      maxOrder = p.turnOrder;
    }
  }

  currentTurnOrder++;
  if (currentTurnOrder > maxOrder) {
    currentTurnOrder = 0;
    roundNumber++;
  }

  // Skip eliminated players
  while ((iterate _players where turnOrder == currentTurnOrder && !eliminated).size() == 0
         && currentTurnOrder <= maxOrder) {
    currentTurnOrder++;
  }

  if (currentTurnOrder > maxOrder) {
    currentTurnOrder = 0;
    roundNumber++;
  }
}

procedure randomizeTurnOrder() {
  int order = 0;
  (iterate _players shuffle).turnOrder = order++;
}

Phase-Based Turns

Problem: Each turn has multiple phases (e.g., draw, play, discard).

enum TurnPhase { Draw, Play, Discard, End }

public TurnPhase currentPhase = TurnPhase::Draw;
public principal currentPlayer;

message DrawAction {}
message PlayAction { int cardId; }
message DiscardAction { int[] cardIds; }

channel<DrawAction> drawChannel;
channel<PlayAction> playChannel;
channel<DiscardAction> discardChannel;

#turn {
  currentPhase = TurnPhase::Draw;
  invoke #drawPhase;

  currentPhase = TurnPhase::Play;
  invoke #playPhase;

  currentPhase = TurnPhase::Discard;
  invoke #discardPhase;

  currentPhase = TurnPhase::End;
  advanceToNextPlayer();
  transition #turn;
}

#drawPhase {
  // Automatically draw 1 card, or let player choose
  future<DrawAction> f = drawChannel.fetch(currentPlayer);
  DrawAction d = f.await();
  drawCard(currentPlayer);
}

#playPhase {
  // Player can play multiple cards until they pass
  bool done = false;
  while (!done) {
    future<PlayAction> f = playChannel.fetch(currentPlayer);
    PlayAction p = f.await();
    if (p.cardId == -1) {
      done = true;  // Player passes
    } else {
      playCard(currentPlayer, p.cardId);
    }
  }
}

#discardPhase {
  // Player must discard down to hand limit
  int handSize = getHandSize(currentPlayer);
  int handLimit = 7;

  if (handSize > handLimit) {
    int toDiscard = handSize - handLimit;
    future<DiscardAction> f = discardChannel.fetch(currentPlayer);
    DiscardAction d = f.await();
    // Validate they discarded the right number
    foreach (cardId in d.cardIds) {
      discardCard(currentPlayer, cardId);
    }
  }
}

procedure drawCard(principal p) {
  // Implementation
}

procedure playCard(principal p, int cardId) {
  // Implementation
}

procedure discardCard(principal p, int cardId) {
  // Implementation
}

function getHandSize(principal p) -> int {
  return 0; // Implementation
}

procedure advanceToNextPlayer() {
  // Implementation
}

This phase-based approach is how I'd model any card game. Each phase is its own state, the turn state orchestrates them in sequence, and the whole thing reads top-to-bottom like a rulebook.

Player Management

Player Joining

Problem: Allow players to join a game with limits and validation.

public int minPlayers = 2;
public int maxPlayers = 4;
public bool gameStarted = false;

record Player {
  public int id;
  private principal who;
  public string name;
  public bool ready;
  public int score;
  public datetime joinedAt;

  index who;

  policy is_me {
    return @who == who;
  }
}

table<Player> _players;

public formula players = iterate _players order by joinedAt;
public formula playerCount = _players.size();
public formula allReady = (iterate _players where !ready).size() == 0;
public formula canStart = playerCount >= minPlayers && allReady && !gameStarted;

message JoinGame {
  string name;
}

channel join(JoinGame msg) {
  // Check if game already started
  if (gameStarted) {
    return;
  }

  // Check if already joined
  if ((iterate _players where who == @who).size() > 0) {
    return;
  }

  // Check player limit
  if (_players.size() >= maxPlayers) {
    return;
  }

  _players <- {
    who: @who,
    name: msg.name,
    ready: false,
    score: 0,
    joinedAt: Time.datetime()
  };
}

message LeaveGame {}

channel leave(LeaveGame msg) {
  if (gameStarted) {
    return; // Cannot leave during game
  }
  (iterate _players where who == @who).delete();
}

message SetReady {
  bool ready;
}

channel setReady(SetReady msg) {
  (iterate _players where who == @who).ready = msg.ready;
}

message StartGame {}

channel startGame(StartGame msg) {
  if (!canStart) {
    return;
  }
  gameStarted = true;
  transition #setup;
}

#setup {
  // Initialize game state
  transition #firstTurn;
}

#firstTurn {
  // Game logic
}

Reconnection Handling

Problem: Handle players disconnecting and reconnecting mid-game.

This is one of those things that sounds simple until you actually try to build it. The state machine helps a lot here -- you can have a dedicated "waiting for reconnect" state with a timeout.

record Player {
  public int id;
  private principal who;
  public string name;
  public bool connected;
  public datetime lastSeen;
  public int disconnectCount;
  public bool forfeited;

  index who;
}

table<Player> _players;

public int maxDisconnectTime = 300;  // 5 minutes to reconnect
public int maxDisconnects = 3;       // Max disconnects before forfeit

@connected {
  list<Player> player = iterate _players where who == @who;
  if (player.size() > 0) {
    player.connected = true;
    player.lastSeen = Time.datetime();
  }
  return true;
}

@disconnected {
  list<Player> player = iterate _players where who == @who;
  if (player.size() > 0) {
    player.connected = false;
    player.lastSeen = Time.datetime();
    int count = 0;
    if (player[0] as p) {
      count = p.disconnectCount;
    }
    player.disconnectCount = count + 1;

    if (count + 1 >= maxDisconnects) {
      player.forfeited = true;
    }
  }
}

// During player's turn, check if they are connected
#playerTurn {
  principal current = getCurrentPlayer();
  list<Player> currentList = iterate _players where who == current;

  bool isConnected = false;
  if (currentList[0] as p) {
    isConnected = p.connected;
  }

  if (!isConnected) {
    // Wait for reconnection with timeout
    transition #waitingForReconnect in maxDisconnectTime;
  } else {
    transition #getMove;
  }
}

#waitingForReconnect {
  principal current = getCurrentPlayer();
  list<Player> currentList = iterate _players where who == current;

  bool isConnected = false;
  if (currentList[0] as p) {
    isConnected = p.connected;
  }

  if (isConnected) {
    transition #getMove;
  } else {
    // Player forfeits turn (or game, depending on rules)
    (iterate _players where who == current).forfeited = true;
    skipToNextPlayer();
    transition #playerTurn;
  }
}

#getMove {
  // Normal move collection
}

function getCurrentPlayer() -> principal {
  return @no_one; // Implementation
}

procedure skipToNextPlayer() {
  // Implementation
}

Spectator Mode

Problem: Allow non-players to watch the game.

record Player {
  public int id;
  private principal who;
  public string name;
  public int score;

  index who;
}

record Spectator {
  public int id;
  private principal who;
  public datetime joinedAt;

  index who;
}

table<Player> _players;
table<Spectator> _spectators;

public bool allowSpectators = true;

@connected {
  // Already a player?
  if ((iterate _players where who == @who).size() > 0) {
    return true;
  }

  // Join as spectator if allowed
  if (allowSpectators) {
    if ((iterate _spectators where who == @who).size() == 0) {
      _spectators <- {who: @who, joinedAt: Time.datetime()};
    }
    return true;
  }

  return false;
}

@disconnected {
  (iterate _spectators where who == @who).delete();
}

// Spectators see public game state but cannot act
public formula spectatorCount = _spectators.size();

// Use procedure to check document state
procedure isPlayer(principal p) -> bool {
  return (iterate _players where who == p).size() > 0;
}

record Card {
  public int id;
  private principal owner;
  public int value;
  use_policy<can_see_value> int visibleValue;

  policy can_see_value {
    // Players see their own cards, spectators see nothing hidden
    return owner == @who;
  }
}

table<Card> _cards;

// Each player's hand (only visible to them)
bubble myHand = iterate _cards where owner == @who;

// Public view - cards are visible based on policy
public formula tableCards = iterate _cards where owner == @no_one;

The per-player privacy system does the heavy lifting here. Spectators and opponents simply don't receive hidden card data in their delta updates. The server never sends it. No client-side hiding that a clever user could bypass.

Game State Machines

Lobby to Game Flow

Problem: Model the complete lifecycle of a game session.

enum GameState { Lobby, Starting, Playing, Paused, Finished }

public GameState state = GameState::Lobby;
public int countdownSeconds = 0;

#lobby {
  state = GameState::Lobby;
  // Players join and ready up
  // Transition triggered by channel message
}

message StartCountdown {}

channel startCountdown(StartCountdown msg) {
  if (state != GameState::Lobby) {
    return;
  }
  if (!canStart) {
    return;
  }
  transition #countdown;
}

#countdown {
  state = GameState::Starting;
  countdownSeconds = 5;
  transition #countdownTick;
}

#countdownTick {
  if (countdownSeconds > 0) {
    countdownSeconds--;
    transition #countdownTick in 1;
  } else {
    transition #playing;
  }
}

#playing {
  state = GameState::Playing;
  invoke #initializeGame;
  transition #gameLoop;
}

#gameLoop {
  // Main game loop
  if (checkWinner()) {
    transition #finished;
  } else {
    invoke #processTurn;
    transition #gameLoop;
  }
}

#finished {
  state = GameState::Finished;
  calculateFinalScores();
  // Document remains for rematch or review
}

// Pause functionality
message PauseGame {}
message ResumeGame {}

private GameState stateBeforePause;

channel pauseGame(PauseGame msg) {
  if (state != GameState::Playing) {
    return;
  }
  stateBeforePause = state;
  transition #paused;
}

#paused {
  state = GameState::Paused;
  // Wait for resume message
}

channel resumeGame(ResumeGame msg) {
  if (state != GameState::Paused) {
    return;
  }
  transition #playing;
}

#initializeGame {
  // Setup logic
}

#processTurn {
  // Turn logic
}

function checkWinner() -> bool {
  return false; // Implementation
}

procedure calculateFinalScores() {
  // Implementation
}

public bool canStart = false;  // Set by readiness checks

Match-Based Games

Problem: Run multiple matches/rounds with aggregate scoring.

public int matchesPerGame = 3;
public int currentMatch = 0;

record MatchResult {
  public int id;
  public int matchNumber;
  private principal winner;
  public int player1Score;
  public int player2Score;
  public datetime completedAt;
}

table<MatchResult> _matchResults;

public formula matchHistory = iterate _matchResults order by matchNumber;

private principal player1;
private principal player2;

// Aggregate scores across matches
public formula player1TotalWins = (iterate _matchResults where winner == player1).size();
public formula player2TotalWins = (iterate _matchResults where winner == player2).size();

#startMatch {
  currentMatch++;
  resetBoardState();
  transition #matchPlay;
}

#matchPlay {
  // Play until match ends
  if (matchComplete()) {
    invoke #recordMatchResult;
    if (currentMatch >= matchesPerGame) {
      transition #gameComplete;
    } else {
      transition #matchBreak;
    }
  } else {
    invoke #processTurn;
    transition #matchPlay;
  }
}

#matchBreak {
  // Brief pause between matches
  transition #startMatch in 5;
}

#recordMatchResult {
  principal matchWinner = determineMatchWinner();
  _matchResults <- {
    matchNumber: currentMatch,
    winner: matchWinner,
    player1Score: getPlayer1Score(),
    player2Score: getPlayer2Score(),
    completedAt: Time.datetime()
  };
}

#gameComplete {
  // Determine overall winner
}

procedure resetBoardState() {
  // Reset for new match
}

function matchComplete() -> bool {
  return false; // Implementation
}

function determineMatchWinner() -> principal {
  return @no_one; // Implementation
}

function getPlayer1Score() -> int {
  return 0; // Implementation
}

function getPlayer2Score() -> int {
  return 0; // Implementation
}

#processTurn {
  // Turn logic
}

Scoring and Leaderboards

In-Game Scoring

Problem: Track and display scores during gameplay.

record Player {
  public int id;
  private principal who;
  public string name;
  public int score;
  public int highScore;
  public int gamesPlayed;
  public int wins;

  index who;
}

table<Player> _players;

// Leaderboard sorted by current game score
public formula scoreBoard = iterate _players order by score desc;

// All-time leaderboard by wins
public formula allTimeLeaderboard = iterate _players order by wins desc limit 10;

procedure addScore(principal player, int points) {
  list<Player> p = iterate _players where who == player;
  int currentScore = 0;
  int currentHigh = 0;

  if (p[0] as pl) {
    currentScore = pl.score;
    currentHigh = pl.highScore;
  }

  p.score = currentScore + points;

  // Update high score if exceeded
  if (currentScore + points > currentHigh) {
    p.highScore = currentScore + points;
  }
}

procedure recordWin(principal player) {
  list<Player> p = iterate _players where who == player;
  int currentWins = 0;
  int currentGames = 0;

  if (p[0] as pl) {
    currentWins = pl.wins;
    currentGames = pl.gamesPlayed;
  }

  p.wins = currentWins + 1;
  p.gamesPlayed = currentGames + 1;
}

procedure recordLoss(principal player) {
  list<Player> p = iterate _players where who == player;
  int currentGames = 0;

  if (p[0] as pl) {
    currentGames = pl.gamesPlayed;
  }

  p.gamesPlayed = currentGames + 1;
}

ELO Rating System

Problem: Implement competitive matchmaking with skill-based ratings.

I won't pretend this is a perfect ELO implementation (the real formula involves exponentiation that's awkward to do precisely in Adama), but it's a reasonable approximation that works for most games.

record PlayerRating {
  public int id;
  private principal who;
  public string name;
  public int rating;
  public int matchesPlayed;
  public int wins;
  public int losses;

  index who;
  index rating;
}

table<PlayerRating> _ratings;

public int initialRating = 1000;
public int kFactor = 32;

public formula rankedLeaderboard = iterate _ratings order by rating desc limit 100;

procedure calculateEloChange(int winnerRating, int loserRating) -> int {
  // Expected score for winner using simplified ELO formula
  double ratingDiffRaw = (loserRating - winnerRating) * 1.0;
  maybe<double> ratingDiffMaybe = ratingDiffRaw / 400.0;
  double ratingDiff = ratingDiffMaybe.getOrDefaultTo(0.0);
  // Approximation of 1 / (1 + 10^x) for typical rating differences
  double expectedScore = 0.5 - ratingDiff * 0.1;
  if (expectedScore < 0.0) { expectedScore = 0.01; }
  if (expectedScore > 1.0) { expectedScore = 0.99; }

  // Actual score is 1 for win
  double change = kFactor * (1.0 - expectedScore);
  return Math.clampIntOf(change);
}

procedure updateRatings(principal winner, principal loser) {
  list<PlayerRating> w = iterate _ratings where who == winner;
  list<PlayerRating> l = iterate _ratings where who == loser;

  int winnerRating = initialRating;
  int loserRating = initialRating;

  if (w[0] as pw) {
    winnerRating = pw.rating;
  }
  if (l[0] as pl) {
    loserRating = pl.rating;
  }

  int change = calculateEloChange(winnerRating, loserRating);

  // Update winner
  if (w.size() > 0) {
    w.rating = winnerRating + change;
    int wins = 0;
    int matches = 0;
    if (w[0] as pw2) {
      wins = pw2.wins;
      matches = pw2.matchesPlayed;
    }
    w.wins = wins + 1;
    w.matchesPlayed = matches + 1;
  }

  // Update loser
  if (l.size() > 0) {
    l.rating = loserRating - change;
    int losses = 0;
    int matches = 0;
    if (l[0] as pl2) {
      losses = pl2.losses;
      matches = pl2.matchesPlayed;
    }
    l.losses = losses + 1;
    l.matchesPlayed = matches + 1;
  }
}

Achievement System

Problem: Track and award achievements for player accomplishments.

enum Achievement {
  FirstWin,
  WinStreak3,
  WinStreak10,
  PerfectGame,
  SpeedDemon,
  Veteran100Games,
  TopRanked
}

record PlayerAchievement {
  public int id;
  private principal who;
  public Achievement achievement;
  public datetime unlockedAt;

  index who;
  index achievement;
}

table<PlayerAchievement> _achievements;

record PlayerStats {
  public int id;
  private principal who;
  public int currentWinStreak;
  public int bestWinStreak;
  public int totalGames;
  public int totalWins;

  index who;
}

table<PlayerStats> _stats;

bubble myAchievements = iterate _achievements where who == @who order by unlockedAt desc;

procedure hasAchievement(principal p, Achievement a) -> bool {
  return (iterate _achievements where who == p && achievement == a).size() > 0;
}

procedure grantAchievement(principal p, Achievement a) {
  if (!hasAchievement(p, a)) {
    _achievements <- {who: p, achievement: a, unlockedAt: Time.datetime()};
  }
}

procedure checkAchievements(principal p) {
  list<PlayerStats> stats = iterate _stats where who == p;
  if (stats[0] as s) {
    // First Win
    if (s.totalWins >= 1) {
      grantAchievement(p, Achievement::FirstWin);
    }

    // Win Streaks
    if (s.currentWinStreak >= 3) {
      grantAchievement(p, Achievement::WinStreak3);
    }
    if (s.currentWinStreak >= 10) {
      grantAchievement(p, Achievement::WinStreak10);
    }

    // Veteran
    if (s.totalGames >= 100) {
      grantAchievement(p, Achievement::Veteran100Games);
    }
  }
}

Timer-Based Mechanics

Turn Timer

Problem: Limit how long a player has to make their move.

public int turnTimeLimit = 30;  // seconds
public int timeRemaining = 0;
public bool timerActive = false;

private principal currentPlayer;

message Move {
  int position;
}

channel<Move> moveChannel;

#playerTurn {
  timerActive = true;
  timeRemaining = turnTimeLimit;
  transition #turnWithTimer;
}

#turnWithTimer {
  // Start the fetch - player can respond anytime
  future<Move> f = moveChannel.fetch(currentPlayer);

  // Also schedule a timeout
  transition #turnTimeout in turnTimeLimit;

  // Wait for move
  Move m = f.await();

  // Got a move before timeout - cancel timeout by transitioning
  timerActive = false;
  applyMove(m);
  advanceToNextPlayer();
  transition #playerTurn;
}

#turnTimeout {
  // Player ran out of time
  timerActive = false;
  handleTimeout(currentPlayer);
  advanceToNextPlayer();
  transition #playerTurn;
}

procedure handleTimeout(principal p) {
  // Options: skip turn, random move, forfeit, etc.
}

procedure applyMove(Move m) {
  // Implementation
}

procedure advanceToNextPlayer() {
  // Implementation
}

Chess Clock (Shared Time Bank)

Problem: Each player has a total time bank that decreases during their turns.

record PlayerTime {
  public int id;
  private principal who;
  public int remainingMs;  // Milliseconds remaining
  public datetime turnStartedAt;
  public bool outOfTime;

  index who;
}

table<PlayerTime> _timers;

public int initialTimeMs = 600000;  // 10 minutes
public int incrementMs = 5000;      // 5 second increment per move

private principal currentPlayer;

procedure startTurn(principal p) {
  (iterate _timers where who == p).turnStartedAt = Time.datetime();
}

procedure endTurn(principal p) {
  list<PlayerTime> timer = iterate _timers where who == p;
  if (timer[0] as t) {
    datetime started = t.turnStartedAt;
    timespan elapsed = started.between(Time.datetime());
    int elapsedMs = Math.clampIntOf(elapsed.seconds() * 1000.0);
    int remaining = t.remainingMs - elapsedMs + incrementMs;

    if (remaining <= 0) {
      timer.remainingMs = 0;
      timer.outOfTime = true;
    } else {
      timer.remainingMs = remaining;
    }
  }
}

// Real-time display formula
bubble myTime = (iterate _timers where who == @who);

public formula activePlayerTime = iterate _timers
  where who == currentPlayer;

Action Cooldowns

Problem: Limit how frequently certain actions can be performed.

record Cooldown {
  public int id;
  private principal who;
  public string action;
  public datetime availableAt;

  index who;
  index action;
}

table<Cooldown> _cooldowns;

procedure isOnCooldown(principal p, string action) -> bool {
  list<Cooldown> cd = iterate _cooldowns where who == p && action == action;
  if (cd[0] as c) {
    return Time.datetime() < c.availableAt;
  }
  return false;
}

procedure setCooldown(principal p, string action, int seconds) {
  list<Cooldown> existing = iterate _cooldowns where who == p && action == action;
  datetime nextAvailable = Time.datetime().future(TimeSpan.makeFromSeconds(seconds));

  if (existing.size() > 0) {
    existing.availableAt = nextAvailable;
  } else {
    _cooldowns <- {who: p, action: action, availableAt: nextAvailable};
  }
}

message UseAbility {
  string abilityName;
}

channel useAbility(UseAbility msg) {
  if (isOnCooldown(@who, msg.abilityName)) {
    return;  // Still on cooldown
  }

  // Perform ability
  executeAbility(@who, msg.abilityName);

  // Set cooldown based on ability
  int cooldownSeconds = getAbilityCooldown(msg.abilityName);
  setCooldown(@who, msg.abilityName, cooldownSeconds);
}

procedure executeAbility(principal p, string ability) {
  // Implementation
}

function getAbilityCooldown(string ability) -> int {
  // Different abilities have different cooldowns
  return 10;  // Default 10 seconds
}

Undo/Redo Patterns

Simple Undo Stack

Problem: Allow players to undo their last move.

record MoveHistory {
  public int id;
  private principal who;
  public int turnNumber;
  public int fromPosition;
  public int toPosition;
  public int capturedPieceId;  // -1 if no capture
  public datetime timestamp;
}

table<MoveHistory> _history;

public int currentTurn = 0;
public bool undoAvailable = false;

procedure recordMove(principal player, int from, int to, int captured) {
  _history <- {
    who: player,
    turnNumber: currentTurn,
    fromPosition: from,
    toPosition: to,
    capturedPieceId: captured,
    timestamp: Time.datetime()
  };
  undoAvailable = true;
}

message UndoMove {}

channel undoMove(UndoMove msg) {
  // Get the last move by this player
  list<MoveHistory> lastMove = iterate _history
    where who == @who
    order by id desc
    limit 1;

  if (lastMove.size() == 0) {
    return;  // No moves to undo
  }

  if (lastMove[0] as move) {
    // Only allow undo if it was the most recent move overall
    list<MoveHistory> mostRecent = iterate _history order by id desc limit 1;
    if (mostRecent[0] as recent) {
      if (recent.id != move.id) {
        return;  // Cannot undo - opponent has moved
      }
    }

    // Reverse the move
    reverseMove(move.fromPosition, move.toPosition, move.capturedPieceId);

    // Remove from history
    lastMove.delete();
    currentTurn--;
  }

  undoAvailable = _history.size() > 0;
}

procedure reverseMove(int from, int to, int capturedId) {
  // Game-specific undo logic
}

Full Game Replay

Problem: Store complete game history for replay and analysis.

record GameAction {
  public int id;
  public int sequenceNumber;
  private principal actor;
  public string actionType;
  public string actionData;  // JSON encoded details
  public datetime timestamp;
}

table<GameAction> _actionLog;

private int actionSequence = 0;

procedure logAction(principal actor, string actionType, string data) {
  _actionLog <- {
    sequenceNumber: actionSequence,
    actor: actor,
    actionType: actionType,
    actionData: data,
    timestamp: Time.datetime()
  };
  actionSequence++;
}

// Full replay data
public formula replayData = iterate _actionLog order by sequenceNumber asc;

// Step-by-step replay with viewer position
view int replayPosition;

bubble currentReplayState = iterate _actionLog
  where sequenceNumber <= @viewer.replayPosition
  order by sequenceNumber asc;

bubble canStepForward = @viewer.replayPosition < actionSequence;
bubble canStepBack = @viewer.replayPosition > 0;

Branching History (What-If Analysis)

Problem: Explore alternative move sequences without affecting the main game.

This is the kind of feature that's surprisingly natural in Adama. Each branch is just a separate table of actions, and the viewer selects which branch they're looking at.

record Branch {
  public int id;
  private principal creator;
  public string name;
  public int parentBranchId;  // -1 for main line
  public int branchPoint;     // Action sequence where branch starts
  public datetime createdAt;

  index creator;
}

record BranchAction {
  public int id;
  public int branchId;
  public int sequenceNumber;
  private principal actor;
  public string actionType;
  public string actionData;

  index branchId;
}

table<Branch> _branches;
table<BranchAction> _branchActions;

bubble myBranches = iterate _branches where creator == @who order by createdAt desc;

message CreateBranch {
  string name;
  int fromAction;  // Which action to branch from
}

channel createBranch(CreateBranch msg) {
  _branches <- {
    creator: @who,
    name: msg.name,
    parentBranchId: -1,
    branchPoint: msg.fromAction,
    createdAt: Time.datetime()
  };
}

message AddBranchMove {
  int branchId;
  string actionType;
  string actionData;
}

channel addBranchMove(AddBranchMove msg) {
  // Verify branch ownership
  list<Branch> branch = iterate _branches where id == msg.branchId && creator == @who;
  if (branch.size() == 0) {
    return;
  }

  // Get next sequence number for this branch
  int nextSeq = (iterate _branchActions where branchId == msg.branchId).size();

  _branchActions <- {
    branchId: msg.branchId,
    sequenceNumber: nextSeq,
    actor: @who,
    actionType: msg.actionType,
    actionData: msg.actionData
  };
}

// View a specific branch
view int viewingBranchId;

bubble branchMoves = iterate _branchActions
  where branchId == @viewer.viewingBranchId
  order by sequenceNumber asc;

The combination of durable execution, per-player privacy, and reactive updates makes Adama a genuinely good fit for multiplayer games. State machines give you clean game flow. The privacy system handles hidden information without any client-side trust. And because the document is the single source of truth, you never have to worry about desync between players.