Complete Examples

Here are three complete, runnable Adama applications. No snippets, no hand-waving -- these are full programs you can deploy and play with. Each one demonstrates multiple patterns working together, and I've tried to make the design decisions visible so you can understand why things are structured the way they are.

Real-Time Chat Room

A chat room with messages, user presence, typing indicators, and moderation. This is one of those applications that sounds simple until you actually build it -- the devil is in the details around presence tracking and message lifecycle.

Overview

This chat room includes:

  • User registration and authentication
  • Real-time message delivery
  • Typing indicators showing who is composing
  • User presence (online/offline status)
  • Message reactions
  • Basic moderation (mute, delete messages)
  • Message history with pagination

Complete Code

// ============================================================
// REAL-TIME CHAT ROOM
// A complete chat application with presence and typing indicators
// ============================================================

// --- Static Configuration ---
@static {
  create { return @who != @no_one; }
  invent { return true; }
  maximum_history = 1000;
}

// --- User Management ---

record User {
  public int id;
  private principal who;
  public string username;
  public string avatarUrl;
  public bool online;
  public bool typing;
  public datetime lastSeen;
  public datetime joinedAt;
  public bool isModerator;
  public bool isMuted;

  index who;

  // Users can see their own muted status
  viewer_is<who> bool myMutedStatus = isMuted;
}

table<User> _users;

private principal roomOwner;

// --- Messages ---

record Message {
  public int id;
  private principal author;
  public string authorName;
  public string content;
  public datetime sentAt;
  public bool edited;
  public bool deleted;

  policy is_visible {
    return !deleted;
  }
}

table<Message> _messages;

record Reaction {
  public int id;
  public int messageId;
  private principal who;
  public string emoji;

  index messageId;
  index who;
}

table<Reaction> _reactions;

// --- Public State ---

public string roomName = "General Chat";
public string roomDescription = "";
public int maxMessages = 500;

// Computed formulas for UI
public formula onlineUsers = iterate _users where online order by username;
public formula onlineCount = (iterate _users where online).size();
public formula typingUsers = iterate _users where typing && online;
public formula totalUsers = _users.size();
public formula totalMessages = _messages.size();

// Recent messages for the main view
public formula recentMessages = iterate _messages
  where !deleted
  order by sentAt desc
  limit 100;

// Reactions grouped by message
public formula messageReactions = iterate _reactions;

// --- Per-User Views ---

// Each user's own profile
bubble myProfile = (iterate _users where who == @who limit 1);

// Paginated message history
view int historyOffset;
view int historyLimit;

bubble messageHistory = iterate _messages
  where !deleted
  order by sentAt desc
  offset @viewer.historyOffset
  limit (@viewer.historyLimit > 0 ? @viewer.historyLimit : 50);

bubble hasMoreHistory = _messages.size() > @viewer.historyOffset + @viewer.historyLimit;

// --- Initialization ---

@construct {
  roomOwner = @who;
}

// --- Connection Handling ---

@connected {
  list<User> existing = iterate _users where who == @who;

  if (existing.size() == 0) {
    // First time connecting - create user record
    _users <- {
      who: @who,
      username: @who.agent(),
      avatarUrl: "",
      online: true,
      typing: false,
      lastSeen: Time.datetime(),
      joinedAt: Time.datetime(),
      isModerator: (@who == roomOwner),
      isMuted: false
    };
  } else {
    // Returning user
    existing.online = true;
    existing.lastSeen = Time.datetime();
  }

  return true;
}

@disconnected {
  (iterate _users where who == @who).online = false;
  (iterate _users where who == @who).typing = false;
  (iterate _users where who == @who).lastSeen = Time.datetime();
}

// --- Channel Handlers ---

message SetUsername {
  string username;
}

channel setUsername(SetUsername msg) {
  if (msg.username.length() < 1 || msg.username.length() > 32) {
    return;
  }
  (iterate _users where who == @who).username = msg.username;
}

message SetAvatar {
  string avatarUrl;
}

channel setAvatar(SetAvatar msg) {
  (iterate _users where who == @who).avatarUrl = msg.avatarUrl;
}

message SetTyping {
  bool isTyping;
}

channel setTyping(SetTyping msg) {
  (iterate _users where who == @who).typing = msg.isTyping;
}

message SendMessage {
  string content;
}

