boardgameio / boardgame.io

State Management and Multiplayer Networking for Turn-Based Games
https://boardgame.io
MIT License
9.93k stars 707 forks source link

Modeling Rounds for Sushi Go! #591

Closed iliu closed 4 years ago

iliu commented 4 years ago

First of all, I really enjoyed this framework! During shelter in place for covid this gives me a lot of joy to build games for friends to play!

I'm trying to build a simple sushi go game. The basic mechanics are: 3 rounds:

I was able to model the game currently by having just 1 move in turn to play the card, end the turn when everyone played a card, and use onEnd to score and re-deal if need. However, when the round is scored, i also want to show the breakdown, and have players confirm. So actually i want two types of turns, one for playing the card, and one for confirming the score after each round.

I tried a couple methods. Splitting the game into play card phase and score phase doesn't work because you can't go back to the same phase, so i can't use phases to model a round. I thought about using stages, but the round progresses globally for all players, and stages seem to be for an individual player. I tried using ctx.events.setActivePlayers to progress all players to state, but i'm calling it in onEnd, and it seems if i setStage or setPhase during onEnd, onEnd gets called twice because it ends the current phase.

The only solution i can think of is modeling my own "phase" in the game state and having these conditionals in the turn that checks the game "phase" and does different actions. But that seems like an architecture smell, and not an elegant solution in an otherwise elegant framework.

Is there anyway to model rounds and phases within the round that can be repeated?

delucis commented 4 years ago

@iliu Glad you’re staying safe!

The inability to retrigger the same phase has come up a few times recently, so it might be something to look at refactoring for scenarios where you have some onBegin and onEnd logic you want to trigger, but only really one phase.

A quick question: does the “confirm score” action actually change how the game progresses or is it more of a UI thing? If it’s mainly adding the ability for players to see that round’s scores, click OK, and keep playing, you might not need a move for that.

If confirming the score does have some impact on the game, or you want to keep the UI logic in the game state, I think you could use a score cards phase:

const phases = {
  play: {
    start: true,
    next: 'score',
    // deal cards when the play phase begins
    onBegin: (G, ctx) => dealCards(G, ctx),
    // end the play phase once all the cards are played
    endIf: G => G.allCardsArePlayed,
    // every move check if it’s the end of a round, and rotate hand if necessary
    onMove: (G, ctx) => {
      if (ctx.playOrderPos === ctx.playOrder.length - 1) {
        // is last player in round (assuming round starts with player 0)
        rotateHand(G);
      }
    },
    moves: {
      // playing a card should end the player’s turn
      playCard: (G, ctx) => ctx.events.endTurn(),
    },
  },

  score: {
    next: 'play',
    // compute scores at the start of the score phase
    onBegin: (G, ctx) => scoreRound(G, ctx),
    // end the score phase once all players have confirmed
    endIf: (G, ctx) => ctx.numMoves > 0 && ctx.activePlayers === null,
    turn: {
      // assuming here players can confirm in any order
      activePlayers: { all: 'confirm' },
      stages: {
        confirm: {
          moves: {
            // remove the player from activePlayers when they confirm the score
            confirmScore: (G, ctx) => ctx.events.endStage()
          },
        },
      },
    },
  },
}
iliu commented 4 years ago

that's great. it never occurred to me that i didn't need to keep the confirmation in the game state. The tradeoff is if the confirm state is in the client, we wouldn't be able to show everyone who isn't "ready" for the next round. But i think that's a minor tradeoff. I can just show the score breakdown as a modal to be dismissed purely on the client side.

a couple questions about your code:

  1. i thought that you can reenter phases? i think i looked through the boardgame.io code (didn't spend that much time though) and saw that you keep track of entered phases, and if you try to go to an already entered phase the game doesn't progress to it. Does using the next key to progress the phases work around it?
  2. is it possible to have next take in a function with game state and return the next state? Some games progress to different phases according to different conditions, i think that would still make a clean and consistent API to progress the game flow. Previous i tried using ctx.events.setPhase in the onEnd callback (after the game states has been calculated), but seem to be observing that onEnd gets called twice, presumably because it's "ending the phase/turn/stage" again.
  3. I didn't know there was an onMove callback! that's cool, is that a place to possibly mutate game state? What's the intuition to use that instead of logic directly within the move itself? does onMove get called on the server side and the move logic gets called on the client side?

Thanks for your responses!

delucis commented 4 years ago

The tradeoff is if the confirm state is in the client, we wouldn't be able to show everyone who isn't "ready" for the next round.

Yes, it depends on your use case. If you need to share a “waiting” indicator or whatever, you would likely want to keep this in the game state.

you keep track of entered phases, and if you try to go to an already entered phase the game doesn't progress to it

This applies to phases cycling while processing a single game action. If a game tries to enter a phase several times while making a move, the framework assumes there is some kind of infinite loop and doesn’t allow that. (That might happen if a move triggered some loop of endIf conditions for example.) Returning to phases used earlier in a game is no problem.

is it possible to have next take in a function with game state and return the next state?

That isn’t currently possible. Although it’s an interesting idea. In the phase.onEnd hook, the phase is already ending — potentially via a setPhase call — so you shouldn’t setPhase again there. Instead, you should centralise your phase ending logic. For example, if onMove is ending the phase, you could figure out what the next phase should be there.

is that a place to possibly mutate game state? What's the intuition to use that instead of logic directly within the move itself?

Yes, you can mutate state in onMove. And it’s a question of style what is move logic and what is hook logic. In theory, you could have moves set a field specifying which move to make in G and then do all the processing in onMove. (It’s not a very good idea though!) In general, I would use onMove for generic code that it makes sense to run for every move. A good example might be calculating a score that might change no matter which move a player made.

delucis commented 4 years ago

Oh, and with regards to server vs client processing. In general, you can think of everything happening on both. The client tries to process the entire action and provide a speedy update to the UI. Meanwhile, the server will process the same action and return the canonical state. The main exceptions to this are moves that use the randomness API, which run on the server only. There’s also a “long-form” move syntax, which lets you declare that a move should only run on the server. (See the Game docs for details.)

iliu commented 4 years ago

thanks! this clarifies things alot and i think i have some ideas on how to better structure the game!

iliu commented 4 years ago

oh sorry 1 last question:

if i call the setPhase/setTurn/setStage events in onMove for example, it will override the next phase/turn/stage that's set in the next key right? the game will not progress to the next state?

delucis commented 4 years ago

Yes — you can think of next as the value used when you call endPhase, but it’s overwritten when using setPhase. You don’t have to specify next in your game configuration either — if you’re always using setPhase, it’s superfluous.

Same for turns: turn.order.next is called to work out who plays next, but if you call endTurn({ next: '2' }) (there is no setTurn event), the next player will be player ID '2'.

Stages are a little different because they are designed to allow players to be in different stages simultaneously. I’d recommend checking out the docs for more details on those.