Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .cursor/rules/create-game.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
---
description: Add a new Pokémon Showdown room game under src/ps/games (any type: grid, single-player, N-player, forms, hidden info)
globs:
- src/ps/games/**
- scripts/debug-games/**
alwaysApply: false
---

# Creating a PS room game

Do not invent a command file. `src/ps/commands/games/core.tsx` builds `/<id>` from `Games`. Copy the **closest existing game**, then strip what you don't need.

| Kind | Copy | Why |
|------|------|-----|
| 2-player grid, click a cell | `othello` | simplest `play i-j` |
| Select piece, then destination | `chess` / `linesofaction` | `action()` parses `select` / `move` from ctx |
| Drop-in-column / one param | `connectfour` | ctx is a single index |
| Dice / "just go" | `snakesladders` | `${this.msg} !` with empty ctx |
| N-player, no named sides | `azul` / `splendor` | `minSize`/`maxSize`, `autostart: false` |
| Single-player puzzle | `lightsout` | `players: 'single'`, `abbr` required |
| Guess / typed input | `mastermind` | `Form` + `{field}` placeholders |
| Hidden boards / setup phase | `battleship` | per-player state, `update(player.id)` |
| Mods or themes | `scrabble` / `chess` | `meta.mods` or `meta.themes` |

## Naming (must all agree)

- Folder name = `GamesList` **string value**.
- `meta.name` with spaces stripped = exported class (`Lights Out` → `LightsOut`).
- Id is concatenated lowercase (`linesofaction`, `snakesladders`).

## Files

Required: `meta.ts`, `types.ts`, `index.ts` (`export { meta }`), `render.tsx`.
Add `logs.ts` if you record moves; `constants.ts` / `mods.ts` only if the template has them.

Register in two places only:

1. `GamesList` in `src/ps/games/types.ts`
2. import + `Games` map in `src/ps/games/index.ts`

Do **not** add UGO spotlight (`BOARD_GAMES_STRUCHNI_ORDER`) or a custom replay page unless asked. Generic replay is `/api/<id>/<gameId>` + `src/web/react/pages/[game].tsx`.

## Meta knobs

- `players: 'many'` (default table games) or `'single'` (needs `abbr`; id is `#<abbr>-<userid>`).
- `turns`: named sides (`B`/`W`). Omit for free-for-all — turns become player ids.
- `minSize` / `maxSize` when not exactly 2.
- `autostart: true` starts when full; `false` needs staff `,start`.
- `timer` / `pokeTimer`: `fromHumanTime(...)`. Single-player often has none.
- `htp`: `{ goal, sections }` — required.
- `ugo`: copy a similar game or `null`. Don't invent new point tables.

## Button / form routing (easy to get wrong)

`this.msg` (`@#GAMEID`) targets **this instance**. `this.simpleMsg` (`@ROOM <gameType>`) is for watch / create / audience (typical single-player chrome).

Spoof: `@#ID <subcommand> <rest>` → `<gameType> <subcommand> <id>, <rest>`.

Only **`play`** (aliases `p`, `!`) calls `game.action(user, ctx, false)`. Everything after `play`/`!` is `ctx`. Other subcommands (`join`, `watch`, `audience`, `create`) are core commands — do not reuse those words as ctx verbs by putting them next to `this.msg` without `play`/`!`.

```tsx
// ✅ reaches action(); ctx is whatever follows play/!
value={`${this.msg} play ${i}-${j}`}
value={`${this.msg} ! select ${i}-${j}`}
value={`${this.msg} !`} // empty ctx (roll)
<Form value={`${this.msg} ! x {tiles}`}> // ctx = "x QZ"

// ❌ parsed as a game subcommand; never hits action()
value={`${this.msg} select ${i}-${j}`}
value={`${this.msg} move ${from}-${to}`}
```

`select` / `move` / `set` are **ctx verbs** parsed inside `action()`, not command names. Use `Button` / `Form` from `@/utils/components/ps`. Grid helper: `Table` from `@/ps/games/render` — skip it if the UI isn't a grid.

## UI (`render.tsx`)

PS room HTML sits in a dark `#page` shell (`color: white`, `background: #000a`). Match existing grid games — copy Othello/Chess patterns, don't invent a palette.

**Chrome defaults** — headers, trays, legend, pickers:
- `Button`: `background: none`, `color: inherit`. Dim headers: `color: gray`.
- Borders only where they carry meaning. No `opacity` on text.
- Do not copy another game's color constant block (`S`/`SS`/`B` from Azul, etc.) into every `render.tsx`.

**Grid boards** — `Table` from `@/ps/games/render`; override default `margin: 20` when the board is the focal element. `labels={null}` when row/col headers don't help (large boards).

**One board, one square** — the cell grid is the board. No nested wrapper with its own border, padding, or rounded frame around the table. Scroll container: `overflow: auto` only.

**Locked board theme** — sparingly, on the central board only when fixed colors improve readability:
- Theme values local inside the board renderer (not file-level aliases).
- Empty cells + subtle grid lines = low contrast (visual guides).
- Interactive elements on the board (valid-move targets, clickable affordances) = higher contrast than the grid.
- Trays, orientation pickers, and legend stay on inherited `#page` theme — not the locked board palette.

**STYLES LIKE FLEX AND GRID DO NOT WORK ON PS. DO NOT TRY TO USE FLEXBOXES IN ANY WAY OR FORM.**

**Valid-move hints** — dashed circles / outlines on target cells (Othello pattern), not filled ghost tiles over the board.

**Option lists** (piece trays, orientation rows) — no bordered box around every item. Unselected: `border: none`. Selected: `outline` or similar, not a padded gray frame.

**Multi-cell previews** — layout from actual cell coordinates (offset by min row/col so shapes don't clip). Markers (stars, labels) render inside the cell they refer to, not absolutely positioned outside the shape.

## HTML page size (hard cap + target)

Game `render()` output becomes a PS **page** via `user.pageHTML` (`src/ps/games/game.ts`). PS truncates or breaks pages above the cap.

| | Value | Source |
|---|---|---|
| Hard cap | **99,500** chars | `MAX_PAGE_HTML_LENGTH` in `src/ps/constants.ts` |
| **Target (must stay under)** | **~80% → ~79,600** | headroom for worst-case UI states |
| Serialization | `jsxToHTML` → `renderToStaticMarkup`, no minification yet | `src/utils/jsxToHTML.ts` |

**Why 80%?** Inline styles repeat per element. A typical mid-game view (board + tray + valid-move buttons + orientation picker) is much heavier than an empty board. Blokus 20×20 hit **114k** before trim — board + full piece tray alone was **111k**.

**Measure before you ship UI:**

1. Point `src/ps/games/test.tsx` at your `render` with a **stress mock** — not an empty board. Include max realistic tray size, selected piece, orientation row, and a generous `validAnchors` / valid-move list.
2. `npm run debug` — watcher logs `HTML N / 99500 (pct%)` (`src/ps/games/debug.ts`). Debug page shows `HTML {HTML_LENGTH} / {HTML_LIMIT}` (`scripts/debug-games/templates/page.html`).
3. If over **80%**, shrink HTML before polishing pixels. Re-measure after every structural change.

**Shrink HTML without ugly UI** (prefer these over shrinking the board or removing affordances):

- **One element per grid cell** — content directly in `<td>`; no inner wrapper `div` for layout/flex when `margin: auto` or `text-align` on the cell suffices.
- **Short color tokens** — `#2e3848` beats `rgba(232, 236, 237, 0.18)`; every cell repeats border/background strings.
- **Local style objects** inside the board renderer (`td`, `dot`, …) — shared keys, not duplicated long literals in JSX.
- **Skip decorative CSS** on cells — no `box-sizing`, `display:flex`, `overflow:hidden` unless the cell actually needs it.
- **Option lists multiply cost** — each tray/orientation `Button` + mini-preview is hundreds of chars; keep preview markup lean (see trimmed `PieceMini` in `src/ps/games/blokus/render.tsx`).
- **Valid-move buttons are expensive** — each anchor is a `<button>` with coordinates in `value`; unavoidable, so budget for them in the stress mock.

Do **not** rely on CSS classes or external stylesheets — game HTML is inline-only. Do **not** assume `jsxToHTML` minifies (TODO in source).

## Class

- `constructor`: `super(ctx); super.persist(ctx);` then init unless `ctx.backup`. Single-player often `super.after(ctx)` as well (see Lights Out).
- `action(user, ctx)` is the only click/form entry. Parse `ctx`; `this.throw()` on junk.
- Persistable data goes in `this.state`. UI-only (`selected`, highlight lists) stays on the instance (not in `backupKeys`); pass into `RenderCtx` only for the viewer who needs it.
- `createGrid(rows, cols, fill)` when you need a 2D board — first size is rows.
- `trySkipPlayer(turn)` → true to skip that side (no legal moves). Omit if every turn can always act.
- Hidden info: `render(side)` gets that player's view; spectators get `side === null`.
- `WinCtx`: use `Player` from `@/ps/games/types`. `exactOptionalPropertyTypes`: omit optional log fields instead of assigning `undefined`.
- `render(side)` → `render.bind(this.renderCtx)(ctx)`. Reuse `GAME.YOUR_TURN` / `WAITING_FOR_*` / `GAME_ENDED` when they fit; single-player copy from Lights Out / Mastermind.

## Debug the UI

`src/ps/games/test.tsx` is the mock. `debug.ts` watches `src/ps/games` and writes HTML into `scripts/debug-games/live/`.

1. Point `test.tsx` at the new `render` (Azul in that file is the N-player example).
2. Build a **stress mock** — worst realistic `RenderCtx` (full tray, selection UI, many valid moves). Check HTML length (see **HTML page size** above); must be **under ~80%** of `MAX_PAGE_HTML_LENGTH`.
3. `jsxToHTML(render.bind({ msg: 'test', simpleMsg: 'test' })(...))` — same path production uses.
4. `npm run debug` — `http://localhost:8081`, live-reload 8082, Desktop/Mobile/Ruka widths. Console + page header show `HTML N / 99500`.
5. Clicks do not play; layout only.

**Stale HTML is common.** The watcher may not refresh `scripts/debug-games/live/page.html` after render changes — hard refresh is not enough if the file itself is old. Regenerate manually (run `test()` + replace `{HTML}` in the template), then open `localhost:8081/page.html` in a browser. Do not assume layout is correct until you have seen the live page.

Do not commit leftover `test.tsx` pointed at a WIP game unless asked.
Binary file added scripts/debug-games/live/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions scripts/debug-games/templates/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<button id="button-mobile" onclick="setView('mobile')">Mobile</button>
<button id="button-ruka" onclick="setView('ruka')">Ruka</button>
</div>
<div style="font: 12px monospace; margin-bottom: 8px; color: #ccc">HTML {HTML_LENGTH} / {HTML_LIMIT}</div>
<center id="page">{HTML}</center>
</center>
<script>
Expand Down
114 changes: 114 additions & 0 deletions src/ps/games/blokus/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
type PieceDef = {
cells: [number, number][];
ref: [number, number];
size: number;
orientations: [number, number][][];
};

export type PieceId =
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| '10'
| '11'
| '12'
| '13'
| '14'
| '15'
| '16'
| '17'
| '18'
| '19'
| '20'
| '21';

const PIECE_DEFS: Record<PieceId, Omit<PieceDef, 'orientations'>> = {
'1': { cells: [[0, 0]], ref: [0, 0], size: 1 },
'2': { cells: [[0, 0], [1, 0]], ref: [0, 0], size: 2 },
'3': { cells: [[0, 0], [1, 0], [2, 0]], ref: [1, 0], size: 3 },
'4': { cells: [[0, 0], [0, 1], [1, 0]], ref: [1, 0], size: 3 },
'5': { cells: [[0, 0], [1, 0], [2, 0], [3, 0]], ref: [1, 0], size: 4 },
'6': { cells: [[0, 0], [1, 0], [0, 1], [1, 1]], ref: [0, 0], size: 4 },
'7': { cells: [[0, 0], [0, 1], [0, 2], [1, 2]], ref: [0, 1], size: 4 },
'8': { cells: [[0, 0], [1, 0], [2, 0], [1, 1]], ref: [1, 0], size: 4 },
'9': { cells: [[0, 0], [1, 0], [1, 1], [2, 1]], ref: [1, 1], size: 4 },
'10': { cells: [[1, 0], [0, 1], [1, 1], [2, 1], [1, 2]], ref: [1, 1], size: 5 },
'11': { cells: [[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]], ref: [2, 0], size: 5 },
'12': { cells: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 3]], ref: [0, 2], size: 5 },
'13': { cells: [[0, 0], [0, 1], [1, 1], [1, 2], [2, 2]], ref: [1, 1], size: 5 },
'14': { cells: [[0, 0], [1, 0], [0, 1], [1, 1], [0, 2]], ref: [0, 1], size: 5 },
'15': { cells: [[0, 0], [1, 0], [2, 0], [1, 1], [1, 2]], ref: [1, 1], size: 5 },
'16': { cells: [[0, 0], [2, 0], [0, 1], [1, 1], [2, 1]], ref: [1, 1], size: 5 },
'17': { cells: [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2]], ref: [0, 2], size: 5 },
'18': { cells: [[0, 0], [1, 0], [1, 1], [2, 1], [2, 2]], ref: [1, 1], size: 5 },
'19': { cells: [[0, 0], [1, 0], [2, 0], [3, 0], [1, 1]], ref: [1, 0], size: 5 },
'20': { cells: [[0, 0], [1, 0], [2, 0], [2, 1], [3, 1]], ref: [1, 0], size: 5 },
'21': { cells: [[1, 0], [1, 1], [2, 1], [0, 2], [1, 2]], ref: [1, 1], size: 5 },
};

function rotate90(cells: [number, number][]): [number, number][] {
return cells.map(([x, y]) => [y, -x]);
}

function rotatePoint([x, y]: [number, number]): [number, number] {
return [y, -x];
}

function flipH(cells: [number, number][]): [number, number][] {
return cells.map(([x, y]) => [-x, y]);
}

function buildOrientations(cells: [number, number][], ref: [number, number]): [number, number][][] {
const results: [number, number][][] = [];
const seen = new Set<string>();
const baseCells = cells.map(([x, y]) => [x, y] as [number, number]);
const baseRef: [number, number] = [ref[0], ref[1]];
let currentCells = baseCells;
let currentRef = baseRef;

for (let flip = 0; flip < 2; flip++) {
for (let rot = 0; rot < 4; rot++) {
const [rx, ry] = currentRef;
const relative = currentCells
.map(([x, y]) => [x - rx, y - ry] as [number, number])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const key = relative.map(c => c.join(',')).join('|');
if (!seen.has(key)) {
seen.add(key);
results.push(relative);
}
currentCells = rotate90(currentCells);
currentRef = rotatePoint(currentRef);
}
currentCells = flipH(baseCells);
currentRef = flipH([baseRef])[0];
}
return results;
}

/** `ref` is a fixed tile on the piece; orientations are offsets from that tile (ref → [0,0]). */
export const PIECES: Record<PieceId, PieceDef> = Object.fromEntries(
Object.entries(PIECE_DEFS).map(([id, def]) => [
id,
{ ...def, orientations: buildOrientations(def.cells, def.ref) },
])
) as Record<PieceId, PieceDef>;

export const ALL_PIECE_IDS = Object.keys(PIECES) as PieceId[];

export const PLAYER_COLORS = ['#1e88e5', '#fdd835', '#e53935', '#43a047'] as const;

export const CORNERS: [number, number][] = [
[0, 0],
[0, -1],
[-1, -1],
[-1, 0],
];

export const BOARD_SIZE = { two: 20, many: 14 } as const;
Loading
Loading