channel sendMessage(SendMessage msg) {
  // Check if user is muted
  list<User> user = iterate _users where who == @who;
  if (user.size() > 0) {
    if (user[0] as u) {
      if (u.isMuted) {
        return;  // Muted users cannot send messages
      }
    }
  }

  // Validate message
  if (msg.content.length() < 1 || msg.content.length() > 2000) {
    return;
  }

  // Get author's current username
  string authorName = @who.agent();
  if (user[0] as u) {
    authorName = u.username;
  }

  // Insert message
  _messages <- {
    author: @who,
    authorName: authorName,
    content: msg.content,
    sentAt: Time.datetime(),
    edited: false,
    deleted: false
  };

  // Clear typing indicator
  (iterate _users where who == @who).typing = false;

  // Enforce message limit (delete oldest if over limit)
  int excess = _messages.size() - maxMessages;
  if (excess > 0) {
    (iterate _messages order by sentAt asc limit excess).delete();
  }
}

message EditMessage {
  int messageId;
  string newContent;
}

channel editMessage(EditMessage msg) {
  // Can only edit own messages
  list<Message> message = iterate _messages where id == msg.messageId;
  if (message.size() == 0) {
    return;
  }

  bool isAuthor = false;
  if (message[0] as m) {
    isAuthor = (m.author == @who);
  }

  if (!isAuthor) {
    return;
  }

  if (msg.newContent.length() < 1 || msg.newContent.length() > 2000) {
    return;
  }

  message.content = msg.newContent;
  message.edited = true;
}

message DeleteMessage {
  int messageId;
}

channel deleteMessage(DeleteMessage msg) {
  list<Message> message = iterate _messages where id == msg.messageId;
  if (message.size() == 0) {
    return;
  }

  // Check if user is author or moderator
  bool canDelete = false;
  if (message[0] as m) {
    if (m.author == @who) {
      canDelete = true;
    }
  }

  // Check moderator status
  list<User> user = iterate _users where who == @who;
  if (user[0] as u) {
    if (u.isModerator) {
      canDelete = true;
    }
  }

  if (canDelete) {
    message.deleted = true;
    // Also delete reactions to this message
    (iterate _reactions where messageId == msg.messageId).delete();
  }
}

message AddReaction {
  int messageId;
  string emoji;
}

channel addReaction(AddReaction msg) {
  // Validate emoji (simple length check)
  if (msg.emoji.length() < 1 || msg.emoji.length() > 8) {
    return;
  }

  // Check if message exists
  if ((iterate _messages where id == msg.messageId).size() == 0) {
    return;
  }

  // Check if already reacted with this emoji
  if ((iterate _reactions where messageId == msg.messageId && who == @who && emoji == msg.emoji).size() > 0) {
    return;  // Already reacted
  }

  _reactions <- {
    messageId: msg.messageId,
    who: @who,
    emoji: msg.emoji
  };
}

message RemoveReaction {
  int messageId;
  string emoji;
}

channel removeReaction(RemoveReaction msg) {
  (iterate _reactions where messageId == msg.messageId && who == @who && emoji == msg.emoji).delete();
}

// --- Moderation ---

message MuteUser {
  principal user;
}

channel muteUser(MuteUser msg) {
  // Only moderators can mute
  list<User> mod = iterate _users where who == @who;
  if (mod.size() == 0) {
    return;
  }

  bool isMod = false;
  if (mod[0] as m) {
    isMod = m.isModerator;
  }

  if (!isMod) {
    return;
  }

  // Cannot mute the room owner
  if (msg.user == roomOwner) {
    return;
  }

  (iterate _users where who == msg.user).isMuted = true;
}

message UnmuteUser {
  principal user;
}

channel unmuteUser(UnmuteUser msg) {
  list<User> mod = iterate _users where who == @who;
  if (mod.size() == 0) {
    return;
  }

  bool isMod = false;
  if (mod[0] as m) {
    isMod = m.isModerator;
  }

  if (!isMod) {
    return;
  }

  (iterate _users where who == msg.user).isMuted = false;
}

message PromoteModerator {
  principal user;
}

channel promoteModerator(PromoteModerator msg) {
  // Only room owner can promote moderators
  if (@who != roomOwner) {
    return;
  }

  (iterate _users where who == msg.user).isModerator = true;
}

// --- Room Settings ---

message UpdateRoomSettings {
  string name;
  string description;
}

channel updateRoomSettings(UpdateRoomSettings msg) {
  // Only room owner can update settings
  if (@who != roomOwner) {
    return;
  }

  if (msg.name.length() > 0 && msg.name.length() <= 64) {
    roomName = msg.name;
  }

  if (msg.description.length() <= 256) {
    roomDescription = msg.description;
  }
}

Key Design Decisions

  1. User Records vs. Sessions: I store user data persistently so returning users keep their username and settings. The alternative -- ephemeral sessions -- would mean users have to re-enter their name every time they reconnect.

  2. Typing Indicators: Updated via a dedicated channel that clients call when the user starts or stops typing. The typing field resets automatically on disconnect, so you don't get ghost "typing..." indicators.

  3. Message Limit: The maxMessages setting prevents unbounded growth. Old messages get automatically pruned. Without this, a popular chat room would eventually eat all your memory.

  4. Soft Deletes: Messages are marked deleted rather than removed. This preserves message IDs for reactions and allows potential recovery.

  5. Reaction Model: Reactions are separate records indexed by message ID. This allows efficient queries and prevents duplicate reactions (one per user per emoji per message).


