Skip to main content
Documents saga a2ui 0.11.0-rc.1 (prerelease). This is the SDK guide docs/tic-tac-toe/2-playable-game.md at source revision fb4126c. It is published as a snapshot: later source changes do not alter it. Licensed under Apache-2.0.
The first card connected a server value to a React component. Now replace the welcome message with a game: the server decides which moves are legal, the view draws its state, and a click hands the next turn to the assistant. Stop the game server and leave the tunnel running. Work through the four parts below in the same app directory. Each part explains the application files it adds or replaces; expand a file to copy or compare the complete code. Your Vite-generated configuration and installed dependencies stay in place. The welcome card returned one string. A game needs a board, whose turn it is, and enough information to reject an old click. Add src/contract.ts, server/rules.ts and server/game-store.ts using the complete files below. contract.ts defines the shapes shared by the server and view. A board has nine entries, numbered 0–8 across rows; an entry is null, X or O. A game also carries gameId, moveNumber, turn, status and its history. The Zod schemas check tool inputs and results at runtime; TypeScript types help while editing. rules.ts implements applyMove without a server or clock. Before placing a mark, it checks that the game is still running, that the submitted move number matches, that the player has the turn, and that the square is empty. It then checks the eight winning lines and whether the board is full. A refused move returns a reason and leaves the board unchanged. Sending expectedMoveNumber prevents an old click from changing a newer board. For example, the view might show move 2 after another call has already applied move 3. The server refuses the old click and returns the current game so the view can catch up. game-store.ts gives those rules somewhere to keep their result. Its memory store creates game identifiers, expires idle games after 30 minutes and bounds the number retained. Each request uses the same store. Restarting the process clears it. <details> <summary>Complete src/contract.ts</summary>
file=src/contract.ts
</details> <details> <summary>Complete server/rules.ts</summary>
file=server/rules.ts
</details> <details> <summary>Complete server/game-store.ts</summary>
file=server/game-store.ts
</details>

2. Expose the game as operations

Replace server/operations.ts. The registration pattern is the one you used for the welcome message: input schema, handler, output schema and projection. The new handlers call the store. There are four tools. new_game creates a game and show_game opens an existing one; both attach the board view. get_game reads the state and play_move applies a move; these return data without opening another card. That distinction lets both players change the game while keeping the original board on screen. For a move, the input contains the game identifier, the expected move number, the player and the square. The projection returns the updated game as structured data and a short board description as text. The assistant can reason about the same state the view displays. Tool descriptions tell it that the person is X and it plays O. Add server/listener.ts and replace server/main.ts. The listener wrapper creates the store and prepares the view once, then closes over them in createServer. The SDK still creates the MCP server for each request. Moving the store into that callback would lose the game between calls. The startup file reads the built HTML, calls this wrapper, prints the endpoint and handles shutdown. No HTTP routing or transport implementation belongs in these app files. <details> <summary>Complete server/operations.ts</summary>
file=server/operations.ts
</details> <details> <summary>Complete server/listener.ts</summary>
file=server/listener.ts
</details> <details> <summary>Complete server/main.ts</summary>
file=server/main.ts
</details>

3. Draw the board from data

The welcome view kept its component and layout together. Separate them now so you can change the board’s appearance without changing its data flow. Add src/text.ts, src/catalog.tsx and src/surface.ts; replace src/index.css and src/main.tsx with the files below. Keep the generated HTML from the first lesson. main.tsx also loads the credit’s stylesheet from the stack.
file=src/main.tsx
catalog.tsx implements the components in React. Column and Row arrange children, Board makes the grid, and SquareFace displays a mark with its accessible label. ActionButton comes from SAGA and handles the control’s interaction state. GameDetails uses the browser’s built-in disclosure control to show the game ID on request. Credit places SAGA’s removable SagaAttribution line, “Built with saga a2ui”, at the foot of the card; delete it and the layout’s credit entry to remove the credit. The catalog identifier matches the one in surface.ts. A catalog identifier only names the catalog, so choose a URL you control; the view never fetches it. surface.ts defines the data and connects it to those components. The Board repeats its child over /squares. Each square binds its mark and label from that row of data. The playable check disables a square when it is occupied, O has the turn, or the game has ended. The layout describes what appears; application code attaches its actions in the next section. text.ts turns game state into status messages, accessible square labels, move descriptions and the message asking the assistant to play. index.css controls the grid, focus indicators and colours. Try changing a border or the grid gap after the game is running; that should change its appearance without affecting a move. <details> <summary>Complete src/text.ts</summary>
file=src/text.ts
</details> <details> <summary>Complete src/catalog.tsx</summary>
file=src/catalog.tsx
</details> <details> <summary>Complete src/surface.ts</summary>
file=src/surface.ts
</details> <details> <summary>Complete src/index.css</summary>
file=src/index.css
</details>

