Repository: boardgameio/boardgame.io (ts)

Issue:
Can't force `endTurn` on early moves, with `moveLimit` > 1
To reproduce, create a game with this in its declaration:

```javascript
turn: 
{
  moveLimit: 2,
},
```

And then in a move, fire the endTurn event with the `force` argument:

```javascript
ctx.events.endTurn( { force: true } );
```

If you take this move as your first action, it just prints a debug message saying eg `INFO: cannot end turn before making 2 moves`. I'm not sure if `force` isn't properly propagating through the event, as in `flows.ts` it does seem to have logic to allow forcing the turn to end early.

For context, I'm trying to build a game where players have two actions by default, but one of those actions should immediately end the turn if taken first.

Pull request description:
feat: deprecate `moveLimit` in favour of `minMoves`/`maxMoves`
- `EndTurn` now checks `minMoves` instead of `moveLimit` like before to determine if ending the turn is possible
- `EndStage` now checks `minMoves` similar to how `EndTurn` does, previously it seemed to have no such check; in contrast to `EndTurn` it does not use the phase config, but instead checks the resolved values directly to support usage of `minMoves` in the args of `setActivePlayers`
- `moveLimits` is kept as-is, to prevent breaking changes; a rename to `maxMoves` or introducing `maxMoves` as an alias while deprecating `moveLimits` may be done at a later stage

In most places, I tried to keep the usage as much in-line with `moveLimits` as possible. Notable exceptions:
- `moveLimits` is used for checking if a turn should be ended **automatically** (where `minMoves` is not)
- `minMoves` is used to determine if **manually** ending a turn or stage is possible (where `moveLimits` now has no relevance anymore).

As this is my first time doing a deep-dive into the codebase, please give a thorough sanity check to everything. I am especially unsure about the integration in `EndStage`, and possible repercussions of checking or not checking for `minMoves` there. Additionally I am not sure if there are more places where a check would be needed. 

I also noticed that `Process` in `flow.ts` does not check if it should auto-`EndStage` based on `moveLimits`, in contrast to the similar check for `EndTurn`. Is this on purpose?

#### Checklist

- [x] Use a separate branch in your local repo (not `main`).
- [x] Test coverage is 100%

---

Closes #934

Patch:
diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md
index 530f9c8..cc66ad6 100644
--- a/docs/documentation/api/Game.md
+++ b/docs/documentation/api/Game.md
@@ -64,8 +64,11 @@
     // Called at the end of each move.
     onMove: (G, ctx) => G,
 
+    // Prevents ending the turn before a minimum number of moves.
+    minMoves: 1,
+
     // Ends the turn automatically after a number of moves.
-    moveLimit: 1,
+    maxMoves: 1,
 
     // Calls setActivePlayers with this as argument at the
     // beginning of the turn.