Simple Card Game

A two-player card game demonstrating deck management, hidden information, turn-based play, and win conditions. I chose a War variant because the rules are dead simple, which lets us focus on the Adama patterns rather than complex game logic.

Overview

This game implements a simple "War" variant:

  • Two players join and are dealt 26 cards each
  • Players simultaneously reveal their top card
  • Higher card wins both cards
  • First player to collect all 52 cards wins
  • Handles ties with a "war" mechanic

Complete Code

// ============================================================
// SIMPLE CARD GAME (WAR VARIANT)
// A two-player card game with hidden hands and simultaneous play
// ============================================================

@static {
  create { return @who != @no_one; }
  invent { return true; }
}

// --- Game Configuration ---

enum Suit { Clubs, Diamonds, Hearts, Spades }
enum Rank {
  Two:2, Three:3, Four:4, Five:5, Six:6, Seven:7, Eight:8,
  Nine:9, Ten:10, Jack:11, Queen:12, King:13, Ace:14
}

enum GamePhase { WaitingForPlayers, Playing, GameOver }

// --- Card and Deck ---

record Card {
  public int id;
  public Suit suit;
  public Rank rank;
  private principal owner;
  private int ordering;
  public bool inPlay;        // Currently revealed on table
  public bool inWarPile;     // Part of war stakes

  index owner;

  policy can_see_card {
    // Owner sees their cards, everyone sees cards in play
    return owner == @who || inPlay;
  }

  use_policy<can_see_card> Suit visibleSuit;
  use_policy<can_see_card> Rank visibleRank;

  method resetForDeal() {
    ordering = Random.genInt();
    owner = @no_one;
    inPlay = false;
    inWarPile = false;
  }
}

table<Card> _deck;

// --- Players ---

record Player {
  public int id;
  private principal who;
  public string name;
  public bool ready;
  public bool hasPlayed;  // Has played card this round
  public int cardCount;

  index who;
}

table<Player> _players;

// --- Game State ---

public GamePhase phase = GamePhase::WaitingForPlayers;
public int roundNumber = 0;
public string gameStatus = "Waiting for players to join...";
private principal player1;
private principal player2;
public principal winner;
public int warDepth = 0;  // How many war rounds deep

// --- Formulas for UI ---

public formula players = iterate _players order by id;
public formula playerCount = _players.size();
public formula canStart = playerCount == 2 && (iterate _players where !ready).size() == 0;

// Cards currently in play (visible to all)
public formula cardsInPlay = iterate _deck where inPlay order by id;

// Each player's hand (only visible to them)
bubble myHand = iterate _deck
  where owner == @who && !inPlay && !inWarPile
  order by ordering;

bubble myCardCount = (iterate _deck where owner == @who).size();

// --- Initialization ---

@construct {
  // Create the 52-card deck
  foreach (suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]) {
    foreach (rank in [Rank::Two, Rank::Three, Rank::Four, Rank::Five,
                      Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine,
                      Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, Rank::Ace]) {
      _deck <- {
        suit: suit,
        rank: rank,
        owner: @no_one,
        ordering: 0,
        inPlay: false,
        inWarPile: false
      };
    }
  }
}

@connected {
  return true;
}

@disconnected {
  // Mark player as not ready if game hasn't started
  if (phase == GamePhase::WaitingForPlayers) {
    (iterate _players where who == @who).ready = false;
  }
}

// --- Joining and Starting ---

message JoinGame {
  string name;
}

channel join(JoinGame msg) {
  if (phase != GamePhase::WaitingForPlayers) {
    return;
  }

  if (_players.size() >= 2) {
    return;
  }

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

  _players <- {
    who: @who,
    name: msg.name.length() > 0 ? msg.name : @who.agent(),
    ready: false,
    hasPlayed: false,
    cardCount: 0
  };

  if (_players.size() == 2) {
    gameStatus = "Two players joined. Click Ready to start!";
  }
}

message SetReady {
  bool ready;
}

channel setReady(SetReady msg) {
  if (phase != GamePhase::WaitingForPlayers) {
    return;
  }

  (iterate _players where who == @who).ready = msg.ready;

  if (canStart) {
    transition #startGame;
  }
}

// --- Game States ---

#startGame {
  phase = GamePhase::Playing;
  roundNumber = 1;

  // Assign players
  list<Player> playerList = iterate _players order by id;
  if (playerList[0] as p1) {
    player1 = p1.who;
  }
  if (playerList[1] as p2) {
    player2 = p2.who;
  }

  // Shuffle and deal
  invoke #shuffleAndDeal;

  gameStatus = "Game started! Play your top card.";
  transition #playRound;
}

