Skip to content

Commit

Permalink
Simplify Random API (#119)
Browse files Browse the repository at this point in the history
* run random ops directly

* make Random use global state to store / restore the PRNG state

* move seed initialization to Game

* inline fast-shuffle to fix Rollup issues

* set Die default to D6 to avoid returning null
  • Loading branch information
nicolodavis committed Feb 23, 2018
1 parent 67069bb commit 8c88b70
Show file tree
Hide file tree
Showing 22 changed files with 344 additions and 310 deletions.
41 changes: 11 additions & 30 deletions docs/random.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,36 +19,22 @@ This poses interesting challenges regarding the implementation.
and thus must be pure. Calling `Math.random()` and other functions that
maintain external state would make the game logic impure and not idempotent.

## Using Randomness in Games

[boardgame.io]() takes a rather unusual approach to randomness: It disallows getting random variables directly.
Instead, a game can ask the engine to generate random numbers, and the engine will inject those into the game on the next move.
### Using Randomness in Games

```js
import { Random } from 'boardgame.io/core';

const SomeGame = Game({
Game({
moves: {
rollDie(G, ctx) {
// G.diceValue will contain the requested
// die value at the end of this move.
return Random.D6(G, 'diceValue');
},
},

flow: {
onMove: G => {
const dice = G.diceValue;
// do something...
return { ...G };
return { ...G, dice: Random.D6() };
},
},
// ...
});
```

This will place a request to a D6 dice roll inside `G`.
While processing the move, the request gets evaluated and the result placed into `diceValue`, where it can be used.
!> The PRNG state is maintained inside `ctx._random` by the `Random`
package automatically.

### Shuffles

Expand All @@ -67,33 +53,28 @@ const SomeGame = Game({
}),
moves: {
shuffleDeck(G) {
return Random.Shuffle(G, 'deck');
return { ...G, deck: Random.Shuffle(G.deck) };
},
},
});
```

## Seed
### Seed

The library uses a `seed` in `ctx` that is stripped before it
The library uses a `seed` in `ctx._random` that is stripped before it
is sent to the client. All the code that needs randomness uses this
`seed` to generate random numbers.

You can override the initial `seed` in the `flow` section like this:
You can override the initial `seed` like this:

```js
Game({
seed: <somevalue>
...

flow: {
seed: <somevalue>

...
}
})
```

## Background
### Background

There is an interesting background article by David Bau called [Random Seeds, Coded Hints, and Quintillions](http://davidbau.com/archives/2010/01/30/random_seeds_coded_hints_and_quintillions.html).
Despite its age, this article gives insight on topics about randomness, like differentiating _local_ and _network_ entropy.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const App = Client({

const SingleView = () => (
<div style={{ padding: 50 }}>
<App gameID="Shuffle" />
<App gameID="Random" />
</div>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@

import { Game, Random } from 'boardgame.io/core';

const Shuffle = Game({
const RandomExample = Game({
name: 'shuffle',

setup: () => ({
deck: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'],
}),

moves: {
shuffle: G => Random.Shuffle(G, 'deck'),
shuffle: G => ({ ...G, deck: Random.Shuffle(G.deck) }),
rollDie: (G, ctx, value) => ({ ...G, dice: Random.Die(value) }),
rollD6: G => ({ ...G, dice: Random.D6() }),
},
});

export default Shuffle;
export default RandomExample;
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import View from './components/view';

const routes = [
{
path: '/shuffle/cards',
text: 'Shuffle Cards',
path: '/random/main',
text: 'Examples',
component: View,
},
];
Expand Down
6 changes: 3 additions & 3 deletions examples/modules/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import tic_tac_toe from './tic-tac-toe';
import chess from './chess';
import phases from './phases';
import liars_dice from './liars-dice';
import shuffle from './shuffle';
import random from './random';

const routes = [
{
Expand All @@ -30,8 +30,8 @@ const routes = [
routes: liars_dice.routes,
},
{
name: 'Shuffle',
routes: shuffle.routes,
name: 'Random API',
routes: random.routes,
},
];

Expand Down
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@
"webpack": "^3.5.5"
},
"dependencies": {
"fast-shuffle": "^1.0.4",
"koa": "^2.3.0",
"koa-router": "^7.2.1",
"koa-socket": "^4.4.0",
Expand Down
3 changes: 0 additions & 3 deletions rollup.npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ const globals = {
'react-json-view': 'ReactJson',
mousetrap: 'Mousetrap',
'socket.io-client': 'io',
'fast-shuffle': 'shuffle',
};

export default [
Expand Down Expand Up @@ -61,8 +60,6 @@ export default [

{
input: 'packages/core.js',
external: ['fast-shuffle'],
globals: { 'fast-shuffle': 'shuffle' },
output: { file: 'dist/core.js', format: 'umd' },
name: 'Core',
plugins: plugins,
Expand Down
19 changes: 9 additions & 10 deletions src/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,18 +165,17 @@ export function Client({ game, numPlayers, board, multiplayer, debug }) {
) {
isActive = false;
}
} else {
// Secrets are normally stripped on the server,
// but we also strip them here in local games so
// that game developers can see their effects
// while prototyping.
let playerID = this.props.playerID;
if (!playerID && state.ctx.currentPlayer != 'any') {
playerID = state.ctx.currentPlayer;
}
G = game.playerView(G, state.ctx, playerID);
}

// Secrets are normally stripped on the server,
// but we also strip them here so that game developers
// can see their effects while prototyping.
let playerID = this.props.playerID;
if (!multiplayer && !playerID && state.ctx.currentPlayer != 'any') {
playerID = state.ctx.currentPlayer;
}
G = game.playerView(G, state.ctx, playerID);

if (state.ctx.gameover !== undefined) {
isActive = false;
}
Expand Down
7 changes: 4 additions & 3 deletions src/client/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,14 @@ test('move dispatchers', () => {
expect(api.unknown).toBe(undefined);

api.A();
expect(store.getState().G).toEqual({});
expect(store.getState().G).not.toMatchObject({ moved: true });
expect(store.getState().G).not.toMatchObject({ victory: true });

api.B();
expect(store.getState().G).toEqual({ moved: true });
expect(store.getState().G).toMatchObject({ moved: true });

api.C();
expect(store.getState().G).toEqual({ victory: true });
expect(store.getState().G).toMatchObject({ victory: true });
});

test('local playerView', () => {
Expand Down
8 changes: 4 additions & 4 deletions src/client/log/log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,20 @@ test('GameLog rewind', () => {
.find('.log-turn')
.at(0)
.simulate('mouseover');
expect(store.getState().G).toEqual({ arg: 1 });
expect(store.getState().G).toMatchObject({ arg: 1 });
root
.find('.log-turn')
.at(0)
.simulate('mouseout');
expect(store.getState().G).toEqual({ arg: 2 });
expect(store.getState().G).toMatchObject({ arg: 2 });
root
.find('.log-turn')
.at(1)
.simulate('mouseover');
expect(store.getState().G).toEqual({ arg: 42 });
expect(store.getState().G).toMatchObject({ arg: 42 });
root
.find('.log-turn')
.at(0)
.simulate('mouseout');
expect(store.getState().G).toEqual({ arg: 2 });
expect(store.getState().G).toMatchObject({ arg: 2 });
});
58 changes: 42 additions & 16 deletions src/core/flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import { TurnOrder } from './turn-order';
import { GenSeed, RunRandom } from './random';

/**
* Helper to create a reducer that manages ctx (with the
Expand All @@ -17,9 +16,9 @@ import { GenSeed, RunRandom } from './random';
* need to use this directly if you are creating a very customized
* game flow that it cannot handle.
*
* @param {...object} setup - Function with the signature
* numPlayers => ctx
* that determines the initial value of ctx.
* @param {...object} ctx - Function with the signature
* numPlayers => ctx
* that determines the initial value of ctx.
* @param {...object} events - Object containing functions
* named after events that this
* reducer will handle. Each function
Expand All @@ -31,14 +30,31 @@ import { GenSeed, RunRandom } from './random';
* (G, ctx, moveName) => boolean
* @param {...object} processMove - A function that's called whenever a move is made.
* (state, action, dispatch) => state.
* @param {...object} optimisticUpdate - (G, ctx, move) => boolean
* Control whether a move should
* be executed optimistically on
* the client while waiting for
* the result of execution from
* the server.
*/
export function Flow({ ctx, events, init, validator, processMove }) {
export function Flow({
ctx,
events,
init,
validator,
processMove,
optimisticUpdate,
}) {
if (!ctx) ctx = () => ({});
if (!events) events = {};
if (!init) init = state => state;
if (!validator) validator = () => true;
if (!processMove) processMove = state => state;

if (optimisticUpdate === undefined) {
optimisticUpdate = () => true;
}

const dispatch = (state, action) => {
if (events.hasOwnProperty(action.type)) {
const context = { playerID: action.playerID };
Expand Down Expand Up @@ -71,6 +87,8 @@ export function Flow({ ctx, events, init, validator, processMove }) {
processGameEvent: (state, action) => {
return dispatch(state, action);
},

optimisticUpdate,
};
}

Expand Down Expand Up @@ -112,8 +130,12 @@ export function Flow({ ctx, events, init, validator, processMove }) {
*
* @param {...object} endPhase - Set to false to disable the `endPhase` event.
*
* @param {string} seed - If present, used as seed for the PRNG.
* If omitted, the seed will be autogenerated.
* @param {...object} optimisticUpdate - (G, ctx, move) => boolean
* Control whether a move should
* be executed optimistically on
* the client while waiting for
* the result of execution from
* the server.
*
* @param {...object} phases - A list of phases in the game.
*
Expand Down Expand Up @@ -172,7 +194,7 @@ export function FlowWithPhases({
turnOrder,
endTurn,
endPhase,
seed,
optimisticUpdate,
}) {
// Attach defaults.
if (endPhase === undefined && phases) {
Expand All @@ -181,8 +203,8 @@ export function FlowWithPhases({
if (endTurn === undefined) {
endTurn = true;
}
if (seed === undefined) {
seed = GenSeed();
if (optimisticUpdate === undefined) {
optimisticUpdate = () => true;
}
if (!phases) phases = [{ name: 'default' }];
if (!endTurnIf) endTurnIf = () => false;
Expand Down Expand Up @@ -359,10 +381,6 @@ export function FlowWithPhases({

const conf = phaseMap[state.ctx.phase];

// run random operations
let { G: GRandom, ctx: ctxRandom } = RunRandom(state.G, state.ctx);
state = { ...state, G: GRandom, ctx: ctxRandom };

const G = conf.onMove(state.G, state.ctx, action);
state = { ...state, G };

Expand Down Expand Up @@ -406,14 +424,22 @@ export function FlowWithPhases({

return Flow({
ctx: numPlayers => ({
random: { seed },
numPlayers,
turn: 0,
currentPlayer: '0',
currentPlayerMoves: 0,
phase: phases[0].name,
}),
init: state => startGame(state, phases[0]),
init: state => {
return startGame(state, phases[0]);
},
optimisticUpdate: (G, ctx, action) => {
// Some random code was executed.
if (ctx._random !== undefined && ctx._random.prngstate !== undefined) {
return false;
}
return optimisticUpdate(G, ctx, action);
},
events: enabledEvents,
validator,
processMove,
Expand Down

0 comments on commit 8c88b70

Please sign in to comment.