Hex board
Replay a strategy game into a complete board that survives a restart, with no board tables of your own.
This demo replays the events of a hex strategy game into the full board: players, territories, the current turn, the combat in progress and the last 40 moves. The board is one fold value. The projection saves it in the same SQLite transaction as its checkpoint, so a restarted process reads the board and resumes from the same point. The demo has no server and no UI, so this page has no screenshot. Its tests are the demo.
The full source is in examples/hex-board.
Run it
From the repository root:
bun install --frozen-lockfile
bun run build
bun run --cwd examples/hex-board testThe tests use a SQLite file in a temporary folder and also run the same fold on the memory host.
Architecture
Each number in the diagram is also a section number below.
| Step | What happens | Streamsy feature | File |
|---|---|---|---|
| 1 | One stream for each game holds its events | Streamsy.json | src/board/fold.ts |
| 2 | A checkpointed fold folds each event into the board | Projection.fold with state and fold | src/board/fold.ts |
| 3 | The SQLite host saves state and offsets in one record | CheckpointStore.layer, BunStorage.layer | test/restart.test.ts |
| 4 | The test runs the projection and reads the board | Projection.run, Projection.read | test/restart.test.ts |
| 5 | A second process on the same file resumes from the saved cut | Checkpoint and state on restart | test/restart.test.ts |
The code below is from the demo. Lines marked // ... are left out.
1. Declare the game event stream
Each game has one JSON stream of game events.
export class GameEvents extends Streamsy.json<GameEvents>()(
"HexBoard/GameEvents",
"games/:gameId/events",
{ params: { gameId: Schema.String }, schema: GameEvent },
) {}See Declare streams.
2. Fold each event into the board
BoardState holds the board and the next move ordinal. stepBoard applies one event. The projection declares state with a schema and an initial value, and fold calls stepBoard once for each item.
/** The next move ordinal survives restart independently of page boundaries. */
export const BoardState = Schema.Struct({
ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
board: ProjectionStateSchema,
});
export type BoardState = typeof BoardState.Type;
export const initialBoard = ({ gameId }: { readonly gameId: string }): BoardState => ({
ordinal: 0,
board: initialProjection(gameId),
});
export const stepBoard = (
state: BoardState,
event: GameEvent,
through: string,
): Result.Result<BoardState, ProjectionIntegrityError> =>
Result.map(projectEvent(state.board, event, through, state.ordinal), (board) => ({
ordinal: state.ordinal + 1,
board,
}));
// ...
export const board = Projection.fold({
id: "hex-board",
params: { gameId: Schema.String },
inputs: { input: GameEvents },
state: { schema: BoardState, initial: initialBoard },
fold: (state, { item }, unit) =>
Effect.fromResult(stepBoard(state, item, unit.ranges.input.nextOffset)),
});unit.ranges.input is always present here. The projection has one input, and an empty read never reaches the handler. Every event in a unit gets the same source offset, the end of the unit. Each move gets its own ordinal, and the ordinal is part of the state, so move ids stay unique after a restart.
The pure reducer returns a Result with ProjectionIntegrityError when the events contradict each other. The fold uses Effect.fromResult to move that error to the typed error channel, so Projection.run fails with it.
3. Commit state with the checkpoint
The test host puts the stream storage and the projection record in one SQLite file. After each unit the projection writes the encoded board and the new checkpoint in one transaction.
const sqlite = (filename: string) =>
CheckpointStore.layer.pipe(
Layer.provideMerge(BunStorage.layer(Streamsy.group(GameEvents), { client: { filename } })),
);The demo creates no application tables. State and offsets share one record in streamsy_owner_records.
See SQLite storage.
4. Run and read the board
The test appends the first part of a game and runs the projection to the end of the stream. Projection.read then returns the state and the input offsets that it matches.
const seed = (events: readonly GameEvent[] = fullGame, cut = split) =>
Effect.gen(function* () {
yield* (yield* GameEvents).create(params);
yield* (yield* GameEvents).append(params, nonEmpty(events.slice(0, cut)));
expect((yield* Projection.run(board)).items).toBe(cut);
});
const snapshot = Effect.gen(function* () {
const { state, offsets } = yield* Projection.read(board);
const checkpoint = yield* (yield* CheckpointStore).load(board);
return {
state,
offsets,
checkpoint,
encoded: Option.flatMap(checkpoint.record, (record) => Option.fromUndefinedOr(record.state)),
};
});See Running.
5. Restart and resume
asProcess provides a Layer for one process: it opens the Layer, runs the program and closes the Layer. The second call on the same file is the restart. Its first run finds no new items, and the board and checkpoint are the same as before. The test then appends the rest of the game and checks the result against a pure fold of all the events.
const asProcess =
<ROut, LE>(fixture: Layer.Layer<ROut, LE>) =>
<A, E, R>(program: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.gen(function* () {
const services = yield* Layer.build(fixture);
return yield* program.pipe(Effect.provideContext(services));
}),
);baseIt.effect(`SQLite restart retains state and checkpoint, then resumes: ${name}`, () =>
Effect.gen(function* () {
const file = yield* tempFile("hex-board-");
const before = yield* seed(events, cut).pipe(Effect.andThen(read), asProcess(sqlite(file)));
// ...
yield* Effect.gen(function* () {
expect((yield* Projection.run(board)).items).toBe(0);
expect(yield* read).toEqual(before);
yield* advance(before, events, cut);
}).pipe(asProcess(sqlite(file)));
}),
);The SQLite tests split two games: the 12-event victory game after 5 events, during an attack, and a 450-event four-player game after 400 events. The long case also checks the exact 40 move ids and the ordinal. Another test appends an attack result that does not match the game. The fold fails with ProjectionIntegrityError, and neither the state nor the checkpoint moves.
See Checkpoints and generations.
Limits
- The board is one value. Each unit writes all of it again. Row sets that grow belong in application tables through the shared SQL client.
- The demo has no output stream, server or UI.
- The two game logs are fixed inputs. The example README says how they were made.
Measurements
Bun 1.4.2, file-backed WAL, 3,005 generated events. These measurements predate state being stored in the checkpoint record. The encoded value was 21,599 B at the end and 22,944 B at most. FULL and NORMAL are the SQLite synchronous settings.
| Measure | FULL | NORMAL |
|---|---|---|
| State upsert, 1 event per unit | 0.549 ms | 0.046 ms |
| State upsert, 32 events per unit | 0.581 ms | 0.049 ms |
| Full projection transaction, 1 event per unit | 1.340 ms | 0.760 ms |
| Full projection transaction, 32 events per unit | 7.547 ms | 5.462 ms |
| Checkpoint only | 0.4258 ms | 0.0098 ms |
| Checkpoint and state | 0.5280 ms | 0.0597 ms |
| Added cost of the state row | about 0.1022 ms | about 0.0499 ms |
The state upsert times leave out encoding and the checkpoint write. The last three rows come from a separate local run. At FULL most of the commit cost is the WAL fsync that every checkpoint commit pays.