#shuffleAndDeal {
  // Reset all cards
  (iterate _deck).resetForDeal();

  // Deal alternating cards
  bool toPlayer1 = true;
  foreach (card in iterate _deck shuffle) {
    if (toPlayer1) {
      card.owner = player1;
    } else {
      card.owner = player2;
    }
    toPlayer1 = !toPlayer1;
  }

  // Update card counts
  (iterate _players where who == player1).cardCount = 26;
  (iterate _players where who == player2).cardCount = 26;
}

#playRound {
  // Reset play state
  (iterate _players).hasPlayed = false;
  (iterate _deck where inPlay).inPlay = false;
  warDepth = 0;

  gameStatus = "Round " + roundNumber + " - Play your card!";

  // Wait for both players to play
  transition #waitForPlays;
}

#waitForPlays {
  // Check if both players have played
  if ((iterate _players where !hasPlayed).size() == 0) {
    transition #resolveRound;
  }
  // Otherwise wait for PlayCard channel messages
}

#resolveRound {
  // Get the played cards
  list<Card> played = iterate _deck where inPlay && !inWarPile;

  if (played.size() < 2) {
    // Something went wrong, reset
    transition #playRound;
    return;
  }

  // Find each player's card
  int p1Rank = 0;
  int p2Rank = 0;
  int p1CardId = 0;
  int p2CardId = 0;

  foreach (card in played) {
    if (card.owner == player1) {
      p1Rank = card.rank.to_int();
      p1CardId = card.id;
    } else if (card.owner == player2) {
      p2Rank = card.rank.to_int();
      p2CardId = card.id;
    }
  }

  if (p1Rank > p2Rank) {
    // Player 1 wins the round
    invoke #collectCards;
    gameStatus = getPlayerName(player1) + " wins the round!";
    transition #checkGameOver;
  } else if (p2Rank > p1Rank) {
    // Player 2 wins the round
    invoke #collectCards;
    gameStatus = getPlayerName(player2) + " wins the round!";
    transition #checkGameOver;
  } else {
    // Tie - go to war!
    gameStatus = "WAR! Cards are tied at rank " + p1Rank + "!";
    transition #war;
  }
}

#war {
  warDepth++;

  // Move current played cards to war pile
  (iterate _deck where inPlay).inWarPile = true;
  (iterate _deck where inPlay).inPlay = false;

  // Each player puts down a face-down card (if they have one)
  // Then plays another card face-up
  invoke #placeWarCards;

  // Reset for new play
  (iterate _players).hasPlayed = false;

  gameStatus = "WAR (depth " + warDepth + ")! Play another card!";
  transition #waitForPlays;
}

#placeWarCards {
  // Each player places one card face-down in the war pile
  list<Card> p1Cards = iterate _deck where owner == player1 && !inPlay && !inWarPile order by ordering limit 1;
  list<Card> p2Cards = iterate _deck where owner == player2 && !inPlay && !inWarPile order by ordering limit 1;

  if (p1Cards.size() > 0) {
    p1Cards.inWarPile = true;
  }
  if (p2Cards.size() > 0) {
    p2Cards.inWarPile = true;
  }
}

#collectCards {
  // Determine who won (player with higher card in play)
  list<Card> played = iterate _deck where inPlay;

  int p1Rank = 0;
  int p2Rank = 0;

  foreach (card in played) {
    if (card.owner == player1) {
      p1Rank = card.rank.to_int();
    } else {
      p2Rank = card.rank.to_int();
    }
  }

  principal roundWinner = (p1Rank > p2Rank) ? player1 : player2;

  // Give all in-play and war-pile cards to winner
  (iterate _deck where inPlay || inWarPile).owner = roundWinner;
  (iterate _deck where inPlay || inWarPile).inPlay = false;
  (iterate _deck where inWarPile).inWarPile = false;

  // Randomize the order of collected cards
  foreach (card in iterate _deck where owner == roundWinner) {
    card.ordering = Random.genInt();
  }

  // Update counts
  (iterate _players where who == player1).cardCount = (iterate _deck where owner == player1).size();
  (iterate _players where who == player2).cardCount = (iterate _deck where owner == player2).size();
}

#checkGameOver {
  int p1Count = (iterate _deck where owner == player1).size();
  int p2Count = (iterate _deck where owner == player2).size();

  if (p1Count == 0) {
    winner = player2;
    transition #gameOver;
  } else if (p2Count == 0) {
    winner = player1;
    transition #gameOver;
  } else {
    roundNumber++;
    transition #playRound in 2;  // Brief pause between rounds
  }
}

