July 18, 2024

Mental Poker Part 9: Discard Game

For an overview on Mental Poker, see Mental Poker Part 0: An Overview. Other articles in this series: https://vladris.com/writings/index.html#mental-poker. In the previous post in the series we looked at building a simple game of rock-paper-scissors. In this post we'll look at implementing a card game.

Overview

We'll build a discard game - players take turns discarding a card that must match either the suit or the value of the card on top of the discard pile. The player who discards their whole hand first wins.

We're implementing a simple game as the focus is not on the game-specific logic, rather how to leverage the Mental Poker toolkit.

The full code for this is in the demos/discard app. The best way to read this post is side by side with the code.

We'll follow a similar structure to the rock-paper-scissors game we looked at in the previous post:

Model

First, let's look at how we implement the deck of cards and associated logic.

Deck

We'll represent a card as a string, for example "9:hearts" is the 9 of hearts. The function getDeck() initializes as unshuffled deck of cards:

function getDeck() {
    const deck: string[] = [];

    for (const value of ["9", "10", "J", "Q", "K", "A"]) {
        for (const suit of ["hearts", "diamonds", "clubs", "spades"]) {
            deck.push(value + ":" + suit);
        }
    }

    return deck;
}

We're using fewer cards (from 9 to Aces) for this demo as the more cards we have the more prime numbers we need to find to encrypt them and it slows things down. Rather than implementing some loading UI, we'll just use fewer cards for the example.

We need a helper function to tell us whether two cards match either value or suit:

function matchSuitOrValue(a: string, b: string) {
    const [aValue, aSuit] = a.split(":");
    const [bValue, bSuit] = b.split(":");

    return aValue === bValue || aSuit === bSuit;
}

Finally, we want a class to wrap a deck and implement the functions needed for using it:

class Deck {
    private myCards: number[] = [];
    private othersCards: number[] = [];
    private drawPile: number[] = [];
    private discardPile: number[] = [];

    private decryptedCards: (string | undefined)[] = [];
    private othersKeys: SRAKeyPair[] = [];

    constructor(
        private encryptedCards: string[],
        private myKeys: SRAKeyPair[],
        private store: RootStore
    ) {
        this.drawPile = encryptedCards.map((_, i) => i);
    }
...

We initialize the class with an array of encrypted cards (the shuffled deck) as encryptedCards, our set of SRA keys (myKeys) and the Redux store (store).

We also need to track cards (by index):

As the other player shares their encryption keys (when they reveal a card to us), we'll store them in the othersKeys array. Similarly, as we decrypt cards, we'll store them in decryptedCards - this is just for convenience, so we don't have to keep decrypting the same values over and over.

We assume we're starting with a shuffled deck of cards as a draw pile, with no player having cards in hand - so we initialize drawPile to the indexes of encryptedCards.

Some helper functions:

...
    
    getKey(index: number) {
        return SRAKeySerializationHelper.serializeSRAKeyPair(
            this.myKeys[index]
        );
    }

    getKeyFromHand(index: number) {
        return SRAKeySerializationHelper.serializeSRAKeyPair(
            this.myKeys[this.myCards[index]]
        );
    }

    cardAt(index: number) {
        if (!this.decryptedCards[index]) {
            const partial = SRA.decryptString(
                this.encryptedCards[index],
                this.myKeys[index]
            );

            this.decryptedCards[index] = SRA.decryptString(
                partial,
                this.othersKeys[index]
            );
        }

        return this.decryptedCards[index]!;
    }

    getDrawIndex() {
        return this.drawPile[0];
    }
    
    canIMove() {
        if (this.discardPile.length === 0) {
            return true;
        }

        return (
            this.drawPile.length > 0 ||
            this.myCards.some((index) =>
                matchSuitOrValue(
                    this.cardAt(index),
                    this.cardAt(this.discardPile[this.discardPile.length - 1])
                )
            )
        );
    }
    ...

These are pretty self-explanatory:

We also need to implement some functions that mutate the deck (in which case we also need to update our view-model so our UI reflects the changes):

...
    async myDraw(serializedSRAKeyPair: SerializedSRAKeyPair) {
        const index = this.drawPile.shift()!;
        this.myCards.push(index);
        this.othersKeys[index] =
            SRAKeySerializationHelper.deserializeSRAKeyPair(
                serializedSRAKeyPair
            );

        await this.updateViewModel();
    }

    async othersDraw() {
        this.othersCards.push(this.drawPile.shift()!);

        await this.updateViewModel();
    }

