Graphical WebKit Desktop Interface for Rubber Bridge in Haskell

In the previous chapter, we developed a pure Contract Rubber Bridge game engine (Bridge_game) and ran it using an interactive Command Line Interface. Because our architecture was fully decoupled, all game domain types, state transition rules, and AI behaviors were encapsulated within a pure Haskell library.

In this chapter, we build upon that foundation by developing a graphical desktop GUI application (Bridge_webkit) utilizing the native macOS WebKit bindings we explored in the WebKit FFI chapter (webkit-haskell).

By embedding macOS’s native WKWebView, we create a visually premium card-table experience with:

  • A beautiful green-felt felt-gradient board layout.
  • Horizontal, curved card fanning utilizing CSS 3D transforms.
  • Highlighting and scaling playable cards while disabling illegal plays.
  • Real-time “We” vs “Them” trick counts.
  • Step-by-step AI card play loops with visual delays, pacing opponent actions for comfortable human viewing.

The source code for this project is located in haskell_book/source-code/Bridge_webkit.


Architectural Overview

The GUI application is structured as a native macOS Cocoa window wrapping a WebKit viewport. It communicates bidirectionally with a Haskell background thread via JSON-encoded FFI callbacks:

 1                   ┌────────────────────────────────────────────────────────┐
 2                                    FRONT-END (app/index.html)             
 3                      - Curved card fanning, bidding box & scorecard panels 
 4                      - Sets 2-second delay between AI opponent turns      
 5                   └───────────────────────────┬────────────────────────────┘
 6                                               
 7                                                JSON FFI Messages via
 8                                                window.webkit_haskell.invoke
 9                                               v
10                   ┌────────────────────────────────────────────────────────┐
11                                    BACK-END (app/Main.hs)                 
12                      - Manages state using standard GHC IORef             
13                      - Dispatches FFI requests, returns JSON payloads     
14                   └───────────────────────────┬────────────────────────────┘
15                                               
16                                                Imports Core Library
17                                               v
18                   ┌────────────────────────────────────────────────────────┐
19                                    BRIDGE ENGINE LIBRARY                  
20                      - Resolves pure state transformations (Bridge_game)  
21                   └────────────────────────────────────────────────────────┘

When the user interacts with the HTML interface (such as choosing a bid or playing a card), the Javascript client invokes a backend command:

1 const newState = await window.webkit_haskell.invoke("play-card", { card: "10S" });
2 renderUI(newState);

The GHC Haskell runtime handles the FFI request, transforms the game state using the pure Bridge_game engine rules, updates the local mutable IORef state container, and returns the modified state as JSON.


The Backend FFI Dispatcher: app/Main.hs

The Haskell backend acts as the bridge dispatcher. It manages game mutable state inside an IORef wrap of the GHC ActiveState record:

1 data ActiveState = ActiveState
2   { currentGameState   :: GameState
3   , currentRubberState  :: RubberState
4   , randomGen           :: StdGen
5   , lastTrickCards      :: [PlayedCard] -- Caches the last completed trick
6   }

Because GHC’s core engine clears the active trick immediately upon playing the 4th card (advancing the trick count and shifting active lead player), a direct state query would clear completed trick cards from the table too fast for a human to see. To resolve this, ActiveState caches the 4 played cards inside the lastTrickCards field before the engine sweeps them.

