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
- 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.
- Implement a ‘Suggest Play’ button in the WebKit GUI that queries the backend AI engine to highlight the heuristically recommended card to play next.