Skip to content

Your first two-player app

Tic-tac-toe by message: two people, each on their own phone, sending one document back and forth. It is examples/tic-tac-toe, and it is exercised end to end — two devices, real files — by tests/examples-shared.spec.ts.

1. Decide the shape

Both copies' marks must be kept, so the tables are shared. And it is a closed group of two: a copy forwarded to a third person must not let them play. That is a session, not merely passable.

2. Declare the tables, and the profile

sql
-- Tic-tac-toe by message · shape: session
--
-- The decision: two people play one game, each on their own phone, sending
-- the document back and forth. Both copies' marks must be kept, so the tables
-- are shared. And it is a closed group of two — a copy forwarded to a third
-- person must not let them play — so it is a session, not merely passable.
--
-- What that costs, and why:
--   * Every game is its own session. The creator plays X; whoever opens the
--     invite takes the open seat and plays O. Who is X and who is O is read
--     from the seats, never stored.
--   * No board, no turn, no winner. All of it is derived by replaying marks in
--     turn order. Two marks at one turn (the same side moved on two copies) is
--     shown to the players to settle, never decided silently.
--   * No UNIQUE(game_id, turn): it would refuse exactly the rows that make a
--     collision visible.
--   * Which game is showing is about this copy, so it is local.

-- dai:profile session max_parties=2 close=any

-- dai:replicated
CREATE TABLE IF NOT EXISTS games (
  x_name TEXT NOT NULL,
  o_name TEXT NOT NULL
);

-- dai:replicated
CREATE TABLE IF NOT EXISTS marks (
  game_id TEXT NOT NULL,     -- the games row's entity, as hex
  turn    INTEGER NOT NULL,  -- 1-based; X plays odd turns, O even
  cell    INTEGER NOT NULL   -- 0..8, left to right, top to bottom
);

-- Local: never merged.
CREATE TABLE IF NOT EXISTS settings (
  id          INTEGER PRIMARY KEY CHECK (id = 1),
  active_game TEXT
);

Two shared tables, games and marks, and one local one. There is no board column, no turn and no winner: all three are computed from the marks every time they are drawn.

SESSION-PROFILE

Declare the session profile

Declare a session document with one line comment in schema.sql: -- dai:profile session max_parties=N close=any|creator. N is the most people the document allows, at least 1 — but today a session seats two whatever N says: session.create() mints the creator's seat and one open seat, and no call adds another (backlog D6). Declare max_parties=2, and do not build an application that needs a third member. close=any lets any member close a session; close=creator lets only the person who created it; it defaults to any. The document must also have at least one table marked -- dai:replicated. Every replicated table then carries the session of each row.

Why. The profile is signed into the document, so the size of the group is the creator's stated limit rather than something the application decides. A malformed profile, or one with no replicated table, is refused at build.

Applies to session · refused at build · SESSION-PROFILE in Constraints

SHARED-NO-DERIVED-STATE

Store facts, derive everything else

Store in a shared table only the facts people enter or acts they take — a receipt, a move, a mark. Never store anything computable from them: no board, no score, no turn, no total, no balance, no "last updated", no status that follows from other rows. Compute it from the rows each time it is drawn, preferably in SQL.

Why. A stored total is a second opinion about what the rows say, and after a merge it is wrong: each copy computed it from the rows it had, and neither computed it from the union.

Applies to passable, session · not checked by anything · SHARED-NO-DERIVED-STATE in Constraints

3. A new game is a new session

Starting a game creates a session — which seats this copy and leaves one seat open — and then inserts the games row into it. In app.js that is the new-game submit handler.

SESSION-CREATE

A new game is a new session

Start each game, match or agreement with const { session, seat } = window.dai.replicated.session.create(). It seats the creator and leaves one open seat for the invitee; session is the id to keep (hex), seat is the open seat. Then insert the thing itself — the games row — with that session (SESSION-ROW-CARRIES-SESSION). Do both in one transaction if you write local rows beside them: session.create() and insert work inside a BEGINCOMMIT you open. The creator is a member from the moment the session exists, so the creator's rows are admitted before anyone has joined — the first move can be made before the invite is sent.

Why. A session is the unit of membership. Rows written outside one belong to nobody, and a second game in the same session would share the first game's roster.

Applies to session · not checked by anything · SESSION-CREATE in Constraints