diff --git a/docs/documentation/phases.md b/docs/documentation/phases.md
index d76fc43..7cfa801 100644
--- a/docs/documentation/phases.md
+++ b/docs/documentation/phases.md
@@ -33,7 +33,7 @@ function PlayCard(G, ctx) {
 const game = {
   setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }),
   moves: { DrawCard, PlayCard },
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 };
 ```
 
@@ -59,7 +59,7 @@ list of moves, which come into effect during that phase:
 ```js
 const game = {
   setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }),
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   phases: {
     draw: {
diff --git a/docs/documentation/stages.md b/docs/documentation/stages.md
index 2dab693..ca49df9 100644
--- a/docs/documentation/stages.md
+++ b/docs/documentation/stages.md
@@ -106,19 +106,23 @@ setActivePlayers({
     ...
   },
 
+  // Prevents manual endStage before the player
+  // has made the specified number of moves.
+  minMoves: 1,
+
   // Calls endStage automatically after the player
   // has made the specified number of moves.
-  moveLimit: 5,
+  maxMoves: 5,
 
   // This takes the stage configuration to the
   // value prior to this setActivePlayers call
   // once the set of active players becomes empty
   // (due to players either calling endStage or
-  // a moveLimit ending the stage for them).
+  // maxMoves ending the stage for them).
   revert: true,
 
   // A next option will be used once the set of active players
-  // becomes empty (either by using moveLimit or manually removing
+  // becomes empty (either by using maxMoves or manually removing
   // players).
   // All options available inside setActivePlayers are available
   // inside next.
@@ -131,7 +135,7 @@ require every other player to discard a card when we play one:
 
 ```js
 function PlayCard(G, ctx) {
-  ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 });
+  ctx.events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 });
 }
 
 const game = {
@@ -152,21 +156,41 @@ const game = {
 
 #### Advanced Move Limits
 
-Passing a `moveLimit` argument to `setActivePlayers` limits all the
+Passing a `minMoves` argument to `setActivePlayers` forces all the
+active players to make at least that number of moves before being able to
+end the stage, but sometimes you might want to set different move limits 
+for different players. For cases like this, `setStage` and `setActivePlayers` 
+support long-form arguments:
+
+```js
+setStage({ stage: 'stage-name', minMoves: 3 });
+```
+
+```js
+setActivePlayers({
+  currentPlayer: { stage: 'stage-name', minMoves: 2 },
+  others: { stage: 'stage-name', minMoves: 1 },
+  value: {
+    '0': { stage: 'stage-name', minMoves: 4 },
+  },
+});
+```
+
+Passing a `maxMoves` argument to `setActivePlayers` limits all the
 active players to making that number of moves, but sometimes you might want
 to set different move limits for different players. For cases like this,
 `setStage` and `setActivePlayers` support long-form arguments:
 
 ```js
-setStage({ stage: 'stage-name', moveLimit: 3 });
+setStage({ stage: 'stage-name', maxMoves: 3 });
 ```
 
 ```js
 setActivePlayers({
-  currentPlayer: { stage: 'stage-name', moveLimit: 2 },
-  others: { stage: 'stage-name', moveLimit: 1 },
+  currentPlayer: { stage: 'stage-name', maxMoves: 2 },
+  others: { stage: 'stage-name', maxMoves: 1 },
   value: {
-    '0': { stage: 'stage-name', moveLimit: 4 },
+    '0': { stage: 'stage-name', maxMoves: 4 },
   },
 });
 ```
@@ -230,7 +254,7 @@ aren't restricted to any particular stage.
 
 #### ALL_ONCE
 
-Equivalent to `{ all: Stage.NULL, moveLimit: 1 }`. Any player can make
+Equivalent to `{ all: Stage.NULL, minMoves: 1, maxMoves: 1 }`. Any player can make
 exactly one move before they are removed from the set of active players.
 
 #### OTHERS
diff --git a/docs/documentation/tutorial.md b/docs/documentation/tutorial.md
index a9e0551..3cb93d0 100644
--- a/docs/documentation/tutorial.md
+++ b/docs/documentation/tutorial.md
@@ -287,16 +287,18 @@ because a player could choose when to end their turn, but in
 Tic-Tac-Toe we know that the turn should always end when a move is made.
 
 There are several different ways to manage turns in boardgame.io.
-We’ll use the `moveLimit` option in our game definition to tell
+We’ll use the `maxMoves` option in our game definition to tell
 the framework to automatically end a player’s turn after a single
-move has been made.
+move has been made, as well as the `minMoves` option, so players
+*have* to make a move and can't just `endTurn`.
 
 ```js
 export const TicTacToe = {
   setup: () => { /* ... */ },
 
   turn: {
-    moveLimit: 1,
+    minMoves: 1,
+    maxMoves: 1,
   },
 
   moves: { /* ... */ },
diff --git a/examples/react-native/game.js b/examples/react-native/game.js
index 5dc0a4f..787d995 100644
--- a/examples/react-native/game.js
+++ b/examples/react-native/game.js
@@ -52,7 +52,7 @@ const TicTacToe = {
     },
   },
 
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   endIf: (G, ctx) => {
     if (IsVictory(G.cells)) {
diff --git a/examples/react-web/src/chess/game.js b/examples/react-web/src/chess/game.js
index 0862e44..d2c5a2b 100644
--- a/examples/react-web/src/chess/game.js
+++ b/examples/react-web/src/chess/game.js
@@ -40,7 +40,7 @@ const ChessGame = {
     },
   },
 
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   endIf: (G) => {
     const chess = Load(G.pgn);
diff --git a/examples/react-web/src/simulator/example-others-once.js b/examples/react-web/src/simulator/example-others-once.js
index 5207256..a442e95 100644
--- a/examples/react-web/src/simulator/example-others-once.js
+++ b/examples/react-web/src/simulator/example-others-once.js
@@ -13,7 +13,8 @@ const code = `{
     play: (G, ctx) => {
       ctx.events.setActivePlayers({
         others: 'discard',
-        moveLimit: 1
+        minMoves: 1, 
+        maxMoves: 1,
       });
       return G;
     },
@@ -47,7 +48,11 @@ export default {
 
     moves: {
       play: (G, ctx) => {
-        ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 });
+        ctx.events.setActivePlayers({
+          others: 'discard',
+          minMoves: 1,
+          maxMoves: 1,
+        });
         return G;
       },
     },
diff --git a/examples/react-web/src/tic-tac-toe/game.js b/examples/react-web/src/tic-tac-toe/game.js
index 4f7d121..84603b2 100644
--- a/examples/react-web/src/tic-tac-toe/game.js
+++ b/examples/react-web/src/tic-tac-toe/game.js
@@ -45,7 +45,8 @@ const TicTacToe = {
   },
 
   turn: {
-    moveLimit: 1,
+    minMoves: 1,
+    maxMoves: 1,
   },
 
   endIf: (G, ctx) => {
diff --git a/examples/snippets/src/example-2/index.js b/examples/snippets/src/example-2/index.js
index b3d2837..cc3dbdf 100644
--- a/examples/snippets/src/example-2/index.js
+++ b/examples/snippets/src/example-2/index.js
@@ -36,7 +36,7 @@ const TicTacToe = {
     },
   },
 
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   endIf: (G, ctx) => {
     if (IsVictory(G.cells)) {
diff --git a/examples/snippets/src/example-3/index.js b/examples/snippets/src/example-3/index.js
index bc9d40d..34f4b7a 100644
--- a/examples/snippets/src/example-3/index.js
+++ b/examples/snippets/src/example-3/index.js
@@ -36,7 +36,7 @@ const TicTacToe = {
     },
   },
 
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   endIf: (G, ctx) => {
     if (IsVictory(G.cells)) {
diff --git a/examples/snippets/src/multiplayer/index.js b/examples/snippets/src/multiplayer/index.js
index 14a8962..4fdfc21 100644
--- a/examples/snippets/src/multiplayer/index.js
+++ b/examples/snippets/src/multiplayer/index.js
@@ -45,7 +45,7 @@ const TicTacToe = {
     },
   },
 
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   endIf: (G, ctx) => {
     if (IsVictory(G.cells)) {
diff --git a/examples/snippets/src/phases-1/game.js b/examples/snippets/src/phases-1/game.js
index 448bbad..f5255ee 100644
--- a/examples/snippets/src/phases-1/game.js
+++ b/examples/snippets/src/phases-1/game.js
@@ -11,7 +11,7 @@ function PlayCard(G, ctx) {
 const game = {
   setup: (ctx) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }),
   moves: { DrawCard, PlayCard },
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 };
 
 export default game;
diff --git a/examples/snippets/src/phases-2/game.js b/examples/snippets/src/phases-2/game.js
index 6caecb7..631cc14 100644
--- a/examples/snippets/src/phases-2/game.js
+++ b/examples/snippets/src/phases-2/game.js
@@ -23,7 +23,7 @@ const game = {
       endIf: (G) => G.deck >= 6,
     },
   },
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 };
 
 export default game;
diff --git a/examples/snippets/src/stages-1/game.js b/examples/snippets/src/stages-1/game.js
index 23d94a1..328bc6d 100644
--- a/examples/snippets/src/stages-1/game.js
+++ b/examples/snippets/src/stages-1/game.js
@@ -1,5 +1,5 @@
 function militia(G, ctx) {
-  ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 });
+  ctx.events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 });
 }
 
 function discard(G, ctx) {}
diff --git a/integration/src/game.js b/integration/src/game.js
index f23cdf9..eae0bca 100644
--- a/integration/src/game.js
+++ b/integration/src/game.js
@@ -52,14 +52,15 @@ const TicTacToe = {
   },
 
   turn: {
-    moveLimit: 1,
+    minMoves: 1,
+    maxMoves: 1,
   },
 
   endIf: (G, ctx) => {
     if (IsVictory(G.cells)) {
       return { winner: ctx.currentPlayer };
     }
-    if (G.cells.filter(c => c === null).length === 0) {
+    if (G.cells.filter((c) => c === null).length === 0) {
       return { draw: true };
     }
   },
diff --git a/src/core/backwards-compatibility.ts b/src/core/backwards-compatibility.ts
new file mode 100644
index 0000000..77ac17b
--- /dev/null
+++ b/src/core/backwards-compatibility.ts
@@ -0,0 +1,23 @@
+type MoveLimitOptions = {
+  minMoves?: number;
+  maxMoves?: number;
+  moveLimit?: number;
+};
+
+/**
+ * Adjust the given options to use the new minMoves/maxMoves if a legacy moveLimit was given
+ * @param options The options object to apply backwards compatibility to
+ * @param enforceMinMoves Use moveLimit to set both minMoves and maxMoves
+ */
+export function supportDeprecatedMoveLimit(
+  options: MoveLimitOptions,
+  enforceMinMoves = false
+) {
+  if (options.moveLimit) {
+    if (enforceMinMoves) {
+      options.minMoves = options.moveLimit;
+    }
+    options.maxMoves = options.moveLimit;
+    delete options.moveLimit;
+  }
+}
diff --git a/src/core/flow.ts b/src/core/flow.ts
index 18c8ca5..fe6ad8f 100644
--- a/src/core/flow.ts
+++ b/src/core/flow.ts
@@ -30,6 +30,7 @@ import type {
   Move,
 } from '../types';
 import { GameMethod } from './game-methods';
+import { supportDeprecatedMoveLimit } from './backwards-compatibility';
 
 /**
  * Flow
@@ -145,6 +146,9 @@ export function Flow({
       phaseConfig.turn.stages = {};
     }
 
+    // turns previously treated moveLimit as both minMoves and maxMoves, this behaviour is kept intentionally
+    supportDeprecatedMoveLimit(phaseConfig.turn, true);
+
     for (const stage in phaseConfig.turn.stages) {
       const stageConfig = phaseConfig.turn.stages[stage];
       const moves = stageConfig.moves || {};
@@ -366,9 +370,17 @@ export function Flow({
     }
     if (typeof arg !== 'object') return state;
 
+    // `arg` should be of type `StageArg`, loose typing as `any` here for historic reasons
+    // stages previously did not enforce minMoves, this behaviour is kept intentionally
+    supportDeprecatedMoveLimit(arg);
+
     let { ctx } = state;
-    let { activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves } =
-      ctx;
+    let {
+      activePlayers,
+      _activePlayersMinMoves,
+      _activePlayersMaxMoves,
+      _activePlayersNumMoves,
+    } = ctx;
 
     // Checking if stage is valid, even Stage.NULL
     if (arg.stage !== undefined) {
@@ -378,18 +390,26 @@ export function Flow({
       activePlayers[playerID] = arg.stage;
       _activePlayersNumMoves[playerID] = 0;
 
-      if (arg.moveLimit) {
-        if (_activePlayersMoveLimit === null) {
-          _activePlayersMoveLimit = {};
+      if (arg.minMoves) {
+        if (_activePlayersMinMoves === null) {
+          _activePlayersMinMoves = {};
         }
-        _activePlayersMoveLimit[playerID] = arg.moveLimit;
+        _activePlayersMinMoves[playerID] = arg.minMoves;
+      }
+
+      if (arg.maxMoves) {
+        if (_activePlayersMaxMoves === null) {
+          _activePlayersMaxMoves = {};
+        }
+        _activePlayersMaxMoves[playerID] = arg.maxMoves;
       }
     }
 
     ctx = {
       ...ctx,
       activePlayers,
-      _activePlayersMoveLimit,
+      _activePlayersMinMoves,
+      _activePlayersMaxMoves,
       _activePlayersNumMoves,
     };
 
@@ -419,8 +439,8 @@ export function Flow({
     // End the turn if the required number of moves has been made.
     const currentPlayerMoves = state.ctx.numMoves || 0;
     if (
-      phaseConfig.turn.moveLimit &&
-      currentPlayerMoves >= phaseConfig.turn.moveLimit
+      phaseConfig.turn.maxMoves &&
+      currentPlayerMoves >= phaseConfig.turn.maxMoves
     ) {
       return true;
     }
@@ -496,15 +516,15 @@ export function Flow({
     const { currentPlayer, numMoves, phase, turn } = state.ctx;
     const phaseConfig = GetPhase(state.ctx);
 
-    // Prevent ending the turn if moveLimit hasn't been reached.
+    // Prevent ending the turn if minMoves haven't been reached.
     const currentPlayerMoves = numMoves || 0;
     if (
       !force &&
-      phaseConfig.turn.moveLimit &&
-      currentPlayerMoves < phaseConfig.turn.moveLimit
+      phaseConfig.turn.minMoves &&
+      currentPlayerMoves < phaseConfig.turn.minMoves
     ) {
       logging.info(
-        `cannot end turn before making ${phaseConfig.turn.moveLimit} moves`
+        `cannot end turn before making ${phaseConfig.turn.minMoves} moves`
       );
       return state;
     }
@@ -554,14 +574,24 @@ export function Flow({
     playerID = playerID || state.ctx.currentPlayer;
 
     let { ctx, _stateID } = state;
-    let { activePlayers, _activePlayersMoveLimit, phase, turn } = ctx;
+    let {
+      activePlayers,
+      _activePlayersNumMoves,
+      _activePlayersMinMoves,
+      _activePlayersMaxMoves,
+      phase,
+      turn,
+    } = ctx;
 
     const playerInStage = activePlayers !== null && playerID in activePlayers;
 
+    const phaseConfig = GetPhase(ctx);
+
     if (!arg && playerInStage) {
-      const phaseConfig = GetPhase(ctx);
       const stage = phaseConfig.turn.stages[activePlayers[playerID]];
-      if (stage && stage.next) arg = stage.next;
+      if (stage && stage.next) {
+        arg = stage.next;
+      }
     }
 
     // Checking if arg is a valid stage, even Stage.NULL
@@ -572,20 +602,40 @@ export function Flow({
     // If player isn’t in a stage, there is nothing else to do.
     if (!playerInStage) return state;
 
+    // Prevent ending the stage if minMoves haven't been reached.
+    const currentPlayerMoves = _activePlayersNumMoves[playerID] || 0;
+    if (
+      _activePlayersMinMoves &&
+      _activePlayersMinMoves[playerID] &&
+      currentPlayerMoves < _activePlayersMinMoves[playerID]
+    ) {
+      logging.info(
+        `cannot end stage before making ${_activePlayersMinMoves[playerID]} moves`
+      );
+      return state;
+    }
+
     // Remove player from activePlayers.
     activePlayers = { ...activePlayers };
     delete activePlayers[playerID];
 
-    if (_activePlayersMoveLimit) {
-      // Remove player from _activePlayersMoveLimit.
-      _activePlayersMoveLimit = { ..._activePlayersMoveLimit };
-      delete _activePlayersMoveLimit[playerID];
+    if (_activePlayersMinMoves) {
+      // Remove player from _activePlayersMinMoves.
+      _activePlayersMinMoves = { ..._activePlayersMinMoves };
+      delete _activePlayersMinMoves[playerID];
+    }
+
+    if (_activePlayersMaxMoves) {
+      // Remove player from _activePlayersMaxMoves.
+      _activePlayersMaxMoves = { ..._activePlayersMaxMoves };
+      delete _activePlayersMaxMoves[playerID];
     }
 
     ctx = UpdateActivePlayersOnceEmpty({
       ...ctx,
       activePlayers,
-      _activePlayersMoveLimit,
+      _activePlayersMinMoves,
+      _activePlayersMaxMoves,
     });
 
     // Create log entry.
@@ -649,7 +699,7 @@ export function Flow({
   function ProcessMove(state: State, action: ActionPayload.MakeMove): State {
     const { playerID, type } = action;
     const { ctx } = state;
-    const { currentPlayer, activePlayers, _activePlayersMoveLimit } = ctx;
+    const { currentPlayer, activePlayers, _activePlayersMaxMoves } = ctx;
     const move = GetMove(ctx, type, playerID);
     const shouldCount =
       !move || typeof move === 'function' || move.noLimit !== true;
@@ -670,8 +720,8 @@ export function Flow({
     };
 
     if (
-      _activePlayersMoveLimit &&
-      _activePlayersNumMoves[playerID] >= _activePlayersMoveLimit[playerID]
+      _activePlayersMaxMoves &&
+      _activePlayersNumMoves[playerID] >= _activePlayersMaxMoves[playerID]
     ) {
       state = EndStage(state, { playerID, automatic: true });
     }
diff --git a/src/core/turn-order.ts b/src/core/turn-order.ts
index 8fb89fe..acc1a33 100644
--- a/src/core/turn-order.ts
+++ b/src/core/turn-order.ts
@@ -16,6 +16,7 @@ import type {
   State,
   TurnConfig,
 } from '../types';
+import { supportDeprecatedMoveLimit } from './backwards-compatibility';
 
 export function SetActivePlayers(
   ctx: Ctx,
@@ -24,7 +25,8 @@ export function SetActivePlayers(
   let activePlayers: typeof ctx.activePlayers = {};
   let _prevActivePlayers: typeof ctx._prevActivePlayers = [];
   let _nextActivePlayers: ActivePlayersArg | null = null;
-  let _activePlayersMoveLimit = {};
+  let _activePlayersMinMoves = {};
+  let _activePlayersMaxMoves = {};
 
   if (Array.isArray(arg)) {
     // support a simple array of player IDs as active players
@@ -33,6 +35,10 @@ export function SetActivePlayers(
     activePlayers = value;
   } else {
     // process active players argument object
+
+    // stages previously did not enforce minMoves, this behaviour is kept intentionally
+    supportDeprecatedMoveLimit(arg);
+
     if (arg.next) {
       _nextActivePlayers = arg.next;
     }
@@ -42,7 +48,8 @@ export function SetActivePlayers(
         ...ctx._prevActivePlayers,
         {
           activePlayers: ctx.activePlayers,
-          _activePlayersMoveLimit: ctx._activePlayersMoveLimit,
+          _activePlayersMinMoves: ctx._activePlayersMinMoves,
+          _activePlayersMaxMoves: ctx._activePlayersMaxMoves,
           _activePlayersNumMoves: ctx._activePlayersNumMoves,
         },
       ];
@@ -51,7 +58,8 @@ export function SetActivePlayers(
     if (arg.currentPlayer !== undefined) {
       ApplyActivePlayerArgument(
         activePlayers,
-        _activePlayersMoveLimit,
+        _activePlayersMinMoves,
+        _activePlayersMaxMoves,
         ctx.currentPlayer,
         arg.currentPlayer
       );
@@ -63,7 +71,8 @@ export function SetActivePlayers(
         if (id !== ctx.currentPlayer) {
           ApplyActivePlayerArgument(
             activePlayers,
-            _activePlayersMoveLimit,
+            _activePlayersMinMoves,
+            _activePlayersMaxMoves,
             id,
             arg.others
           );
@@ -76,7 +85,8 @@ export function SetActivePlayers(
         const id = ctx.playOrder[i];
         ApplyActivePlayerArgument(
           activePlayers,
-          _activePlayersMoveLimit,
+          _activePlayersMinMoves,
+          _activePlayersMaxMoves,
           id,
           arg.all
         );
@@ -87,17 +97,26 @@ export function SetActivePlayers(
       for (const id in arg.value) {
         ApplyActivePlayerArgument(
           activePlayers,
-          _activePlayersMoveLimit,
+          _activePlayersMinMoves,
+          _activePlayersMaxMoves,
           id,
           arg.value[id]
         );
       }
     }
 
-    if (arg.moveLimit) {
+    if (arg.minMoves) {
+      for (const id in activePlayers) {
+        if (_activePlayersMinMoves[id] === undefined) {
+          _activePlayersMinMoves[id] = arg.minMoves;
+        }
+      }
+    }
+
+    if (arg.maxMoves) {
       for (const id in activePlayers) {
-        if (_activePlayersMoveLimit[id] === undefined) {
-          _activePlayersMoveLimit[id] = arg.moveLimit;
+        if (_activePlayersMaxMoves[id] === undefined) {
+          _activePlayersMaxMoves[id] = arg.maxMoves;
         }
       }
     }
@@ -107,8 +126,12 @@ export function SetActivePlayers(
     activePlayers = null;
   }
 
-  if (Object.keys(_activePlayersMoveLimit).length === 0) {
-    _activePlayersMoveLimit = null;
+  if (Object.keys(_activePlayersMinMoves).length === 0) {
+    _activePlayersMinMoves = null;
+  }
+
+  if (Object.keys(_activePlayersMaxMoves).length === 0) {
+    _activePlayersMaxMoves = null;
   }
 
   const _activePlayersNumMoves = {};
@@ -119,7 +142,8 @@ export function SetActivePlayers(
   return {
     ...ctx,
     activePlayers,
-    _activePlayersMoveLimit,
+    _activePlayersMinMoves,
+    _activePlayersMaxMoves,
     _activePlayersNumMoves,
     _prevActivePlayers,
     _nextActivePlayers,
@@ -134,7 +158,8 @@ export function SetActivePlayers(
 export function UpdateActivePlayersOnceEmpty(ctx: Ctx) {
   let {
     activePlayers,
-    _activePlayersMoveLimit,
+    _activePlayersMinMoves,
+    _activePlayersMaxMoves,
     _activePlayersNumMoves,
     _prevActivePlayers,
     _nextActivePlayers,
@@ -145,25 +170,32 @@ export function UpdateActivePlayersOnceEmpty(ctx: Ctx) {
       ctx = SetActivePlayers(ctx, _nextActivePlayers);
       ({
         activePlayers,
-        _activePlayersMoveLimit,
+        _activePlayersMinMoves,
+        _activePlayersMaxMoves,
         _activePlayersNumMoves,
         _prevActivePlayers,
       } = ctx);
     } else if (_prevActivePlayers.length > 0) {
       const lastIndex = _prevActivePlayers.length - 1;
-      ({ activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves } =
-        _prevActivePlayers[lastIndex]);
+      ({
+        activePlayers,
+        _activePlayersMinMoves,
+        _activePlayersMaxMoves,
+        _activePlayersNumMoves,
+      } = _prevActivePlayers[lastIndex]);
       _prevActivePlayers = _prevActivePlayers.slice(0, lastIndex);
     } else {
       activePlayers = null;
-      _activePlayersMoveLimit = null;
+      _activePlayersMinMoves = null;
+      _activePlayersMaxMoves = null;
     }
   }
 
   return {
     ...ctx,
     activePlayers,
-    _activePlayersMoveLimit,
+    _activePlayersMinMoves,
+    _activePlayersMaxMoves,
     _activePlayersNumMoves,
     _prevActivePlayers,
   };
@@ -172,13 +204,15 @@ export function UpdateActivePlayersOnceEmpty(ctx: Ctx) {
 /**
  * Apply an active player argument to the given player ID
  * @param {Object} activePlayers
- * @param {Object} _activePlayersMoveLimit
+ * @param {Object} _activePlayersMinMoves
+ * @param {Object} _activePlayersMaxMoves
  * @param {String} playerID The player to apply the parameter to
  * @param {(String|Object)} arg An active player argument
  */
 function ApplyActivePlayerArgument(
   activePlayers: Ctx['activePlayers'],
-  _activePlayersMoveLimit: Ctx['_activePlayersMoveLimit'],
+  _activePlayersMinMoves: Ctx['_activePlayersMinMoves'],
+  _activePlayersMaxMoves: Ctx['_activePlayersMaxMoves'],
   playerID: PlayerID,
   arg: StageArg
 ) {
@@ -187,8 +221,12 @@ function ApplyActivePlayerArgument(
   }
 
   if (arg.stage !== undefined) {
+    // stages previously did not enforce minMoves, this behaviour is kept intentionally
+    supportDeprecatedMoveLimit(arg);
+
     activePlayers[playerID] = arg.stage;
-    if (arg.moveLimit) _activePlayersMoveLimit[playerID] = arg.moveLimit;
+    if (arg.minMoves) _activePlayersMinMoves[playerID] = arg.minMoves;
+    if (arg.maxMoves) _activePlayersMaxMoves[playerID] = arg.maxMoves;
   }
 }
 
@@ -414,7 +452,7 @@ export const ActivePlayers = {
    * This is typically used in a phase where you want to elicit a response
    * from every player in the game.
    */
-  ALL_ONCE: { all: Stage.NULL, moveLimit: 1 },
+  ALL_ONCE: { all: Stage.NULL, minMoves: 1, maxMoves: 1 },
 
   /**
    * OTHERS
@@ -431,5 +469,5 @@ export const ActivePlayers = {
    * This is typically used in a phase where you want to elicit a response
    * from every *other* player in the game.
    */
-  OTHERS_ONCE: { others: Stage.NULL, moveLimit: 1 },
+  OTHERS_ONCE: { others: Stage.NULL, minMoves: 1, maxMoves: 1 },
 };
diff --git a/src/types.ts b/src/types.ts
index e9508be..b9837a8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -59,13 +59,24 @@ export type PartialGameState = Pick<State, 'G' | 'ctx' | 'plugins'>;
 export type StageName = string;
 export type PlayerID = string;
 
-export type StageArg = StageName | { stage?: StageName; moveLimit?: number };
+export type StageArg =
+  | StageName
+  | {
+      stage?: StageName;
+      /** @deprecated Use `minMoves` and `maxMoves` instead. */
+      moveLimit?: number;
+      minMoves?: number;
+      maxMoves?: number;
+    };
 
 export interface ActivePlayersArg {
   currentPlayer?: StageArg;
   others?: StageArg;
   all?: StageArg;
   value?: Record<PlayerID, StageArg>;
+  minMoves?: number;
+  maxMoves?: number;
+  /** @deprecated Use `minMoves` and `maxMoves` instead. */
   moveLimit?: number;
   revert?: boolean;
   next?: ActivePlayersArg;
@@ -86,11 +97,13 @@ export interface Ctx {
   gameover?: any;
   turn: number;
   phase: string;
-  _activePlayersMoveLimit?: Record<PlayerID, number>;
+  _activePlayersMinMoves?: Record<PlayerID, number>;
+  _activePlayersMaxMoves?: Record<PlayerID, number>;
   _activePlayersNumMoves?: Record<PlayerID, number>;
   _prevActivePlayers?: Array<{
     activePlayers: null | ActivePlayers;
-    _activePlayersMoveLimit?: Record<PlayerID, number>;
+    _activePlayersMinMoves?: Record<PlayerID, number>;
+    _activePlayersMaxMoves?: Record<PlayerID, number>;
     _activePlayersNumMoves?: Record<PlayerID, number>;
   }>;
   _nextActivePlayers?: ActivePlayersArg;
@@ -256,6 +269,9 @@ export interface TurnConfig<
   CtxWithPlugins extends Ctx = Ctx
 > {
   activePlayers?: ActivePlayersArg;
+  minMoves?: number;
+  maxMoves?: number;
+  /** @deprecated Use `minMoves` and `maxMoves` instead. */
   moveLimit?: number;
   onBegin?: (G: G, ctx: CtxWithPlugins) => any;
   onEnd?: (G: G, ctx: CtxWithPlugins) => any;


Test patch:
diff --git a/src/ai/ai.test.ts b/src/ai/ai.test.ts
index 89e303b..ae21c12 100644
--- a/src/ai/ai.test.ts
+++ b/src/ai/ai.test.ts
@@ -53,7 +53,7 @@ const TicTacToe = ProcessGameConfig({
     },
   },
 
-  turn: { moveLimit: 1 },
+  turn: { minMoves: 1, maxMoves: 1 },
 
   endIf: (G, ctx) => {
     if (IsVictory(G.cells)) {
diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts
index b36ed62..e82be57 100644
--- a/src/core/flow.test.ts
+++ b/src/core/flow.test.ts
@@ -241,69 +241,222 @@ describe('turn', () => {
     }
   });
 
-  describe('moveLimit', () => {
-    test('without phases', () => {
+  describe('minMoves', () => {
+    describe('without phases', () => {
       const flow = Flow({
         turn: {
-          moveLimit: 2,
+          minMoves: 2,
         },
       });
-      let state = flow.init({ ctx: flow.ctx(2) } as State);
-      expect(state.ctx.turn).toBe(1);
-      state = flow.processMove(state, makeMove('move', null, '0').payload);
-      expect(state.ctx.turn).toBe(1);
-      state = flow.processEvent(state, gameEvent('endTurn'));
-      expect(state.ctx.turn).toBe(1);
-      state = flow.processMove(state, makeMove('move', null, '0').payload);
-      expect(state.ctx.turn).toBe(2);
+
+      test('player cannot endTurn if not enough moves were made', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+      });
+
+      test('player can endTurn after enough moves were made', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
     });
 
-    test('with phases', () => {
+    describe('with phases', () => {
       const flow = Flow({
-        turn: { moveLimit: 2 },
+        turn: { minMoves: 2 },
         phases: {
           B: {
             turn: {
-              moveLimit: 1,
+              minMoves: 1,
             },
           },
         },
       });
-      let state = flow.init({ ctx: flow.ctx(2) } as State);
 
-      expect(state.ctx.turn).toBe(1);
-      expect(state.ctx.currentPlayer).toBe('0');
+      test('player cannot endTurn if not enough moves were made in default phase', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
 
-      state = flow.processMove(state, makeMove('move', null, '0').payload);
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
 
-      expect(state.ctx.turn).toBe(1);
-      expect(state.ctx.currentPlayer).toBe('0');
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
 
-      state = flow.processEvent(state, gameEvent('endTurn'));
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+      });
 
-      expect(state.ctx.turn).toBe(1);
-      expect(state.ctx.currentPlayer).toBe('0');
+      test('player can endTurn after enough moves were made in default phase', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
 
-      state = flow.processMove(state, makeMove('move', null, '0').payload);
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
 
-      expect(state.ctx.turn).toBe(2);
-      expect(state.ctx.currentPlayer).toBe('1');
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
 
-      state = flow.processEvent(state, gameEvent('setPhase', 'B'));
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
 
-      expect(state.ctx.phase).toBe('B');
-      expect(state.ctx.turn).toBe(3);
-      expect(state.ctx.currentPlayer).toBe('0');
+      test('player cannot endTurn if no move was made in explicit phase', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
 
-      state = flow.processMove(state, makeMove('move', null, '0').payload);
-      expect(state.ctx.turn).toBe(4);
-      expect(state.ctx.currentPlayer).toBe('1');
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+        state = flow.processMove(state, makeMove('move', null, '1').payload);
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+
+        state = flow.processEvent(state, gameEvent('setPhase', 'B'));
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(3);
+        expect(state.ctx.currentPlayer).toBe('0');
+      });
+
+      test('player can endTurn after having made a move, fewer moves needed in explicit phase', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+        state = flow.processMove(state, makeMove('move', null, '1').payload);
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+
+        state = flow.processEvent(state, gameEvent('setPhase', 'B'));
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(4);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
+    });
+  });
+
+  describe('maxMoves', () => {
+    describe('without phases', () => {
+      const flow = Flow({
+        turn: {
+          maxMoves: 2,
+        },
+      });
+
+      test('manual endTurn works, even if not enough moves were made', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
+
+      test('turn automatically ends after making enough moves', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
+    });
+
+    describe('with phases', () => {
+      const flow = Flow({
+        turn: { maxMoves: 2 },
+        phases: {
+          B: {
+            turn: { maxMoves: 1 },
+          },
+        },
+      });
+
+      test('manual endTurn works in all phases, even if fewer than maxMoves have been made', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+
+        state = flow.processEvent(state, gameEvent('setPhase', 'B'));
+
+        expect(state.ctx.turn).toBe(3);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processEvent(state, gameEvent('endTurn'));
+
+        expect(state.ctx.turn).toBe(4);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
+
+      test('automatic endTurn triggers after fewer moves in different phase', () => {
+        let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+        expect(state.ctx.turn).toBe(1);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+
+        expect(state.ctx.turn).toBe(2);
+        expect(state.ctx.currentPlayer).toBe('1');
+
+        state = flow.processEvent(state, gameEvent('setPhase', 'B'));
+
+        expect(state.ctx.turn).toBe(3);
+        expect(state.ctx.currentPlayer).toBe('0');
+
+        state = flow.processMove(state, makeMove('move', null, '0').payload);
+
+        expect(state.ctx.turn).toBe(4);
+        expect(state.ctx.currentPlayer).toBe('1');
+      });
     });
 
     test('with noLimit moves', () => {
       const flow = Flow({
         turn: {
-          moveLimit: 2,
+          maxMoves: 2,
         },
         moves: {
           A: () => {},
@@ -522,7 +675,7 @@ describe('stages', () => {
         turn: {
           activePlayers: {
             currentPlayer: 'A',
-            moveLimit: 1,
+            maxMoves: 1,
           },
           endIf: (G, ctx) => ctx.activePlayers === null,
           stages: {
@@ -569,7 +722,7 @@ describe('stage events', () => {
     test('with multiple active players', () => {
       const flow = Flow({
         turn: {
-          activePlayers: { all: 'A', moveLimit: 5 },
+          activePlayers: { all: 'A', minMoves: 2, maxMoves: 5 },
         },
       });
       let state = { G: {}, ctx: flow.ctx(3) } as State;
@@ -578,13 +731,13 @@ describe('stage events', () => {
       expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' });
       state = flow.processEvent(
         state,
-        gameEvent('setStage', { stage: 'B', moveLimit: 1 })
+        gameEvent('setStage', { stage: 'B', minMoves: 1 })
       );
       expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'A', '2': 'A' });
 
       state = flow.processEvent(
         state,
-        gameEvent('setStage', { stage: 'B', moveLimit: 1 }, '1')
+        gameEvent('setStage', { stage: 'B', maxMoves: 1 }, '1')
       );
       expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'B', '2': 'A' });
     });
@@ -606,17 +759,34 @@ describe('stage events', () => {
       expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
     });
 
-    test('with move limit', () => {
+    test('with min moves', () => {
+      const flow = Flow({});
+      let state = { G: {}, ctx: flow.ctx(2) } as State;
+      state = flow.init(state);
+
+      expect(state.ctx._activePlayersMinMoves).toBeNull();
+      expect(state.ctx._activePlayersMaxMoves).toBeNull();
+      state = flow.processEvent(
+        state,
+        gameEvent('setStage', { stage: 'A', minMoves: 1 })
+      );
+      expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 1 });
+      expect(state.ctx._activePlayersMaxMoves).toBeNull();
+    });
+
+    test('with max moves', () => {
       const flow = Flow({});
       let state = { G: {}, ctx: flow.ctx(2) } as State;
       state = flow.init(state);
 
-      expect(state.ctx._activePlayersMoveLimit).toBeNull();
+      expect(state.ctx._activePlayersMinMoves).toBeNull();
+      expect(state.ctx._activePlayersMaxMoves).toBeNull();
       state = flow.processEvent(
         state,
-        gameEvent('setStage', { stage: 'A', moveLimit: 2 })
+        gameEvent('setStage', { stage: 'A', maxMoves: 1 })
       );
-      expect(state.ctx._activePlayersMoveLimit).toEqual({ '0': 2 });
+      expect(state.ctx._activePlayersMinMoves).toBeNull();
+      expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1 });
     });
 
     test('empty argument ends stage', () => {
@@ -716,7 +886,7 @@ describe('stage events', () => {
     test('with multiple active players', () => {
       const flow = Flow({
         turn: {
-          activePlayers: { all: 'A', moveLimit: 5 },
+          activePlayers: { all: 'A', maxMoves: 5 },
         },
       });
       let state = { G: {}, ctx: flow.ctx(3) } as State;
@@ -727,6 +897,35 @@ describe('stage events', () => {
       expect(state.ctx.activePlayers).toEqual({ '1': 'A', '2': 'A' });
     });
 
+    test('with min moves', () => {
+      const flow = Flow({
+        turn: {
+          activePlayers: { all: 'A', minMoves: 2 },
+        },
+      });
+      let state = { G: {}, ctx: flow.ctx(2) } as State;
+      state = flow.init(state);
+
+      expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
+
+      state = flow.processEvent(state, gameEvent('endStage'));
+
+      // player 0 is not allowed to end the stage, they haven't made any move yet
+      expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
+
+      state = flow.processMove(state, makeMove('move', null, '0').payload);
+      state = flow.processEvent(state, gameEvent('endStage'));
+
+      // player 0 is still not allowed to end the stage, they haven't made the minimum number of moves
+      expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
+
+      state = flow.processMove(state, makeMove('move', null, '0').payload);
+      state = flow.processEvent(state, gameEvent('endStage'));
+
+      // having made 2 moves, player 0 was allowed to end the stage
+      expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
+    });
+
     test('maintains move count', () => {
       const flow = Flow({
         moves: { A: () => {} },
@@ -1783,6 +1982,68 @@ test('stage events should not be processed out of turn', () => {
   });
 });
 
+describe('backwards compatibility for moveLimit', () => {
+  test('turn config maps moveLimit to minMoves/maxMoves', () => {
+    const flow = Flow({
+      moves: {
+        pass: () => {},
+      },
+      turn: {
+        moveLimit: 2,
+      },
+    });
+    let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+    expect(state.ctx.turn).toBe(1);
+    expect(state.ctx.currentPlayer).toBe('0');
+
+    state = flow.processMove(state, makeMove('pass', null, '0').payload);
+    state = flow.processMove(state, makeMove('pass', null, '0').payload);
+
+    expect(state.ctx.turn).toBe(2);
+    expect(state.ctx.currentPlayer).toBe('1');
+
+    state = flow.processMove(state, makeMove('pass', null, '1').payload);
+
+    state = flow.processEvent(state, gameEvent('endTurn', null, '1'));
+
+    // player should not be able to endTurn because they haven't made minMoves yet
+
+    expect(state.ctx.turn).toBe(2);
+    expect(state.ctx.currentPlayer).toBe('1');
+  });
+
+  test('setActivePlayers maps moveLimit to maxMoves only', () => {
+    const flow = Flow({});
+    let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+    expect(state.ctx._activePlayersMinMoves).toBeNull();
+    expect(state.ctx._activePlayersMaxMoves).toBeNull();
+
+    state = flow.processEvent(
+      state,
+      gameEvent('setActivePlayers', { all: 'A', moveLimit: 1 })
+    );
+
+    expect(state.ctx._activePlayersMinMoves).toBeNull();
+    expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1, '1': 1 });
+  });
+
+  test('setStage maps moveLimit to maxMoves only', () => {
+    const flow = Flow({});
+    let state = flow.init({ ctx: flow.ctx(2) } as State);
+
+    expect(state.ctx._activePlayersMinMoves).toBeNull();
+    expect(state.ctx._activePlayersMaxMoves).toBeNull();
+    state = flow.processEvent(
+      state,
+      gameEvent('setStage', { stage: 'A', moveLimit: 2 })
+    );
+    expect(state.ctx._activePlayersMinMoves).toBeNull();
+    expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2 });
+  });
+});
+
 // These tests serve to document the order in which the various game hooks
 // are executed and also to catch any potential breaking changes.
 describe('hook execution order', () => {
@@ -1805,7 +2066,7 @@ describe('hook execution order', () => {
           calls.push('moves.endStage');
         },
         setActivePlayers: (G, ctx) => {
-          ctx.events.setActivePlayers({ all: 'A', moveLimit: 1 });
+          ctx.events.setActivePlayers({ all: 'A', minMoves: 1, maxMoves: 1 });
           calls.push('moves.setActivePlayers');
         },
       },
@@ -1942,7 +2203,7 @@ describe('hook execution order', () => {
     ]);
   });
 
-  test('hooks called on stage end triggered by moveLimit', () => {
+  test('hooks called on stage end triggered by maxMoves', () => {
     client.updatePlayerID('1');
     client.moves.move();
     client.updatePlayerID('0');
diff --git a/src/core/turn-order.test.ts b/src/core/turn-order.test.ts
index adb63bf..d27b37f 100644
--- a/src/core/turn-order.test.ts
+++ b/src/core/turn-order.test.ts
@@ -388,7 +388,16 @@ describe('setActivePlayers', () => {
   test('undefined stage leaves player inactive', () => {
     const newState = flow.processEvent(
       state,
-      gameEvent('setActivePlayers', [{ value: { '1': { moveLimit: 2 } } }])
+      gameEvent('setActivePlayers', [
+        {
+          value: {
+            '1': {
+              minMoves: 2,
+              maxMoves: 2,
+            },
+          },
+        },
+      ])
     );
     expect(newState.ctx.activePlayers).toBeNull();
   });
@@ -410,7 +419,8 @@ describe('setActivePlayers', () => {
         B: (G, ctx) => {
           ctx.events.setActivePlayers({
             value: { '0': Stage.NULL, '1': Stage.NULL },
-            moveLimit: 1,
+            minMoves: 1,
+            maxMoves: 1,
           });
           return G;
         },
@@ -434,7 +444,8 @@ describe('setActivePlayers', () => {
       moves: {
         B: (G, ctx) => {
           ctx.events.setActivePlayers({
-            moveLimit: 1,
+            minMoves: 1,
+            maxMoves: 1,
             others: Stage.NULL,
           });
           return G;
@@ -466,7 +477,8 @@ describe('setActivePlayers', () => {
         A: (G) => G,
         B: (G, ctx) => {
           ctx.events.setActivePlayers({
-            moveLimit: 1,
+            minMoves: 1,
+            maxMoves: 1,
             currentPlayer: 'start',
           });
           return G;
@@ -517,7 +529,7 @@ describe('setActivePlayers', () => {
         },
 
         turn: {
-          activePlayers: { currentPlayer: 'stage', moveLimit: 1 },
+          activePlayers: { currentPlayer: 'stage', minMoves: 1, maxMoves: 1 },
         },
       };
 
@@ -544,7 +556,8 @@ describe('setActivePlayers', () => {
             A: (G, ctx) => {
               ctx.events.setActivePlayers({
                 currentPlayer: 'stage2',
-                moveLimit: 1,
+                minMoves: 1,
+                maxMoves: 1,
                 revert: true,
               });
             },
@@ -571,7 +584,8 @@ describe('setActivePlayers', () => {
           _prevActivePlayers: [
             {
               activePlayers: { '0': 'stage1' },
-              _activePlayersMoveLimit: null,
+              _activePlayersMinMoves: null,
+              _activePlayersMaxMoves: null,
               _activePlayersNumMoves: { '0': 1 },
             },
           ],
@@ -591,7 +605,8 @@ describe('setActivePlayers', () => {
             A: (G, ctx) => {
               ctx.events.setActivePlayers({
                 currentPlayer: 'stage2',
-                moveLimit: 1,
+                minMoves: 1,
+                maxMoves: 1,
                 revert: true,
               });
             },
@@ -601,7 +616,8 @@ describe('setActivePlayers', () => {
           turn: {
             activePlayers: {
               currentPlayer: 'stage1',
-              moveLimit: 3,
+              minMoves: 2,
+              maxMoves: 3,
             },
           },
         };
@@ -612,7 +628,8 @@ describe('setActivePlayers', () => {
         expect(state.ctx).toMatchObject({
           activePlayers: { '0': 'stage1' },
           _prevActivePlayers: [],
-          _activePlayersMoveLimit: { '0': 3 },
+          _activePlayersMinMoves: { '0': 2 },
+          _activePlayersMaxMoves: { '0': 3 },
           _activePlayersNumMoves: {
             '0': 0,
           },
@@ -623,7 +640,8 @@ describe('setActivePlayers', () => {
         expect(state.ctx).toMatchObject({
           activePlayers: { '0': 'stage1' },
           _prevActivePlayers: [],
-          _activePlayersMoveLimit: { '0': 3 },
+          _activePlayersMinMoves: { '0': 2 },
+          _activePlayersMaxMoves: { '0': 3 },
           _activePlayersNumMoves: {
             '0': 1,
           },
@@ -637,10 +655,12 @@ describe('setActivePlayers', () => {
             {
               activePlayers: { '0': 'stage1' },
               _activePlayersNumMoves: { '0': 2 },
-              _activePlayersMoveLimit: { '0': 3 },
+              _activePlayersMinMoves: { '0': 2 },
+              _activePlayersMaxMoves: { '0': 3 },
             },
           ],
-          _activePlayersMoveLimit: { '0': 1 },
+          _activePlayersMinMoves: { '0': 1 },
+          _activePlayersMaxMoves: { '0': 1 },
           _activePlayersNumMoves: {
             '0': 0,
           },
@@ -651,7 +671,8 @@ describe('setActivePlayers', () => {
         expect(state.ctx).toMatchObject({
           activePlayers: { '0': 'stage1' },
           _prevActivePlayers: [],
-          _activePlayersMoveLimit: { '0': 3 },
+          _activePlayersMinMoves: { '0': 2 },
+          _activePlayersMaxMoves: { '0': 3 },
           _activePlayersNumMoves: {
             '0': 2,
           },
@@ -668,10 +689,12 @@ describe('setActivePlayers', () => {
         turn: {
           activePlayers: {
             currentPlayer: 'stage1',
-            moveLimit: 1,
+            minMoves: 1,
+            maxMoves: 1,
             next: {
               currentPlayer: 'stage2',
-              moveLimit: 1,
+              minMoves: 1,
+              maxMoves: 1,
               next: {
                 currentPlayer: 'stage3',
               },
@@ -688,7 +711,8 @@ describe('setActivePlayers', () => {
         _prevActivePlayers: [],
         _nextActivePlayers: {
           currentPlayer: 'stage2',
-          moveLimit: 1,
+          minMoves: 1,
+          maxMoves: 1,
           next: {
             currentPlayer: 'stage3',
           },
@@ -721,7 +745,8 @@ describe('setActivePlayers', () => {
         turn: {
           activePlayers: {
             all: 'play',
-            moveLimit: 3,
+            minMoves: 1,
+            maxMoves: 3,
           },
           stages: {
             play: { moves: { A: () => {} } },
@@ -732,7 +757,13 @@ describe('setActivePlayers', () => {
       const reducer = CreateGameReducer({ game });
       let state = InitializeGame({ game, numPlayers: 3 });
 
-      expect(state.ctx._activePlayersMoveLimit).toEqual({
+      expect(state.ctx._activePlayersMinMoves).toEqual({
+        '0': 1,
+        '1': 1,
+        '2': 1,
+      });
+
+      expect(state.ctx._activePlayersMaxMoves).toEqual({
         '0': 3,
         '1': 3,
         '2': 3,
@@ -767,8 +798,8 @@ describe('setActivePlayers', () => {
       const game = {
         turn: {
           activePlayers: {
-            currentPlayer: { stage: 'play', moveLimit: 2 },
-            others: { stage: 'play', moveLimit: 1 },
+            currentPlayer: { stage: 'play', minMoves: 1, maxMoves: 2 },
+            others: { stage: 'play', maxMoves: 1 },
           },
           stages: {
             play: { moves: { A: () => {} } },
@@ -779,7 +810,9 @@ describe('setActivePlayers', () => {
       const reducer = CreateGameReducer({ game });
       let state = InitializeGame({ game, numPlayers: 3 });
 
-      expect(state.ctx._activePlayersMoveLimit).toEqual({
+      expect(state.ctx._activePlayersMinMoves).toStrictEqual({ '0': 1 });
+
+      expect(state.ctx._activePlayersMaxMoves).toEqual({
         '0': 2,
         '1': 1,
         '2': 1,
@@ -808,19 +841,25 @@ describe('setActivePlayers', () => {
       expect(state.ctx.activePlayers).toBeNull();
     });
 
-    test('player-specific limit overrides moveLimit arg', () => {
+    test('player-specific limit overrides move limit args', () => {
       const game = {
         turn: {
           activePlayers: {
-            all: { stage: 'play', moveLimit: 2 },
-            moveLimit: 1,
+            all: { stage: 'play', minMoves: 2, maxMoves: 2 },
+            minMoves: 1,
+            maxMoves: 1,
           },
         },
       };
 
       const state = InitializeGame({ game, numPlayers: 2 });
 
-      expect(state.ctx._activePlayersMoveLimit).toEqual({
+      expect(state.ctx._activePlayersMinMoves).toEqual({
+        '0': 2,
+        '1': 2,
+      });
+
+      expect(state.ctx._activePlayersMaxMoves).toEqual({
         '0': 2,
         '1': 2,
       });
@@ -831,9 +870,9 @@ describe('setActivePlayers', () => {
         turn: {
           activePlayers: {
             value: {
-              '0': { stage: 'play', moveLimit: 1 },
-              '1': { stage: 'play', moveLimit: 2 },
-              '2': { stage: 'play', moveLimit: 3 },
+              '0': { stage: 'play', maxMoves: 1 },
+              '1': { stage: 'play', minMoves: 1, maxMoves: 2 },
+              '2': { stage: 'play', minMoves: 2, maxMoves: 3 },
             },
           },
           stages: {
@@ -845,7 +884,12 @@ describe('setActivePlayers', () => {
       const reducer = CreateGameReducer({ game });
       let state = InitializeGame({ game, numPlayers: 3 });
 
-      expect(state.ctx._activePlayersMoveLimit).toEqual({
+      expect(state.ctx._activePlayersMinMoves).toStrictEqual({
+        '1': 1,
+        '2': 2,
+      });
+
+      expect(state.ctx._activePlayersMaxMoves).toEqual({
         '0': 1,
         '1': 2,
         '2': 3,
@@ -909,7 +953,8 @@ describe('setActivePlayers', () => {
           militia: (G, ctx) => {
             ctx.events.setActivePlayers({
               others: 'discard',
-              moveLimit: 1,
+              minMoves: 1,
+              maxMoves: 1,
               revert: true,
             });
           },