#gameOver {
  phase = GamePhase::GameOver;
  gameStatus = getPlayerName(winner) + " wins the game!";
}

// --- Helper Functions ---

procedure getPlayerName(principal p) -> string {
  list<Player> player = iterate _players where who == p;
  if (player[0] as pl) {
    return pl.name;
  }
  return "Unknown";
}

// --- Play Card Channel ---

message PlayCard {}

channel playCard(PlayCard msg) {
  if (phase != GamePhase::Playing) {
    return;
  }

  // Check if it's a valid player
  if (@who != player1 && @who != player2) {
    return;
  }

  // Check if already played this round
  list<Player> player = iterate _players where who == @who;
  if (player.size() == 0) {
    return;
  }

  bool alreadyPlayed = false;
  if (player[0] as p) {
    alreadyPlayed = p.hasPlayed;
  }

  if (alreadyPlayed) {
    return;
  }

  // Get top card from player's deck
  list<Card> topCard = iterate _deck
    where owner == @who && !inPlay && !inWarPile
    order by ordering
    limit 1;

  if (topCard.size() == 0) {
    return;  // No cards left
  }

  // Play the card
  topCard.inPlay = true;

  // Mark player as having played
  player.hasPlayed = true;

  // Check if both have played and trigger resolution
  if ((iterate _players where !hasPlayed).size() == 0) {
    transition #resolveRound;
  }
}

// --- Rematch ---

message RequestRematch {}

channel requestRematch(RequestRematch msg) {
  if (phase != GamePhase::GameOver) {
    return;
  }

  // Reset game state
  phase = GamePhase::WaitingForPlayers;
  roundNumber = 0;
  winner = @no_one;
  warDepth = 0;
  (iterate _players).ready = false;
  (iterate _players).hasPlayed = false;

  gameStatus = "Ready for rematch? Click Ready!";
}

Key Design Decisions

  1. Card Privacy: The can_see_card policy is doing the real work here. Players only see their own cards and cards that are face-up on the table. The server never sends hidden card data to the wrong client -- this isn't client-side hiding that someone could inspect-element their way around.

  2. Simultaneous Play: Both players submit their card independently. The game waits until both have played before resolving. No peeking.

  3. War Handling: When cards tie, the state machine handles multiple rounds of war with cards accumulating in a war pile. This was trickier to get right than I expected -- you need to track which cards are "in play" versus "in the war stakes."

  4. Randomization: Cards get random ordering values for shuffling. When cards are collected after a round, they get new random orderings so the winner's deck is properly shuffled.

  5. Pause Between Rounds: The in 2 delay on transition #playRound gives players a couple seconds to see the result before the next round starts. Small detail, but it matters for the experience.


Collaborative Todo List

A shared task management application with assignments, due dates, categories, and real-time collaboration. This is probably the most "real app" of the three examples -- it's the kind of thing you might actually ship.

Overview

This todo list includes:

  • Task creation with titles, descriptions, due dates
  • Categories for organization
  • User assignments
  • Completion tracking
  • Filtering and sorting
  • Activity log

Complete Code

// ============================================================
// COLLABORATIVE TODO LIST
// A shared task management application with assignments and categories
// ============================================================

@static {
  create { return @who != @no_one; }
  invent { return true; }
}

// --- Types ---

enum Priority { Low, Medium, High, Urgent }
enum TaskStatus { Todo, InProgress, Done, Cancelled }

// --- Users ---

record User {
  public int id;
  private principal who;
  public string name;
  public string email;
  public string avatarColor;
  public datetime joinedAt;
  public bool isAdmin;

  index who;
}

table<User> _users;

// --- Categories ---

record Category {
  public int id;
  public string name;
  public string color;
  public int taskCount;

  index name;
}

table<Category> _categories;

// --- Tasks ---

record Task {
  public int id;
  public string title;
  public string description;
  public int categoryId;
  public Priority priority;
  public TaskStatus status;
  private principal createdBy;
  public string createdByName;
  private principal assignedTo;
  public string assignedToName;
  public datetime createdAt;
  public datetime updatedAt;
  public maybe<datetime> dueDate;
  public maybe<datetime> completedAt;
  public int commentCount;

  index categoryId;
  index status;
  index assignedTo;
  index priority;
}

table<Task> _tasks;

// --- Comments ---

record Comment {
  public int id;
  public int taskId;
  private principal author;
  public string authorName;
  public string content;
  public datetime createdAt;

  index taskId;
}

table<Comment> _comments;

// --- Activity Log ---

record Activity {
  public int id;
  private principal actor;
  public string actorName;
  public string action;
  public string target;
  public datetime timestamp;
}

table<Activity> _activity;

// --- Public State ---

public string listName = "Team Tasks";
private principal listOwner;