4. Connect a click to the next turn

Add src/controller.ts and src/actions.ts, then replace src/view.tsx. These files connect the server and board you have just defined. view.tsx keeps the host’s link seam (host.links) for the Credit component, which opens the credit’s destination through the host. Trace one click through them before running the game:
  1. view.tsx attaches playSquare to each rendered square. The repeated button supplies its square index.
  2. In actions.ts, playSquare reads that index and the displayed game’s identifier and move number. It locks the board while calling play_move as X.
  3. On success, game.present displays the returned state. If the server refused the move, the board explains why and uses the server’s current game.
  4. After an accepted move, handOver shares the game ID, board and move instructions as hidden context, then adds Your turn to the conversation. Claude puts that message in the chat box: click Send to let the assistant play. ChatGPT sends it automatically. The card reminds the player to send any message waiting in the chat box.
  5. While O has the turn, the controller reads get_game. When O’s tool call changes the state, it presents the newer board and enables the available squares.
The short turn message depends on the hidden context to identify the game. If the host cannot accept that context, the card reports the failure and does not send an ambiguous Your turn. Click Show game ID on a card when you need its identifier for troubleshooting. The controller owns the displayed game, pending click and wait for O. It ignores older replies and stops reading when the view closes. A failed move request is uncertain: the server may have applied it before the connection failed. refresh reads the game without submitting the move again. If the read confirms the clicked square at the expected move number, the action sends the missing turn message. If it cannot read the game, the squares stay disabled. The complete controller also slows its reads after about a minute while continuing to wait for a host confirmation. This is why a delayed confirmation does not require a typed turn prompt or a page refresh. The optional “Check for the assistant’s move” control is for recovering a failed read. In view.tsx, onToolResult selects the game and reads its current state from the server. A host can remount a card with the result it saved when the game began; that empty snapshot must not become a playable board after several moves. The squares stay disabled until the read finishes. createReactActions binds the three actions to the controls. Each square binding has a subject containing the game and move number; a late action result for an old subject cannot overwrite the current board. onTeardown stops the application’s wait when the host closes the view. New game prepares Let’s play a new game. The tool description tells the assistant how to open it. The host opens a fresh card whose saved result identifies that game. Replacing the game inside the old card would lose the selection when the host remounted it. The action ignores repeated clicks while its message is awaiting confirmation. <details> <summary>Complete src/controller.ts</summary>
file=src/controller.ts
</details> <details> <summary>Complete src/actions.ts</summary>
file=src/actions.ts
</details> <details> <summary>Complete src/view.tsx</summary>
file=src/view.tsx
</details>

Play and check the flow

Run npm run typecheck, npm run build, then npm start. Return to Build and play the game to refresh the host’s tools and open the board in a new chat. Click the centre square. X should appear there and the board should disable its empty squares while O has the turn. In Claude, Your turn appears in the chat box; click Send. ChatGPT sends it automatically. O’s move should appear in this same board, then the remaining squares should become available. Finish a game and click New game. In Claude, click Send for Let’s play a new game. The assistant opens a fresh card. Each card stays attached to its own game, including when the host remounts it. To check your understanding, find the two places that prevent a move on an occupied square: the layout disables the control, and the server rules refuse the move. The server check is still needed because an assistant or another client can call the tool without using your board. When this works, add a move list by extending the catalog and layout.