SESSION-ROW-CARRIES-SESSION

Every insert names its session

In a session document, pass the session id as the third argument of every insert: window.dai.replicated.insert("moves", values, session). change and remove take no session — they inherit the entity's.

Why. Every replicated row in a session document belongs to a session. Run against the write rules: an insert without one throws "A row for … carries no session, but <table> declares the session profile", and nothing is written.

Applies to session · refused at run time · SESSION-ROW-CARRIES-SESSION in Constraints

4. Invite, and take the seat

The Invite button asks the host to share: the host makes the link that carries the document and the key. When the other person opens it, the application binds the open seat — once at start-up, and again only when a file or link is opened, never on a background merge. That is joinIfInvited in app.js.

SESSION-INVITE

An invite is a shared link

Invite the other party by asking the host to share, naming the session: a button that calls window.dai.requestShare(session). There is no invite call of your own. The copy that travels holds only that session's rows — none of the document's other sessions, and none of this copy's local tables — so the recipient gets this one game and nothing else of the sender's. Without a session, requestShare() offers the whole document, every session in it, as the host's own menu does; use it for that, never for an invite. After a copy has been shared by link, the host moves new rows between the copies on its own, and they arrive as dai:merged with via "mailbox"; a copy handed over as a file carries its rows when it is opened. The application never sends rows itself; a "send" button that calls requestShare() again is only needed where copies travel as files.

Why. The host mints the key that lets the two copies exchange rows and makes the link; the application only asks, and only the application knows which game it is inviting to. Filtering to that game is what keeps a person's other games — and whatever they keep only on their own device — out of every invite they send.

Applies to session · not checked by anything · SESSION-INVITE in Constraints

SESSION-JOIN-ON-OPEN

Take the open seat when an invite is opened

