Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ Pre-1.0, breaking changes land in a **MINOR** bump: `^0.1.0` resolves to
[Versions and compatibility](https://eigeninteractive.com/docs/reference/compatibility)
for how this package, the engine and the generated `eigen_api` client pair up.

## [Unreleased]
### Added
- GameContentContext.transition gives a game the step into the current frame (from, to), or null when there is nothing to animate: a cold load, a rejoin, or the opening frame. Animate only when it is non-null, which replaces tracking the last rendered version in widget state. Replay supplies it too, so one animation path serves live play and replay.

### Changed
- The game screen renders from one live session instead of assembling one from four sources. gameSessionProvider is the single subscription, and status, roster, frame, outcomes and the wire-compatibility verdict are all pure selectors over it; no game screen reads gameSummaryProvider any more, which stays what it always was, the index behind lists. This fixes a creator's waiting room never offering Start once the roster filled, every seat staying in the waiting room after the game began, and a socket reconnect replacing the board with a spinner until somebody moved.
- eigen\_api pin raised to ^0.3.0: the engine's session-snapshot wire is a new release line, and a shell speaking 0.2.x cannot read it.

### Removed
- Roster, Joined and LobbyAccepted are gone from the wire, along with the roster, sync and frame socket message kinds. The socket carries one message, a complete per-seat session snapshot, and every accepted command answers with the same value. A list screen must never subscribe to a session; the home card now resolves its avatars from the index row, as the lobby card already did, instead of opening one socket per row.

## [0.3.7] - 2026-08-09
### Added
- `configure_firebase` now fills in the deployment values that exist only once a Firebase project does: `GOOGLE_WEB_CLIENT_ID` in `app-config.json`, from the OAuth client Firebase created, and with the new `--worker <dir>` flag, `FIREBASE_PROJECT_ID` in the Cloudflare Worker's `wrangler.jsonc`. The Worker edit rewrites one assignment in place, so that file's comments survive, and refuses when the key does not appear exactly once. An app-only repository omits the flag and keeps the app half.
Expand Down Expand Up @@ -151,6 +162,7 @@ server-side concern now live in the engine.
- `google_fonts`, which fetched Inter at runtime, replaced by the bundled
package font above.

[Unreleased]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.7...HEAD
[0.3.7]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.4...v0.3.5
Expand Down
2 changes: 2 additions & 0 deletions example/test/board_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ GameContentContext _context({
required List<int> pendingPlayers,
GameStatus gameStatus = GameStatus.active,
List<Outcome> outcomes = const [],
GameTransition? transition,
Future<ActionSubmitResult> Function(Map<String, dynamic>)? onAction,
}) {
return GameContentContext(
Expand All @@ -179,6 +180,7 @@ GameContentContext _context({
version: 1,
timing: TimingContext(clock: ServerClock()),
),
transition: transition,
gameStatus: gameStatus,
outcomes: outcomes,
actionPending: false,
Expand Down
76 changes: 19 additions & 57 deletions lib/core/api/game_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,58 +33,21 @@ Uri buildGameSocketUri({
);
}

/// One message from a game's socket, or the signal that the socket (re)opened.
/// The socket carries exactly one message: the complete live truth about the
/// game as this seat sees it.
///
/// The server never reads from this socket, since every client-to-server
/// command rides HTTP, so this is a one-way feed of the message kinds the protocol
/// defines, plus the connection signal the ordering pipeline needs.
sealed class GameSocketEvent {
const GameSocketEvent();
}

/// The socket just (re)opened.
///
/// Emitted on the first connection and on every reconnect. Nothing is
/// reconciled from this alone: the server states where the game is in the
/// [GameSocketRoster] or [GameSocketSync] that follows, so the pipeline acts on
/// that rather than guessing from the fact of connecting.
final class GameSocketConnected extends GameSocketEvent {
const GameSocketConnected();
}

/// A pre-game roster snapshot: who is seated and what the game's status is.
///
/// Unversioned and idempotent: the server pushes one on every roster change
/// and one on socket open while the game is still in the waiting room, so a
/// reconnect simply gets the current one.
final class GameSocketRoster extends GameSocketEvent {
const GameSocketRoster(this.roster);

final Roster roster;
}

/// Where the game currently is, sent once on a mid-game socket open.
///
/// Hand-parsed rather than generated: unlike [Roster] and [Frame], which appear
/// in HTTP responses and so exist in the OpenAPI document, this message is
/// socket-only and has no generated counterpart.
final class GameSocketSync extends GameSocketEvent {
const GameSocketSync(this.version);

/// The newest committed version at the moment the socket opened.
final int version;
}

/// One versioned frame for the receiving seat.
/// The server never reads from this socket, since every client-to-server command
/// rides HTTP, so this is a one-way feed of [Session] snapshots. There is
/// deliberately nothing else on it, and no "connected" signal to reason about:
/// an open always answers with a snapshot, so there is never a window in which
/// a client holds a frame without the status it belongs to, and never anything
/// to infer from the bare fact of connecting.
///
/// Only ever this socket's own seat's view; the server resolves each frame's
/// owner against the roster before sending, so another player's hidden
/// information never crosses the wire.
final class GameSocketFrame extends GameSocketEvent {
const GameSocketFrame(this.frame);

final Frame frame;
}
/// `frame` is only ever this socket's own seat's view. The server resolves the
/// seat from the connection's authenticated principal against the roster before
/// sending, so another player's hidden information never crosses the wire, and a
/// client holding no seat receives the envelope with no frame at all.
typedef GameSocketEvent = Session;

/// Opens a game's socket and keeps it open for the screen's lifetime.
///
Expand Down Expand Up @@ -118,6 +81,11 @@ class GameSocket {
/// and the recovery for it is the reconnect this already performs. Errors
/// that are *not* recoverable that way, such as a malformed message, are logged and
/// skipped rather than tearing down a working connection.
///
/// A reconnect needs no announcement, because the server's first message on
/// the new connection is the current snapshot. When nothing was missed it
/// carries a `seq` the caller already holds and is discarded there, so the
/// common case on a flaky connection costs one message and no rebuild.
Stream<GameSocketEvent> connect(String gameId) async* {
var backoff = _initialBackoff;

Expand All @@ -134,7 +102,6 @@ class GameSocket {
channel = WebSocketChannel.connect(_socketUri(gameId, token));
await channel.ready;
backoff = _initialBackoff;
yield const GameSocketConnected();

await for (final message in channel.stream) {
final event = _decode(message);
Expand Down Expand Up @@ -174,12 +141,7 @@ class GameSocket {
GameSocketEvent? _decode(dynamic message) {
try {
final json = jsonDecode(message as String) as Map<String, dynamic>;
return switch (json['type']) {
'roster' => GameSocketRoster(Roster.fromJson(json)),
'frame' => GameSocketFrame(Frame.fromJson(json)),
'sync' => GameSocketSync(json['version'] as int),
_ => null,
};
return json['type'] == 'session' ? Session.fromJson(json) : null;
} catch (error, stack) {
developer.log(
'unreadable game socket message',
Expand Down
12 changes: 12 additions & 0 deletions lib/core/game/game_module.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:eigen_flutter/core/game/game_creation_spec.dart';
import 'package:eigen_flutter/core/game/game_frame.dart';
import 'package:eigen_flutter/core/game/game_transition.dart';
import 'package:eigen_flutter/core/game/my_seat.dart';
import 'package:eigen_flutter/core/game/players_context.dart';
import 'package:eigen_flutter/core/game/timing_context.dart';
Expand Down Expand Up @@ -48,6 +49,7 @@ class GameContentContext {
const GameContentContext({
required this.config,
required this.frame,
required this.transition,
required this.gameStatus,
required this.outcomes,
required this.actionPending,
Expand All @@ -65,6 +67,16 @@ class GameContentContext {
/// players and timing. Rebuilt on every observation event.
final GameFrame frame;

/// The step from the frame the player last saw to [frame], or null when there
/// is none to animate.
///
/// Animate when this is non-null; render statically when it is null, which is
/// a cold load, a rejoin, or the opening frame. Read what happened from
/// [GameTransition.to]'s observation rather than diffing the two frames: the
/// cues are embedded there by `computeObservation`, and a diff cannot recover
/// causality.
final GameTransition? transition;

/// Current lifecycle status of the game.
final GameStatus gameStatus;

Expand Down
77 changes: 77 additions & 0 deletions lib/core/game/game_session.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'package:eigen_api/eigen_api.dart';

/// One game's live session, as the client holds it: the newest snapshot the
/// server stated, plus the frame being rendered and the one it replaced.
///
/// This is the whole of a game screen's state. Nothing else holds a piece of it,
/// which is the point: the server states the complete truth for this seat in one
/// value, so status, roster, version and observation can never disagree with
/// each other, and there is no second source for any of them to drift from.
///
/// [frame] is not always [Session.frame]. While a gap is animating, the frames
/// the client missed are emitted one at a time against the *previous* envelope,
/// so a client that missed a finish plays out the moves and only then shows the
/// outcome. That is why the frame is carried alongside the snapshot rather than
/// read out of it.
class GameSession {
const GameSession({
required this.snapshot,
required this.frame,
this.previousFrame,
});

/// The envelope: status, roster, timing, config, and the immutable header.
final Session snapshot;

/// The frame to render, or null when there is nothing to render: the waiting
/// room, a game aborted before it started, or a viewer holding no seat.
final Frame? frame;

/// The frame [frame] replaced, or null when none was rendered before it.
///
/// Null on a cold load and on the first frame of a game, which is exactly when
/// a game must *not* animate: there is no predecessor the player saw, so the
/// cue is history rather than an event. Games read this through
/// `GameContentContext.transition`.
final Frame? previousFrame;

/// Monotonic per game, incremented by every commit. Totally orders snapshots
/// where `version` cannot, because a lobby change has none.
int get seq => snapshot.seq;

GameStatus get status => snapshot.status;

/// The version being rendered, which during a gap animation is behind
/// [Session.version]. Null in the lobby.
int? get version => frame?.version ?? snapshot.version;

/// Whether the game has reached a status nothing can move it out of.
bool get isTerminal =>
status == GameStatus.finished || status == GameStatus.aborted;

/// Advance to a newly stated snapshot.
GameSession applySnapshot(Session next) =>
GameSession(snapshot: next, frame: next.frame, previousFrame: frame);

/// Advance one recovered frame without touching the envelope.
///
/// Status, roster and `seq` hold still while a missed span plays out, so an
/// animation cannot render a finished game's banner over a mid-game move. The
/// authoritative snapshot lands last and supplies them.
GameSession applyGapFrame(Frame gap) =>
GameSession(snapshot: snapshot, frame: gap, previousFrame: frame);

/// Whether [next] should replace this session.
///
/// Ordinarily that is simply a higher `seq`, which resolves a command response
/// racing its own socket push, a duplicate delivery, and a reconnect that
/// missed nothing. The terminal clause is not a special case bolted on: a
/// finished or aborted game is absorbing, so such a snapshot needs no
/// ordering, and the abort teardown drops the storage `seq` lived in, so a
/// re-initialised object legitimately reports a lower one.
bool supersededBy(Session next) =>
next.seq > seq ||
(!isTerminal &&
(next.status == GameStatus.finished ||
next.status == GameStatus.aborted));
}
25 changes: 25 additions & 0 deletions lib/core/game/game_transition.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:eigen_flutter/core/game/game_frame.dart';

/// The step from one frame to the next: the unit a game animates.
///
/// Animation is the presentation of a frame *transition*, not of a state, and
/// not of a diff. Two frames are given rather than one because the step is what
/// carries meaning; what happened during it is read from [to]'s observation,
/// where `computeObservation` embedded the cues this seat is permitted to see.
/// Never diff [from] against [to] to work out the cause: a hidden move leaves no
/// visible footprint, two different causes can leave the same one, and a
/// composite resolution collapses into a single diff.
///
/// A game receives this as `GameContentContext.transition`, and it is null
/// exactly when animating would be wrong: a cold load, a rejoin, or the opening
/// frame. In those cases the newest frame is still correct and complete, so
/// render it statically and treat its cues as history rather than as events.
class GameTransition {
const GameTransition({required this.from, required this.to});

/// The frame the player was looking at.
final GameFrame from;

/// The frame now on screen, one version later.
final GameFrame to;
}
6 changes: 4 additions & 2 deletions lib/eigen_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ export 'package:eigen_api/eigen_api.dart'
Rating,
RatingDelta,
RatingIdentity,
Roster,
Seat,
SeatTypeEnum;
SeatTypeEnum,
Session;

export 'app_runner.dart' show runEngineApp, MyApp;

Expand All @@ -72,6 +72,8 @@ export 'core/config/app_config.dart'
export 'core/errors/engine_exception.dart';
export 'core/game/game_creation_spec.dart';
export 'core/game/game_frame.dart';
export 'core/game/game_session.dart';
export 'core/game/game_transition.dart';
export 'core/game/game_module.dart';
export 'core/game/game_player.dart';
export 'core/game/my_seat.dart';
Expand Down
Loading