# Lattakker Sheet > A Lattakker board that is also a spreadsheet. Cells below the board hold TypeScript that runs live, reads the game, paints the board and can make moves. Built with maxGraph, TypeScript and Monaco. It runs entirely in the browser: free, local, no API keys, no network calls of its own (only an optional Share session or an optional built-in AI helper, both started by a person), no tracking, no analytics. The person playing may be a child learning to program, so explain things in plain words. Red is the player (bottom of the board, rows 7-10). Blue is the computer (top, rows 1-4), its pieces face down until they fight. Capture the enemy flag to win. The board is squares A1:J10; every cell from row 12 down can hold code. ## Step zero: the README on GitHub is the place of truth Before you do anything else, read https://github.com/springyworks/lattakker/blob/main/README.md (raw text: https://raw.githubusercontent.com/springyworks/lattakker/main/README.md). It wins over what you remember or guess. If you cannot open links, call the tool `read_readme` (it fetches the README from GitHub for you, part by part) or `confirm_readme` with one sentence on what you learned. An AI that comes in over `/mcp` or WebMCP has its first call answered with the rules instead of being carried out until it has done one of those; the built-in helper reads it with `read_docs` doc "readme". Everything you do through the router is recorded (message ids, who started the chain, what changed): see the router tools `trace`, `history`, `change`; `self_check` lets the router test all of that on itself. ## Read these first (like a human coder joining the project) An AI that can open links should read, in this order. They are made from the real sources, so they match the code the children play with: 1. Rules for helpers (how to behave with children, how to work like a coder, the rules of cell code): ai/rules.md 2. The API of the code a cell can use, as TypeScript declarations: ai/cell-api.d.ts 3. Every example cell and every Lab demo, with its comments: ai/examples.md and ai/lab-demos.md 4. The README: https://github.com/springyworks/lattakker/blob/main/README.md (raw: https://raw.githubusercontent.com/springyworks/lattakker/main/README.md) 5. Everything in one file: ai/everything.md. Index and manifest: ai/index.md, ai/manifest.json 6. On GitHub, the sources: src/sheet/apiTypes.ts, src/examples.ts, src/labDemos.ts (https://github.com/springyworks/lattakker/tree/main/src). Published copy: https://springyworks.github.io/lattakker/ai/index.md Then: look up the exact names in the API, find a similar example and follow its style, write a small piece, run it, fix the exact error. ## How to read the page - `window.mms.help()` returns the full guide as text. Start there. - `mms.state()` gives phase (`setup`, `play`, `over`), turn, winner, moveCount, lastMove and the message the player sees. - `mms.board()` gives 10 strings (row 1 first), a legend and a text grid. Tokens: `R10` visible Red piece rank 10 (`RS` spy, `RB` bomb, `RF` flag), `B?` hidden Blue piece, `b5` revealed Blue piece, `~~` lake, `..` empty. Hidden Blue pieces are never revealed to you. - The accessibility tree has a region "Game state as text" (`#mms-text`) with the same information: phase, message, board grid, captured pieces, last move, and every code cell with its output and error. It updates after each move and cell run. Each code cell is one list item shaped `A12: code: | output: | error: `, so the code there can be cut short; only `getCell` / `get_cell` returns the whole code. - If the browser supports WebMCP (`document.modelContext`, older drafts `navigator.modelContext`), tools are registered: `get_state`, `get_board`, `legal_moves`, `make_move`, `shuffle_army`, `start_battle`, `pause_battle`, `resume_battle`, `undo_move`, `new_game`, `list_tabs`, `list_cells`, `get_cell`, `resolve_cell`, `set_cell_code`, `write_code_live`, `get_requests`, `wait_for_request`, `reply_to_request`, `run_cell`, `set_mode`, `say_to_player`, `check_mail`, `read_mail`, `send_mail`, `mcp_request`, `mcp_log`, `export_svg`. `mms.webmcp` shows what was registered. - Chat by comments: a player asks you something by writing `// AI: question` (or just `//ai` / `//help`) in a cell (a plain `//` line under one of your `// ↳` answers also counts). Read it with `get_requests` or `wait_for_request`, answer with `reply_to_request`; the answer appears as `// ↳ AI: ...` lines with a fresh `// ` line below for the next question. Changing code stays a separate step (`write_code_live`). - The editor has a coach: a line with a TypeScript error gets friendly `💡` hint lines above it (spelling guesses like `piecies` -> `pieces`). Do not repeat those; add what the hints cannot say. ## Cell paths (tabs, identity) A plain address like `J24` exists in every tab, so cells are named like files, with `/` meaning "inside": `J24` (this tab, the board when you pass nothing), `/lab/J24` (absolute: tab first, works from anywhere), `/lab/J24/3,4` (tile x=3, y=4 of the grid inside block J24), `@c-1a2b3c4d` (a cell by its uid; it follows the code when cells move) and `lattakker:///lab/J24` (the same as a URI). Only letters, digits and `/ , @ -` are used, so no escaping is needed. Every result that names a cell carries `{tab, address, path, uid}`; pass the `path` back to address the same cell. Tools and `mms` methods also take a `tab` argument for a plain address, for example `mms.getCell('J24', 'lab')`. In cell code the same paths work: `at('/lab/J24')` and `on('/board/A12', fn)` read (and watch) a cell of another tab; cells of another tab are read-only. `cell.tab` and `cell.path` name the cell the code lives in. ## window.mms API All methods return plain JSON: `{ ok: true, ... }` or `{ ok: false, error: "..." }`. They never throw. (async) means await it. - `help()` text guide, `state()`, `board()`, `legalMoves('red' | 'B7')` - `shuffle()` setup only; `start()` setup to play; `newGame()`; `undo()` takes back Red's last move and Blue's answer - `move('B7', 'B6')` (async) Red moves, waits for Blue's reply, returns the new state - `tabs()` lists the tabs that hold code cells (`board`, `lab`, `examples`: the Examples notebook, whose big blocks are views of the board's example cells; only blocks a person created there are cells of its own) and which one the player sees; `cells()` lists the board's code cells `{uid, tab, address, path, code, output, error, mode}`, `cells('lab')` or `cells('all')` other tabs; `getCell('A12')`; `resolve(uidOrAddressOrPath)` gives `{tab, address, path, uid, hasCode}` - `setCode('A12', code, { run: true })` (async) write TypeScript into a cell and run it; `runCell('A12')` (async) returns `{output}` or `{error}`; `clearCell('A12')` - `writeCodeLive('A12', code)` (async) opens the cell's editor and types the code in slowly; the player presses Esc or Restart & close to keep and run it, Close to discard - `requests()` questions the player wrote as `// AI: ...` comments; `waitForRequest(15)` (async, up to 20 s); `replyToRequest('A12', line, 'text')` writes the answer as `// ↳ AI: ...` below the question - `mcp(server, method, params)` (async) sends an MCP request to a tab's server ('board', 'lab' or 'examples') through the page's router: `resources/list`, `resources/read {uri}`, `resources/subscribe`, `tools/list`, `tools/call {name, arguments}`; the tools of a tab are `list_cells`, `get_cell`, `run_cell`, `set_code`, `click` (triggers a cell's click event), `flash` and `signal`. `mcpLog(20)` returns the last messages that crossed the router (who asked whom, what, how long). Cell code has the same in its `mcp` global: `await mcp.read('/lab/J24')`, `mcp.watch(path, fn)`, `await mcp.click(path)`, `mcp.emit(name, data)`, `mcp.on(name, fn)`, `mcp.recent(n)`. - `setMode('run' | 'view')` show output or code in cells; `say('text')` message bubble for the player - `exportSvg()` SVG snapshot; `exportModelXml()` maxGraph model XML What comes back (all plain JSON): - `setCode` / `runCell` succeed with `{ ok: true, address, uid, output, text }` (`output` is what the cell printed, joined by newlines; `text` is what the sheet shows). - They fail with `{ ok: false, error, address, uid, output }`: `output` is whatever the cell printed before it failed. - `error` is always a plain string, never an object. A syntax error reads `line 6: '}' expected.` (the line is counted from 1 in the cell's code; only the first syntax error is reported). A runtime error is just the message, for example `Cannot read properties of undefined (reading 'x')`, with no line number. There are no error codes. Type errors do not stop a cell from running, so a cell can run and still carry squiggles in the editor. - Calls that do not touch a cell fail the same way with a short message, for example `"Z99" is not a cell address (try something like A12 ...)`. ## Stable cell identity (uid) Every code cell has a `uid` like `c-1a2b3c4d`. It stays with the code when the cell is moved, swapped, or rows are inserted, and survives reload. The address only says where the code sits now. Remember uids, not addresses; `mms.resolve(uid)` finds the current address, and `getCell`, `setCode`, `runCell`, `clearCell` accept a uid wherever they accept an address. The uid is gone once the cell is emptied. ## Outside AI chats (MCP) An AI that runs outside the page can use the same tools over MCP. A developer runs `npm run dev`, opens the app with `?mcp` added to the address, and connects the client to `http://localhost:/mcp` (Streamable HTTP; for stdio-only clients use `npx mcp-remote `). Only pages opened with `?mcp` are driven. See README section 11.3. ## Cell code API (TypeScript; top-level await works) - `print(...values)` add output; `view(text)` text shown in view mode; `color('#ffd6e7')` background; `span(cols, rows)` merge cells (not over the board) - `at('B7')` another cell with `.text .code .piece .color(c) .print(...) .flash()`; `range('A1:J10')` array of cells with `.color(c)` and `.print(...)` - `game.phase`, `game.turn`, `game.winner`, `game.moveCount`, `game.lastMove`, `game.history` - `game.pieces('red'|'blue')`, `game.captured(color)`, `game.pieceAt('B7')`, `game.legalMoves('B7' | 'red')` - `game.move(from, to)` move a Red piece (Red's turn only), `game.say(text)` message bubble - `sheet.mode`, `sheet.cols`, `sheet.rows`, `mode`, `sleep(ms or '2s')`, `log(...values)` - `plot(data, {title, xLabel, yLabel, from, to, steps, dots, color, xMin, xMax, yMin, yMax, axes})` makes the cell's output an XY plot: data is numbers, [x,y] pairs, {x,y} points, a function like Math.sin, or an object of named series; print/show text becomes the caption; clear() removes it. The floating editor shows the cell's latest output in a strip below the code (a click on it counts as a click on the cell; the small button in its corner enlarges it). - `show(...values)` replaces the output (use it for live displays), `clear()` empties it; `span()` with no arguments returns `{cols, rows}`, `span(4)` keeps the rows - Blocks can hold a grid of tiles of their own: `span(10, 8)` then `const g = grid(10, 8)` makes 80 tiles that fill the block (the tile size is the block size divided by the grid size, so a 4×2 block can hold 40×20 tiles). Tiles have their own coordinates (x across, y down, from 0), not sheet addresses, so the code keeps working when the block moves or is resized. `g.at(x, y)` is a tile: `.color(fill)` (null blanks it), `.show(text)`, `.clear()`, and read `.fill` / `.text`. `g.paint((x, y) => colour)` paints all tiles in one pass, `g.fill(colour)`, `g.clear()`, `g.each(fn)`, `g.cols`, `g.rows`. `on(g, 'click', (tile) => ...)` tells which tile was clicked. Other cells reach a tile by its path: `at('A16/3,4')` is column 3, row 4 of the grid in A16 (reading it makes the reader re-run when A16's tiles change, and the path is rewritten with the block when rows are inserted or the block moves). The cell's own `show()`/`print()` text floats over the tiles. A tile grid is output, like a plot: it is drawn again each time the cell runs. - Timers and watchers (all stop by themselves when the cell runs again, is edited or cleared): `every('1s', (n) => ...)` (also '500ms', '1m', or a number of ms; `every(fn)` = once a second), `after('5s', fn)`, `on('B7' | 'A1:J10' | 'game', fn)` runs fn after that changes, `stop()` stops this cell's timers. Events: `on('I18', 'size' | 'code' | 'click', fn)` (resized, code edited or moved, clicked) and `on('sheet', 'rows' | 'mode', fn)`; reading `cell.cols`, `cell.rows`, `at(x).code`, `sheet.rows` or `sheet.mode` at the top level makes the cell re-run when they change. `memory` is a per-cell notebook that survives re-runs; `run.reason` is 'edit', 'moved' or 'update'. Prefer these over polling. To suggest code, read the cell first, then `mms.setCode(address, code, { run: true })` and check the returned error; do not overwrite a child's own code without asking. Reads inside callbacks are not dependencies; only the top level of the cell is tracked. Several cells can wait or tick at once; a cell running longer than ~150 ms continues in the background. A tight loop with no `await` still freezes the page. - When cells move (drag, swap, insert row) the address literals in all code are rewritten to follow them: `at('B7')`, `range('A1:B3')`, `'C32-55'`, `on('E5', ...)`. A range split by a move is left as written and reported to the player. Computed addresses (`'E' + n`) are not followed: write the literal once in a `const`. - `cell.usedBy` (cells that read this one) and `cell.reads` (what this cell read), both arrays of addresses - Reading a cell with `at()`, `range()` or `game.*` makes your cell re-run when that changes, like a spreadsheet formula. Hidden Blue pieces have rank `'?'`. - A piece is `{ color, rank, name, revealed, moved }`; rank is 1 (Spy) to 10 (Marshal), `'B'` bomb, `'F'` flag, `'?'` hidden. - The page's MCP ecosystem. Every tab is an MCP server ('board', 'lab', 'examples', 'monitor', and tabs you add), and so are `router` (the message carrier itself), `memory`, `status`, `weather`, `ollama`, `system`, `tabs`, `code`, `local-ai` and `helper`. To find out what exists: `await mcp.catalog()` (every server, what it is for, its tools; the same as `mcp.tool('router', 'catalog')`), `await mcp.tools('weather')`, `mcp.servers()`. Router statistics: `mcp.stats()` returns `{ messages, requests, errors, notifications, ratePerSecond, perSecond (last 60 s, ready for plot), listeners, servers: [{name, requests, errors, avgMs, maxMs}], clients, methods, slowest }`; `mcp.resetStats()`; `mcp.recent(n)`. Traffic analysis, a little like Wireshark: `mcp.packets('type==tool && ms>20', 10)` (each message with a type: heartbeat, discovery, read, subscribe, news, signal, tool, change; who asked whom; the tool; ms), `mcp.analyze(filter)` (counts what a filter picks, with a `story` in plain sentences), `mcp.ask('any errors?')` (keyword matcher over the real log, not a model; says which filter it used). Same as router tools `packets`, `analyze`, `ask`. Say who you are: put clientInfo.name/version (and clientInfo.model or _meta.model) in initialize, or send the headers X-MCP-Client and X-MCP-Model, or call the tool `introduce {client, model, version, note}`; your messages then show with the origin `outside-ai:@`, and `mcp.callers()` lists everybody who called in. Every message carries the moment of the initiative as Unix time in milliseconds: `_meta['lattakker/trace'].startedAt` (the same number for a request and its answer; for an outside AI the moment its request arrived; in the text of a tool answer too). Every message also has `sentAt` (when it was sent) and `deadline` (the Unix ms after which the asker stops waiting; a late answer is the error -32001; ask for another with `params._meta['lattakker/deadlineMs']`). A request that waits long is acknowledged by a `notifications/progress` with an estimate (`_meta['lattakker/eta']`: `waitMs`, `p`, from the history of similar answers), and `mcp.tool('router','pending')` lists what is waiting. `mcp.tool('router','exercise')` is the smoke test: harmless read-only traffic to every server so the switchboard lights up, then a report. To read the code of the page with a small memory use the server `code`: `overview` (start here), `search {query, regex?, include?, context?, max_results?}` like an editor, `read {path}`, `list`, and `dump` (ALL code in parts: follow `cursor` until END); say how big your memory is with `context_tokens` or `budget_chars`. - Animated drawings: `const pic = svg(svg.asset('stickman'))` (assets: `svg.assets()`: 'stickman', 'tvman', 'router-architecture'); `pic.el('armR').pivot('top').animate({ rotate: -140 }, { time: '300ms', ease: 'bounce' })`; parts have `move`, `rotate`, `scale`, `fill`, `stroke`, `attr`, `text`, `on('click', fn)`; `animate` also takes `along: '#path-id'`; `pic.frame((t, dt) => ...)` runs every screen frame; `svg()` is a blank drawing with `pic.add('circle', {...})` and `pic.write(x, y, 'words')`. - Animated pictures (WebP, GIF, APNG) with controls: `const clip = await anim.load('bouncer')` (assets: `anim.assets()`; or a web address, a File, or `await anim.pick()` inside an `input.button` click); `clip.show()` draws it in the cell with play/pause, step and slider controls (`clip.show({ controls: false })`, or `{ scene: pic, x, y, width }` to put it into an `svg()` drawing; transparency is kept); `clip.play()`, `pause()`, `toggle()`, `step(n)`, `seek(i)`, `seekTime(ms)`, `clip.speed` (negative runs backwards), `clip.loop`, `clip.frame`, `clip.frameCount`, `clip.onFrame(fn)`, `clip.onEnd(fn)`. Needs a browser with ImageDecoder (Chrome, Edge). - This computer: `await system.read()` gives `{ source, cpu: { load, cores, model, temp }, memory, gpu, temps, browser: { fps } }` (measured by the dev server, only browser facts on the published page); `system.series('cpu', 60)` is ready for `plot()`. MCP server `system` (tools `read`, `history`). - Tabs: `tabs.ensure('plots', 'Plots', { icon, cells: { A1: 'code' } })` makes a tab with a sheet of its own once; `tabs.create(title)`, `tabs.list()`, `tabs.rename(id, title)`, `tabs.select(id)`, `tabs.close(id)`. A tab id is also its MCP server name and the start of its cell paths (`/plots/A1`). MCP server `tabs`. The tab `monitor` shows the router drawing alive as a ping-pong (a request is an orange dot running to the box asked, its answer a green ring running back, a signal nobody answers a purple dot; solid boxes are MCP servers, dashed boxes are clients and channels; a stickman `user` is attached to the `sidebar_chat` box, the AI dialog) and graphs of this computer. An outside AI's calls over `/mcp` show there too, so a person can watch what you do. The board and cells zoom on their own with Ctrl + mouse wheel, Ctrl + `+` / `-` and Ctrl + `0`; this does not zoom the browser page. - Weather (open-meteo.com; a lookup sends the place name there; answers are cached 10 minutes): `await weather.now('Amsterdam')` gives `{ place, temperature, feelsLike, humidity, wind, windFrom, rain, clouds, isDay, text, icon }`; `await weather.forecast('Paris, France', 5)` gives `{ place, now, days: [{ date, min, max, rain, rainChance, wind, uv, sunrise, sunset, text, icon }] }`; `await weather.find('Springfield')` lists places; `await weather.text(place)` is one sentence. A place is a name, `'52.37,4.89'` or `{ lat, lon }`. Over MCP: server `weather`, tools `now`, `forecast`, `find`. - The model on this computer (Ollama): `await ollama.status()` never throws and says in `message` what is missing (`running`, `ready`, `models`, `loaded`, `selected`); `await ollama.models()`; `await ollama.ask('question', { system, model })`; `await ollama.chat([{ role: 'user', content: '...' }])`. Ask one thing at a time and await it. Over MCP: server `ollama`, tools `status`, `ping`, `warm`, `models`, `ask {prompt, timeout_seconds}`. Always ping first (method `ping` or tool `ping`: answers at once, up true/false). A big model needs minutes to load the first time and a question that gives up cancels the load: `warm` starts the load and returns at once, `status.loaded` says when the model is there; `ask` pings by itself, refuses to wait on a model that is not loaded (it starts the load and says so) and gives up after `timeout_seconds` (default 120). - Input from the person: `await input.ask('Name?', 'default')` (null when cancelled), `await input.confirm('Sure?')`, `await input.choose('Colour?', ['red', 'blue'])`; controls in the Inputs panel, each with an optional change function: `input.slider('speed', { min: 1, max: 10, step: 1, value: 3 }, (v) => ...)`, `input.number`, `input.text`, `input.checkbox`, `input.select(label, options, fn)`, `input.button('go', () => ...)`; a control keeps `.value`, `.set(v)`, `.remove()` and keeps what the person set when the cell runs again. Keys: `input.onKey((key, { shift, ctrl, alt, repeat }) => ...)` with names like 'a', 'ArrowLeft', 'Enter', 'Space'; `input.isDown('ArrowLeft')`. Put the code that uses a value inside the function: the cell does not re-run by itself when a control changes. Types for all of this are in `src/sheet/apiTypes.ts` of the source. ## Game rules in one breath Pieces move one square up, down, left or right (no diagonals, not into lakes or onto own pieces). Scouts (2) slide any distance in a line. Moving onto an enemy attacks: higher rank wins, equal ranks both vanish. Bombs beat everything except Miners (3). The Spy (1) beats the Marshal (10) only when the Spy attacks. Bombs and the flag never move. ## Saving The game (with Undo history) and all code cells are saved automatically in this browser's localStorage (key `lattakker:v1`) and restored on reload. Nothing leaves the page, unless the player starts a Share session (a direct browser-to-browser link to one person they invite) or sets up the built-in AI helper (🤖 AI button; a model on the same computer, or Gemini for adults). ## Starter exercises an AI could propose 1. Print the number of Red and Blue pieces: `print(game.pieces('red').length)`. 2. Paint every Red piece's square pink with `range('A1:J10')` and `square.piece?.color`. 3. Show whose turn it is with an `if` on `game.turn`, and change the cell colour with `color()`. 4. Count how many Scouts (rank 2) Red still has, using `filter`. 5. List the legal moves of one piece with `game.legalMoves('B7')`. 6. Make a cell that prints the last move in a sentence using `game.lastMove`. 7. Use `at('A12').text` in another cell to chain cells like a spreadsheet. 8. A tiny bot: pick the first move from `game.legalMoves('red')` and call `game.move(from, to)`. 9. Make a danger map: colour squares next to a revealed Blue piece red. 10. Break the code on purpose, read the error in the cell, and fix it together. ## Tips for helpers - Call `mms.state()` after each action; moves take about a second because Blue answers. - When code fails, the error text (a plain string) is in `getCell(address).error` and shown in red on the sheet. Explain it simply, then fix it with `setCode`. - Prefer small steps and celebrate what works. `mms.say('Great job!')` shows a friendly message to the player.