When this copy opens an invite, bind its open seat with window.dai.replicated.session.join(session, seat): once at start-up, and again in the dai:merged listener only when event.detail.via === "carrier" — never for "mailbox". Join only if this copy is not already a member and an open seat exists: the open seat is a _dai_seat_current row for the session whose seat no _dai_binding_current row binds. Join the session the invite was sent for. An invite carries only that session and none of the sender's local rows (SESSION-INVITE), so it is a session with an open seat that this copy did not create and is not a member of — in a fresh copy made from an invite there is exactly one. That includes a copy whose seat was contested or replaced: opening the creator's fresh invite is how it gets back in, and excluding copies that were ever seated would lock it out for good. Prefer the item that is showing when it is joinable (a copy that arrived as a whole document carries the sender's local rows, including which item was showing), otherwise take the newest joinable one, and make it the item showing.

Why. Membership comes from opening an invite, not from rows arriving. A copy that joined on every background merge would re-take a seat it had lost, and a copy that joined twice would contest its own seat.

Applies to session · not checked by anything · SESSION-JOIN-ON-OPEN in Constraints

5. Read, derive, redraw

Every read is from a _current view; state() replays the marks in turn order to get the board. When the other player's marks arrive, dai:merged fires and the application draws again.

SHARED-READ-CURRENT

Read shared rows from the _current view

Read a replicated table t only through the view t_current, which holds one row per live entity. Never SELECT from t itself for display or logic. Use t_conflicts or t_heads only to show or resolve a conflict (SHARED-SURFACE-CONFLICTS).

Why. The base table holds every version of every row: superseded edits, tombstones of deleted rows, and — in a session — rows from non-members and rows written after the close. Reading it shows all of them at once.

Applies to passable, session · checked by the lint (shared-base-read) · SHARED-READ-CURRENT in Constraints

SHARED-REDRAW-ON-MERGE

Redraw when the other copy's rows arrive

Listen for the dai:merged event on window and redraw everything drawn from shared tables when it fires: window.addEventListener("dai:merged", (event) => { redraw(); }). event.detail carries applied, duplicate, rejected, newReplicas, conflicts and via — "carrier" when a file or link was opened, "mailbox" when rows arrived in the background. If the page uses the kit's reading elements, call window.daiKit.refresh() in the listener. A redraw must never discard what the person is in the middle of — text typed into a field, an editor that is open, a selection: rows arrive whenever the other copy's changes do, including mid-sentence. Keep work in progress outside what the redraw rebuilds — in a form written once in the HTML rather than recreated on every draw, or in a local drafts table the redraw reads back — or leave the element being edited untouched until it is saved or cancelled. The same failure arriving at start-up rather than mid-edit is NO-INPUT-LOST-WHILE-OPENING.

Why. Nothing else tells the application that another copy's rows landed. Without it the application draws once and redraws only after its own writes, so a two-person document looks broken in exactly the case it exists for. And a redraw that rebuilds an open editor from the stored wording throws away what was being typed, silently — found by running a blind candidate over the mailbox, where a background merge landed while a term was being edited.

Applies to passable, session · checked by the lint (shared-no-merge-listener) · SHARED-REDRAW-ON-MERGE in Constraints

6. Say who is outside, and why

A copy can hold the game without being in it: it was forwarded rather than invited, or its seat was taken on another device, or the match is closed. The application says which, instead of showing a board that silently ignores taps. If two people open the same invite, the seat is contested and the creator is offered a fresh invite.

SESSION-MEMBERSHIP

Read membership, and show the three ways to be outside

This copy's replica id is SELECT lower(hex(id)) AS id FROM _dai_replica. It is a member of a session when _dai_member has a row for (session, replica). Enable writing only for members, and show a copy that is not one which of three states it is in: it holds the rows but never joined (it was forwarded the document, not invited); it joined but its seat was contested or replaced (SESSION-CONTESTED-SEAT); or the session is closed (SESSION-CLOSE). The _current views of a session document show only admitted rows — rows by members, written before any close.

Why. A non-member's rows are kept but never admitted, so an application that let a non-member play would show them their own moves and nobody else ever would. Saying which state a copy is in is the difference between a message and a hang.

Applies to session · not checked by anything · SESSION-MEMBERSHIP in Constraints

SESSION-CONTESTED-SEAT

A contested seat is a state to show

A seat bound by two or more different replicas is contested — two people opened the same invite — and admits neither. Detect it as a _dai_binding_current seat with count(DISTINCT _r_replica) > 1 for the session. Show the creator that the invite went to more than one device and offer a fresh invite: window.dai.replicated.session.reseat(session), then share again. Show a copy whose own seat was lost that nothing it did lost its place, and that the creator can send a new invite. reseat refuses with NOT_SEAT_CREATOR for anyone but the creator and with CANNOT_RESEAT when no seat is contested.

Why. It is resolved without a clock deciding who opened the invite first, so neither copy can be admitted until the creator repairs it; an application that treated it as an error would leave both people stuck.

Applies to session · refused at run time; not checked by anything · SESSION-CONTESTED-SEAT in Constraints

7. Finish, then close

Winning is a mark like any other. Closing the match is a separate act, offered only when the game is over.

SESSION-CLOSE

Closing is separate from finishing

Ending the activity is an ordinary row: a resignation, a final mark, a signature. Closing the session is a separate, heavier act — window.dai.replicated.session.close(session) — after which rows written later than what the closer had seen are not admitted. Offer it only on a finished session, never as the way to end a live one. Closing as part of an act whose point is finality — sealing an agreement once both have accepted it — is exactly what close is for: write the act as a row, then close. Read whether a session is closed from _dai_close_current (any row for the session). Under close=creator a non-creator's close is refused with CLOSE_NOT_PERMITTED; hide or disable the control for them.

Why. A close is final for the group, and it is decided by what the closer had seen rather than by a clock. Folding it into "resign" would end a session the other person had not finished with.

Applies to session · refused at run time; not checked by anything · SESSION-CLOSE in Constraints

The whole application

app.js
js
// Tic-tac-toe — a session document. Each game is a session of two: the
// creator plays X, the invitee takes the open seat and plays O. The board, the
// turn and the winner are derived from marks every time; shared rows are
// written through window.dai.replicated and read from the _current views.

const $ = (id) => document.getElementById(id);
let db; // opened by the start-up at the end of this file
const shared = window.dai.replicated;
const rows = (sql, bind) => (bind === undefined ? db.selectObjects(sql) : db.selectObjects(sql, bind));
const one = (sql, bind) => rows(sql, bind)[0] ?? null;

const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];

function say(text) {
  $("notice").textContent = text;
  $("notice").hidden = !text;
}

