@xavatech/create-xara-game 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @xavatech/create-xara-game
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Starts a game for Xara chats: a working starter built with [`@xavatech/xara-game-sdk`](https://www.npmjs.com/package/@xavatech/xara-game-sdk), with tests, and with the [game skill](https://docs.usexara.ai/games/skill) installed, so a coding assistant can turn your description into the real game.
4
+
5
+ ```bash
6
+ npx @xavatech/create-xara-game word-chain --name "Word Chain" --players 2-4
7
+ cd word-chain
8
+ npm run dev
9
+ ```
10
+
11
+ | Option | |
12
+ | --- | --- |
13
+ | `--name` | The game's name, as players see it. Defaults to the folder's name. |
14
+ | `--description` | One line about the game. |
15
+ | `--players` | `2`, or a range like `2-4`. Defaults to `2`. |
16
+ | `--xara-games <dir>` | For Xara's own games. Also adds the game to that [xara-games](https://github.com/xavatech/xara-games) checkout. |
17
+
18
+ The starter is a placeholder game: add 1, 2, or 3, and whoever makes the total 10 wins.
19
+
20
+ ## Xara's own games
21
+
22
+ Name the folder `xara-<game>` and pass `--xara-games`:
23
+
24
+ ```bash
25
+ npx @xavatech/create-xara-game xara-word-chain --name "Word Chain" --players 2-4 --xara-games ./xara-games
26
+ ```
27
+
28
+ This also adds the game as the submodule `games/word-chain` (pointing at `github.com/xavatech/xara-word-chain`), adds it to `games.json`, and builds it into `site/word-chain/`. With the site served at `GAMES_URL`, run the backend's `Game` seeder to list it. Then push the game's repo.
29
+
30
+ ## Change the starter
31
+
32
+ `template/` is copied as is, after these are replaced: `__PATH__`, `__NAME__`, `__DESCRIPTION__`, `__PLAYERS__`. `template/gitignore` becomes `.gitignore`, because npm leaves `.gitignore` files out of packages. `template/.cursor/skills/build-xara-game/SKILL.md` is a copy of the skill in `xara-game-sdk`. Keep them the same.
33
+
34
+ ```bash
35
+ npm publish
36
+ ```
package/index.mjs ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import { cpSync, existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+
8
+ const USAGE = `Start a game for Xara chats.
9
+
10
+ npx @xavatech/create-xara-game <folder> [options]
11
+
12
+ --name <name> The game's name, as players see it. Defaults to the folder's name.
13
+ --description <description> One line about the game.
14
+ --players <players> How many play: 2, or a range like 2-4. Defaults to 2.
15
+ --xara-games <dir> For Xara's own games: also add it to that xara-games checkout.`;
16
+
17
+ const fail = (message) => {
18
+ console.error(`\n${message}\n`);
19
+ process.exit(1);
20
+ };
21
+
22
+ const { values, positionals } = parseArgs({
23
+ allowPositionals: true,
24
+ options: {
25
+ name: { type: "string" },
26
+ description: { type: "string" },
27
+ players: { type: "string", default: "2" },
28
+ "xara-games": { type: "string" },
29
+ help: { type: "boolean", short: "h" },
30
+ },
31
+ });
32
+ if (values.help || !positionals[0]) {
33
+ console.log(USAGE);
34
+ process.exit(values.help ? 0 : 1);
35
+ }
36
+
37
+ const dir = resolve(positionals[0]);
38
+ const folder = basename(dir);
39
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(folder)) fail("Name the folder like word-chain: lowercase letters, numbers, and dashes.");
40
+ if (existsSync(dir)) fail(`${dir} already exists.`);
41
+
42
+ // Xara's own repos are named xara-<game>; the game is served at /<game>/.
43
+ const path = folder.replace(/^xara-/, "");
44
+ const name = values.name?.trim() || path.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
45
+ const description = values.description?.trim() ?? "";
46
+ const [minPlayers, maxPlayers = minPlayers] = values.players.split("-").map(Number);
47
+ if (![minPlayers, maxPlayers].every(Number.isInteger) || minPlayers < 2 || maxPlayers < minPlayers || maxPlayers > 8) {
48
+ fail("--players is from 2 to 8, like 2 or 2-4.");
49
+ }
50
+
51
+ const gamesDir = values["xara-games"] && resolve(values["xara-games"]);
52
+ const listFile = gamesDir && join(gamesDir, "games.json");
53
+ const list = listFile && (existsSync(listFile) ? JSON.parse(readFileSync(listFile, "utf8")) : fail(`${listFile} was not found.`));
54
+ if (list?.some((game) => game.path === path || game.name === name)) fail(`${path} or "${name}" is already in games.json.`);
55
+
56
+ const run = (command, cwd) => execSync(command, { cwd, stdio: "inherit" });
57
+
58
+ console.log(`\nCreating ${name} in ${dir}\n`);
59
+ cpSync(join(dirname(fileURLToPath(import.meta.url)), "template"), dir, { recursive: true });
60
+ renameSync(join(dir, "gitignore"), join(dir, ".gitignore"));
61
+ const players = minPlayers === maxPlayers ? `${minPlayers}` : `${minPlayers} to ${maxPlayers}`;
62
+ for (const entry of readdirSync(dir, { recursive: true, encoding: "utf8" })) {
63
+ const file = join(dir, entry);
64
+ if (statSync(file).isDirectory()) continue;
65
+ const text = readFileSync(file, "utf8");
66
+ const filled = text
67
+ .replace(/__PATH__/g, path)
68
+ .replace(/__NAME__/g, () => name)
69
+ .replace(/__DESCRIPTION__/g, () => description)
70
+ .replace(/__PLAYERS__/g, players);
71
+ if (filled !== text) writeFileSync(file, filled);
72
+ }
73
+
74
+ run("npm install --no-fund --no-audit", dir);
75
+ run(`git init -q -b main && git add -A && git commit -q -m "${name.replace(/"/g, "")} for Xara chats"`, dir);
76
+
77
+ if (gamesDir) {
78
+ run(`git -c protocol.file.allow=always submodule add -q "${dir}" games/${path}`, gamesDir);
79
+ run(`git config -f .gitmodules submodule.games/${path}.url https://github.com/xavatech/${folder}.git`, gamesDir);
80
+ writeFileSync(listFile, `${JSON.stringify([...list, { path, name, description, minPlayers, maxPlayers }], null, 2)}\n`);
81
+ run(`node build.mjs ${path}`, gamesDir);
82
+ run("git add -A", gamesDir);
83
+ }
84
+
85
+ console.log(`
86
+ ${name} is ready.
87
+
88
+ cd ${positionals[0]}
89
+ npm run dev Play it in a browser; every player shares the screen.
90
+
91
+ Then describe the real game to your coding assistant in that folder.`);
92
+
93
+ if (gamesDir) {
94
+ console.log(`
95
+ It is in xara-games at games/${path}, and built into site/${path}/.
96
+
97
+ List it: bun run avanda.ts app seed -t Game (in xara_backend, with xara-games served at GAMES_URL)
98
+ Push it: create github.com/xavatech/${folder}, then
99
+ cd ${dir} && git remote add origin https://github.com/xavatech/${folder}.git && git push -u origin main
100
+ cd ${gamesDir} && git commit -m "Add ${name}"`);
101
+ } else {
102
+ console.log(`To ship it, see README.md.`);
103
+ }
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "@xavatech/create-xara-game",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Start a game for Xara chats: a working starter with the Xara game SDK and the coding-assistant skill.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-xara-game": "index.mjs"
8
+ },
9
+ "files": [
10
+ "index.mjs",
11
+ "template"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.3"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "license": "MIT"
20
+ }
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: build-xara-game
3
+ description: >-
4
+ Builds a turn-based web game for Xara chats from a plain-language game
5
+ description, using @xavatech/xara-game-sdk. Use when a third party asks to
6
+ create, scaffold, or implement a game for Xara, describes a game people
7
+ should play from a Xara chat, or mentions the Xara game SDK.
8
+ ---
9
+
10
+ # Build a Xara game
11
+
12
+ The user describes a game. Build it as its own static web page with `@xavatech/xara-game-sdk`. Create a new project. Do not add the game to the SDK repo, and do not call Xara's HTTP API.
13
+
14
+ Xara already checks membership, acceptance, whose turn it is, and that a move follows the previous one. The game checks whether a move is legal.
15
+
16
+ ## Turn the description into rules
17
+
18
+ Write this down before the screen:
19
+
20
+ - Player count, fixed or a range, between 2 and 8. The starter goes first. Turns continue in that order.
21
+ - One move, as JSON the game sends. Keep it under 4,000 characters.
22
+ - How replaying `state.moves` draws the current game. Every phone, including someone watching, rebuilds from that list. Do not keep board state only in memory.
23
+ - Who wins, and what a draw is.
24
+ - A purchase, only if the description includes one: the item name, a whole naira amount from 1 to 1,000,000, and a reference.
25
+
26
+ A fact every phone must share, and that is not itself a move (a secret number, a shuffled deck), is derived from `session.id` plus the move list. There is no hidden game state on the server.
27
+
28
+ The chat card shows one summary to everyone. It never says "you". Name the player by their mark, the winning value, or similar. Stay within 120 characters.
29
+
30
+ ## Project
31
+
32
+ Vite and TypeScript. `base: "./"` so the built files work from any https path. One private package. Depend on `@xavatech/xara-game-sdk`.
33
+
34
+ ```
35
+ index.html
36
+ src/main.ts
37
+ src/rules.ts
38
+ src/rules.test.ts
39
+ src/style.css
40
+ vite.config.ts
41
+ ```
42
+
43
+ Rules live in `src/rules.ts` and have tests. `src/main.ts` only draws state and sends moves. Do not add a helper module for something `rules.ts` can do.
44
+
45
+ Lock the page to itself:
46
+
47
+ ```html
48
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'none'">
49
+ ```
50
+
51
+ No other origins, no CDN fonts, no iframes.
52
+
53
+ ## Connect
54
+
55
+ ```ts
56
+ import { connect, inXara, mockApp } from "@xavatech/xara-game-sdk";
57
+
58
+ if (import.meta.env.DEV && !inXara()) {
59
+ mockApp({
60
+ players: [
61
+ { id: 1, name: "Ada" },
62
+ { id: 2, name: "Bayo" },
63
+ ],
64
+ approvePayment: (item, amount) => window.confirm(`Buy ${item} for ₦${amount}?`),
65
+ });
66
+ }
67
+
68
+ type Move = { cell: number };
69
+ const xara = connect<Move>();
70
+ xara.onChange(draw);
71
+ draw(await xara.load());
72
+ ```
73
+
74
+ `mockApp` is for the browser during development. It is not part of the build the app opens.
75
+
76
+ ## Draw and play
77
+
78
+ Redraw from the `GameState` passed to `draw`. Fields the game uses:
79
+
80
+ - `session.status`: `invited`, `active`, `completed`, `declined`, `cancelled`
81
+ - `session.moveCount`, `session.turn`, `session.id`, `session.outcome` (`winners`, `summary`)
82
+ - `players`: `id`, `name`, `status`, `you`
83
+ - `me`: `id`, `watching` (a group member who is not playing)
84
+ - `moves`: `step`, `playerId`, `move`, `at` — oldest first. `move` is the JSON this game sent
85
+ - `purchases`: `reference`, `status` (`requested` or `paid`), `receipt`
86
+
87
+ While `session.status` is `invited` and this phone's player is still invited, show an accept control that calls `xara.accept()`. The session starts after every invitee accepts. Someone watching gets no accept control and no move control.
88
+
89
+ Accept a move only when `xara.isMyTurn()` is true. Ignore clicks otherwise.
90
+
91
+ ```ts
92
+ await xara.move({ cell }, won
93
+ ? { outcome: { winners: [xara.state!.me.id], summary: "X wins with three in a row" } }
94
+ : {});
95
+ ```
96
+
97
+ Pass `outcome` on the move that ends the game. `winners` is the player ids. An empty list is a draw. Call `xara.end(outcome)` only when the game ends without a move. `xara.close()` leaves the game screen.
98
+
99
+ Give status, the board, and any purchase line a fixed height so a new move does not push the page around.
100
+
101
+ ## A purchase
102
+
103
+ Skip this when the description has nothing to sell.
104
+
105
+ ```ts
106
+ const paid = await xara.buy({
107
+ item: "Hint",
108
+ amount: 100,
109
+ reference: `hint-${state.session.id}-${state.session.moveCount}`,
110
+ });
111
+ if (paid.status === "success") showHint();
112
+ ```
113
+
114
+ The reference matches `/^[A-Za-z0-9_.:-]{1,64}$/`. The same reference returns the existing purchase. A different item or amount on that reference is refused. Put `moveCount` in the reference only when a later turn is a new purchase; leave it out when the purchase is once per session (`hint-${session.id}`).
115
+
116
+ After a reload, read `state.purchases`. Show the item when one with that reference has `status === "paid"`. The page never asks for a PIN. The app shows its own approval sheet. The payee is the business that submitted the game.
117
+
118
+ `XaraGameError` codes are `rejected`, `timeout`, and `closed`. A closed error means the page was not opened from a Xara chat.
119
+
120
+ ## Names to use
121
+
122
+ Use the names above. These older names are not the SDK: `ready`, `onState`, `viewer`, `requestPayment`, `installMockHost`, `winner_ids`, `revision`, `turn_user_id`.
123
+
124
+ ## Check
125
+
126
+ 1. `npm test` for the rules, including an illegal move, a win, a draw, and a purchase reference if the game sells something.
127
+ 2. `npm run build`.
128
+ 3. With the dev server, play a full game through `mockApp`: accept, take turns, reach the outcome, and buy once if the game sells something. Play again with the same reference and confirm it does not describe a second charge.
129
+ 4. Replay the same `moves` and confirm both players would draw the same board.
130
+
131
+ ## What to tell the user
132
+
133
+ The built `dist/` is a static site. It has to be served over https on one origin. They submit that URL in the Xara business dashboard with the player counts and SDK version `2.0.0`. The game stays in review until an admin approves it. Changing the URL or the player counts after approval sends it back to review. Changing the name, description, or icon does not.
134
+
135
+ Do not submit, host, or publish the package unless the user asks.
@@ -0,0 +1,21 @@
1
+ # __NAME__
2
+
3
+ __DESCRIPTION__
4
+
5
+ A game people start from a Xara chat, built with [`@xavatech/xara-game-sdk`](https://www.npmjs.com/package/@xavatech/xara-game-sdk). It starts as a placeholder: add 1, 2, or 3, and whoever makes the total 10 wins. Describe the real game to your coding assistant. The skill in `.cursor/skills/build-xara-game` tells it how.
6
+
7
+ ## Run it
8
+
9
+ ```bash
10
+ npm run dev # http://localhost:5173, with a mock app: every player shares the screen
11
+ npm test # the rules
12
+ npm run build # static files in dist/
13
+ ```
14
+
15
+ ## Ship it
16
+
17
+ 1. Serve `dist/` over **https**. Xara only opens https games, and only answers SDK calls from that URL's origin.
18
+ 2. In the Xara business dashboard, open **Games → Submit a game** with the URL, __PLAYERS__ players, and SDK version `2.0.0`.
19
+ 3. Once an admin approves it, people find it under **+ → Game** in their chats.
20
+
21
+ See [Games overview](https://docs.usexara.ai/games/overview).
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ dist
3
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'none'" />
7
+ <title>__NAME__</title>
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <ol id="players" class="players" aria-label="Players"></ol>
12
+ <p id="status" class="status" aria-live="polite">Loading…</p>
13
+ <p id="total" class="total" aria-label="Total">0</p>
14
+ <div id="pad" class="pad" role="group" aria-label="Add to the total"></div>
15
+ <div class="actions">
16
+ <button id="accept" class="action" tabindex="-1">Accept and play</button>
17
+ </div>
18
+ <p id="note" class="note" role="alert"></p>
19
+ </main>
20
+ <script type="module" src="./src/main.ts"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__PATH__",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "__NAME__ for Xara chats, built with the Xara game SDK.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "tsc --noEmit && vite build",
10
+ "preview": "vite preview",
11
+ "test": "vitest run"
12
+ },
13
+ "dependencies": {
14
+ "@xavatech/xara-game-sdk": "^2.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.2",
18
+ "vite": "^6.0.7",
19
+ "vitest": "^2.1.8"
20
+ }
21
+ }
@@ -0,0 +1,116 @@
1
+ import "./style.css";
2
+ import { connect, inXara, mockApp, XaraGameError, type GameState } from "@xavatech/xara-game-sdk";
3
+ import { STEPS, TARGET, canAdd, isWon, totalOf, type Move } from "./rules";
4
+
5
+ // In `npm run dev` outside the app, a mock app lets every player take turns on one screen.
6
+ if (import.meta.env.DEV && !inXara()) {
7
+ mockApp({
8
+ game: { id: 1, name: "__NAME__" },
9
+ players: [
10
+ { id: 1, name: "Ada" },
11
+ { id: 2, name: "Bayo" },
12
+ ],
13
+ approvePayment: (item, amount) => window.confirm(`Buy ${item} for ₦${amount}?`),
14
+ });
15
+ }
16
+
17
+ type State = GameState<Move>;
18
+
19
+ const xara = connect<Move>();
20
+ const playersEl = document.getElementById("players")!;
21
+ const statusEl = document.getElementById("status")!;
22
+ const totalEl = document.getElementById("total")!;
23
+ const padEl = document.getElementById("pad")!;
24
+ const acceptEl = document.getElementById("accept") as HTMLButtonElement;
25
+ const noteEl = document.getElementById("note")!;
26
+
27
+ const keys = STEPS.map((add) => {
28
+ const button = document.createElement("button");
29
+ button.className = "key";
30
+ button.textContent = `+${add}`;
31
+ button.disabled = true;
32
+ button.addEventListener("click", () => play(add));
33
+ padEl.append(button);
34
+ return button;
35
+ });
36
+
37
+ let busy = false;
38
+
39
+ const nameOf = (state: State, playerId: number | null) =>
40
+ state.players.find((player) => player.id === playerId)?.name ?? "Someone";
41
+
42
+ function statusText(state: State): string {
43
+ const { session, me } = state;
44
+ switch (session.status) {
45
+ case "invited":
46
+ return state.players.find((player) => player.you)?.status === "invited"
47
+ ? "You've been invited to play"
48
+ : "Waiting for everyone to accept";
49
+ case "active":
50
+ return xara.isMyTurn() ? "Your turn" : `${nameOf(state, session.turn)}'s turn`;
51
+ case "completed": {
52
+ const outcome = session.outcome!;
53
+ if (outcome.leftBy) return `${nameOf(state, outcome.leftBy)} left the game`;
54
+ if (!outcome.winners.length) return "It's a draw";
55
+ return outcome.winners.includes(me.id) ? "You won!" : `${nameOf(state, outcome.winners[0])} won`;
56
+ }
57
+ case "declined":
58
+ return "The invite was declined";
59
+ default:
60
+ return "This game was called off";
61
+ }
62
+ }
63
+
64
+ function render(state: State) {
65
+ const total = totalOf(state.moves);
66
+ const playable = xara.isMyTurn() && !busy;
67
+
68
+ playersEl.replaceChildren(
69
+ ...state.players.map((player) => {
70
+ const item = document.createElement("li");
71
+ item.textContent = player.name;
72
+ item.classList.toggle("turn", state.session.status === "active" && player.id === state.session.turn);
73
+ return item;
74
+ })
75
+ );
76
+ statusEl.textContent = statusText(state);
77
+ totalEl.textContent = `${total} / ${TARGET}`;
78
+ keys.forEach((button, i) => (button.disabled = !playable || !canAdd(total, STEPS[i])));
79
+
80
+ const invited = state.session.status === "invited" && state.players.find((player) => player.you)?.status === "invited";
81
+ acceptEl.classList.toggle("shown", invited && !busy);
82
+ acceptEl.disabled = !invited || busy;
83
+ }
84
+
85
+ async function run(action: () => Promise<unknown>) {
86
+ busy = true;
87
+ noteEl.textContent = "";
88
+ if (xara.state) render(xara.state);
89
+ try {
90
+ await action();
91
+ } catch (error) {
92
+ noteEl.textContent = error instanceof XaraGameError ? error.message : "Something went wrong";
93
+ } finally {
94
+ busy = false;
95
+ if (xara.state) render(xara.state);
96
+ }
97
+ }
98
+
99
+ function play(add: number) {
100
+ const state = xara.state;
101
+ if (!state || !xara.isMyTurn() || busy) return;
102
+ const total = totalOf(state.moves);
103
+ if (!canAdd(total, add)) return;
104
+ // Every player reads the same summary, so it can't say "you".
105
+ const outcome = isWon(total + add)
106
+ ? { winners: [state.me.id], summary: `${nameOf(state, state.me.id)} reached ${TARGET}` }
107
+ : undefined;
108
+ return run(() => xara.move({ add }, { outcome }));
109
+ }
110
+
111
+ acceptEl.addEventListener("click", () => run(() => xara.accept()));
112
+
113
+ xara.onChange(render);
114
+ xara.load().catch((error) => {
115
+ statusEl.textContent = error instanceof XaraGameError ? error.message : "Couldn't start the game";
116
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PlayedMove } from "@xavatech/xara-game-sdk";
3
+ import { canAdd, isWon, totalOf, type Move } from "./rules";
4
+
5
+ const moves = (adds: number[]): PlayedMove<Move>[] =>
6
+ adds.map((add, i) => ({ step: i + 1, playerId: (i % 2) + 1, move: { add }, at: "" }));
7
+
8
+ describe("rules", () => {
9
+ test("adds up every move, as after a reload", () => {
10
+ expect(totalOf(moves([3, 2, 1]))).toBe(6);
11
+ });
12
+
13
+ test("skips moves that break the rules", () => {
14
+ expect(totalOf(moves([3, 5, 3, 3, 3]))).toBe(9);
15
+ expect(canAdd(9, 2)).toBe(false);
16
+ });
17
+
18
+ test("making the total 10 wins", () => {
19
+ expect(isWon(totalOf(moves([3, 3, 3, 1])))).toBe(true);
20
+ expect(isWon(9)).toBe(false);
21
+ });
22
+ });
@@ -0,0 +1,20 @@
1
+ import type { PlayedMove } from "@xavatech/xara-game-sdk";
2
+
3
+ /** A starter game: take turns adding 1, 2, or 3. Whoever makes the total 10 wins. Replace it with yours. */
4
+ export const TARGET = 10;
5
+ export const STEPS = [1, 2, 3] as const;
6
+
7
+ export type Move = { add: number };
8
+
9
+ /** The total after every move so far. Moves that don't follow the rules are skipped, on every phone alike. */
10
+ export function totalOf(moves: PlayedMove<Move>[]): number {
11
+ return moves.reduce((total, played) => (canAdd(total, played.move.add) ? total + played.move.add : total), 0);
12
+ }
13
+
14
+ export function canAdd(total: number, add: number): boolean {
15
+ return (STEPS as readonly number[]).includes(add) && total + add <= TARGET;
16
+ }
17
+
18
+ export function isWon(total: number): boolean {
19
+ return total === TARGET;
20
+ }
@@ -0,0 +1,134 @@
1
+ :root {
2
+ --blue: #1e5eff;
3
+ --blue-soft: #e8efff;
4
+ --ink: #102a24;
5
+ --muted: #607080;
6
+ font-family: system-ui, -apple-system, sans-serif;
7
+ color: var(--ink);
8
+ background: #f4f8ff;
9
+ -webkit-tap-highlight-color: transparent;
10
+ }
11
+
12
+ * {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ html,
17
+ body {
18
+ margin: 0;
19
+ height: 100%;
20
+ overscroll-behavior: none;
21
+ user-select: none;
22
+ -webkit-user-select: none;
23
+ }
24
+
25
+ /* Every row has a fixed height, so a new state never moves the page. */
26
+ main {
27
+ min-height: 100dvh;
28
+ max-width: 420px;
29
+ margin: 0 auto;
30
+ padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom));
31
+ display: grid;
32
+ grid-template-rows: 40px 28px 96px 64px 52px 20px;
33
+ align-content: center;
34
+ row-gap: 20px;
35
+ }
36
+
37
+ .players {
38
+ margin: 0;
39
+ padding: 0;
40
+ list-style: none;
41
+ display: flex;
42
+ justify-content: center;
43
+ gap: 8px;
44
+ overflow: hidden;
45
+ }
46
+
47
+ .players li {
48
+ padding: 8px 12px;
49
+ border-radius: 14px;
50
+ background: #fff;
51
+ border: 1.5px solid transparent;
52
+ font-size: 14px;
53
+ font-weight: 600;
54
+ white-space: nowrap;
55
+ }
56
+
57
+ .players li.turn {
58
+ border-color: var(--blue);
59
+ }
60
+
61
+ .status,
62
+ .note {
63
+ margin: 0;
64
+ overflow: hidden;
65
+ text-overflow: ellipsis;
66
+ white-space: nowrap;
67
+ text-align: center;
68
+ }
69
+
70
+ .status {
71
+ line-height: 28px;
72
+ font-size: 18px;
73
+ font-weight: 700;
74
+ }
75
+
76
+ .total {
77
+ margin: 0;
78
+ display: grid;
79
+ place-items: center;
80
+ border-radius: 24px;
81
+ background: #fff;
82
+ font-size: 40px;
83
+ font-weight: 800;
84
+ font-variant-numeric: tabular-nums;
85
+ }
86
+
87
+ .pad {
88
+ display: grid;
89
+ grid-template-columns: repeat(3, 1fr);
90
+ gap: 10px;
91
+ }
92
+
93
+ .key {
94
+ border: 0;
95
+ border-radius: 18px;
96
+ background: var(--blue);
97
+ color: #fff;
98
+ font: inherit;
99
+ font-size: 22px;
100
+ font-weight: 800;
101
+ touch-action: manipulation;
102
+ }
103
+
104
+ .key:disabled {
105
+ background: var(--blue-soft);
106
+ color: var(--muted);
107
+ }
108
+
109
+ .actions {
110
+ display: grid;
111
+ place-items: center;
112
+ }
113
+
114
+ .action {
115
+ height: 48px;
116
+ padding: 0 24px;
117
+ border: 0;
118
+ border-radius: 999px;
119
+ background: var(--blue);
120
+ color: #fff;
121
+ font: inherit;
122
+ font-size: 15px;
123
+ font-weight: 700;
124
+ visibility: hidden;
125
+ }
126
+
127
+ .action.shown {
128
+ visibility: visible;
129
+ }
130
+
131
+ .note {
132
+ font-size: 13px;
133
+ color: #c0392b;
134
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2020", "DOM"],
7
+ "types": ["vite/client"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "isolatedModules": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vite";
2
+
3
+ // Relative asset paths, so the built game works from any https path it is served under.
4
+ export default defineConfig({
5
+ base: "./",
6
+ build: { outDir: "dist", emptyOutDir: true },
7
+ });