State Machines

Most backends are passive. A request comes in, you do something, you send a response. The client drives everything. But I kept running into situations — games, workflows, auctions — where the server needs to be in charge. The server should decide whose turn it is, ask specific people for input, enforce ordering, handle timeouts. The client shouldn't be orchestrating any of that.

So I built state machines into the document model.

The Dungeon Master Metaphor

Think of an Adama document as a Dungeon Master running a tabletop game. The DM maintains the world state, enforces rules, controls the flow, waits for player input, and remembers everything — even if the session pauses and resumes days later.

That's what these state machines do. Your code runs synchronously, blocks when it needs player input, and the platform handles all the messy business of pausing, persisting, and resuming. You write what looks like a straight-line program, and the infrastructure makes it durable.

Where This Shines

State machines are the right tool when you have multi-stage workflows:

  • Onboarding flows: Guide users through multi-step registration
  • Approval processes: Route documents through review chains
  • Turn-based games: Manage player turns, rounds, and game phases
  • Auctions: Handle bidding periods with time limits
  • Tournaments: Coordinate brackets and match progression

Without them, you'd be tracking "where we are" manually with scattered conditionals. State machines make the flow explicit.

Turn-Based Games

Board games and card games map naturally onto this. Players act in sequence, waiting for their turn. The state machine lets you write game logic that reads like the game's rules:

message Move { int x; int y; }
channel<Move> move_channel;
private principal current_player;

procedure applyMove(Move m) {}
procedure checkWinner() -> bool { return false; }
procedure getNextPlayer() -> principal { return current_player; }

#playerTurn {
  // Ask current player for their move
  future<Move> f = move_channel.fetch(current_player);
  Move m = f.await();

  // Apply the move
  applyMove(m);

  // Check for win condition
  if (checkWinner()) {
    transition #gameOver;
  } else {
    // Next player's turn
    current_player = getNextPlayer();
    transition #playerTurn;
  }
}

#gameOver {}

Get a move, apply it, check for winner, switch players, repeat. The await() blocks until the player responds, but the document stays responsive to other connections and queries in the meantime.

Defining States

States are defined with a hash prefix (#) followed by a name and a code block:

#start {
  // Code executed when document enters this state
}

#waiting {
  // Code for the waiting state
}

#gameOver {
  // Code for game over state
}

State names must be valid identifiers. I recommend descriptive names that say what the document is doing or waiting for.

The No-State

A document can be in no state at all — represented by the special label #. This is the default before any transition occurs. A document with no active state is idle; it responds to messages and connections but doesn't execute state machine code.

label current = #;  // The no-state

State Transitions

The transition keyword moves the document from one state to another:

@construct {
  transition #start;
}

#start {
  // Do initial setup
  transition #waitingForPlayers;
}

#waitingForPlayers {
  // Wait logic here
}

Transition Semantics

When you call transition, here's what actually happens:

  1. The current state's code continues executing until it returns
  2. After the current transaction commits, the new state begins
  3. The new state's code runs in a fresh transaction

Transitions are not immediate jumps — they schedule the next state to run after the current code completes. This is a subtle but important distinction.

Conditional Transitions

Use standard control flow to pick which state to transition to:

int score = 0;
int lives = 3;

#checkResult {
  if (score >= 100) {
    transition #winner;
  } else if (lives == 0) {
    transition #gameOver;
  } else {
    transition #nextRound;
  }
}

#winner {}
#gameOver {}
#nextRound {}

Dynamic Transitions

You can compute the target state dynamically:

int count = 0;

#tick {
  count++;
  transition (count < 10 ? #tick : #done);
}

#done {}

Delayed Transitions

Add in followed by a number to delay the transition by that many seconds:

#active {
  transition #expired in 3600;  // Transition after 1 hour
}

#expired {}

Time Units

The delay is in seconds. Decimals work for sub-second precision:

bool visible = false;

#flash {
  visible = true;
  transition #hide in 0.5;  // Half a second
}

#hide {
  visible = false;
  transition #flash in 0.5;
}

Common delay values:

  • 0.5 - half a second
  • 60 - one minute
  • 3600 - one hour
  • 86400 - one day

Durability

Here's the part I'm most proud of. Delayed transitions are durable — they survive server restarts. If you schedule a transition for one hour from now and the server restarts after 30 minutes, the transition still fires 30 minutes later.