/** Runs writes and says what went wrong, rather than failing silently. */
function write(fn) {
  try {
    const result = fn();
    say("");
    return result ?? true;
  } catch (error) {
    const message = String(error?.message ?? error);
    say(
      message.startsWith("WRITE_SURFACE_UNAVAILABLE")
        ? "Open this document in the DAI opener to play."
        : `That did not save: ${message}`,
    );
    return false;
  }
}

// ---- reading ------------------------------------------------------------

/** This copy's replica id, or null before this copy has written anything. */
const myReplica = () => one("SELECT lower(hex(id)) AS id FROM _dai_replica")?.id ?? null;

function games() {
  return rows(
    `SELECT lower(hex(_r_entity)) AS id, lower(hex(_r_session)) AS session,
            x_name, o_name, _r_conflicted AS conflicted
       FROM games_current ORDER BY _r_lc`,
  );
}

function activeGame() {
  const list = games();
  const id = one("SELECT active_game FROM settings WHERE id = 1")?.active_game;
  return list.find((g) => g.id === id) ?? list[list.length - 1] ?? null;
}

/** Everything about the seats of a session, as this copy sees it. */
function seats(session) {
  const mine = myReplica();
  const creator = one(
    "SELECT lower(hex(_r_replica)) AS r FROM _dai_seat_current WHERE lower(hex(_r_session)) = ? LIMIT 1",
    [session],
  )?.r;
  const isMember = (replica) =>
    !!replica &&
    !!one("SELECT 1 AS x FROM _dai_member WHERE lower(hex(session)) = ? AND lower(hex(replica)) = ?", [session, replica]);
  const bound = !!mine &&
    !!one(
      "SELECT 1 AS x FROM _dai_binding_current WHERE lower(hex(_r_session)) = ? AND lower(hex(_r_replica)) = ?",
      [session, mine],
    );
  const contested = rows(
    `SELECT 1 AS x FROM _dai_binding_current b
       JOIN _dai_seat_current s ON s._r_session = b._r_session AND s.seat = b.seat
      WHERE lower(hex(b._r_session)) = ?
      GROUP BY b.seat HAVING count(DISTINCT b._r_replica) > 1`,
    [session],
  ).length > 0;
  const openSeat = one(
    `SELECT lower(hex(s.seat)) AS seat FROM _dai_seat_current s
      WHERE lower(hex(s._r_session)) = ?
        AND s.seat NOT IN (SELECT b.seat FROM _dai_binding_current b WHERE b._r_session = s._r_session)
      LIMIT 1`,
    [session],
  )?.seat ?? null;
  const opponent = one(
    `SELECT lower(hex(replica)) AS r FROM _dai_member
      WHERE lower(hex(session)) = ? AND lower(hex(replica)) <> ? LIMIT 1`,
    [session, creator ?? ""],
  )?.r ?? null;
  const closed = !!one("SELECT 1 AS x FROM _dai_close_current WHERE lower(hex(_r_session)) = ? LIMIT 1", [session]);
  const amCreator = !!mine && mine === creator;
  const member = isMember(mine);
  return {
    creator, opponent, amCreator, member, closed, contested, openSeat,
    // Bound once, not admitted now: the seat was contested or replaced.
    seatLost: bound && !member,
    // Holds the rows but was never invited: forwarded, not joined.
    notIn: !amCreator && !bound,
  };
}

/** Replays the marks. Nothing here is stored. */
function state(game) {
  const s = seats(game.session);
  const marks = rows(
    `SELECT lower(hex(_r_entity)) AS entity, lower(hex(_r_replica)) AS by, turn, cell
       FROM marks_current WHERE game_id = ?
      ORDER BY turn, _r_lc, lower(hex(_r_replica)), _r_seq`,
    [game.id],
  );
  const board = Array(9).fill(null);
  let turn = 1;
  let collision = null;
  for (;;) {
    const side = turn % 2 === 1 ? "X" : "O";
    const author = side === "X" ? s.creator : s.opponent;
    const candidates = marks.filter((m) => m.turn === turn && m.by === author && board[m.cell] === null);
    if (candidates.length === 0) break;
    if (candidates.length > 1) {
      collision = { turn, side, candidates };
      break;
    }
    board[candidates[0].cell] = side;
    turn += 1;
  }
  const line = LINES.find(([a, b, c]) => board[a] && board[a] === board[b] && board[a] === board[c]);
  const winner = line ? board[line[0]] : null;
  const full = board.every(Boolean);
  const toMove = turn % 2 === 1 ? "X" : "O";
  const mySide = s.amCreator ? "X" : s.member ? "O" : null;
  const over = Boolean(winner) || full;
  // X may move before O has joined: an X mark only needs the creator to be
  // known. O's marks arrive in the same file as O's binding.
  const canPlay = s.member && !s.closed && !over && !collision && mySide === toMove;
  return { seats: s, board, turn, toMove, mySide, collision, winner, line, over, canPlay };
}