    async myDiscard(index: number) {
        const cardIndex = this.myCards.splice(index, 1)[0];
        this.discardPile.push(cardIndex);

        this.updateViewModel();
    }

    async othersDiscard(
        index: number,
        serializedSRAKeyPair: SerializedSRAKeyPair
    ) {
        const cardIndex = this.othersCards.splice(index, 1)[0];
        this.othersKeys[cardIndex] =
            SRAKeySerializationHelper.deserializeSRAKeyPair(
                serializedSRAKeyPair
            );
        this.discardPile.push(cardIndex);

        this.updateViewModel();
    }
    ...

The actions are:

Note all these functions end up calling updateViewModel(). That's because all of the functions change state, so we need to update our Redux store and reflect the changes on the UI:

...
    private async updateViewModel() {
        await this.store.dispatch(
            updateDeckViewModel({
                drawPile: this.drawPile.length,
                discardPile: this.discardPile.map((i) => this.cardAt(i)),
                myCards: this.myCards.map((i) => this.cardAt(i)),
                othersHand: this.othersCards.length,
            })
        );
    }
}

We haven't looked at the Redux store yet. We'll cover this later on but here we dispatch a deck view-model update. The deck view-model contains the size of the draw pile, the cards in the discard pile and our hand, and the number of cards in the other player's hand.

type DeckViewModel = {
    drawPile: number;
    discardPile: string[];
    myCards: string[];
    othersHand: number;
};

const defaultDeckViewModel: DeckViewModel = {
    drawPile: 0,
    discardPile: [],
    myCards: [],
    othersHand: 0,
};

These is all the deck management logic we need. Let's move on to game actions.

Dealing

We'll be using the library-provided shuffle. We covered this in part 6 so we won't go over it again. This is exposed by as a shuffle() function. So assuming our deck is shuffled, the first action we need to handle is dealing cards. In Mental Poker, dealing a card to Bob means Alice needs to share her key to that card. Then Bob can use his key and Alice's key to see the card, while Alice cannot see it since she doesn't have Bob's key. This is the equivalent of Bob holding a card in his hand.

We define a DealAction:

type DealAction = {
    clientId: ClientId;
    type: "DealAction";
    cards: number[];
    keys: SerializedSRAKeyPair[];
}

Here, cards are the indexes of the cards in the deck and keys are the corresponding SRA keys for each card. Here's the state machine for dealing cards to both players:

async function deal(imFirst: boolean, count: number) {
    const queue = store.getState().queue.value!;

    await store.dispatch(updateGameStatus("Dealing"));

    const cards = new Array(count).fill(0).map((_, i) => imFirst ? i + count : i);
    const keys = cards.map((card) => store.getState().deck.value!.getKey(card)!);

    await sm.run(sm.sequence([
        sm.local(async (queue: IQueue<Action>, context: RootStore) => {
            await queue.enqueue({ 
                clientId: context.getState().id.value, 
                type: "DealAction",
                cards,
                keys });
        }),
        sm.repeat(sm.transition(async (action: DealAction, context: RootStore) => {
            if (action.type !== "DealAction") {
                throw new Error("Invalid action type");
            }

            if (action.clientId === context.getState().id.value) {
                return;
            }

            const deck = context.getState().deck.value!;

            for (let i = 0; i < action.cards.length; i++) {
                if (imFirst) {
                    if (action.cards[i] !== i) {
                        throw new Error("Unexpected card index");
                    }
                    await deck.myDraw(action.keys[i]);
                } else {
                    await deck.othersDraw();
                }
            }

            for (let i = 0; i < action.cards.length; i++) {
                if (imFirst) {
                    await deck.othersDraw();
                } else {
                    if (action.cards[i] !== i + action.cards.length) {
                        throw new Error("Unexpected card index");
                    }
                    await deck.myDraw(action.keys[i]);
                }
            }
        }), 2)
    ]), queue, store);
}

In preparation of dealing, we:

With this done, our state machine consists of:

Local transitions and remote transitions are explained in part 5, in which we talked about the state machine.

Drawing cards

Drawing a card is a two-step process. We need to tell the other player we intend to draw a card (from the draw pile), and they need to give us their key to that card. Similarly, if the other player tells us they want to draw a card, we give them our key to that card.

We need two actions:

type DrawRequestAction = {
    clientId: ClientId;
    type: "DrawRequest";
    cardIndex: number;
}

type DrawResponseAction = {
    clientId: ClientId;
    type: "DrawResponse";
    cardIndex: number;
    key: SerializedSRAKeyPair;
}

If we want to draw a card, here is our state machine:

