summaryrefslogtreecommitdiff
path: root/pkg/ui/views
diff options
context:
space:
mode:
authorSanto Cariotti <santo@dcariotti.me>2025-04-24 13:53:54 +0200
committerSanto Cariotti <santo@dcariotti.me>2025-04-24 13:53:54 +0200
commitb35829071ab0d0f3479021eac151b90e49a2fca5 (patch)
tree0e2af88ed2732e2211ffbf5689488e010174783a /pkg/ui/views
parent4ae96a216eb50ccec7712fa9ed0d4dc8d9950f68 (diff)
Play co-op 2 vs 2
Diffstat (limited to 'pkg/ui/views')
-rw-r--r--pkg/ui/views/game.go30
-rw-r--r--pkg/ui/views/game_api.go36
-rw-r--r--pkg/ui/views/game_keymap.go4
-rw-r--r--pkg/ui/views/game_moves.go11
-rw-r--r--pkg/ui/views/game_restore.go39
-rw-r--r--pkg/ui/views/game_util.go18
-rw-r--r--pkg/ui/views/play_api.go93
-rw-r--r--pkg/ui/views/play_keymap.go51
-rw-r--r--pkg/ui/views/play_util.go4
9 files changed, 212 insertions, 74 deletions
diff --git a/pkg/ui/views/game.go b/pkg/ui/views/game.go
index afb81f5..a298de7 100644
--- a/pkg/ui/views/game.go
+++ b/pkg/ui/views/game.go
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/boozec/rahanna/internal/api/database"
+ "github.com/boozec/rahanna/pkg/p2p"
"github.com/boozec/rahanna/pkg/ui/multiplayer"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
@@ -93,18 +94,17 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m, cmd = m.handleChessMoveMsg(msg)
cmds = append(cmds, cmd)
case SendRestoreMsg:
- cmd = m.handleSendRestoreMsg()
+ cmd = m.handleSendRestoreMsg(p2p.NetworkID(msg))
cmds = append(cmds, cmd)
case RestoreMoves:
cmd = m.handleRestoreMoves(msg)
cmds = append(cmds, cmd)
-
case database.Game:
m, cmd = m.handleDatabaseGameMsg(msg)
cmds = append(cmds, cmd, m.updateMovesListCmd())
case EndGameMsg:
if msg.abandoned {
- if m.network.Me() == m.playerPeer(1) {
+ if m.network.Me() == m.playerPeer(1) || m.network.Me() == m.playerPeer(3) {
m.game.Outcome = string(chess.WhiteWon)
} else {
m.game.Outcome = string(chess.BlackWon)
@@ -115,7 +115,7 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = m.network.Close()
case RestoreGameMsg:
- m.network.Send([]byte("restore"), []byte(m.network.Me()))
+ m.network.SendAll([]byte("restore"), []byte(m.network.Me()))
m.restore = false
case error:
@@ -135,8 +135,7 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil {
m.err = err
} else {
- m.turn++
- m.network.Send([]byte("new-move"), []byte(moveStr))
+ m.network.SendAll([]byte("new-move"), []byte(moveStr))
m.err = nil
}
cmds = append(cmds, m.getMoves(), m.updateMovesListCmd())
@@ -187,12 +186,12 @@ func (m GameModel) View() string {
switch m.game.Outcome {
case string(chess.WhiteWon):
outcome = "White won"
- if m.network.Me() == m.playerPeer(1) {
+ if m.network.Me() == m.playerPeer(1) || m.network.Me() == m.playerPeer(3) {
outcome += " (YOU)"
}
case string(chess.BlackWon):
outcome = "Black won"
- if m.network.Me() == m.playerPeer(2) {
+ if m.network.Me() == m.playerPeer(2) || m.network.Me() == m.playerPeer(4) {
outcome += " (YOU)"
}
case string(chess.Draw):
@@ -243,9 +242,22 @@ func (m GameModel) View() string {
errorStr = m.err.Error()
}
+ var playersHeader string
+ switch m.game.Type {
+ case database.SingleGameType:
+ playersHeader = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#f1c40f")).
+ Render(fmt.Sprintf("♔ %s vs ♚ %s", m.game.Player1.Username, m.game.Player2.Username))
+ case database.PairGameType:
+ playersHeader = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#f1c40f")).
+ Render(fmt.Sprintf("♔ %s - %s vs ♚ %s - %s",
+ m.game.Player1.Username, m.game.Player3.Username, m.game.Player2.Username, m.game.Player4.Username))
+ }
+
content := lipgloss.JoinVertical(
lipgloss.Center,
- lipgloss.NewStyle().Foreground(lipgloss.Color("#f1c40f")).Render(fmt.Sprintf("♔ %s vs ♚ %s", m.game.Player1.Username, m.game.Player2.Username)),
+ playersHeader,
lipgloss.JoinHorizontal(
lipgloss.Top,
availableMovesListView,
diff --git a/pkg/ui/views/game_api.go b/pkg/ui/views/game_api.go
index 3c649f9..2f32f70 100644
--- a/pkg/ui/views/game_api.go
+++ b/pkg/ui/views/game_api.go
@@ -6,7 +6,6 @@ import (
"os"
"github.com/boozec/rahanna/internal/api/database"
- "github.com/boozec/rahanna/pkg/p2p"
tea "github.com/charmbracelet/bubbletea"
"github.com/notnil/chess"
)
@@ -16,18 +15,29 @@ func (m GameModel) handleDatabaseGameMsg(msg database.Game) (GameModel, tea.Cmd)
var cmd tea.Cmd
- // Establish peer connection
- if m.network.Peer() == p2p.EmptyNetworkID {
- if m.network.Me() == m.playerPeer(1) {
- if m.game.IP2 != "" {
- remote := m.game.IP2
- m.network.AddPeer(m.playerPeer(2), remote)
- }
- } else {
- if m.game.IP1 != "" {
- remote := m.game.IP1
- m.network.AddPeer(m.playerPeer(1), remote)
- }
+ peers := map[int]string{
+ 1: m.game.IP1,
+ 2: m.game.IP2,
+ 3: m.game.IP3,
+ 4: m.game.IP4,
+ }
+
+ myPlayerNum := -1
+ switch m.network.Me() {
+ case m.playerPeer(1):
+ myPlayerNum = 1
+ case m.playerPeer(2):
+ myPlayerNum = 2
+ case m.playerPeer(3):
+ myPlayerNum = 3
+ case m.playerPeer(4):
+ myPlayerNum = 4
+ }
+
+ // Add all peers to every other peer
+ for playerNum, ip := range peers {
+ if playerNum != myPlayerNum && ip != "" {
+ m.network.AddPeer(m.playerPeer(playerNum), ip)
}
}
diff --git a/pkg/ui/views/game_keymap.go b/pkg/ui/views/game_keymap.go
index ceacfc1..faf794a 100644
--- a/pkg/ui/views/game_keymap.go
+++ b/pkg/ui/views/game_keymap.go
@@ -38,13 +38,13 @@ func (m GameModel) handleKeyMsg(msg tea.KeyMsg) (GameModel, tea.Cmd) {
// Abandon game only if it is not finished
if m.game.Outcome == "*" {
var outcome string
- if m.network.Me() == m.playerPeer(1) {
+ if m.network.Me() == m.playerPeer(1) || m.network.Me() == m.playerPeer(3) {
outcome = string(chess.BlackWon)
} else {
outcome = string(chess.WhiteWon)
}
- m.network.Send([]byte("abandon"), []byte("🏳️"))
+ m.network.SendAll([]byte("abandon"), []byte("🏳️"))
return m, m.endGame(outcome)
}
case key.Matches(msg, m.keys.Quit):
diff --git a/pkg/ui/views/game_moves.go b/pkg/ui/views/game_moves.go
index 32f440f..ed11d30 100644
--- a/pkg/ui/views/game_moves.go
+++ b/pkg/ui/views/game_moves.go
@@ -27,6 +27,7 @@ func (i item) FilterValue() string { return i.title }
func (m *GameModel) getMoves() tea.Cmd {
m.network.AddReceiveFunction(func(msg p2p.Message) {
gm := multiplayer.GameMove{
+ Source: msg.Source,
Type: msg.Type,
Payload: msg.Payload,
}
@@ -40,7 +41,7 @@ func (m *GameModel) getMoves() tea.Cmd {
case multiplayer.AbandonGameMessage:
return EndGameMsg{abandoned: true}
case multiplayer.RestoreGameMessage:
- return SendRestoreMsg{}
+ return SendRestoreMsg(move.Source)
case multiplayer.RestoreAckGameMessage:
return RestoreMoves(string(move.Payload))
default:
@@ -79,14 +80,8 @@ func (m GameModel) handleUpdateMovesListMsg() GameModel {
}
func (m GameModel) handleChessMoveMsg(msg ChessMoveMsg) (GameModel, tea.Cmd) {
- err := m.chessGame.MoveStr(string(msg))
+ m.err = m.chessGame.MoveStr(string(msg))
cmds := []tea.Cmd{m.getMoves(), m.updateMovesListCmd()}
- if err != nil {
- m.err = err
- } else {
- m.turn++
- m.err = nil
- }
if m.chessGame.Outcome() != chess.NoOutcome {
cmds = append(cmds, m.endGame(m.chessGame.Outcome().String()))
diff --git a/pkg/ui/views/game_restore.go b/pkg/ui/views/game_restore.go
index 4ce8184..95f8844 100644
--- a/pkg/ui/views/game_restore.go
+++ b/pkg/ui/views/game_restore.go
@@ -5,25 +5,45 @@ import (
"strings"
"time"
+ "github.com/boozec/rahanna/pkg/p2p"
tea "github.com/charmbracelet/bubbletea"
)
// Catch for `RestoreGameMessage` message from multiplayer
-type SendRestoreMsg struct{}
+type SendRestoreMsg p2p.NetworkID
// Catch for `RestoreAckGameMessage` message from multiplayer
type RestoreMoves string
// For `RestoreGameMessage` from multiplayer it fixes the peer with the new
// address and sends back an ACK to the peer' sender
-func (m GameModel) handleSendRestoreMsg() tea.Cmd {
+func (m GameModel) handleSendRestoreMsg(source p2p.NetworkID) tea.Cmd {
_ = m.getGame()()
- if m.network.Me() == m.playerPeer(1) {
- remote := m.game.IP2
- m.network.AddPeer(m.playerPeer(2), remote)
- } else {
- remote := m.game.IP1
- m.network.AddPeer(m.playerPeer(1), remote)
+
+ peers := map[int]string{
+ 1: m.game.IP1,
+ 2: m.game.IP2,
+ 3: m.game.IP3,
+ 4: m.game.IP4,
+ }
+
+ myPlayerNum := -1
+ switch m.network.Me() {
+ case m.playerPeer(1):
+ myPlayerNum = 1
+ case m.playerPeer(2):
+ myPlayerNum = 2
+ case m.playerPeer(3):
+ myPlayerNum = 3
+ case m.playerPeer(4):
+ myPlayerNum = 4
+ }
+
+ // Add all peers to every other peer
+ for playerNum, ip := range peers {
+ if playerNum != myPlayerNum && ip != "" {
+ m.network.AddPeer(m.playerPeer(playerNum), ip)
+ }
}
// FIXME: add a loading modal
@@ -35,7 +55,7 @@ func (m GameModel) handleSendRestoreMsg() tea.Cmd {
payload += fmt.Sprintf("%s\n", move.String())
}
- m.err = m.network.Send([]byte("restore-ack"), []byte(payload))
+ m.err = m.network.Send(source, []byte("restore-ack"), []byte(payload))
return nil
}
@@ -47,7 +67,6 @@ func (m *GameModel) handleRestoreMoves(msg RestoreMoves) tea.Cmd {
m.chessGame.MoveStr(move)
}
- m.turn = len(moves) - 1
cmds := []tea.Cmd{m.getMoves(), m.updateMovesListCmd()}
return tea.Batch(cmds...)
}
diff --git a/pkg/ui/views/game_util.go b/pkg/ui/views/game_util.go
index f5e4a5d..8b8c658 100644
--- a/pkg/ui/views/game_util.go
+++ b/pkg/ui/views/game_util.go
@@ -3,6 +3,7 @@ package views
import (
"fmt"
+ "github.com/boozec/rahanna/internal/api/database"
"github.com/boozec/rahanna/pkg/p2p"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -27,7 +28,22 @@ func (m GameModel) buildWindowContent(content string, formWidth int) string {
}
func (m GameModel) isMyTurn() bool {
- return m.turn%2 == 0 && m.network.Me() == m.playerPeer(1) || m.turn%2 == 1 && m.network.Me() == m.playerPeer(2)
+ if m.game == nil {
+ return false
+ }
+
+ var totalPlayers int
+
+ switch m.game.Type {
+ case database.SingleGameType:
+ totalPlayers = 2
+ case database.PairGameType:
+ totalPlayers = 4
+ }
+
+ moves := len(m.chessGame.Moves())
+ currentPlayer := (moves % totalPlayers) + 1
+ return m.network.Me() == m.playerPeer(currentPlayer)
}
func (m GameModel) playerPeer(n int) p2p.NetworkID {
diff --git a/pkg/ui/views/play_api.go b/pkg/ui/views/play_api.go
index 62cd523..3846abe 100644
--- a/pkg/ui/views/play_api.go
+++ b/pkg/ui/views/play_api.go
@@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
+ "sync"
"github.com/boozec/rahanna/internal/api/database"
"github.com/boozec/rahanna/internal/logger"
@@ -18,6 +19,7 @@ import (
type responseOk struct {
Name string `json:"name"`
+ Type string `json:"type"`
GameID int `json:"id"`
IP string `json:"ip"`
Port int `json:"int"`
@@ -45,14 +47,31 @@ func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) {
m.currentGameId = msg.Ok.GameID
logger, _ := logger.GetLogger()
- callbackCompleted := make(chan bool)
- m.network = multiplayer.NewGameNetwork(fmt.Sprintf("%s-1", m.playName), fmt.Sprintf("%s:%d", msg.Ok.IP, msg.Ok.Port), p2p.DefaultHandshake, func(net.Conn) error {
- close(callbackCompleted)
+ var wg sync.WaitGroup
+ var expectedPeers int
+
+ switch msg.Ok.Type {
+ case string(database.SingleGameType):
+ expectedPeers = 1
+ case string(database.PairGameType):
+ expectedPeers = 3
+ default:
+ logger.Fatal("Type not recognized")
+ }
+ wg.Add(expectedPeers)
+
+ handshakeCounter := 0
+ m.network = multiplayer.NewGameNetwork(fmt.Sprintf("%s-1", m.playName), fmt.Sprintf("%s:%d", msg.Ok.IP, msg.Ok.Port), func(net.Conn) error {
+ handshakeCounter++
+ if handshakeCounter <= expectedPeers && expectedPeers > 0 {
+ wg.Done()
+ }
return nil
- }, logger)
+ }, p2p.DefaultHandshake, logger)
return m, func() tea.Msg {
- <-callbackCompleted
+ wg.Wait()
+
return StartGameMsg{}
}
}
@@ -67,21 +86,65 @@ func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) {
var ip []string
var localID string
+ var expectedPeers int
- if m.game.LastPlayer == 2 {
- ip = strings.Split(m.game.IP2, ":")
- localID = fmt.Sprintf("%s-2", m.game.Name)
- } else {
+ switch m.game.LastPlayer {
+ case 1:
ip = strings.Split(m.game.IP1, ":")
localID = fmt.Sprintf("%s-1", m.game.Name)
+
+ switch m.game.Type {
+ case database.SingleGameType:
+ expectedPeers = 1
+ case database.PairGameType:
+ expectedPeers = 3
+ }
+
+ case 2:
+ ip = strings.Split(m.game.IP2, ":")
+ localID = fmt.Sprintf("%s-2", m.game.Name)
+ switch m.game.Type {
+ case database.SingleGameType:
+ expectedPeers = 0
+ case database.PairGameType:
+ expectedPeers = 2
+ }
+
+ case 3:
+ ip = strings.Split(m.game.IP3, ":")
+ localID = fmt.Sprintf("%s-3", m.game.Name)
+ expectedPeers = 1
+
+ case 4:
+ ip = strings.Split(m.game.IP4, ":")
+ localID = fmt.Sprintf("%s-4", m.game.Name)
+ expectedPeers = 0
}
+ var wg sync.WaitGroup
+
+ if m.gameToRestore != nil {
+ expectedPeers = 0
+ }
+
+ wg.Add(expectedPeers)
+
if len(ip) == 2 {
localIP := ip[0]
localPort, _ := strconv.ParseInt(ip[1], 10, 32)
logger, _ := logger.GetLogger()
- network := multiplayer.NewGameNetwork(localID, fmt.Sprintf("%s:%d", localIP, localPort), p2p.DefaultHandshake, p2p.DefaultHandshake, logger)
+
+ handshakeCounter := 0
+ network := multiplayer.NewGameNetwork(localID, fmt.Sprintf("%s:%d", localIP, localPort), func(conn net.Conn) error {
+ handshakeCounter++
+ if handshakeCounter <= expectedPeers && expectedPeers > 0 {
+ wg.Done()
+ }
+ return nil
+ }, p2p.DefaultHandshake, logger)
+
+ wg.Wait()
return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.game.ID, network, m.gameToRestore != nil))
}
@@ -96,7 +159,7 @@ func (m *PlayModel) handleGamesResponse(msg []database.Game) (tea.Model, tea.Cmd
return m, nil
}
-func (m *PlayModel) newGameCallback() tea.Cmd {
+func (m *PlayModel) newGameCallback(gameType database.GameType) tea.Cmd {
return func() tea.Msg {
// Get authorization token
authorization, err := getAuthorizationToken()
@@ -114,7 +177,8 @@ func (m *PlayModel) newGameCallback() tea.Cmd {
// Prepare request payload
payload, err := json.Marshal(map[string]string{
- "ip": fmt.Sprintf("%s:%d", ip, port),
+ "ip": fmt.Sprintf("%s:%d", ip, port),
+ "type": string(gameType),
})
if err != nil {
return playResponse{Error: err.Error()}
@@ -140,6 +204,7 @@ func (m *PlayModel) newGameCallback() tea.Cmd {
// Decode successful response
var response struct {
Name string `json:"name"`
+ Type string `json:"type"`
ID int `json:"id"`
Error string `json:"error"`
}
@@ -147,7 +212,7 @@ func (m *PlayModel) newGameCallback() tea.Cmd {
return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)}
}
- return playResponse{Ok: responseOk{Name: response.Name, GameID: response.ID, IP: ip, Port: port}}
+ return playResponse{Ok: responseOk{Name: response.Name, Type: response.Type, GameID: response.ID, IP: ip, Port: port}}
}
}
@@ -169,8 +234,8 @@ func (m PlayModel) enterGame() tea.Cmd {
// Prepare request payload
payload, err := json.Marshal(map[string]string{
- "ip": fmt.Sprintf("%s:%d", ip, port),
"name": m.namePrompt.Value(),
+ "ip": fmt.Sprintf("%s:%d", ip, port),
})
if err != nil {
return playResponse{Error: err.Error()}
diff --git a/pkg/ui/views/play_keymap.go b/pkg/ui/views/play_keymap.go
index 24d5307..9d2f014 100644
--- a/pkg/ui/views/play_keymap.go
+++ b/pkg/ui/views/play_keymap.go
@@ -5,6 +5,7 @@ import (
"fmt"
"strconv"
+ "github.com/boozec/rahanna/internal/api/database"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -21,13 +22,14 @@ const (
// Keyboard controls
type playKeyMap struct {
- EnterNewGame key.Binding
- StartNewGame key.Binding
- RestoreGame key.Binding
- GoLogout key.Binding
- NextPage key.Binding
- PrevPage key.Binding
- Exit key.Binding
+ EnterNewGame key.Binding
+ StartNewSingleGame key.Binding
+ StartNewPairGame key.Binding
+ RestoreGame key.Binding
+ GoLogout key.Binding
+ NextPage key.Binding
+ PrevPage key.Binding
+ Exit key.Binding
}
// Default key bindings for the play model
@@ -36,9 +38,13 @@ var defaultPlayKeyMap = playKeyMap{
key.WithKeys("alt+E", "alt+e"),
key.WithHelp("Alt+E", "Enter a play using code"),
),
- StartNewGame: key.NewBinding(
+ StartNewSingleGame: key.NewBinding(
key.WithKeys("alt+s", "alt+S"),
- key.WithHelp("Alt+S", "Start a new play"),
+ key.WithHelp("Alt+S", "Start a new single play"),
+ ),
+ StartNewPairGame: key.NewBinding(
+ key.WithKeys("alt+p", "alt+P"),
+ key.WithHelp("Alt+P", "Start a new pair play"),
),
RestoreGame: key.NewBinding(
key.WithKeys("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"),
@@ -72,12 +78,22 @@ func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, cmd
}
- case key.Matches(msg, m.keys.StartNewGame):
+ case key.Matches(msg, m.keys.StartNewSingleGame):
+ if m.page == LandingPage {
+ m.page = StartGamePage
+ if !m.isLoading {
+ m.isLoading = true
+ return m, m.newGameCallback(database.SingleGameType)
+ }
+
+ return m, cmd
+ }
+ case key.Matches(msg, m.keys.StartNewPairGame):
if m.page == LandingPage {
m.page = StartGamePage
if !m.isLoading {
m.isLoading = true
- return m, m.newGameCallback()
+ return m, m.newGameCallback(database.PairGameType)
}
return m, cmd
@@ -138,9 +154,13 @@ func (m PlayModel) renderNavigationButtons() string {
altCodeStyle.Render(m.keys.RestoreGame.Help().Key),
m.keys.RestoreGame.Help().Desc)
- startKey := fmt.Sprintf("%s %s",
- altCodeStyle.Render(m.keys.StartNewGame.Help().Key),
- m.keys.StartNewGame.Help().Desc)
+ startSingleKey := fmt.Sprintf("%s %s",
+ altCodeStyle.Render(m.keys.StartNewSingleGame.Help().Key),
+ m.keys.StartNewSingleGame.Help().Desc)
+
+ startPairKey := fmt.Sprintf("%s %s",
+ altCodeStyle.Render(m.keys.StartNewPairGame.Help().Key),
+ m.keys.StartNewPairGame.Help().Desc)
nextPageKey := fmt.Sprintf("%s %s",
altCodeStyle.Render(m.keys.NextPage.Help().Key),
@@ -153,7 +173,8 @@ func (m PlayModel) renderNavigationButtons() string {
return lipgloss.JoinVertical(
lipgloss.Left,
enterKey,
- startKey,
+ startSingleKey,
+ startPairKey,
restoreKey,
lipgloss.JoinHorizontal(lipgloss.Left, prevPageKey, " | ", nextPageKey),
logoutKey,
diff --git a/pkg/ui/views/play_util.go b/pkg/ui/views/play_util.go
index b2f70b5..14f462a 100644
--- a/pkg/ui/views/play_util.go
+++ b/pkg/ui/views/play_util.go
@@ -45,13 +45,13 @@ func formatGamesForPage(userID int, games []database.Game, altCodeStyle lipgloss
if game.Outcome != "*" {
if len(game.Outcome) >= 2 {
if game.Outcome[0:2] == "1-" {
- if game.Player1.ID == userID {
+ if game.Player1.ID == userID || game.Player3 != nil && game.Player3.ID == userID {
icon = winIcon
} else {
icon = loseIcon
}
} else if game.Outcome[0:2] == "0-" {
- if game.Player2 != nil && game.Player2.ID == userID {
+ if game.Player2 != nil && game.Player2.ID == userID || game.Player4 != nil && game.Player4.ID == userID {
icon = winIcon
} else {
icon = loseIcon