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.