Demos

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 test

The tests use a SQLite file in a temporary folder and also run the same fold on the memory host.

Architecture

Architecture diagram. A test runtime appends game events to the GameEvents stream. The board projection folds each event with stepBoard. The SQLite file holds state and offsets in one checkpoint record. Projection.read returns the board to the test. A restarted runtime on the same file finds no new items and resumes.

Each number in the diagram is also a section number below.

StepWhat happensStreamsy featureFile
1One stream for each game holds its eventsStreamsy.jsonsrc/board/fold.ts
2A checkpointed fold folds each event into the boardProjection.fold with state and foldsrc/board/fold.ts
3The SQLite host saves state and offsets in one recordCheckpointStore.layer, BunStorage.layertest/restart.test.ts
4The test runs the projection and reads the boardProjection.run, Projection.readtest/restart.test.ts
5A second process on the same file resumes from the saved cutCheckpoint and state on restarttest/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.

src/board/fold.ts
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.

src/board/fold.ts
/** 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.

See State and checkpoints.

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.

test/restart.test.ts
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.

test/restart.test.ts
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.

test/restart.test.ts
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));
      }),
    );
test/restart.test.ts
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.

MeasureFULLNORMAL
State upsert, 1 event per unit0.549 ms0.046 ms
State upsert, 32 events per unit0.581 ms0.049 ms
Full projection transaction, 1 event per unit1.340 ms0.760 ms
Full projection transaction, 32 events per unit7.547 ms5.462 ms
Checkpoint only0.4258 ms0.0098 ms
Checkpoint and state0.5280 ms0.0597 ms
Added cost of the state rowabout 0.1022 msabout 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.

On this page