This matters for real applications. Auction deadlines, session timeouts, scheduled events — they all execute reliably without external cron jobs or timers. No separate scheduler needed.

Canceling Delayed Transitions

A new transition replaces any pending delayed transition:

#countdown {
  transition #explode in 60;
}

#defused {
  // The pending transition to #explode is canceled
  transition #safe;
}

#explode {}
#safe {}

Practical Example: Bomb Timer

public string display = "Ready";
private int ticks = 0;

@construct {
  transition #tick;
}

#tick {
  display = "Tick";
  transition #tock in 0.5;
}

#tock {
  display = "Tock";
  ticks++;
  transition (ticks < 10 ? #tick : #boom) in 0.5;
}

#boom {
  display = "BOOM!";
}

Tick. Tock. Tick. Tock. Alternates every half second, then explodes after 10 tocks. All durable. Restart the server mid-countdown and it picks right back up.

The invoke Keyword

The invoke keyword calls a state as a subroutine. Unlike transition, execution continues after the invoked state completes:

int x = 0;

#main {
  x = 1;
  invoke #double_x;
  // x is now 2
  invoke #double_x;
  // x is now 4
  transition #done;
}

#double_x {
  x = x * 2;
}

#done {
  // Final state
}

invoke vs transition

Feature invoke transition
Returns to caller Yes No
Use for Subroutines, reusable logic State changes
Blocking Synchronous call Schedules next state

Use invoke when you want to reuse state logic without leaving your current flow:

#setupGame {
  invoke #shuffleDeck;
  invoke #dealCards;
  invoke #setupBoard;
  transition #playRound;
}

#shuffleDeck {
  // Shuffle logic
}

#dealCards {
  // Deal logic
}

#setupBoard {
  // Board setup logic
}

#playRound {}

This reads like a recipe. I like that.

Label Variables

Store state references in variables using the label type:

label currentState = #start;
label nextState;

@construct {
  nextState = #waiting;
  transition currentState;
}

#start {
  transition nextState;
}

#waiting {
  // ...
}

Label Comparison

Compare labels using == and !=:

label ptr = #start;

#start {
  if (ptr == #start) {
    ptr = #next;
  }
  transition ptr;
}

#next {
  // ...
}

The No-State Label

The special value # represents no state:

label target = #;  // No state assigned

@construct {
  if (target == #) {
    target = #default;
  }
  transition target;
}

#default {}

Async with Channels

Here's where it gets interesting. The real power of state machines shows up when you combine them with async channels. Channels let your state machine pause and wait for input from specific users.

Incomplete Channels

An incomplete channel is declared with angle brackets containing the message type:

message Decision {
  string choice;
}

channel<Decision> decide;

Unlike regular channels (which have handlers), incomplete channels are used within state machine code to fetch messages from specific users.

fetch and await

Use fetch() to request input from a principal, then await() to block until it arrives:

message Decision {
  string choice;
}

channel<Decision> decide;
private principal player;
string selectedChoice = "";

#askUser {
  future<Decision> f = decide.fetch(player);
  Decision d = f.await();
  // Process the decision
  selectedChoice = d.choice;
  transition #processChoice;
}

#processChoice {}

When this state runs:

  1. fetch(player) signals that the document needs input from player
  2. The client sees they have a pending request on the decide channel
  3. The state blocks at await() until the player responds
  4. Once the player sends a Decision message, execution continues

Blocking Semantics

The await() call does not busy-wait or consume resources. The document state is persisted, and execution resumes only when the requested input arrives. This can take milliseconds or days — the platform handles it. Your code doesn't care.

Parallel Fetches

The separation of fetch() and await() is deliberate — it enables parallel requests:

private principal player1;
private principal player2;

message Answer {
  string text;
}

channel<Answer> answer;

procedure processAnswers(Answer a1, Answer a2) {}

#getResponses {
  // Request input from both players
  future<Answer> f1 = answer.fetch(player1);
  future<Answer> f2 = answer.fetch(player2);

  // Both players can now respond independently
  // The order they respond doesn't matter

  // Wait for player1's response
  Answer a1 = f1.await();

  // Wait for player2's response
  Answer a2 = f2.await();

  // Process both answers
  processAnswers(a1, a2);
  transition #nextPhase;
}

#nextPhase {}

Even though we await() player1 first, player2 can respond at any time. If player2 responds before player1, their answer is queued and immediately available when we reach f2.await(). I split fetch and await into two calls specifically so you could do this.