// ---- joining ------------------------------------------------------------

/**
 * Take the open seat of the game showing, if this copy arrived with an invite.
 * Called at start-up and when a file or link is opened — never on a background
 * mailbox merge. The sent copy carries the sender's local settings (local
 * tables travel; they are never merged), so the game showing is the one the
 * invite was sent from.
 */
function joinIfInvited() {
  // An invite carries only the game it was sent for, and none of the sender's
  // local rows — so the game to join is one this copy can join: not its own,
  // not one it is already in, with a seat open. That includes a copy whose seat
  // was contested and replaced: opening the creator's fresh invite is how it
  // gets back in. Prefer the game showing, if it is one.
  const joinable = (g) => {
    const s = seats(g.session);
    return !s.member && !s.amCreator && !!s.openSeat;
  };
  const active = activeGame();
  const target = active && joinable(active) ? active : [...games()].reverse().find(joinable);
  if (!target) return;
  const seat = seats(target.session).openSeat;
  if (write(() => shared.session.join(target.session, seat))) {
    db.exec({ sql: "UPDATE settings SET active_game = ? WHERE id = 1", bind: [target.id] });
  }
}

// ---- drawing ------------------------------------------------------------

function nameOf(game, side) {
  return side === "X" ? game.x_name : game.o_name;
}

function drawGameList() {
  const list = $("game-list");
  const current = activeGame();
  list.replaceChildren();
  for (const g of games()) {
    const option = document.createElement("option");
    option.value = g.id;
    option.textContent = `${g.x_name} v ${g.o_name}`;
    option.selected = g.id === current?.id;
    list.append(option);
  }
  list.hidden = list.options.length < 2;
}

function drawSeat(game, st) {
  const panel = $("seat");
  const s = st.seats;
  let text = "";
  $("reseat").hidden = true;
  if (s.contested && s.amCreator) {
    text = "Two people opened this invite, so neither can play. Send a fresh invite to the one person you meant.";
    $("reseat").hidden = false;
  } else if (s.seatLost) {
    text = `Your seat in this game was taken on another device or replaced. Nothing you did lost it — ${game.x_name} can send you a fresh invite.`;
  } else if (s.notIn) {
    text = `This game reached you, but you have not been invited into it. Open an invite from ${game.x_name} to play.`;
  } else if (s.closed) {
    text = "This match is closed.";
  }
  $("seat-text").textContent = text;
  panel.hidden = !text;
}

function drawBoard(game, st) {
  const board = $("board");
  board.replaceChildren();
  const canPlay = st.canPlay;
  st.board.forEach((mark, cell) => {
    const b = document.createElement("button");
    b.type = "button";
    b.className = "cell" + (st.line?.includes(cell) ? " win" : "");
    b.textContent = mark ?? "";
    b.setAttribute("aria-label", mark ? `Square ${cell + 1}, ${mark}` : `Square ${cell + 1}, empty`);
    b.disabled = !canPlay || mark !== null;
    b.addEventListener("click", () => {
      write(() => shared.insert("marks", { game_id: game.id, turn: st.turn, cell }, game.session));
      draw();
    });
    board.append(b);
  });
}

function drawStatus(game, st) {
  const s = st.seats;
  let status;
  if (st.winner) status = `${nameOf(game, st.winner)} wins.`;
  else if (st.over) status = "A draw.";
  else if (st.collision) status = "Two marks at one turn — settle it below.";
  else if (st.canPlay) status = `Your move, ${nameOf(game, st.mySide)}.${s.opponent ? "" : " Then send the invite."}`;
  else if (!s.member) status = "You are not playing in this game.";
  else if (!s.opponent) status = "Waiting for your invite to be opened.";
  else status = `${nameOf(game, st.toMove)}'s move. Send them this game.`;
  $("status").textContent = status;
  $("players").textContent = `${game.x_name} (X) v ${game.o_name} (O)`;
}