async function drawCard() {
    const queue = store.getState().queue.value!;

    await store.dispatch(updateGameStatus("Waiting"));

    await sm.run([
        sm.local(async (queue: IQueue<Action>, context: RootStore) => {
            await queue.enqueue({ 
                clientId: context.getState().id.value, 
                type: "DrawRequest",
                cardIndex: context.getState().deck.value!.getDrawIndex() });
        }),
        sm.transition((action: DrawRequestAction) => {
            if (action.type !== "DrawRequest") {
                throw new Error("Invalid action type");
            }
        }),
        sm.transition(async (action: DrawResponseAction, context: RootStore) => {
            if (action.type !== "DrawResponse") {
                throw new Error("Invalid action type");
            }

            await context.getState().deck.value!.myDraw(action.key);
        }),
    ], queue, store);

    await store.dispatch(updateGameStatus("OthersTurn"));
    await waitForOpponent();
}

We again get the async queue from the store and update the game status. Then we run the state machine consisting of 3 transitions:

Finally, after running the state machine and drawing the card, we update the game status again to other player's turn and call waitForOpponent(), which we'll cover later.

This fully implements us drawing a card from the top of the discard pile and updating the deck.

Discarding cards

Similar to drawing cards, we need to implement discarding cards. Discarding a card is easier - we don't need a key from the other player, rather we just provide the key to the card we're discarding such that the other player can see it.

type DiscardRequestAction = {
    clientId: ClientId;
    type: "DiscardRequest";
    cardIndex: number;
    key: SerializedSRAKeyPair;
}

Our DiscardRequestAction contains the card index and our key.

The corresponding state machine:

async function discardCard(index: number) {
    const queue = store.getState().queue.value!;

    await store.dispatch(updateGameStatus("Waiting"));

    await sm.run([
        sm.local(async (queue: IQueue<Action>, context: RootStore) => {
            await queue.enqueue({
                clientId: context.getState().id.value, 
                type: "DiscardRequest",
                cardIndex: index,
                key: context.getState().deck.value!.getKeyFromHand(index)});
        }),
        sm.transition(async (action: DiscardRequestAction, context: RootStore) => {
            if (action.type !== "DiscardRequest") {
                throw new Error("Invalid action type");
            }

            await context.getState().deck.value!.myDiscard(action.cardIndex);
        }),
    ], queue, store);

    if (store.getState().deckViewModel.value.myCards.length === 0) {
        await store.dispatch(updateGameStatus("Win"));
    } else {
        await store.dispatch(updateGameStatus("OthersTurn"));
        await waitForOpponent();
    }
}

As usual, we get the queue and update game state. Then we run the state machine:

After running the state machine, we need to check whether we discarded the last card in our hand. If we did, we can update the game state to us winning. Otherwise we wait for the other player's move.

Can't move

The last action we need to look at is the situation in which we can't discard any card (no matching suit or value) and we also can't draw a card (draw pile is empty). In this case we lose the game. Since it is our turn, we need to let the other player know that we're not pondering our next move, rather that we can't do anything and we lose. We'll model this as a simple CantMoveAction:

type CantMoveAction = {
    clientId: ClientId;
    type: "CantMove";
}

This action has no payload. The state machine is also very simple:

async function cantMove() {
    const queue = store.getState().queue.value!;

    await queue.enqueue({ 
        clientId: store.getState().id.value, 
        type: "CantMove" });

    await store.dispatch(updateGameStatus("Loss"));
}

At the end of it, we update the game status to us losing.

So far, we have the 3 possible actions we can take when it is our turn:

Next, we need to model responding to the other player's move.

Opponent's turn

The opponent can take the same actions as we can, so we don't need to declare any new action types, rather we need a state machine that responds to actions incoming from the other player:

async function waitForOpponent() {
    const queue = store.getState().queue.value!;

    const othersAction = await queue.dequeue();

    switch (othersAction.type) {
        case "DrawRequest":
            await sm.run([
                sm.local(async (queue: IQueue<Action>, context: RootStore) => {
                    if (othersAction.cardIndex !== store.getState().deck.value!.getDrawIndex()) {
                        throw new Error("Invalid card index for draw");
                    }

                    await queue.enqueue({
                        clientId: store.getState().id.value,
                        type: "DrawResponse",
                        cardIndex: othersAction.cardIndex,
                        key: store.getState().deck.value!.getKey(othersAction.cardIndex)
                    })}),
                sm.transition(async (action: DrawResponseAction, context: RootStore) => {
                    if (action.type !== "DrawResponse") {
                        throw new Error("Invalid action type");
                    }

                    await context.getState().deck.value!.othersDraw();
                })], queue, store);
            await store.dispatch(updateGameStatus("MyTurn"));
            break;
        case "DiscardRequest":
            await store.getState().deck.value!.othersDiscard(othersAction.cardIndex, othersAction.key);

            if (store.getState().deckViewModel.value.othersHand === 0) {
                await store.dispatch(updateGameStatus("Loss"));
            } else if (store.getState().deck.value?.canIMove()) {
                await store.dispatch(updateGameStatus("MyTurn"));
            } else {
                await cantMove();
            }

            break;
        case "CantMove":
            await store.dispatch(updateGameStatus("Win"));
            break;
        }
}

