Conversation
A multiplayer board game example that demonstrates real-time state synchronization using IceRPC streams. The server pushes a snapshot followed by incremental event updates to each client, showcasing bidirectional communication, streaming, discriminated unions, and request context.
There was a problem hiding this comment.
Pull request overview
This PR adds a new example demonstrating a multiplayer "Game of the Goose" board game using IceRPC with Slice. The example showcases real-time state synchronization through bidirectional streaming, where a server maintains authoritative game state and pushes updates to connected clients via snapshot-then-deltas pattern. It demonstrates key IceRPC/Slice features including bidirectional communication, discriminated unions, streaming, and request context for session management.
Changes:
- Added complete Game of the Goose example with client/server architecture
- Demonstrates snapshot + delta streaming pattern for real-time state sync
- Shows use of discriminated unions for heterogeneous game events
- Implements session-based authentication using IceRPC request context
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| examples/slice/README.md | Added GameOfTheGoose entry to the examples index |
| examples/slice/GameOfTheGoose/slice/Game.slice | Defines Slice interfaces, structs, and discriminated unions for game protocol |
| examples/slice/GameOfTheGoose/Server/Server.csproj | Server project configuration with IceRPC dependencies |
| examples/slice/GameOfTheGoose/Server/Program.cs | Server entry point with command-line options for player count |
| examples/slice/GameOfTheGoose/Server/GameUpdateSource.cs | Channel-based streaming source for game updates |
| examples/slice/GameOfTheGoose/Server/GameService.cs | IceRPC service implementing GameRoom and GameSession interfaces |
| examples/slice/GameOfTheGoose/Server/Game.cs | Core game logic with board rules and turn management |
| examples/slice/GameOfTheGoose/Server/Board.cs | Game board constants defining spaces and rules |
| examples/slice/GameOfTheGoose/README.md | Documentation explaining the example, game rules, and usage |
| examples/slice/GameOfTheGoose/GameOfTheGoose.slnx | Solution file for the example |
| examples/slice/GameOfTheGoose/Client/Program.cs | Client application with event handling and user interaction |
| examples/slice/GameOfTheGoose/Client/GameUpdateSinkService.cs | Service receiving game updates from server |
| examples/slice/GameOfTheGoose/Client/GameState.cs | Thread-safe client-side game state management |
| examples/slice/GameOfTheGoose/Client/Client.csproj | Client project configuration with IceRPC dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $"{state.Players[extra.Player].Name} gets an extra turn (goose)", | ||
|
|
||
| GameEvent.SkipTurn skip => | ||
| $"{state.Players[skip.Player].Name}'s turn will be skipped (trap)", |
There was a problem hiding this comment.
The message "turn will be skipped" uses future tense, but according to the GameEvent.SkipTurn documentation, this event is emitted when "A player's turn is being skipped" (present continuous), not when they will skip a turn in the future. The message should be updated to reflect the present tense, such as "turn skipped" or "is skipping their turn".
| $"{state.Players[skip.Player].Name}'s turn will be skipped (trap)", | |
| $"{state.Players[skip.Player].Name}'s turn is being skipped (trap)", |
The SkipTurn event was used for two different meanings: landing on a trap space and actually skipping a turn during advancement. Split the first case into a new Trapped event for clarity.
| } | ||
| while (string.IsNullOrEmpty(playerName)); | ||
|
|
||
| var sink = new GameUpdateSinkService(); |
There was a problem hiding this comment.
I think the sink variable deserves a comment. As well as maybe one where we assign it as the dispatcher for the connection.
|
|
||
| if (snapshot.State.Status is GameStatus.Started) | ||
| { | ||
| Console.WriteLine(state.Turn == myPlayerIndex |
There was a problem hiding this comment.
Can we call FormatEvent on this?
| /// <summary>The number of spaces on the board. Players start at space 0 and the first to reach space 39 | ||
| /// wins.</summary> |
There was a problem hiding this comment.
| /// <summary>The number of spaces on the board. Players start at space 0 and the first to reach space 39 | |
| /// wins.</summary> | |
| /// <summary>The number of spaces on the board.</summary> |
| /// <summary>Goose spaces that move you to the next goose space and grant you an extra turn. If you land on | ||
| /// space 5, you move to space 9 and get an extra turn. Landing on the last goose space moves you to the final | ||
| /// track space.</summary> | ||
| public static readonly ImmutableList<byte> GooseSpaces = [5, 9, 14, 18, 23, 27, 32]; | ||
|
|
||
| /// <summary>Bad luck spaces that cause you to skip your next turn.</summary> |
There was a problem hiding this comment.
I think we should use keep using third-person point of view.
| /// <summary>Goose spaces that move you to the next goose space and grant you an extra turn. If you land on | |
| /// space 5, you move to space 9 and get an extra turn. Landing on the last goose space moves you to the final | |
| /// track space.</summary> | |
| public static readonly ImmutableList<byte> GooseSpaces = [5, 9, 14, 18, 23, 27, 32]; | |
| /// <summary>Bad luck spaces that cause you to skip your next turn.</summary> | |
| /// <summary>Goose spaces that move the player to the next goose space and grant an extra turn. If the player lands on | |
| /// space 5, they move to space 9 and get an extra turn. Landing on the last goose space moves the player to the final | |
| /// track space.</summary> | |
| public static readonly ImmutableList<byte> GooseSpaces = [5, 9, 14, 18, 23, 27, 32]; | |
| /// <summary>Bad luck spaces that cause the player to skip their next turn.</summary> |
| } | ||
|
|
||
| /// <summary>Rolls dice for the given player and applies board rules.</summary> | ||
| internal Result<byte, GameError> RollDice(byte playerIndex, List<GameEvent> events) |
There was a problem hiding this comment.
I'm having a hard time finding the line between what logic has handled by Game and what's the responsibility of the caller.
For example AddPlayer doesn't check the player count is too high and relies on the caller to never mess this up.
However, RollDice has various checks and returns a Result when arguably the caller should deal with this.
There was a problem hiding this comment.
I will prefer to keep/move the game rules here, and keep the services more focused on the IceRPC side, streaming events, handling the session.
| } | ||
| catch (Exception ex) | ||
| { | ||
| Console.WriteLine($"[StreamUpdates] exception: {ex}"); |
There was a problem hiding this comment.
If a client is having issues don't we want to just tear down the whole game. Otherwise won't the clients start to hang waiting for their turn?
There was a problem hiding this comment.
We should probably add a disconnected event, and let rest of the player continues.
I initially wanted to show how you can implement session recovery, get a new snapshot of the state, and receive events. Where we show how session != connection model works here. But I didn't want to add much more code in this initial version.
| players: [cs::type("List<Player>")] Sequence<Player> | ||
|
|
||
| /// The current status of the game | ||
| status: GameStatus |
There was a problem hiding this comment.
Can we ever return a GameStatus::Finished state? If you join a finished game?
There was a problem hiding this comment.
Need to check what I implemented, but the idea was to return an error like GameAlreadyFinished.
| ? $"Joined! The game has started. {Ansi.Bold}{Ansi.Yellow}It's your turn{Ansi.Reset}" | ||
| : $"Joined! The game has started. It's {state.Players[state.Turn].Name}'s turn"); | ||
| } | ||
| else |
There was a problem hiding this comment.
Else can't this be game finished?
There was a problem hiding this comment.
No, joining a finished game returns a JoinResult.Failure, handled above.
| /// A new player has joined the game | ||
| PlayerJoined(player: Player) | ||
|
|
||
| /// The dice have been rolled by the player whose turn it is, and the result of the roll is available. |
There was a problem hiding this comment.
As I understand it, the result of the dice roll decides the next event(s) (player moved, trapped, etc.).
So, you could just send the dice roll and let each client compute what comes next.
This suggests to me you should not send the dice roll: keep this private to the server and only distribute "visible" updates to the client.
But keeping the dice roll private to the server goes against the spirit of the game (each player wants to know what's happening).
The main issue here is the Game of the Goose is (as far as I can tell) a deterministic game. Each dice roll is all you need to figure out mechanically a bunch of derived events.
There was a problem hiding this comment.
The main issue here is the Game of the Goose is (as far as I can tell) a deterministic game. Each dice roll is all you need to figure out mechanically a bunch of derived events.
That is correct.
| // Listen for game events in the background. Canceled on quit or Ctrl+C. | ||
| using var cts = new CancellationTokenSource(); | ||
| _ = CancelKeyPressed.ContinueWith(_ => cts.Cancel(), TaskScheduler.Default); | ||
| ulong expectedSeq = snapshot.LastSeq + 1; |
There was a problem hiding this comment.
I don't think we should transmit a field to check our code is not buggy. That's an odd thing to do.
Unless I am missing something: if there is no bug in our code, can we ever hit this out-of-order update?
| } | ||
|
|
||
| /// The result of a join operation. | ||
| enum JoinResult |
There was a problem hiding this comment.
We should use Result<Success, Failure>, not create our own enum.
| /// @param snapshot: The game state at the time the player joined. | ||
| /// @param event: A stream of game events that occurred after the snapshot. The first event | ||
| /// has a sequence number of snapshot.lastSeq + 1. | ||
| onGameUpdate(snapshot: GameSnapshot, event: stream GameUpdate) |
There was a problem hiding this comment.
This is no a good operation name for a stream of updates. onGameUpdate suggests you get a call for each update.
| gameEvent: GameEvent | ||
| } | ||
|
|
||
| interface GameUpdateSink |
There was a problem hiding this comment.
It's not clear to me why you picked this bidir design over a simpler call like:
struct PlayerReady {
playerIndex: uint8
session: string
snapshot: GameSnapshot
}
join(playerName: string) ->
(result: Result<PlayerReady, GameError>, updates: stream GameUpdate)Making calls "the other way around" is about acknowledgement, which becomes murkier with streams. Here, it would be that the server wants to make sure that once it's sent the GameFinished update, the client sends a "void" to acknowledge it received all updates? Doesn't sound obvious.
| by a stream of incremental event updates. Each client applies these events locally to stay in sync with the server, | ||
| which is a common pattern for games, chat rooms, and other interactive applications. | ||
|
|
||
| Key concepts demonstrated: |
There was a problem hiding this comment.
I would try to simplify and drop non-core features.
For example:
- it's not clear that bidir is needed here (see other comments)
- the session IDs aren't core to this example
A multiplayer board game example that demonstrates real-time state synchronization using IceRPC streams.
The server pushes a snapshot followed by incremental event updates to each client, showcasing bidirectional communication, streaming, discriminated unions, and request context.