// Statistics
public formula totalTasks = _tasks.size();
public formula completedTasks = (iterate _tasks where status == TaskStatus::Done).size();
public formula openTasks = (iterate _tasks where status == TaskStatus::Todo || status == TaskStatus::InProgress).size();
public formula overdueTasks = (iterate _tasks
  where_as t:
    t.status != TaskStatus::Done
    && t.status != TaskStatus::Cancelled
    && t.dueDate.has()
    && t.dueDate.getOrDefaultTo(Time.datetime()) < Time.datetime()
).size();

// Public lists
public formula categories = iterate _categories order by name;
public formula users = iterate _users order by name;
public formula recentActivity = iterate _activity order by timestamp desc limit 20;

// --- Per-User Views ---

// My assigned tasks
bubble myTasks = iterate _tasks
  where assignedTo == @who && status != TaskStatus::Cancelled
  order by priority desc, dueDate asc;

bubble myProfile = (iterate _users where who == @who limit 1);

// Filtered task view
view string categoryFilter;
view string statusFilter;
view string assigneeFilter;
view string sortBy;
view string searchTerm;

bubble filteredTasks = iterate _tasks
  where_as t:
    (@viewer.categoryFilter == "" || ("" + t.categoryId) == @viewer.categoryFilter) &&
    (@viewer.statusFilter == "" || t.status.to_string() == @viewer.statusFilter) &&
    (@viewer.assigneeFilter == "" || t.assignedTo.agent() == @viewer.assigneeFilter) &&
    (@viewer.searchTerm == "" ||
     t.title.contains(@viewer.searchTerm) ||
     t.description.contains(@viewer.searchTerm))
  order_dyn (@viewer.sortBy != "" ? @viewer.sortBy : "-priority");

// Task detail view
view int selectedTaskId;

bubble selectedTask = (iterate _tasks where id == @viewer.selectedTaskId limit 1);

bubble selectedTaskComments = iterate _comments
  where taskId == @viewer.selectedTaskId
  order by createdAt asc;

// --- Initialization ---

@construct {
  listOwner = @who;

  // Create default categories
  _categories <- {name: "General", color: "#6B7280", taskCount: 0};
  _categories <- {name: "Feature", color: "#3B82F6", taskCount: 0};
  _categories <- {name: "Bug", color: "#EF4444", taskCount: 0};
  _categories <- {name: "Documentation", color: "#10B981", taskCount: 0};
}

@connected {
  list<User> existing = iterate _users where who == @who;

  if (existing.size() == 0) {
    _users <- {
      who: @who,
      name: @who.agent(),
      email: "",
      avatarColor: getRandomColor(),
      joinedAt: Time.datetime(),
      isAdmin: (@who == listOwner)
    };
  }

  return true;
}

function getRandomColor() -> string {
  int r = Random.genBoundInt(6);
  if (r == 0) { return "#EF4444"; }
  if (r == 1) { return "#F59E0B"; }
  if (r == 2) { return "#10B981"; }
  if (r == 3) { return "#3B82F6"; }
  if (r == 4) { return "#8B5CF6"; }
  return "#EC4899";
}

// --- Activity Logging ---

procedure logActivity(principal actor, string action, string target) {
  string actorName = actor.agent();
  list<User> user = iterate _users where who == actor;
  if (user[0] as u) {
    actorName = u.name;
  }

  _activity <- {
    actor: actor,
    actorName: actorName,
    action: action,
    target: target,
    timestamp: Time.datetime()
  };

  // Keep only last 100 activities
  int excess = _activity.size() - 100;
  if (excess > 0) {
    (iterate _activity order by timestamp asc limit excess).delete();
  }
}

// --- User Profile ---

message UpdateProfile {
  string name;
  string email;
}

channel updateProfile(UpdateProfile msg) {
  if (msg.name.length() < 1 || msg.name.length() > 64) {
    return;
  }

  (iterate _users where who == @who).name = msg.name;
  (iterate _users where who == @who).email = msg.email;
}

// --- Categories ---

message CreateCategory {
  string name;
  string color;
}

channel createCategory(CreateCategory msg) {
  // Check if admin
  list<User> user = iterate _users where who == @who;
  bool isAdmin = false;
  if (user[0] as u) {
    isAdmin = u.isAdmin;
  }
  if (!isAdmin) {
    return;
  }

  if (msg.name.length() < 1 || msg.name.length() > 32) {
    return;
  }

  // Check for duplicate
  if ((iterate _categories where name == msg.name).size() > 0) {
    return;
  }

  _categories <- {
    name: msg.name,
    color: msg.color.length() > 0 ? msg.color : "#6B7280",
    taskCount: 0
  };

  logActivity(@who, "created category", msg.name);
}

message DeleteCategory {
  int categoryId;
}