We dequeue an action, then we respond based on its type:

Note for the discard request, to keep things simple, we aren't checking whether the move is legal. If we want to secure the implementation, we should check that the card the other player is discarding matches either the suit or value of the card on top of the discard pile.

Actions and status

We already covered all possible actions:

type Action = DealAction | DrawRequestAction | DrawResponseAction | DiscardRequestAction | CantMoveAction;

The possible game status:

type GameStatus = "Waiting" | "Shuffling" | "Dealing" | "MyTurn" | "OthersTurn" | "Win" | "Loss" | "Draw";

We just implemented all the game logic - the possible actions a player can take, and the request/response needed to model the game of discard. We have the full model, so let's move on to the Redux store.

Store

Like in the previous post, we will be using Redux and the Redux Toolkit.

The sate we'll be maintaining:

Using createAction from the Redux Toolkit:

const updateId = createAction<string>("id/update");
const updateOtherPlayer = createAction<string>("otherPlayer/update");
const updateQueue = createAction<IQueue<Action>>("queue/update");
const updateGameStatus = createAction<GameStatus>("gameStatus/update");
const updateDeck = createAction<Deck>("deck/update");
const updateDeckViewModel = createAction<DeckViewModel>("deckViewModel/update");

We'll also use the same helper to create Redux reducers as for rock-paper-scissors:

function makeUpdateReducer<T>(
    initialValue: T,
    updateAction: ReturnType<typeof createAction>
) {
    return createReducer({ value: initialValue }, (builder) => {
        builder.addCase(updateAction, (state, action) => {
            state.value = action.payload;
        });
    });
}

Our Redux store is:

const store = configureStore({
    reducer: {
        id: makeUpdateReducer("", updateId),
        otherPlayer: makeUpdateReducer("Not joined", updateOtherPlayer),
        queue: makeUpdateReducer<IQueue<Action> | undefined>(
            undefined,
            updateQueue
        ),
        gameStatus: makeUpdateReducer("Waiting", updateGameStatus),
        deck: makeUpdateReducer<Deck | undefined>(undefined, updateDeck),
        deckViewModel: makeUpdateReducer<DeckViewModel>(defaultDeckViewModel, updateDeckViewModel),
    },
    middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware({
            serializableCheck: false,
        }),
});

This is all we need to connect the model with the view.

UI

We'll use React.

Card

The first component we need is a card:

type CardViewProps = {
    card: string | undefined;
    onClick?: () => void;
};

const suiteMap = new Map([
    ["hearts", "♥"],
    ["diamonds", "♦"],
    ["clubs", "♣"],
    ["spades", "♠"]
]);

const CardView: React.FC<CardViewProps> = ({ card, onClick }) => {
    const number = card?.split(":")[0];
    const suite = card ? suiteMap.get(card.split(":")[1]) : undefined;
    const color = suite === "♥" || suite === "♦" ? "red" : "black";

    return <div style={{ width: 70, height: 100, borderColor: "black", borderWidth: 1, borderStyle: "solid", borderRadius: 5, 
                    backgroundColor: card ? "white" : "darkred"}} onClick={onClick}>
        <div style={{ display: card ? "block" : "none", paddingLeft: 15, paddingRight: 15, color }}>
            <p style={{ marginTop: 20, marginBottom: 0, textAlign: "left", fontSize: 25 }}>{number}</p>
            <p style={{ marginTop: 0, textAlign: "right", fontSize: 30 }}>{suite}</p>
        </div>
    </div>
}

This renders a card which can be a string or undefined. If it is a string, we render the value and suit. Otherwise we render the back of the card - a dark red rectangle. Cards have an optional onClick() event.

Hand

A HandView renders several cards:

type HandViewProps = {
    prefix: string;
    cards: (string | undefined)[];
    onClick?: (index: number) => void;
};

