board-game-engine 0.0.11 → 1.0.3

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 +1,138 @@
1
- WIP object oriented board game engine with serializable game state for use in stateless backends
1
+ # board-game-engine
2
+
3
+ Runs games written with the early-in-development JSON-based B.A.G.E.L. (Board-based Automated Game Engine Language). Built upon [boardgame.io](https://boardgame.io/)
4
+
5
+ Currently supports enough rules flexibility to describe:
6
+ - Tic Tac Toe
7
+ - Checkers
8
+ - Othello
9
+ - 4-in-a-Row But There is Gravity
10
+ - Crazy Eights
11
+
12
+ [**B.A.G.E.L. docs:**](https://boardgameengine.com/docs/index.html) — reference for moves, conditions, values, shorthand, and examples.
13
+ [**board-game-engine-react**](https://github.com/mnbroatch/board-game-engine-react) — react component wrapping this repo
14
+ [**boardgameengine.com**](https://boardgameengine.com) — Main page
15
+ - Create custom games or edit
16
+ - Create multiplayer lobbies for playing custom B.A.G.E.L. games, also includes client-side editor sandbox. Maintained by author of board-game-engine
17
+
18
+ ---
19
+
20
+ ## Benefits
21
+
22
+ The B.A.G.E.L. Domain-Specific Langage:
23
+ - declarative
24
+ - safe because there is no custom code to run
25
+ - this means it would be hard for, say, bad LLM output to embed anything too nasty inside "user"-defined games
26
+ - complete enough to produce multiplayer web prototypes
27
+
28
+ Using B.A.G.E.L. in conjunction with this engine also enables UX features:
29
+ - client-side staging for multi-step moves
30
+ - You can for instance select a piece, then select a destination to put it at, all before committing the move
31
+ - Undo steps before commit
32
+ - Highlight currently-playable targets during a move
33
+
34
+ ---
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ npm install board-game-engine
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Public API
45
+
46
+
47
+ ### `Client`
48
+
49
+ A client that runs a B.A.G.E.L.-defined game
50
+
51
+ **Constructor: `new Client(options)`**
52
+
53
+ | Option | Type | Description |
54
+ |--------|------|-------------|
55
+ | `gameRules` | string | JSON string of the B.A.G.E.L. game definition |
56
+ | `numPlayers` | number | Number of players in client-side game. |
57
+ | `onClientUpdate` | function | Callback after state updates (e.g. to re-render UI). |
58
+ | `debug` | object | boardgame.io debug panel config; e.g. `false`. |
59
+
60
+ **Multiplayer** — For connecting to a remote game, see [Multiplayer](#multiplayer) below. Options such as `server` and `matchId` are passed through to the boardgame.io client; see the [boardgame.io Client API](https://boardgame.io/documentation/#/api/Client) for details.
61
+
62
+
63
+ **Methods**
64
+
65
+ - **`connect()`** — Connects to the game (local or server), starts the client, subscribes to updates. Returns `this`.
66
+ - **`getState()`** — Returns current state. See [getState() return value](#getstate-return-value) below.
67
+ - **`update()`** — Triggers `onClientUpdate` if set.
68
+ - **`doStep(target)`** — Applies one step of a multi-step move (e.g. “from” then “to”). For B.A.G.E.L. games only.
69
+ - **`undoStep()`** — Undoes the last step of the current move. B.A.G.E.L. games only.
70
+ - **`reset()`** — Clears the current move builder (targets/steps). B.A.G.E.L. games only.
71
+
72
+ **Properties**
73
+
74
+ - **`client`** — boardgame.io client instance
75
+ - **`moveBuilder`** — Mostly for internal use with multi-step moves but could inform UI
76
+
77
+ #### getState() return value
78
+
79
+ `Client` can run normal boardgame.io games, with limited features compared to B.A.G.E.L. games.
80
+
81
+ In non-B.A.G.E.L. games, `doStep(target)` is not used. instead, the boardgame.io client's moves object is returned from getState().
82
+
83
+ - `state` — current game state
84
+ - `gameover` — game-over result when the game has ended
85
+ - `allClickable` — B.A.G.E.L. games only. Clickable targets for the current step of the current move
86
+ - `moves` — non-B.A.G.E.L. games only. From boardgame.io client
87
+ - `currentMoves` — non-B.A.G.E.L. games only. `moves` object filtered to only contain current phase / stage moves. May break for complex turns.
88
+
89
+ **Example**
90
+
91
+ ```js
92
+ import { Client } from 'board-game-engine'
93
+
94
+ const client = new Client({
95
+ gameRules: JSON.stringify(myGameRules),
96
+ numPlayers: 2,
97
+ onClientUpdate: () => render(client.getState())
98
+ })
99
+
100
+ client.connect()
101
+ // Later, when user picks a target (e.g. a cell or piece):
102
+ client.doStep(target)
103
+ ```
104
+
105
+ #### Multiplayer
106
+
107
+ To connect to a remote match instead of running client-only, pass `server`, `matchId`, `playerID`, and `credentials`. When `credentials` is absent, the client runs in client-only mode.
108
+
109
+ Note: boardgame.io does not allow adding games to the server instance after it is instantiated. boardgameengine.com hacks around that, but your server will need to have access to any games you intend to play when you boot it up.
110
+
111
+
112
+ | Option | Type | Description |
113
+ |--------|------|-------------|
114
+ | `server` | string | URL of gameserver running boardgame.io server instance |
115
+ | `matchId` | string | Match ID. |
116
+ | `gameName` | string | Game name |
117
+ | `playerID` | string | Player ID (first player is `'0'`, next is `'1'`, etc.) |
118
+ | `credentials` | string | Credentials for your server to interpret |
119
+ | `multiplayer` | object | boardgame.io multiplayer transport. Defaults to `SocketIO({ server, socketOpts: { transports: ['websocket', 'polling'] } })`, (SocketIO is exported from `boardgame.io/multiplayer` |
120
+
121
+ All options are required, (only one of `server` or `multiplayer`). For full details on the client options and multiplayer setup, see the [boardgame.io Client API](https://boardgame.io/documentation/#/api/Client).
122
+
123
+ ---
124
+
125
+ ### `gameFactory(gameRules, gameName)`
126
+
127
+ Builds a boardgame.io-compatible game from a B.A.G.E.L. game definition. Useful for preloading games in server code (server games must be preloaded in boardgame.io)
128
+
129
+ ---
130
+
131
+ ## How it fits
132
+
133
+ 1. You write a B.A.G.E.L. game definition (JSON).
134
+ 2. **board-game-engine** turns it into a boardgame.io game
135
+ 3. The game runs with boardgame.io (local or server).
136
+ 4. `Client` wrapper adds client-side functionality like valid move highlighting
137
+
138
+ For the full language reference, examples, and getting started, see **[https://boardgameengine.com/docs/index.html](https://boardgameengine.com/docs/index.html)**.
@@ -27071,7 +27071,7 @@ function createPayload(bgioState, moveRule, targets, context) {
27071
27071
  var Client2 = class {
27072
27072
  constructor(options) {
27073
27073
  this.options = options;
27074
- this.game = options.boardgameIOGame || gameFactory(JSON.parse(options.gameRules), options.gameName);
27074
+ this.game = options.boardgameIOGame || gameFactory(JSON.parse(options.gameRules));
27075
27075
  if (!options.boardgameIOGame) {
27076
27076
  this.moveBuilder = { targets: [], stepIndex: 0, eliminatedMoves: [] };
27077
27077
  this.optimisticWinner = null;
@@ -27085,18 +27085,18 @@ var Client2 = class {
27085
27085
  collapseOnLoad: true,
27086
27086
  impl: Debug
27087
27087
  },
27088
- gameId,
27089
- boardgamePlayerID,
27090
- clientToken,
27091
- singlePlayer = !clientToken
27088
+ matchID,
27089
+ playerID,
27090
+ credentials,
27091
+ multiplayer = SocketIO({ server, socketOpts: { transports: ["websocket", "polling"] } })
27092
27092
  } = this.options;
27093
27093
  try {
27094
- const clientOptions = singlePlayer ? { game: this.game, numPlayers, debug } : {
27094
+ const clientOptions = !credentials ? { game: this.game, numPlayers, debug } : {
27095
27095
  game: this.game,
27096
- multiplayer: SocketIO({ server, socketOpts: { transports: ["websocket", "polling"] } }),
27097
- matchID: gameId,
27098
- playerID: boardgamePlayerID,
27099
- credentials: clientToken,
27096
+ multiplayer,
27097
+ matchID,
27098
+ playerID,
27099
+ credentials,
27100
27100
  debug
27101
27101
  };
27102
27102
  this.client = Client(clientOptions);
@@ -27112,46 +27112,45 @@ var Client2 = class {
27112
27112
  this.options.onClientUpdate?.();
27113
27113
  }
27114
27114
  getState() {
27115
- const clientState = this.client?.getState();
27116
- if (!clientState) return {};
27115
+ const bgioState = this.client?.getState();
27116
+ if (!bgioState) return {};
27117
+ const state = this.options.boardgameIOGame ? bgioState : {
27118
+ ...bgioState,
27119
+ G: deserialize(JSON.stringify(bgioState.G), registry)
27120
+ };
27121
+ const gameover = this.optimisticWinner ?? state?.ctx?.gameover;
27122
+ const currentMoves = gameover ? [] : getCurrentMoves(state, this.client);
27117
27123
  if (this.options.boardgameIOGame) {
27118
27124
  return {
27119
- state: clientState,
27120
- gameover: clientState?.ctx?.gameover,
27121
- moves: this.client.moves
27125
+ state,
27126
+ gameover,
27127
+ moves: this.client.moves,
27128
+ currentMoves
27122
27129
  };
27123
27130
  }
27124
- const state = {
27125
- ...clientState,
27126
- G: deserialize(JSON.stringify(clientState.G), registry),
27127
- originalG: clientState.G
27128
- };
27129
- const gameover = state?.ctx?.gameover;
27130
- const moves = !gameover ? Object.entries(getCurrentMoves(state, this.client)).reduce((acc, [moveName, rawMove]) => {
27131
+ const _wrappedMoves = Object.entries(currentMoves).reduce((acc, [moveName, rawMove]) => {
27131
27132
  const move = (payload) => {
27132
27133
  this.client.moves[moveName](preparePayload(payload));
27133
27134
  };
27134
27135
  move.moveInstance = rawMove.moveInstance;
27135
27136
  return { ...acc, [moveName]: move };
27136
- }, {}) : [];
27137
- const possibleMoves = getPossibleMoves(state, moves, this.moveBuilder);
27138
- const allClickable = possibleMoves.allClickable;
27139
- const possibleMoveMeta = possibleMoves.possibleMoveMeta;
27140
- return { state, gameover, moves, allClickable, possibleMoveMeta };
27137
+ }, {});
27138
+ const { allClickable, _possibleMoveMeta } = getPossibleMoves(state, _wrappedMoves, this.moveBuilder);
27139
+ return { state, gameover, allClickable, _wrappedMoves, _possibleMoveMeta };
27141
27140
  }
27142
27141
  doStep(_target) {
27143
27142
  if (this.options.boardgameIOGame) return;
27144
- const { state, moves, possibleMoveMeta } = this.getState();
27143
+ const { state, _wrappedMoves, _possibleMoveMeta } = this.getState();
27145
27144
  const target = _target.abstract ? _target : state.G.bank.locate(_target.entityId);
27146
- const newEliminated = Object.entries(possibleMoveMeta).filter(([_2, meta]) => !hasTarget(meta.clickableForMove, target)).map(([name]) => name).concat(this.moveBuilder.eliminatedMoves);
27147
- if (newEliminated.length === Object.keys(moves).length) {
27145
+ const newEliminated = Object.entries(_possibleMoveMeta).filter(([_2, meta]) => !hasTarget(meta.clickableForMove, target)).map(([name]) => name).concat(this.moveBuilder.eliminatedMoves);
27146
+ if (newEliminated.length === Object.keys(_wrappedMoves).length) {
27148
27147
  console.error("invalid move with target:", target?.rule);
27149
27148
  return;
27150
27149
  }
27151
- const remainingMoveEntries = Object.entries(possibleMoveMeta).filter(([name]) => !newEliminated.includes(name));
27152
- if (isMoveCompleted(state, moves, remainingMoveEntries, this.moveBuilder.stepIndex)) {
27150
+ const remainingMoveEntries = Object.entries(_possibleMoveMeta).filter(([name]) => !newEliminated.includes(name));
27151
+ if (isMoveCompleted(state, _wrappedMoves, remainingMoveEntries, this.moveBuilder.stepIndex)) {
27153
27152
  const [moveName] = remainingMoveEntries[0];
27154
- const move = moves[moveName];
27153
+ const move = _wrappedMoves[moveName];
27155
27154
  const payload = createPayload(
27156
27155
  state,
27157
27156
  move.moveInstance.rule,
@@ -27194,7 +27193,7 @@ function hasTarget(clickableSet, target) {
27194
27193
  }
27195
27194
  function getPossibleMoves(bgioState, moves, moveBuilder) {
27196
27195
  const { eliminatedMoves, stepIndex } = moveBuilder;
27197
- const possibleMoveMeta = {};
27196
+ const _possibleMoveMeta = {};
27198
27197
  const allClickable = /* @__PURE__ */ new Set();
27199
27198
  Object.entries(moves).filter(([moveName]) => !eliminatedMoves.includes(moveName)).forEach(([moveName, move]) => {
27200
27199
  const moveRule = resolveProperties(bgioState, { ...move.moveInstance.rule, moveName });
@@ -27212,10 +27211,10 @@ function getPossibleMoves(bgioState, moves, moveBuilder) {
27212
27211
  const clickableForMove = new Set(
27213
27212
  moveIsAllowed && moveSteps?.[stepIndex]?.getClickable(context) || []
27214
27213
  );
27215
- possibleMoveMeta[moveName] = { clickableForMove };
27214
+ _possibleMoveMeta[moveName] = { clickableForMove };
27216
27215
  clickableForMove.forEach((entity) => allClickable.add(entity));
27217
27216
  });
27218
- return { possibleMoveMeta, allClickable };
27217
+ return { _possibleMoveMeta, allClickable };
27219
27218
  }
27220
27219
  function isMoveCompleted(state, moves, remainingMoveEntries, stepIndex) {
27221
27220
  return remainingMoveEntries.length === 1 && getSteps(state, moves[remainingMoveEntries[0][0]].moveInstance.rule).length === stepIndex + 1;
@@ -27071,7 +27071,7 @@ ${message}`);
27071
27071
  var Client2 = class {
27072
27072
  constructor(options) {
27073
27073
  this.options = options;
27074
- this.game = options.boardgameIOGame || gameFactory(JSON.parse(options.gameRules), options.gameName);
27074
+ this.game = options.boardgameIOGame || gameFactory(JSON.parse(options.gameRules));
27075
27075
  if (!options.boardgameIOGame) {
27076
27076
  this.moveBuilder = { targets: [], stepIndex: 0, eliminatedMoves: [] };
27077
27077
  this.optimisticWinner = null;
@@ -27085,18 +27085,18 @@ ${message}`);
27085
27085
  collapseOnLoad: true,
27086
27086
  impl: Debug
27087
27087
  },
27088
- gameId,
27089
- boardgamePlayerID,
27090
- clientToken,
27091
- singlePlayer = !clientToken
27088
+ matchID,
27089
+ playerID,
27090
+ credentials,
27091
+ multiplayer = SocketIO({ server, socketOpts: { transports: ["websocket", "polling"] } })
27092
27092
  } = this.options;
27093
27093
  try {
27094
- const clientOptions = singlePlayer ? { game: this.game, numPlayers, debug } : {
27094
+ const clientOptions = !credentials ? { game: this.game, numPlayers, debug } : {
27095
27095
  game: this.game,
27096
- multiplayer: SocketIO({ server, socketOpts: { transports: ["websocket", "polling"] } }),
27097
- matchID: gameId,
27098
- playerID: boardgamePlayerID,
27099
- credentials: clientToken,
27096
+ multiplayer,
27097
+ matchID,
27098
+ playerID,
27099
+ credentials,
27100
27100
  debug
27101
27101
  };
27102
27102
  this.client = Client(clientOptions);
@@ -27112,46 +27112,45 @@ ${message}`);
27112
27112
  this.options.onClientUpdate?.();
27113
27113
  }
27114
27114
  getState() {
27115
- const clientState = this.client?.getState();
27116
- if (!clientState) return {};
27115
+ const bgioState = this.client?.getState();
27116
+ if (!bgioState) return {};
27117
+ const state = this.options.boardgameIOGame ? bgioState : {
27118
+ ...bgioState,
27119
+ G: deserialize(JSON.stringify(bgioState.G), registry)
27120
+ };
27121
+ const gameover = this.optimisticWinner ?? state?.ctx?.gameover;
27122
+ const currentMoves = gameover ? [] : getCurrentMoves(state, this.client);
27117
27123
  if (this.options.boardgameIOGame) {
27118
27124
  return {
27119
- state: clientState,
27120
- gameover: clientState?.ctx?.gameover,
27121
- moves: this.client.moves
27125
+ state,
27126
+ gameover,
27127
+ moves: this.client.moves,
27128
+ currentMoves
27122
27129
  };
27123
27130
  }
27124
- const state = {
27125
- ...clientState,
27126
- G: deserialize(JSON.stringify(clientState.G), registry),
27127
- originalG: clientState.G
27128
- };
27129
- const gameover = state?.ctx?.gameover;
27130
- const moves = !gameover ? Object.entries(getCurrentMoves(state, this.client)).reduce((acc, [moveName, rawMove]) => {
27131
+ const _wrappedMoves = Object.entries(currentMoves).reduce((acc, [moveName, rawMove]) => {
27131
27132
  const move = (payload) => {
27132
27133
  this.client.moves[moveName](preparePayload(payload));
27133
27134
  };
27134
27135
  move.moveInstance = rawMove.moveInstance;
27135
27136
  return { ...acc, [moveName]: move };
27136
- }, {}) : [];
27137
- const possibleMoves = getPossibleMoves(state, moves, this.moveBuilder);
27138
- const allClickable = possibleMoves.allClickable;
27139
- const possibleMoveMeta = possibleMoves.possibleMoveMeta;
27140
- return { state, gameover, moves, allClickable, possibleMoveMeta };
27137
+ }, {});
27138
+ const { allClickable, _possibleMoveMeta } = getPossibleMoves(state, _wrappedMoves, this.moveBuilder);
27139
+ return { state, gameover, allClickable, _wrappedMoves, _possibleMoveMeta };
27141
27140
  }
27142
27141
  doStep(_target) {
27143
27142
  if (this.options.boardgameIOGame) return;
27144
- const { state, moves, possibleMoveMeta } = this.getState();
27143
+ const { state, _wrappedMoves, _possibleMoveMeta } = this.getState();
27145
27144
  const target = _target.abstract ? _target : state.G.bank.locate(_target.entityId);
27146
- const newEliminated = Object.entries(possibleMoveMeta).filter(([_2, meta]) => !hasTarget(meta.clickableForMove, target)).map(([name]) => name).concat(this.moveBuilder.eliminatedMoves);
27147
- if (newEliminated.length === Object.keys(moves).length) {
27145
+ const newEliminated = Object.entries(_possibleMoveMeta).filter(([_2, meta]) => !hasTarget(meta.clickableForMove, target)).map(([name]) => name).concat(this.moveBuilder.eliminatedMoves);
27146
+ if (newEliminated.length === Object.keys(_wrappedMoves).length) {
27148
27147
  console.error("invalid move with target:", target?.rule);
27149
27148
  return;
27150
27149
  }
27151
- const remainingMoveEntries = Object.entries(possibleMoveMeta).filter(([name]) => !newEliminated.includes(name));
27152
- if (isMoveCompleted(state, moves, remainingMoveEntries, this.moveBuilder.stepIndex)) {
27150
+ const remainingMoveEntries = Object.entries(_possibleMoveMeta).filter(([name]) => !newEliminated.includes(name));
27151
+ if (isMoveCompleted(state, _wrappedMoves, remainingMoveEntries, this.moveBuilder.stepIndex)) {
27153
27152
  const [moveName] = remainingMoveEntries[0];
27154
- const move = moves[moveName];
27153
+ const move = _wrappedMoves[moveName];
27155
27154
  const payload = createPayload(
27156
27155
  state,
27157
27156
  move.moveInstance.rule,
@@ -27194,7 +27193,7 @@ ${message}`);
27194
27193
  }
27195
27194
  function getPossibleMoves(bgioState, moves, moveBuilder) {
27196
27195
  const { eliminatedMoves, stepIndex } = moveBuilder;
27197
- const possibleMoveMeta = {};
27196
+ const _possibleMoveMeta = {};
27198
27197
  const allClickable = /* @__PURE__ */ new Set();
27199
27198
  Object.entries(moves).filter(([moveName]) => !eliminatedMoves.includes(moveName)).forEach(([moveName, move]) => {
27200
27199
  const moveRule = resolveProperties(bgioState, { ...move.moveInstance.rule, moveName });
@@ -27212,10 +27211,10 @@ ${message}`);
27212
27211
  const clickableForMove = new Set(
27213
27212
  moveIsAllowed && moveSteps?.[stepIndex]?.getClickable(context) || []
27214
27213
  );
27215
- possibleMoveMeta[moveName] = { clickableForMove };
27214
+ _possibleMoveMeta[moveName] = { clickableForMove };
27216
27215
  clickableForMove.forEach((entity) => allClickable.add(entity));
27217
27216
  });
27218
- return { possibleMoveMeta, allClickable };
27217
+ return { _possibleMoveMeta, allClickable };
27219
27218
  }
27220
27219
  function isMoveCompleted(state, moves, remainingMoveEntries, stepIndex) {
27221
27220
  return remainingMoveEntries.length === 1 && getSteps(state, moves[remainingMoveEntries[0][0]].moveInstance.rule).length === stepIndex + 1;