We define a state-stepping helper playCardAndStep to capture completed tricks and handle scoring transitions:

 1 playCardAndStep :: Card -> Player -> ActiveState -> ActiveState
 2 playCardAndStep cardVal actor state =
 3   let gsVal = state.currentGameState
 4       currentTrick' = (actor, cardVal) : gsVal.currentTrick
 5       
 6       -- If playing the 4th card, cache the completed trick
 7       completedTrick =
 8         if length currentTrick' == 4
 9         then map (\(p, c) -> PlayedCard (show p) (show c)) (reverse currentTrick')
10         else []
11         
12       gs' = applyCardPlay cardVal gsVal
13       
14       -- If the deal is complete, transition to Scoring rubber scores
15       state' =
16         if gs'.phase == Scoring
17         then
18           let
19             rsVal = state.currentRubberState
20             contractVal = maybe (SuitBid 1 NoTrump) id gs'.contract
21             level = case contractVal of SuitBid l _ -> l; _ -> 1
22             strain = case contractVal of SuitBid _ s -> s; _ -> NoTrump
23             declarerVal = maybe South id gs'.declarer
24             doubledVal = gs'.doubled
25             tricksWon = if side declarerVal == 0 then gs'.tricksNs else gs'.tricksEw
26             (rs', _) = scoreRubberDeal level strain tricksWon declarerVal doubledVal rsVal
27             rs'' = rs' { dealsPlayed = rs'.dealsPlayed + 1, currentDealer = nextPlayer rs'.currentDealer }
28           in state { currentGameState = gs', currentRubberState = rs'' }
29         else
30           state { currentGameState = gs' }
31   in state' { lastTrickCards = completedTrick }

Exposing the Step-by-Step AI Endpoint

To slow down opponent actions for the human player to see, we must decouple AI execution from the human play handler. Instead of running all subsequent AI plays recursively in a single thread tick, the backend exposes a single-play endpoint "ai-play-single".

This endpoint evaluates the current actor, runs the heuristic card selection AI exactly once, and returns the modified state:

 1   registerHandler app "ai-play-single" $ \_ -> do
 2     putStrLn "[Haskell] Received ai-play-single command"
 3     state <- readIORef stateRef
 4     let gsVal = state.currentGameState
 5     case currentActor gsVal of
 6       Just actor -> do
 7         let isHuman = actor == South
 8             isDummy = Just actor == gsVal.dummy
 9             declarerSide = fmap side gsVal.declarer
10             humanPlaysDummy = isDummy && (declarerSide == Just 0)
11             humanPlaysThis = isHuman || humanPlaysDummy
12         if humanPlaysThis
13           then return $ Aeson.object ["error" Aeson..= ("It is human's turn to play, not AI." :: String)]
14           else do
15             let cardVal = aiSelectCard (gsVal.hands Map.! actor) (map snd gsVal.currentTrick) gsVal.trickLead gsVal.trumpSuit actor (maybe South id gsVal.declarer)
16                 state' = playCardAndStep cardVal actor state
17             writeIORef stateRef state'
18             stateFinal <- readIORef stateRef
19             return $ Aeson.toJSON (makePayload stateFinal.currentGameState stateFinal.currentRubberState (stateFinal.currentRubberState.dealsPlayed + 1) stateFinal.lastTrickCards)
20       Nothing ->
21         return $ Aeson.object ["error" Aeson..= ("No active player found." :: String)]

Frontend Layout & Design: app/index.html

The GUI is rendered using HTML5, vanilla CSS transitions, and client-side JavaScript.

1. Card-Fanning Mechanics

We display the South player’s cards in an elegant overlapping arc using CSS custom properties (--index, --total, and --offset) set by JS, mapping them onto 3D transformations:

 1 .south-hand .card {
 2   position: absolute;
 3   left: calc(50% - 34px);
 4   bottom: 20px;
 5   transform-origin: bottom center;
 6   /* Distribute cards horizontally by 54px and rotate by 2 degrees per card */
 7   transform: translateX(calc((var(--index) - (var(--total) - 1) / 2) * 54px)) 
 8              rotate(calc((var(--index) - (var(--total) - 1) / 2) * 2deg)) 
 9              translateY(calc(var(--offset) * 4px));
10 }

By using 54px spacing (for a 68px wide card), only a tiny portion overlaps, leaving the card suits and indices completely visible.

2. Player Turn Highlights & Click Disabling

When it is the South player’s turn to play a card, we add the .south-turn class to the hand container. We then highlight legal playable cards, lifting them up and styling them with glowing borders while dimming and disabling unplayable cards:

 1 /* Lift legal cards slightly when it's your turn */
 2 .south-hand.south-turn .card.legal {
 3   transform: translateX(calc((var(--index) - (var(--total) - 1) / 2) * 54px)) 
 4              rotate(calc((var(--index) - (var(--total) - 1) / 2) * 2deg)) 
 5              translateY(calc(var(--offset) * 4px - 18px)) 
 6              scale(1.12);
 7   z-index: 10;
 8   border-color: rgba(223, 178, 63, 0.4);
 9   box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4), 0 0 10px rgba(223, 178, 63, 0.2);
10 }
11 
12 /* Elevate legal card on mouse hover */
13 .south-hand.south-turn .card.legal:hover {
14   transform: translateX(calc((var(--index) - (var(--total) - 1) / 2) * 54px)) 
15              rotate(calc((var(--index) - (var(--total) - 1) / 2) * 2deg)) 
16              translateY(-36px) 
17              scale(1.28);
18   z-index: 100;
19   border-color: var(--gold);
20   box-shadow: 0 15px 30px rgba(0,0,0,0.5), 0 0 20px var(--gold);
21   cursor: pointer;
22 }
23 
24 /* Grayscale and disable unplayable cards */
25 .south-hand.south-turn .card:not(.legal) {
26   filter: brightness(0.4) grayscale(0.5);
27   opacity: 0.5;
28   pointer-events: none;
29 }

When it is another player’s turn (or during the bidding auction), the .south-turn class is removed, revealing the human player’s hand in full color and brightness without accidental hover movements.

3. We vs Them Scoreboard Panel

The Left Scorecard panel displays trick counts won by the human partnership (“We (N-S)”) and the AI opponents (“Them (E-W)”) at all times:

 1 <div class="tricks-counter-box" style="margin-top: 12px; margin-bottom: 12px; padding: 10px; background: rgba(0,0,0,0.25); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: space-around; align-items: center; text-align: center;">
 2   <div>
 3     <div style="font-size: 0.7em; text-transform: uppercase; color: rgba(255,255,255,0.5); font-weight: 700; letter-spacing: 0.5px;">We (N-S)</div>
 4     <div id="tricks-ns" style="font-size: 1.8rem; font-weight: 800; color: var(--gold);">0</div>
 5   </div>
 6   <div style="font-size: 0.9em; font-weight: 700; color: rgba(255,255,255,0.2);">VS</div>
 7   <div>
 8     <div style="font-size: 0.7em; text-transform: uppercase; color: rgba(255,255,255,0.5); font-weight: 700; letter-spacing: 0.5px;">Them (E-W)</div>
 9     <div id="tricks-ew" style="font-size: 1.8rem; font-weight: 800; color: #f3f4f6;">0</div>
10   </div>
11 </div>

Game Loop Delay & Timing Flow

To coordinate pauses between card plays, the JavaScript client checks if the next player is an AI and registers a 2000ms delay before dispatching the FFI call:

 1 // Check if the current actor is driven by AI
 2 function isAiTurn(state) {
 3   if (state.phase !== "Playing") return false;
 4   const actor = state.activeActor;
 5   if (!actor || actor === "South") return false;
 6 
 7   // North dummy play check: human declarer plays partner's cards
 8   const isNorthDummy = (actor === "North" && state.dummy === "North");
 9   const isNsDeclarer = (state.declarer === "North" || state.declarer === "South");
10   if (isNorthDummy && isNsDeclarer) {
11     return false;
12   }
13 
14   return true;
15 }
16 
17 let aiTimeout = null;
18 
19 function checkAndTriggerAi() {
20   if (aiTimeout) {
21     clearTimeout(aiTimeout);
22     aiTimeout = null;
23   }
24   if (isAiTurn(gameState)) {
25     // Wait 2 seconds before making the AI play its card
26     aiTimeout = setTimeout(triggerAiPlay, 2000);
27   }
28 }
29 
30 async function triggerAiPlay() {
31   aiTimeout = null;
32   const state = await invokeBackend('ai-play-single', {});
33   if (state) {
34     renderUI(state);
35   }
36 }

Whenever a play completes a trick, GHC clears the active cards on the backend. The frontend handles this by rendering the lastCompletedTrick cache instead if currentTrick is empty, displaying a banner announcement such as “East wins the trick!” during the 2-second transition pause.


Building and Running the Desktop Application

The graphical desktop client uses Cabal for dependency resolution and compilations.

Compilation

Build the GUI package inside the Bridge_webkit directory:

1 cd source-code/Bridge_webkit
2 cabal build

This compiles GHC executable targets, imports local packages, builds the Cocoa FFI wrappers, and binds the HTML assets.

Launching the Client

Execute the compiled application:

1 cabal run bridge-webkit

Upon launching, the native macOS window initializes, loads app/index.html into the embedded WKWebView, and begins the bridge bidding auction!

Optional Practice Problems

  1. Update the WebKit Bridge HTML frontend UI to show a prominent visual indicator indicating the current player with the lead, active bidder, and the current trump suit.
  2. Implement a ‘Suggest Play’ button in the WebKit GUI that queries the backend AI engine to highlight the heuristically recommended card to play next.