channel deleteCategory(DeleteCategory msg) {
  // Check if admin
  list<User> user = iterate _users where who == @who;
  bool isAdmin = false;
  if (user[0] as u) {
    isAdmin = u.isAdmin;
  }
  if (!isAdmin) {
    return;
  }

  // Move tasks in this category to General (id 1)
  (iterate _tasks where categoryId == msg.categoryId).categoryId = 1;

  // Delete the category
  list<Category> cat = iterate _categories where id == msg.categoryId;
  string catName = "";
  if (cat[0] as c) {
    catName = c.name;
  }

  cat.delete();

  if (catName.length() > 0) {
    logActivity(@who, "deleted category", catName);
  }
}

// --- Tasks ---

message CreateTask {
  string title;
  string description;
  int categoryId;
  Priority priority;
  maybe<datetime> dueDate;
}

channel createTask(CreateTask msg) {
  if (msg.title.length() < 1 || msg.title.length() > 256) {
    return;
  }

  string creatorName = @who.agent();
  list<User> user = iterate _users where who == @who;
  if (user[0] as u) {
    creatorName = u.name;
  }

  _tasks <- {
    title: msg.title,
    description: msg.description,
    categoryId: msg.categoryId > 0 ? msg.categoryId : 1,
    priority: msg.priority,
    status: TaskStatus::Todo,
    createdBy: @who,
    createdByName: creatorName,
    assignedTo: @no_one,
    assignedToName: "",
    createdAt: Time.datetime(),
    updatedAt: Time.datetime(),
    dueDate: msg.dueDate,
    completedAt: @maybe<datetime>,
    commentCount: 0
  };

  // Update category count
  list<Category> cat = iterate _categories where id == msg.categoryId;
  if (cat[0] as c) {
    cat.taskCount = c.taskCount + 1;
  }

  logActivity(@who, "created task", msg.title);
}

message UpdateTask {
  int taskId;
  string title;
  string description;
  int categoryId;
  Priority priority;
  maybe<datetime> dueDate;
}

channel updateTask(UpdateTask msg) {
  list<Task> task = iterate _tasks where id == msg.taskId;
  if (task.size() == 0) {
    return;
  }

  // Track old category for count update
  int oldCategoryId = 0;
  if (task[0] as t) {
    oldCategoryId = t.categoryId;
  }

  // Update fields
  if (msg.title.length() >= 1 && msg.title.length() <= 256) {
    task.title = msg.title;
  }
  task.description = msg.description;
  task.categoryId = msg.categoryId > 0 ? msg.categoryId : 1;
  task.priority = msg.priority;
  task.dueDate = msg.dueDate;
  task.updatedAt = Time.datetime();

  // Update category counts if changed
  if (oldCategoryId != msg.categoryId) {
    list<Category> oldCat = iterate _categories where id == oldCategoryId;
    if (oldCat[0] as oc) {
      oldCat.taskCount = oc.taskCount - 1;
    }

    list<Category> newCat = iterate _categories where id == msg.categoryId;
    if (newCat[0] as nc) {
      newCat.taskCount = nc.taskCount + 1;
    }
  }

  logActivity(@who, "updated task", msg.title);
}

message AssignTask {
  int taskId;
  principal assignee;
}

channel assignTask(AssignTask msg) {
  list<Task> task = iterate _tasks where id == msg.taskId;
  if (task.size() == 0) {
    return;
  }

  string assigneeName = "";
  if (msg.assignee != @no_one) {
    list<User> assigneeUser = iterate _users where who == msg.assignee;
    if (assigneeUser[0] as au) {
      assigneeName = au.name;
    }
  }

  task.assignedTo = msg.assignee;
  task.assignedToName = assigneeName;
  task.updatedAt = Time.datetime();

  string taskTitle = "";
  if (task[0] as t) {
    taskTitle = t.title;
  }

  if (assigneeName.length() > 0) {
    logActivity(@who, "assigned " + taskTitle + " to", assigneeName);
  } else {
    logActivity(@who, "unassigned", taskTitle);
  }
}

message UpdateTaskStatus {
  int taskId;
  TaskStatus status;
}

channel updateTaskStatus(UpdateTaskStatus msg) {
  list<Task> task = iterate _tasks where id == msg.taskId;
  if (task.size() == 0) {
    return;
  }

  TaskStatus oldStatus = TaskStatus::Todo;
  if (task[0] as t) {
    oldStatus = t.status;
  }

  task.status = msg.status;
  task.updatedAt = Time.datetime();

  if (msg.status == TaskStatus::Done) {
    task.completedAt = Time.datetime();
  } else {
    task.completedAt = @maybe<datetime>;
  }

  string taskTitle = "";
  if (task[0] as t2) {
    taskTitle = t2.title;
  }

  logActivity(@who, "changed " + taskTitle + " to", msg.status.to_string());
}