function drawCollision(game, st) {
  const panel = $("collision");
  panel.hidden = !st.collision;
  if (!st.collision) return;
  const c = st.collision;
  $("collision-text").textContent =
    `${nameOf(game, c.side)} marked two squares at the same turn, on two copies. Keep the one that should stand.`;
  const choices = $("collision-choices");
  choices.replaceChildren();
  for (const m of c.candidates) {
    const b = document.createElement("button");
    b.type = "button";
    b.textContent = `Keep square ${m.cell + 1}`;
    b.disabled = !st.seats.member;
    b.addEventListener("click", () => {
      write(() => {
        for (const other of c.candidates) if (other.entity !== m.entity) shared.remove("marks", other.entity);
      });
      draw();
    });
    choices.append(b);
  }
}

function drawNamesConflict(game) {
  const panel = $("names-conflict");
  panel.hidden = !game.conflicted;
  if (!game.conflicted) return;
  const versions = rows(
    "SELECT x_name, o_name FROM games_heads WHERE lower(hex(_r_entity)) = ? AND _r_deleted = 0",
    [game.id],
  );
  const choices = $("name-versions");
  choices.replaceChildren();
  for (const v of versions) {
    const b = document.createElement("button");
    b.type = "button";
    b.textContent = `${v.x_name} v ${v.o_name}`;
    b.addEventListener("click", () => {
      // A change names every current version as its parent, so it settles it.
      write(() => shared.change("games", game.id, { x_name: v.x_name, o_name: v.o_name }));
      draw();
    });
    choices.append(b);
  }
}

function draw() {
  drawGameList();
  const game = activeGame();
  $("play").hidden = !game;
  if (!game) return;
  const st = state(game);
  drawStatus(game, st);
  drawBoard(game, st);
  drawCollision(game, st);
  drawNamesConflict(game);
  drawSeat(game, st);
  $("invite").hidden = !(st.seats.amCreator && st.seats.openSeat && !st.seats.contested);
  $("rename").hidden = !st.seats.member || st.seats.closed;
  $("close-match").hidden = !(st.over && st.seats.member && !st.seats.closed);
}

// ---- acting -------------------------------------------------------------

$("new-game").addEventListener("submit", (event) => {
  event.preventDefault();
  const you = $("you").value.trim();
  const them = $("them").value.trim();
  if (!you || !them) return;
  const made = write(() => {
    db.exec("BEGIN");
    try {
      // A new game is a new session: this copy is seated, one seat is left open.
      const { session } = shared.session.create();
      const id = shared.insert("games", { x_name: you, o_name: them }, session);
      db.exec({ sql: "UPDATE settings SET active_game = ? WHERE id = 1", bind: [id] });
      db.exec("COMMIT");
      return id;
    } catch (error) {
      db.exec("ROLLBACK");
      throw error;
    }
  });
  if (made) $("new-game").reset();
  draw();
});

$("game-list").addEventListener("change", () => {
  db.exec({ sql: "UPDATE settings SET active_game = ? WHERE id = 1", bind: [$("game-list").value] });
  draw();
});

// The host makes the invite: it mints the key and the link. Naming the game's
// session makes it an invite into this game only — the other games and this
// device's settings stay here.
$("invite").addEventListener("click", () => {
  const game = activeGame();
  if (game) window.dai.requestShare(game.session);
});

$("reseat").addEventListener("click", () => {
  const game = activeGame();
  if (game && write(() => shared.session.reseat(game.session))) window.dai.requestShare(game.session);
  draw();
});

// An inline form, not prompt(): the application runs in a sandboxed frame,
// where the browser's own dialogs are not available.
$("rename").addEventListener("click", () => {
  const game = activeGame();
  if (!game) return;
  $("rename-x").value = game.x_name;
  $("rename-o").value = game.o_name;
  $("rename-form").hidden = false;
  $("rename-x").focus();
});

$("rename-cancel").addEventListener("click", () => {
  $("rename-form").hidden = true;
});

$("rename-form").addEventListener("submit", (event) => {
  event.preventDefault();
  const game = activeGame();
  const x = $("rename-x").value.trim();
  const o = $("rename-o").value.trim();
  if (!game || !x || !o) return;
  // change() takes every column, not only the ones that changed.
  if (write(() => shared.change("games", game.id, { x_name: x, o_name: o }))) $("rename-form").hidden = true;
  draw();
});