Practical Example: Tic-Tac-Toe Turn

enum SquareState { Open, X, O }

private principal playerX;
private principal playerO;
public principal current;

message Play {
  int id;
}

channel<Play> play;

record Square {
  public int id;
  public int x;
  public int y;
  public SquareState state;
}

table<Square> _squares;

procedure checkWinner(SquareState mark) -> bool { return false; }

#turn {
  // Find open squares
  list<Square> open = iterate _squares where state == SquareState::Open;

  if (open.size() == 0) {
    transition #stalemate;
    return;
  }

  // Ask current player to pick an open square
  if (play.decide(current, @convert<Play>(open)).await() as pick) {
    // Mark the square
    let mark = (current == playerX) ? SquareState::X : SquareState::O;
    (iterate _squares where id == pick.id).state = mark;

    // Check for winner (simplified)
    if (checkWinner(mark)) {
      transition #victory;
      return;
    }

    // Switch players
    current = (current == playerX) ? playerO : playerX;
    transition #turn;
  }
}

#stalemate {}
#victory {}

The decide() method is similar to fetch() but restricts the player's choices to a set of valid options. The player can only pick from the open squares — no cheating.

Channel Methods

Incomplete channels provide several methods:

Method Return Type Description
fetch(principal) future<T> Request a message from the principal
decide(principal, T[]) future<maybe<T>> Request the principal to choose from options

For array channels (channel<T[]>):

Method Return Type Description
fetch(principal) future<T[]> Request an array of messages
choose(principal, T[], int) future<maybe<T[]>> Request a subset selection with limit

Durable Execution

This is the thing that makes all of this worth the complexity. Adama's state machine provides durable execution — your code can run for days, weeks, or longer while surviving any infrastructure failure.

State Survives Crashes

When a state machine blocks (waiting for player input or a delayed transition), the entire document state is persisted:

  • Current state label
  • All document fields
  • Pending futures and their requestors
  • Scheduled delayed transitions

If the server crashes and restarts, execution resumes exactly where it left off. Players can disconnect and reconnect, and their pending requests remain active.

Messages Queued During Restart

If a player sends a message while the server is restarting:

  1. The message is queued in durable storage
  2. When the server recovers, queued messages are processed
  3. The state machine continues from its persisted state

No input is lost, even during maintenance windows or unexpected outages.

No External Coordination Required

Traditional systems need external job queues, schedulers, and state tracking to implement durable workflows. I eliminated all of that:

  • No separate database for workflow state
  • No external cron jobs for timeouts
  • No message queues to manage
  • No recovery logic to write

Your code reads synchronously; the platform handles durability. That's the deal.

Complete Example: Card Game Round

Here's a complete example showing a card game round with multiple async interactions:

message DrawCount {
  int count;
}

message CardPick {
  int card_id;
}

channel<DrawCount> how_many;
channel<CardPick> pick_card;

private principal player1;
private principal player2;
private principal current_player;

record Card {
  public int id;
  private principal holder;
  public int value;
}

table<Card> deck;

procedure deckEmpty() -> bool { return (iterate deck).size() == 0; }

#round {
  // Ask current player how many cards to draw
  future<DrawCount> draw_fut = how_many.fetch(current_player);
  DrawCount draw = draw_fut.await();

  // Draw that many cards
  invoke #drawCards;

  // Ask player to play a card
  invoke #playCard;

  // Switch to other player
  current_player = (current_player == player1) ? player2 : player1;

  // Continue to next round or end game
  if (deckEmpty()) {
    transition #scoring;
  } else {
    transition #round;
  }
}

#drawCards {
  // Draw logic using draw.count
}

#playCard {
  // Get cards in player's hand
  list<Card> hand = iterate deck where holder == current_player;

  // Ask player to pick a card
  future<CardPick> pick_fut = pick_card.fetch(current_player);
  CardPick pick = pick_fut.await();

  // Process the played card
  // ...
}

#scoring {
  // Calculate final scores
  transition #gameOver;
}

#gameOver {
  // Display results, offer rematch
}

At its heart, state machines turn documents from passive data stores into active workflow orchestrators. You write what looks like blocking synchronous code, and the platform makes it durable, resumable, and concurrent. That's the whole trick — and it's the right trick for games and workflows.

Previous Channels
Next Web