message DeleteTask {
  int taskId;
}

channel deleteTask(DeleteTask msg) {
  list<Task> task = iterate _tasks where id == msg.taskId;
  if (task.size() == 0) {
    return;
  }

  // Get info for logging
  string taskTitle = "";
  int categoryId = 0;
  if (task[0] as t) {
    taskTitle = t.title;
    categoryId = t.categoryId;
  }

  // Delete comments first
  (iterate _comments where taskId == msg.taskId).delete();

  // Update category count
  list<Category> cat = iterate _categories where id == categoryId;
  if (cat[0] as c) {
    cat.taskCount = c.taskCount - 1;
  }

  // Delete task
  task.delete();

  logActivity(@who, "deleted task", taskTitle);
}

// --- Comments ---

message AddComment {
  int taskId;
  string content;
}

channel addComment(AddComment msg) {
  if (msg.content.length() < 1 || msg.content.length() > 2000) {
    return;
  }

  list<Task> task = iterate _tasks where id == msg.taskId;
  if (task.size() == 0) {
    return;
  }

  string authorName = @who.agent();
  list<User> user = iterate _users where who == @who;
  if (user[0] as u) {
    authorName = u.name;
  }

  _comments <- {
    taskId: msg.taskId,
    author: @who,
    authorName: authorName,
    content: msg.content,
    createdAt: Time.datetime()
  };

  // Update comment count
  int count = 0;
  if (task[0] as t) {
    count = t.commentCount;
  }
  task.commentCount = count + 1;
  task.updatedAt = Time.datetime();

  string taskTitle = "";
  if (task[0] as t2) {
    taskTitle = t2.title;
  }

  logActivity(@who, "commented on", taskTitle);
}

message DeleteComment {
  int commentId;
}

channel deleteComment(DeleteComment msg) {
  list<Comment> comment = iterate _comments where id == msg.commentId;
  if (comment.size() == 0) {
    return;
  }

  // Check ownership
  bool canDelete = false;
  int taskId = 0;
  if (comment[0] as c) {
    canDelete = (c.author == @who);
    taskId = c.taskId;
  }

  // Admins can delete any comment
  list<User> user = iterate _users where who == @who;
  if (user[0] as u) {
    if (u.isAdmin) {
      canDelete = true;
    }
  }

  if (!canDelete) {
    return;
  }

  comment.delete();

  // Update task comment count
  list<Task> task = iterate _tasks where id == taskId;
  if (task.size() > 0) {
    int count = 0;
    if (task[0] as t) {
      count = t.commentCount;
    }
    task.commentCount = count - 1;
  }
}

// --- List Settings ---

message UpdateListSettings {
  string name;
}

channel updateListSettings(UpdateListSettings msg) {
  if (@who != listOwner) {
    return;
  }

  if (msg.name.length() >= 1 && msg.name.length() <= 64) {
    listName = msg.name;
  }
}

message PromoteAdmin {
  principal user;
}

channel promoteAdmin(PromoteAdmin msg) {
  if (@who != listOwner) {
    return;
  }

  (iterate _users where who == msg.user).isAdmin = true;

  string userName = "";
  list<User> promoted = iterate _users where who == msg.user;
  if (promoted[0] as u) {
    userName = u.name;
  }

  logActivity(@who, "promoted to admin", userName);
}

Key Design Decisions

  1. Flexible Filtering: The filteredTasks bubble accepts multiple filter parameters through view variables. The client can set any combination of category, status, assignee, search term, and sort order -- the server handles the query. This is where the reactive model really pays off; each client gets their own filtered view without any extra server work.

  2. Activity Log: All significant actions get logged, with automatic pruning to keep only the last 100 entries. Without the pruning, this would grow without bound -- and that's the kind of thing you don't notice until production.

  3. Category Counts: I maintain denormalized taskCount on categories for efficient display. The alternative would be counting on every render, which gets expensive. The tradeoff is that you have to remember to update the count whenever tasks are created, moved, or deleted. It's more code, but it's worth it.

  4. Soft User Data: Task creators and assignees have their names stored directly on the task record. This means historical data stays readable even if users change their display names later. The tradeoff is that if a user renames themselves, old tasks still show the old name -- but I think that's actually the right behavior for an audit trail.

  5. Comment Counts: Tasks track their comment count directly for the same reason as category counts -- efficient list display without re-counting on every render.

  6. Admin Permissions: Only admins can create/delete categories and delete others' comments. The list owner can promote additional admins. This is a simple two-tier permission model that works for small teams.

Each of these examples is meant to be a starting point. Take them, modify them, extend them. The patterns they demonstrate -- presence tracking, hidden information, filtering, activity logging -- will serve you in whatever you end up building.

Previous Game Patterns
Next Advanced