diff --git a/packages/onitama-game/index.html b/packages/onitama-game/index.html
new file mode 100644
index 0000000..29f4889
--- /dev/null
+++ b/packages/onitama-game/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Onitama - boardgame-phaser
+
+
+
+
+
+
diff --git a/packages/onitama-game/package.json b/packages/onitama-game/package.json
new file mode 100644
index 0000000..57da812
--- /dev/null
+++ b/packages/onitama-game/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "onitama-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@preact/signals-core": "^1.5.1",
+ "boardgame-core": "link:../../../boardgame-core",
+ "boardgame-phaser": "workspace:*",
+ "mutative": "^1.3.0",
+ "phaser": "^3.80.1",
+ "preact": "^10.19.3"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.8.1",
+ "@preact/signals": "^2.9.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.1.0"
+ }
+}
diff --git a/packages/onitama-game/src/game/onitama.ts b/packages/onitama-game/src/game/onitama.ts
new file mode 100644
index 0000000..e9f7571
--- /dev/null
+++ b/packages/onitama-game/src/game/onitama.ts
@@ -0,0 +1 @@
+export * from "boardgame-core/samples/onitama";
diff --git a/packages/onitama-game/src/main.tsx b/packages/onitama-game/src/main.tsx
new file mode 100644
index 0000000..8804b0e
--- /dev/null
+++ b/packages/onitama-game/src/main.tsx
@@ -0,0 +1,13 @@
+import { h } from 'preact';
+import { GameUI } from 'boardgame-phaser';
+import * as gameModule from './game/onitama';
+import './style.css';
+import App from "@/ui/App";
+import {OnitamaScene} from "@/scenes/OnitamaScene";
+
+const ui = new GameUI({
+ container: document.getElementById('ui-root')!,
+ root: ,
+});
+
+ui.mount();
diff --git a/packages/onitama-game/src/scenes/OnitamaScene.ts b/packages/onitama-game/src/scenes/OnitamaScene.ts
new file mode 100644
index 0000000..e8abb33
--- /dev/null
+++ b/packages/onitama-game/src/scenes/OnitamaScene.ts
@@ -0,0 +1,454 @@
+import Phaser from 'phaser';
+import type { OnitamaState, PlayerType, Pawn, Card } from '@/game/onitama';
+import { prompts } from '@/game/onitama';
+import { GameHostScene } from 'boardgame-phaser';
+import { spawnEffect, type Spawner } from 'boardgame-phaser';
+
+const CELL_SIZE = 80;
+const BOARD_OFFSET = { x: 150, y: 100 };
+const BOARD_SIZE = 5;
+const CARD_WIDTH = 100;
+const CARD_HEIGHT = 140;
+
+export class OnitamaScene extends GameHostScene {
+ private boardContainer!: Phaser.GameObjects.Container;
+ private gridGraphics!: Phaser.GameObjects.Graphics;
+ private infoText!: Phaser.GameObjects.Text;
+ private winnerOverlay?: Phaser.GameObjects.Container;
+ private redCardsContainer!: Phaser.GameObjects.Container;
+ private blackCardsContainer!: Phaser.GameObjects.Container;
+ private spareCardContainer!: Phaser.GameObjects.Container;
+ private cardGraphics!: Phaser.GameObjects.Graphics;
+
+ constructor() {
+ super('OnitamaScene');
+ }
+
+ create(): void {
+ super.create();
+
+ this.boardContainer = this.add.container(0, 0);
+ this.gridGraphics = this.add.graphics();
+ this.cardGraphics = this.add.graphics();
+ this.drawBoard();
+
+ this.disposables.add(spawnEffect(new PawnSpawner(this)));
+
+ this.redCardsContainer = this.add.container(0, 0);
+ this.blackCardsContainer = this.add.container(0, 0);
+ this.spareCardContainer = this.add.container(0, 0);
+
+ this.addEffect(() => {
+ this.updateCards();
+ });
+
+ this.addEffect(() => {
+ const winner = this.state.winner;
+ if (winner) {
+ this.showWinner(winner);
+ } else if (this.winnerOverlay) {
+ this.winnerOverlay.destroy();
+ this.winnerOverlay = undefined;
+ }
+ });
+
+ this.infoText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30, '', {
+ fontSize: '20px',
+ fontFamily: 'Arial',
+ color: '#4b5563',
+ }).setOrigin(0.5);
+
+ this.addEffect(() => {
+ this.updateInfoText();
+ });
+
+ this.setupInput();
+ }
+
+ private updateInfoText(): void {
+ const currentPlayer = this.state.currentPlayer;
+ if (this.state.winner) {
+ this.infoText.setText(`${this.state.winner} wins!`);
+ } else {
+ this.infoText.setText(`${currentPlayer}'s turn (Turn ${this.state.turn + 1})`);
+ }
+ }
+
+ private drawBoard(): void {
+ const g = this.gridGraphics;
+ g.lineStyle(2, 0x6b7280);
+
+ for (let i = 0; i <= BOARD_SIZE; i++) {
+ g.lineBetween(
+ BOARD_OFFSET.x + i * CELL_SIZE,
+ BOARD_OFFSET.y,
+ BOARD_OFFSET.x + i * CELL_SIZE,
+ BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
+ );
+ g.lineBetween(
+ BOARD_OFFSET.x,
+ BOARD_OFFSET.y + i * CELL_SIZE,
+ BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
+ BOARD_OFFSET.y + i * CELL_SIZE,
+ );
+ }
+
+ g.strokePath();
+
+ this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Onitama', {
+ fontSize: '28px',
+ fontFamily: 'Arial',
+ color: '#1f2937',
+ }).setOrigin(0.5);
+ }
+
+ private setupInput(): void {
+ for (let row = 0; row < BOARD_SIZE; row++) {
+ for (let col = 0; col < BOARD_SIZE; col++) {
+ const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
+ const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
+
+ const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
+
+ zone.on('pointerdown', () => {
+ if (this.state.winner) return;
+ this.handleCellClick(col, row);
+ });
+ }
+ }
+ }
+
+ private selectedPiece: { x: number, y: number } | null = null;
+
+ private handleCellClick(x: number, y: number): void {
+ const pawn = this.getPawnAtPosition(x, y);
+
+ if (this.selectedPiece) {
+ if (pawn && pawn.owner === this.state.currentPlayer) {
+ this.selectedPiece = { x, y };
+ this.highlightValidMoves();
+ return;
+ }
+
+ const fromX = this.selectedPiece.x;
+ const fromY = this.selectedPiece.y;
+ this.selectedPiece = null;
+
+ if (pawn && pawn.owner === this.state.currentPlayer) {
+ return;
+ }
+
+ this.tryMove(fromX, fromY, x, y);
+ } else {
+ if (pawn && pawn.owner === this.state.currentPlayer) {
+ this.selectedPiece = { x, y };
+ this.highlightValidMoves();
+ }
+ }
+ }
+
+ private highlightValidMoves(): void {
+ if (!this.selectedPiece) return;
+
+ const currentPlayer = this.state.currentPlayer;
+ const cardNames = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards;
+ const moves = this.getValidMovesForPiece(this.selectedPiece.x, this.selectedPiece.y, cardNames);
+
+ moves.forEach(move => {
+ const x = BOARD_OFFSET.x + move.toX * CELL_SIZE + CELL_SIZE / 2;
+ const y = BOARD_OFFSET.y + move.toY * CELL_SIZE + CELL_SIZE / 2;
+
+ const highlight = this.add.circle(x, y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100);
+ highlight.setInteractive({ useHandCursor: true });
+ highlight.on('pointerdown', () => {
+ this.selectedPiece = null;
+ this.clearHighlights();
+ this.tryMove(move.fromX, move.fromY, move.toX, move.toY);
+ });
+ });
+ }
+
+ private clearHighlights(): void {
+ this.children.list.forEach(child => {
+ if ('depth' in child && child.depth === 100) {
+ child.destroy();
+ }
+ });
+ }
+
+ private getValidMovesForPiece(fromX: number, fromY: number, cardNames: string[]): Array<{ card: string, fromX: number, fromY: number, toX: number, toY: number }> {
+ const moves: Array<{ card: string, fromX: number, fromY: number, toX: number, toY: number }> = [];
+ const player = this.state.currentPlayer;
+
+ for (const cardName of cardNames) {
+ const card = this.state.cards[cardName];
+ if (!card) continue;
+
+ for (const move of card.moveCandidates) {
+ const toX = fromX + move.dx;
+ const toY = fromY + move.dy;
+
+ if (this.isValidMove(fromX, fromY, toX, toY, player)) {
+ moves.push({ card: cardName, fromX, fromY, toX, toY });
+ }
+ }
+ }
+
+ return moves;
+ }
+
+ private isValidMove(fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): boolean {
+ if (toX < 0 || toX >= BOARD_SIZE || toY < 0 || toY >= BOARD_SIZE) {
+ return false;
+ }
+
+ const targetPawn = this.getPawnAtPosition(toX, toY);
+ if (targetPawn && targetPawn.owner === player) {
+ return false;
+ }
+
+ const pawn = this.getPawnAtPosition(fromX, fromY);
+ if (!pawn || pawn.owner !== player) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private tryMove(fromX: number, fromY: number, toX: number, toY: number): void {
+ this.clearHighlights();
+
+ const currentPlayer = this.state.currentPlayer;
+ const cardNames = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards;
+ const validMoves = this.getValidMovesForPiece(fromX, fromY, cardNames);
+
+ if (validMoves.length > 0) {
+ const move = validMoves[0];
+ const error = this.gameHost.tryAnswerPrompt(
+ prompts.move,
+ currentPlayer,
+ move.card,
+ fromX,
+ fromY,
+ toX,
+ toY
+ );
+ if (error) {
+ console.warn('Invalid move:', error);
+ }
+ }
+ }
+
+ private getPawnAtPosition(x: number, y: number): Pawn | null {
+ const key = `${x},${y}`;
+ const pawnId = this.state.regions.board.partMap[key];
+ return pawnId ? this.state.pawns[pawnId] : null;
+ }
+
+ private updateCards(): void {
+ this.redCardsContainer.removeAll(true);
+ this.blackCardsContainer.removeAll(true);
+ this.spareCardContainer.removeAll(true);
+ this.cardGraphics.clear();
+
+ this.renderCardHand('red', this.state.redCards, 20, 200, this.redCardsContainer);
+ this.renderCardHand('black', this.state.blackCards, 20, 400, this.blackCardsContainer);
+ this.renderSpareCard(this.state.spareCard, 650, 300, this.spareCardContainer);
+ }
+
+ private renderCardHand(player: PlayerType, cardNames: string[], x: number, y: number, container: Phaser.GameObjects.Container): void {
+ cardNames.forEach((cardName, index) => {
+ const card = this.state.cards[cardName];
+ if (!card) return;
+
+ const cardObj = this.createCardVisual(card, CARD_WIDTH, CARD_HEIGHT);
+ cardObj.x = x + index * (CARD_WIDTH + 10);
+ cardObj.y = y;
+ container.add(cardObj);
+ });
+
+ const label = this.add.text(x, y - 30, `${player.toUpperCase()}'s Cards`, {
+ fontSize: '16px',
+ fontFamily: 'Arial',
+ color: player === 'red' ? '#ef4444' : '#3b82f6',
+ });
+ container.add(label);
+ }
+
+ private renderSpareCard(cardName: string, x: number, y: number, container: Phaser.GameObjects.Container): void {
+ const card = this.state.cards[cardName];
+ if (!card) return;
+
+ const cardObj = this.createCardVisual(card, CARD_WIDTH, CARD_HEIGHT);
+ cardObj.x = x;
+ cardObj.y = y;
+ container.add(cardObj);
+
+ const label = this.add.text(x, y - 30, 'Spare Card', {
+ fontSize: '16px',
+ fontFamily: 'Arial',
+ color: '#6b7280',
+ }).setOrigin(0.5, 0);
+ container.add(label);
+ }
+
+ private createCardVisual(card: Card, width: number, height: number): Phaser.GameObjects.Container {
+ const container = this.add.container(0, 0);
+
+ const bg = this.add.rectangle(0, 0, width, height, 0xf9fafb, 1)
+ .setStrokeStyle(2, 0x6b7280);
+ container.add(bg);
+
+ const title = this.add.text(0, -height / 2 + 15, card.id, {
+ fontSize: '12px',
+ fontFamily: 'Arial',
+ color: '#1f2937',
+ }).setOrigin(0.5);
+ container.add(title);
+
+ const grid = this.add.graphics();
+ const cellSize = 16;
+ const gridWidth = 5 * cellSize;
+ const gridHeight = 5 * cellSize;
+ const gridStartX = -gridWidth / 2;
+ const gridStartY = -gridHeight / 2 + 30;
+
+ for (let row = 0; row < 5; row++) {
+ for (let col = 0; col < 5; col++) {
+ const x = gridStartX + col * cellSize;
+ const y = gridStartY + row * cellSize;
+
+ if (row === 2 && col === 2) {
+ grid.fillStyle(0x3b82f6, 1);
+ grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
+ } else {
+ const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row);
+ if (isTarget) {
+ grid.fillStyle(0xef4444, 0.6);
+ grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
+ }
+ }
+ }
+ }
+ container.add(grid);
+
+ const playerText = this.add.text(0, height / 2 - 15, card.startingPlayer, {
+ fontSize: '10px',
+ fontFamily: 'Arial',
+ color: '#6b7280',
+ }).setOrigin(0.5);
+ container.add(playerText);
+
+ return container;
+ }
+
+ private showWinner(winner: string): void {
+ if (this.winnerOverlay) {
+ this.winnerOverlay.destroy();
+ }
+
+ this.winnerOverlay = this.add.container();
+
+ const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
+
+ const bg = this.add.rectangle(
+ BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
+ BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
+ BOARD_SIZE * CELL_SIZE,
+ BOARD_SIZE * CELL_SIZE,
+ 0x000000,
+ 0.6,
+ ).setInteractive({ useHandCursor: true });
+
+ bg.on('pointerdown', () => {
+ this.gameHost.start();
+ });
+
+ this.winnerOverlay.add(bg);
+
+ const winText = this.add.text(
+ BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
+ BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
+ text,
+ {
+ fontSize: '36px',
+ fontFamily: 'Arial',
+ color: '#fbbf24',
+ },
+ ).setOrigin(0.5);
+
+ this.winnerOverlay.add(winText);
+
+ this.tweens.add({
+ targets: winText,
+ scale: 1.2,
+ duration: 500,
+ yoyo: true,
+ repeat: 1,
+ });
+ }
+}
+
+class PawnSpawner implements Spawner {
+ constructor(public readonly scene: OnitamaScene) {}
+
+ *getData() {
+ for (const pawn of Object.values(this.scene.state.pawns)) {
+ if (pawn.regionId === 'board') {
+ yield pawn;
+ }
+ }
+ }
+
+ getKey(pawn: Pawn): string {
+ return pawn.id;
+ }
+
+ onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void {
+ const [x, y] = pawn.position;
+ obj.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2;
+ obj.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2;
+ }
+
+ onSpawn(pawn: Pawn) {
+ const container = this.scene.add.container(0, 0);
+
+ const bgColor = pawn.owner === 'red' ? 0xef4444 : 0x3b82f6;
+ const circle = this.scene.add.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
+ .setStrokeStyle(2, 0x1f2937);
+ container.add(circle);
+
+ const label = pawn.type === 'master' ? 'M' : 'S';
+ const text = this.scene.add.text(0, 0, label, {
+ fontSize: '24px',
+ fontFamily: 'Arial',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+ container.add(text);
+
+ const [x, y] = pawn.position;
+ container.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2;
+ container.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2;
+
+ container.setScale(0);
+ this.scene.tweens.add({
+ targets: container,
+ scale: 1,
+ duration: 300,
+ ease: 'Back.easeOut',
+ });
+
+ return container;
+ }
+
+ onDespawn(obj: Phaser.GameObjects.Container) {
+ this.scene.tweens.add({
+ targets: obj,
+ scale: 0,
+ alpha: 0,
+ duration: 300,
+ ease: 'Back.easeIn',
+ onComplete: () => obj.destroy(),
+ });
+ }
+}
diff --git a/packages/onitama-game/src/style.css b/packages/onitama-game/src/style.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/packages/onitama-game/src/style.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/onitama-game/src/ui/App.tsx b/packages/onitama-game/src/ui/App.tsx
new file mode 100644
index 0000000..53b50ba
--- /dev/null
+++ b/packages/onitama-game/src/ui/App.tsx
@@ -0,0 +1,38 @@
+import {useComputed} from '@preact/signals';
+import { createGameHost } from 'boardgame-core';
+import Phaser from 'phaser';
+import { h } from 'preact';
+import { PhaserGame, PhaserScene } from 'boardgame-phaser';
+
+export default function App(props: { gameModule: any, gameScene: { new(): Phaser.Scene } }) {
+
+ const gameHost = useComputed(() => {
+ const gameHost = createGameHost(props.gameModule);
+ return { gameHost };
+ });
+
+ const scene = useComputed(() => new props.gameScene());
+
+ const handleReset = () => {
+ gameHost.value.gameHost.start();
+ };
+ const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
+
+ return (
+
+ );
+}
diff --git a/packages/onitama-game/tsconfig.json b/packages/onitama-game/tsconfig.json
new file mode 100644
index 0000000..d7f3323
--- /dev/null
+++ b/packages/onitama-game/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "noEmit": true,
+ "declaration": false,
+ "declarationMap": false,
+ "sourceMap": false
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/onitama-game/vite.config.ts b/packages/onitama-game/vite.config.ts
new file mode 100644
index 0000000..acb8595
--- /dev/null
+++ b/packages/onitama-game/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+import tailwindcss from '@tailwindcss/vite';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [preact(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 677ec14..2b7dd04 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -88,6 +88,46 @@ importers:
specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0)
+ packages/onitama-game:
+ dependencies:
+ '@preact/signals-core':
+ specifier: ^1.5.1
+ version: 1.14.1
+ boardgame-core:
+ specifier: link:../../../boardgame-core
+ version: link:../../../boardgame-core
+ boardgame-phaser:
+ specifier: workspace:*
+ version: link:../framework
+ mutative:
+ specifier: ^1.3.0
+ version: 1.3.0
+ phaser:
+ specifier: ^3.80.1
+ version: 3.90.0
+ preact:
+ specifier: ^10.19.3
+ version: 10.29.0
+ devDependencies:
+ '@preact/preset-vite':
+ specifier: ^2.8.1
+ version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
+ '@preact/signals':
+ specifier: ^2.9.0
+ version: 2.9.0(preact@10.29.0)
+ '@tailwindcss/vite':
+ specifier: ^4.0.0
+ version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
+ tailwindcss:
+ specifier: ^4.0.0
+ version: 4.2.2
+ typescript:
+ specifier: ^5.3.3
+ version: 5.9.3
+ vite:
+ specifier: ^5.1.0
+ version: 5.4.21(lightningcss@1.32.0)
+
packages/regicide-game:
dependencies:
'@preact/signals-core':