$("close-match").addEventListener("click", () => {
  const game = activeGame();
  if (game) write(() => shared.session.close(game.session));
  draw();
});

// The other player's marks arrive here, and nowhere else. Join only when a
// file or link was opened; a background mailbox merge never takes a seat.
window.addEventListener("dai:merged", (event) => {
  if (event.detail?.via === "carrier") joinIfInvited();
  draw();
});

// Start-up (NO-INPUT-LOST-WHILE-OPENING): nothing can be pressed until this has
// finished, and if it fails the person is told, not left at "Opening…".
try {
  db = await window.dai.openDatabase();
  joinIfInvited();
  draw();
  $("opening").hidden = true;
  $("app").hidden = false;
  $("app").inert = false;
} catch (error) {
  $("opening").classList.add("failed");
  $("opening").textContent =
    `This game could not be opened: ${error?.message ?? error}. ` +
    "Try opening the document again in the DAI opener, or ask whoever sent it for a new copy.";
}
index.html
html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="description" content="Tic-tac-toe with one other person, by message" />
  <meta name="dai:does" content="Start a game and send the invite to one person" />
  <meta name="dai:does" content="Take turns on your own phones, sending it back and forth" />
  <meta name="dai:does" content="Keeps every game you have played together" />
  <meta name="theme-color" content="#1d2340" />
  <title>Tic-tac-toe</title>
  <link rel="stylesheet" href="./app.css" />
</head>
<body>
  <script type="application/sql">
    -- Local seed only. Shared rows are never seeded with SQL.
    INSERT OR IGNORE INTO settings (id) VALUES (1);
  </script>

  <!-- Nothing to press until app.js has started: a form on screen before then
       takes typing the app cannot handle yet, and loses it. -->
  <p id="opening" class="opening" role="status">Opening…</p>

  <main id="app" hidden inert>
    <header>
      <h1>Tic-tac-toe</h1>
      <select id="game-list" aria-label="Game"></select>
    </header>

    <p id="notice" class="notice" role="status" hidden></p>

    <section id="play" hidden>
      <p id="players" class="players"></p>
      <p id="status" class="status" aria-live="polite"></p>
      <div id="board" class="board" role="grid" aria-label="Board"></div>

      <div id="names-conflict" class="panel" hidden>
        <p>The player names were changed on both copies. Keep one:</p>
        <div id="name-versions" class="choices"></div>
      </div>

      <div id="collision" class="panel" hidden>
        <p id="collision-text"></p>
        <div id="collision-choices" class="choices"></div>
      </div>

      <div id="seat" class="panel" hidden>
        <p id="seat-text"></p>
        <div class="choices">
          <button id="reseat" type="button" hidden>Send a fresh invite</button>
        </div>
      </div>

      <div class="actions">
        <button id="invite" type="button">Invite</button>
        <button id="rename" type="button" class="quiet">Edit names</button>
        <button id="close-match" type="button" class="quiet" hidden>Close match</button>
      </div>

      <form id="rename-form" class="panel" autocomplete="off" hidden>
        <label>X <input id="rename-x" required maxlength="24" /></label>
        <label>O <input id="rename-o" required maxlength="24" /></label>
        <div class="choices">
          <button type="submit">Save names</button>
          <button id="rename-cancel" type="button" class="quiet">Cancel</button>
        </div>
      </form>
    </section>

    <form id="new-game" class="panel" autocomplete="off">
      <h2>New game</h2>
      <label>You <input id="you" required maxlength="24" placeholder="Your name" /></label>
      <label>Them <input id="them" required maxlength="24" placeholder="Their name" /></label>
      <button type="submit">Start — you are X</button>
    </form>
  </main>

  <script type="module" src="./app.js"></script>
</body>
</html>

Try it

bash
npx dai build ./examples/tic-tac-toe -n "Tic-tac-toe"

Open the file in the DAI opener, start a game, make the first move and press Invite. Open the link on a second device: it takes the open seat and it is O's move. Shared tables are written only under a host, so the opener — not a file double-clicked into a browser — is where this works; see SHARED-NEEDS-HOST.

Released under the MIT License. Dynamic Application Interface standard.