const HandView: React.FC<HandViewProps> = ({ cards, prefix, onClick }) => {
    return <div style={{ display: "flex", flexDirection: "row", justifyContent: "center" }}>{
            cards.map((card, i) => <CardView key={prefix + ":" + i} card={ card } onClick={() => { if (onClick) { onClick(i) } }} />)
        }
    </div>
}

This can be the player's hand, where we should have string values for each card and an onClick() event hooked up for when the player clicks on a card to discard it. It can also be the other player's hand, in which case we should have undefined values for each card and just show their backs.

Table

MainView implements a view of the whole table:

const useSelector: TypedUseSelectorHook<RootState> = useReduxSelector;

const MainView = () => {
    const idSelector = useSelector((state) => state.id);
    const otherPlayer = useSelector((state) => state.otherPlayer);
    const gameStateSelector = useSelector((state) => state.gameStatus);
    const deckViewModel = useSelector((state) => state.deckViewModel);

    const myTurn = gameStateSelector.value === "MyTurn";

    const canDiscard = (index: number) => {
        if (deckViewModel.value.discardPile.length === 0) {
            return true;
        }

        return matchSuitOrValue(
            deckViewModel.value.myCards[index],
            deckViewModel.value.discardPile[deckViewModel.value.discardPile.length - 1]);
    }

    return <div>
        <div>
            <p>Id: {idSelector.value}</p>
            <p>Other player: {otherPlayer.value}</p>
            <p>Status: {gameStateSelector.value}</p>
        </div>
        <div style={{ height: 200, textAlign: "center" }}>
            <HandView prefix={"others"} cards={ new Array(deckViewModel.value.othersHand).fill(undefined) } />
        </div>
        <div style={{ height: 200, display: "flex", flexDirection: "row", justifyContent: "center" }}>
            <div style={{ display: deckViewModel.value.drawPile > 0 ? "block" : "none", margin: 5 }} onClick={() => { if (myTurn) { drawCard()} }}>
                <span>{deckViewModel.value.drawPile} card{deckViewModel.value.drawPile !== 1 ? "s" : ""}</span>
                <CardView card={ undefined } />
            </div>
            <div style={{ display: deckViewModel.value.discardPile.length > 0 ? "block" : "none", margin: 5 }}>
                <span>{deckViewModel.value.discardPile.length} card{deckViewModel.value.discardPile.length !== 1 ? "s" : ""}</span>
                <CardView card={ deckViewModel.value.discardPile[deckViewModel.value.discardPile.length - 1] } />
            </div>
        </div>
        <div style={{ height: 200, textAlign: "center" }}>
            <HandView
                prefix={"mine"}
                cards={ deckViewModel.value.myCards }
                onClick={(index) => { if (myTurn && canDiscard(index)) { discardCard(index) } }} />
        </div>
    </div>

This consists of:

If it is our turn, we hook up drawCard() to the draw pile's onClick() and for each card we can discard, we hook up discardCard() to the card's onClick().

And that's it. Rendering it all on the page:

const root = ReactDOM.createRoot(document.getElementById("root")!);
root.render(
    <Provider store={store}>
        <MainView />
    </Provider>
);

Here, Provider comes from the react-redux package and makes the Redux store available to the React components.

Initialization

Like with rock-paper-scissors, let's look at how we initialize the game:

getLedger<Action>().then(async (ledger) => {
    const id = randomClientId();

    await store.dispatch(updateId(id));

    const queue = await upgradeTransport(2, id, ledger);

    await store.dispatch(updateQueue(queue));

    for (const action of ledger.getActions()) {
        if (action.clientId !== id) {
            await store.dispatch(updateOtherPlayer(action.clientId));
            break;
        }
    }

    const [sharedPrime, turnOrder] = await establishTurnOrder(2, id, queue);

    await store.dispatch(updateGameStatus("Shuffling"));

    const [keys, deck] = await shuffle(id, turnOrder, sharedPrime, getDeck(), queue, 64);
 
    const imFirst = turnOrder[0] === id;

    await store.dispatch(updateDeck(new Deck(deck, keys, store)));

    await deal(imFirst, 5);

    await store.dispatch(updateGameStatus(imFirst ? "MyTurn" : "OthersTurn"));

    if (!imFirst) {
        await waitForOpponent();
    }
});

This initialization is a bit longer than the one for rock-paper-scissors, since we have to shuffle and deal cards, and the order in which the players go is important.

Summary

We looked at implementing a discard card game using the Mental Poker toolkit. The full source code for the demo is under demos/discard.

We finally put the whole toolkit to its intended use and built an end-to-end interactive, 2-player card game.