plan: Phaser 3 web client - 6 phases, separate deployment

Standalone Phaser 3 + Vite + vanilla JS (JSDoc) Gomoku web client.
Connects to existing server via WebSocket. DOM overlays for menus,
Canvas for board. 6 phases, ~12h effort.
This commit is contained in:
2026-04-10 09:34:45 +07:00
parent 77e141c017
commit cb0761bedd
7 changed files with 1196 additions and 0 deletions
@@ -0,0 +1,100 @@
# Phase 1: Project Scaffold
## Context Links
- [Plan overview](plan.md)
- Server WS handler: `landlords-server/.../handler/WebsocketTransferHandler.java`
- Server WS proxy: `landlords-server/.../proxy/WebsocketProxy.java`
## Overview
- **Priority:** P1 (blocker for all other phases)
- **Status:** Pending
- **Description:** Initialize Vite + Phaser 3 project in `web-client/`, verify Phaser boots a blank canvas.
## Requirements
- `npm create vite` with vanilla JS template (or manual init)
- Phaser 3 latest stable as dependency
- Vite dev server on port 5173 (default)
- `index.html` with a `#game-container` div for Phaser canvas + a `#ui-overlay` div for DOM menus
- `src/main.js` creates Phaser.Game with config from `src/config/game-config.js`
- BootScene placeholder that shows "Loading..." text
## Architecture
```
web-client/
package.json
vite.config.js
index.html <-- #game-container + #ui-overlay
src/
main.js <-- Phaser.Game instantiation
config/
game-config.js <-- Phaser config: 800x800, Scale.FIT, scenes list
scenes/
boot-scene.js <-- placeholder "Loading..." text
public/
(empty, for future assets)
```
## Related Code Files
### Files to Create
- `web-client/package.json`
- `web-client/vite.config.js`
- `web-client/index.html`
- `web-client/src/main.js`
- `web-client/src/config/game-config.js`
- `web-client/src/scenes/boot-scene.js`
### Files to Modify
- None
## Implementation Steps
1. Create `web-client/` directory at repo root
2. Create `package.json` with:
- `name: "caro-web-client"`
- `type: "module"`
- `scripts: { "dev": "vite", "build": "vite build", "preview": "vite preview" }`
- `dependencies: { "phaser": "^3.80.0" }`
- `devDependencies: { "vite": "^6.0.0" }`
3. Create `vite.config.js`:
```js
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 5173 }
});
```
4. Create `index.html`:
- Minimal HTML5 boilerplate
- `<div id="game-container"></div>` -- Phaser mounts here
- `<div id="ui-overlay"></div>` -- DOM menus render here (hidden by default)
- `<script type="module" src="/src/main.js"></script>`
- Basic CSS: body margin 0, background #1a1a2e, flex-center the container, overlay absolute positioned over canvas
5. Create `src/config/game-config.js`:
- Export Phaser config object: `type: Phaser.AUTO`, `width: 800`, `height: 800`
- `scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }`
- `parent: 'game-container'`
- `backgroundColor: '#2d2d44'`
- `scene: [BootScene]` (import from scenes)
6. Create `src/scenes/boot-scene.js`:
- Extends `Phaser.Scene`, key: `'BootScene'`
- `create()`: display centered "Loading..." text
- Will be expanded in Phase 3 to transition to MenuScene
7. Create `src/main.js`:
- Import config from `game-config.js`
- `new Phaser.Game(config)`
- Export game instance for potential service access
8. Run `npm install` and `npm run dev` to verify Phaser boots
## Success Criteria
- [ ] `npm run dev` starts Vite on port 5173
- [ ] Browser shows Phaser canvas with "Loading..." text
- [ ] No console errors
- [ ] `npm run build` produces working static build in `dist/`
## Risk Assessment
- **Node.js not installed:** User must have Node.js. Document in README.
- **Phaser version mismatch:** Pin to `^3.80.0` for stability.
## Next Steps
- Phase 2: Services layer (can start immediately after scaffold)
@@ -0,0 +1,190 @@
# Phase 2: Services Layer
## Context Links
- [Plan overview](plan.md)
- [Phase 1: Scaffold](phase-01-project-scaffold.md)
- Server `Msg` entity: `landlords-common/.../entity/Msg.java` -- `{code, data, info}`
- Server event codes: `landlords-common/.../enums/ServerEventCode.java`, `ClientEventCode.java`
- Server WS handler: `landlords-server/.../handler/WebsocketTransferHandler.java`
## Overview
- **Priority:** P1 (all scenes depend on these services)
- **Status:** Pending
- **Blocked by:** Phase 1
- **Description:** Build three decoupled service modules: WebSocket connection, event bus, game state. Plus protocol constants extracted from server source.
## Key Insights
- Server WS message format: `{"code": "CODE_...", "data": "json_string_or_plain_string", "info": ""}`
- `data` field is a JSON **string** (not nested object) -- must `JSON.parse(data)` when data is structured
- Server sends `CODE_CLIENT_CONNECT` + `CODE_CLIENT_NICKNAME_SET` ~2s after WS handshake (server has `Thread.sleep(2000L)`)
- Heartbeat: server reads idle timeout; client must send `CODE_CLIENT_HEAD_BEAT` every ~50s
- Server ignores heartbeat messages (no handler called, just keeps connection alive)
## Architecture
```
connection-service.js
|-- wraps browser WebSocket
|-- auto-reconnect with backoff
|-- heartbeat timer (50s interval)
|-- on message: parse JSON -> event-bus.emit(code, parsedData)
|-- send(code, data): serialize to Msg format -> ws.send()
event-bus.js
|-- on(event, callback): subscribe
|-- off(event, callback): unsubscribe
|-- emit(event, data): notify all subscribers
game-state-service.js
|-- stores: clientId, nickname, roomId, isBlack, isMyTurn, boardState[][], moves[]
|-- reset methods for new game / exit room
|-- no logic, pure state container
protocol-constants.js
|-- SERVER_EVENTS: all CODE_CLIENT_* codes (server -> client)
|-- CLIENT_EVENTS: all CODE_* codes (client -> server)
|-- string constants, no enums needed
```
### Data Flow: Sending a Move
```
GameScene.onBoardClick(row, col)
-> connectionService.send('CODE_GAME_MOVE', JSON.stringify({row, col}))
-> ws.send('{"code":"CODE_GAME_MOVE","data":"{\"row\":7,\"col\":7}","info":""}')
```
### Data Flow: Receiving a Move
```
ws.onmessage(frame)
-> JSON.parse(frame.data) => {code: "CODE_GAME_MOVE_SUCCESS", data: "{\"row\":7,...}", info: ""}
-> eventBus.emit('CODE_GAME_MOVE_SUCCESS', {row:7, col:7, piece:"BLACK", playerNickname:"p1", playerId:1})
(data string auto-parsed to object by connection-service)
```
## Related Code Files
### Files to Create
- `web-client/src/services/connection-service.js`
- `web-client/src/services/event-bus.js`
- `web-client/src/services/game-state-service.js`
- `web-client/src/config/protocol-constants.js`
### Files to Modify
- None
## Implementation Steps
### 1. `protocol-constants.js`
Export two frozen objects with all event code strings:
```js
/** @enum {string} Codes the client sends TO the server */
export const ServerEventCode = Object.freeze({
NICKNAME_SET: 'CODE_CLIENT_NICKNAME_SET',
INFO_SET: 'CODE_CLIENT_INFO_SET',
ROOM_CREATE: 'CODE_ROOM_CREATE',
ROOM_CREATE_PVE: 'CODE_ROOM_CREATE_PVE',
ROOM_JOIN: 'CODE_ROOM_JOIN',
GET_ROOMS: 'CODE_GET_ROOMS',
GAME_MOVE: 'CODE_GAME_MOVE',
GAME_READY: 'CODE_GAME_READY',
CLIENT_EXIT: 'CODE_CLIENT_EXIT',
GAME_WATCH: 'CODE_GAME_WATCH',
GAME_WATCH_EXIT: 'CODE_GAME_WATCH_EXIT',
HEARTBEAT: 'CODE_CLIENT_HEAD_BEAT',
});
/** @enum {string} Codes the server sends TO the client */
export const ClientEventCode = Object.freeze({
CLIENT_CONNECT: 'CODE_CLIENT_CONNECT',
NICKNAME_SET: 'CODE_CLIENT_NICKNAME_SET',
SHOW_OPTIONS: 'CODE_SHOW_OPTIONS',
SHOW_ROOMS: 'CODE_SHOW_ROOMS',
ROOM_CREATE_SUCCESS: 'CODE_ROOM_CREATE_SUCCESS',
ROOM_JOIN_SUCCESS: 'CODE_ROOM_JOIN_SUCCESS',
ROOM_JOIN_FAIL_FULL: 'CODE_ROOM_JOIN_FAIL_BY_FULL',
ROOM_JOIN_FAIL_INEXIST: 'CODE_ROOM_JOIN_FAIL_BY_INEXIST',
GAME_STARTING: 'CODE_GAME_STARTING',
GAME_MOVE_SUCCESS: 'CODE_GAME_MOVE_SUCCESS',
GAME_MOVE_INVALID: 'CODE_GAME_MOVE_INVALID',
GAME_MOVE_OCCUPIED: 'CODE_GAME_MOVE_OCCUPIED',
GAME_MOVE_OUT_OF_BOUNDS: 'CODE_GAME_MOVE_OUT_OF_BOUNDS',
GAME_MOVE_NOT_YOUR_TURN: 'CODE_GAME_MOVE_NOT_YOUR_TURN',
GAME_OVER: 'CODE_GAME_OVER',
GAME_READY: 'CODE_GAME_READY',
CLIENT_EXIT: 'CODE_CLIENT_EXIT',
CLIENT_KICK: 'CODE_CLIENT_KICK',
GAME_WATCH: 'CODE_GAME_WATCH',
GAME_WATCH_SUCCESSFUL: 'CODE_GAME_WATCH_SUCCESSFUL',
PVE_DIFFICULTY_NOT_SUPPORT: 'CODE_PVE_DIFFICULTY_NOT_SUPPORT',
});
```
### 2. `event-bus.js`
Simple pub/sub:
- `_listeners` Map of `event -> Set<callback>`
- `on(event, cb)` -- add listener
- `off(event, cb)` -- remove listener
- `emit(event, data)` -- call all listeners for event
- Export singleton instance
- ~40 lines
### 3. `connection-service.js`
WebSocket wrapper:
- `connect(url)` -- create WebSocket, attach handlers
- `send(code, data)` -- build `{code, data, info:""}`, `ws.send(JSON.stringify(msg))`
- `disconnect()` -- close WS, clear heartbeat
- Internal: `_onMessage(event)`:
1. `JSON.parse(event.data)` to get `{code, data, info}`
2. Try `JSON.parse(msg.data)` for structured data; fall back to raw string
3. `eventBus.emit(msg.code, parsedData)`
- Internal: `_startHeartbeat()` -- `setInterval` every 50000ms, send `CODE_CLIENT_HEAD_BEAT`
- Internal: `_stopHeartbeat()` -- `clearInterval`
- `onopen`: emit internal `'ws:connected'` event
- `onclose`: emit `'ws:disconnected'`, attempt reconnect with exponential backoff (1s, 2s, 4s, max 30s)
- `onerror`: log, let `onclose` handle reconnect
- Export singleton
- ~80 lines
**Critical detail:** The `data` field in outgoing messages must be a **string**. For structured data like `{row, col}`, use `JSON.stringify({row, col})` as the data value. For simple strings like nickname, pass the string directly.
### 4. `game-state-service.js`
Plain state object:
- `clientId` -- set on `CODE_CLIENT_CONNECT`
- `nickname` -- set after nickname submission
- `roomId` -- set on room create/join
- `isBlack` -- derived from `CODE_GAME_STARTING` comparing clientId to blackPlayerId
- `isMyTurn` -- toggled on each `CODE_GAME_MOVE_SUCCESS`
- `board` -- 15x15 2D array, initialized to `null`, set cells on move success
- `moves` -- array of `{row, col, piece, playerNickname}` for move history
- `isSpectating` -- boolean
- `reset()` -- clear room/game state
- `resetBoard()` -- clear board/moves for rematch
- Export singleton
- ~60 lines
## Todo List
- [ ] Create `protocol-constants.js` with all event codes
- [ ] Create `event-bus.js` with on/off/emit
- [ ] Create `connection-service.js` with connect/send/heartbeat/reconnect
- [ ] Create `game-state-service.js` with state fields and reset methods
- [ ] Verify WS connection to running server in browser console
## Success Criteria
- [ ] `eventBus.on('CODE_CLIENT_CONNECT', cb)` fires when server sends connect event
- [ ] `connectionService.send('CODE_CLIENT_NICKNAME_SET', 'TestUser')` accepted by server
- [ ] Heartbeat keeps connection alive beyond 60s
- [ ] `game-state-service` stores and resets state correctly
- [ ] All files under 100 lines each
- [ ] JSDoc on all exported functions
## Risk Assessment
- **`data` field double-encoding:** Server expects `data` as a string. If we pass an object, server's `MapHelper.parser()` will fail. Mitigation: `connection-service.send()` must `JSON.stringify` objects before placing in `data` field.
- **Reconnect during active game:** If WS drops mid-game, server has no rejoin mechanism. Mitigation: show "Connection lost" overlay; on reconnect, user starts fresh (server already cleaned up the room via `CODE_CLIENT_OFFLINE`).
## Next Steps
- Phase 3 (Boot + Menu scenes) depends on these services being complete
@@ -0,0 +1,206 @@
# Phase 3: Boot + Menu Scenes
## Context Links
- [Plan overview](plan.md)
- [Phase 2: Services](phase-02-services-layer.md)
- Server nickname handler: `landlords-server/.../event/ServerEventListener_CODE_CLIENT_NICKNAME_SET.java` -- max 10 chars, non-empty
- Server show options flow: after nickname set, server sends `CODE_SHOW_OPTIONS`
- Server room list: `CODE_GET_ROOMS` -> `CODE_SHOW_ROOMS` with `[{roomId, roomOwner, roomClientCount, roomType}]`
## Overview
- **Priority:** P1
- **Status:** Pending
- **Blocked by:** Phase 1, Phase 2
- **Description:** BootScene connects to server, MenuScene manages all pre-game UI via DOM overlays (nickname, lobby, PVP/PVE menus).
## Key Insights
- Server flow after WS connect: waits 2s, sends `CODE_CLIENT_CONNECT` (data=clientId), then `CODE_CLIENT_NICKNAME_SET` (data=null, meaning "please set nickname")
- If nickname invalid (empty or >10 chars), server sends `CODE_CLIENT_NICKNAME_SET` again with `{invalidLength: N}` -- client should re-prompt
- After valid nickname, server sends `CODE_SHOW_OPTIONS` -- client shows main menu
- PVP: `CODE_ROOM_CREATE` -> `CODE_ROOM_CREATE_SUCCESS` (room JSON) -> wait for opponent -> auto-starts on join
- PVE: `CODE_ROOM_CREATE_PVE` with data "1"/"2"/"3" -> server auto-starts immediately
- Join room: `CODE_ROOM_JOIN` with data=roomId string -> `CODE_ROOM_JOIN_SUCCESS` -> auto-starts if 2 players
## Architecture
### Scene Flow
```
BootScene
create(): connect to WS, show "Connecting..." text on canvas
on 'CODE_CLIENT_CONNECT': store clientId, show "Connected!"
on 'CODE_CLIENT_NICKNAME_SET': transition to MenuScene
MenuScene
create(): show nickname form via menu-ui.js
Substates (managed by menu-ui.js DOM swaps):
1. NICKNAME -- input + submit button
2. LOBBY -- main menu: PVP / PVE / Spectate buttons
3. PVP_MENU -- "Create Room" button + room list table + join button
4. PVE_MENU -- difficulty picker (Easy/Medium/Hard)
5. SPECTATE_MENU -- room list + watch button
6. WAITING -- "Waiting for opponent..." (after PVP room create)
on 'CODE_SHOW_OPTIONS': switch to LOBBY substate
on 'CODE_SHOW_ROOMS': populate room list table
on 'CODE_ROOM_CREATE_SUCCESS': switch to WAITING, store roomId
on 'CODE_ROOM_JOIN_SUCCESS': store room info
on 'CODE_GAME_STARTING': hide all overlays, transition to GameScene
on 'CODE_ROOM_JOIN_FAIL_*': show error toast
on 'CODE_PVE_DIFFICULTY_NOT_SUPPORT': show error toast
on 'CODE_GAME_WATCH_SUCCESSFUL': transition to GameScene (spectator mode)
```
### DOM Overlay Strategy
- All menus live in `#ui-overlay` div (positioned absolute over Phaser canvas)
- `menu-ui.js` manages showing/hiding substate containers
- Phaser canvas stays visible as background (dark board aesthetic)
- On transition to GameScene, hide `#ui-overlay` entirely
## Related Code Files
### Files to Create
- `web-client/src/scenes/boot-scene.js` (overwrite Phase 1 placeholder)
- `web-client/src/scenes/menu-scene.js`
- `web-client/src/ui/menu-ui.js`
### Files to Modify
- `web-client/index.html` -- add DOM overlay structure and CSS
- `web-client/src/config/game-config.js` -- add MenuScene to scene list
## Implementation Steps
### 1. Update `index.html` -- Add DOM overlay structure
Inside `#ui-overlay`, add containers for each substate:
```html
<div id="ui-overlay" class="hidden">
<div id="nickname-screen" class="ui-screen hidden">
<h1>Caro (Gomoku)</h1>
<input id="nickname-input" maxlength="10" placeholder="Enter nickname..." />
<button id="nickname-submit">Play</button>
<p id="nickname-error" class="error hidden"></p>
</div>
<div id="lobby-screen" class="ui-screen hidden">
<h2>Welcome, <span id="player-name"></span></h2>
<button id="btn-pvp">Player vs Player</button>
<button id="btn-pve">Player vs AI</button>
<button id="btn-spectate">Spectate</button>
</div>
<div id="pvp-screen" class="ui-screen hidden">
<h2>PVP Lobby</h2>
<button id="btn-create-room">Create Room</button>
<button id="btn-refresh-rooms">Refresh</button>
<table id="room-table"><thead><tr><th>ID</th><th>Owner</th><th>Players</th><th></th></tr></thead><tbody></tbody></table>
<button id="btn-back-pvp">Back</button>
</div>
<div id="pve-screen" class="ui-screen hidden">
<h2>Play vs AI</h2>
<button data-difficulty="1">Easy</button>
<button data-difficulty="2">Medium</button>
<button data-difficulty="3">Hard</button>
<button id="btn-back-pve">Back</button>
</div>
<div id="spectate-screen" class="ui-screen hidden">
<h2>Spectate</h2>
<button id="btn-refresh-spectate">Refresh</button>
<table id="spectate-table"><thead><tr><th>ID</th><th>Owner</th><th>Players</th><th></th></tr></thead><tbody></tbody></table>
<button id="btn-back-spectate">Back</button>
</div>
<div id="waiting-screen" class="ui-screen hidden">
<h2>Waiting for opponent...</h2>
<p>Room ID: <span id="waiting-room-id"></span></p>
<button id="btn-cancel-wait">Cancel</button>
</div>
</div>
```
CSS: dark theme, centered cards, simple button styles. Keep inline in `<style>` tag (~50 lines).
### 2. `boot-scene.js` (~50 lines)
```
- constructor: super({ key: 'BootScene' })
- create():
- Display "Connecting..." centered text
- connectionService.connect(wsUrl)
- eventBus.on(CLIENT_CONNECT, (clientId) => {
gameState.clientId = clientId
this.statusText.setText('Connected!')
})
- eventBus.on(NICKNAME_SET, () => {
this.scene.start('MenuScene')
})
- wsUrl: derive from window.location or fallback 'ws://localhost:1025/ratel'
- Config: check URL param ?ws=... for override, else default
```
### 3. `menu-scene.js` (~80 lines)
```
- constructor: super({ key: 'MenuScene' })
- create():
- menuUi.show('nickname')
- Register event listeners on eventBus for all menu-related server events
- Wire up menuUi callbacks for user actions
- Event handlers:
- CODE_SHOW_OPTIONS: menuUi.show('lobby')
- CODE_SHOW_ROOMS: menuUi.populateRooms(data)
- CODE_ROOM_CREATE_SUCCESS: gameState.roomId = data.id; menuUi.show('waiting')
- CODE_ROOM_JOIN_SUCCESS: store room info in gameState
- CODE_GAME_STARTING: store game info, menuUi.hideAll(), this.scene.start('GameScene')
- CODE_ROOM_JOIN_FAIL_*: menuUi.showError('Room full' / 'Room not found')
- CODE_GAME_WATCH_SUCCESSFUL: gameState.isSpectating = true; menuUi.hideAll(); this.scene.start('GameScene')
- shutdown(): unsubscribe all eventBus listeners, menuUi.hideAll()
```
### 4. `menu-ui.js` (~150 lines)
DOM manipulation module:
- `show(screen)` -- hide all `.ui-screen`, show target, show overlay
- `hideAll()` -- hide overlay
- `populateRooms(roomList)` -- clear + rebuild room table tbody with join/watch buttons
- `showError(msg)` -- show toast or error text, auto-hide after 3s
- `bindActions(callbacks)` -- attach click handlers to all buttons, pass action callbacks:
- `onNicknameSubmit(nickname)`
- `onCreateRoom()`
- `onJoinRoom(roomId)`
- `onCreatePVE(difficulty)`
- `onWatchRoom(roomId)`
- `onRefreshRooms()`
- `onCancelWait()`
- `onBack()`
Each callback in menu-scene.js calls the appropriate `connectionService.send()`.
## Todo List
- [ ] Add DOM overlay HTML structure to `index.html`
- [ ] Add CSS styles (dark theme) to `index.html`
- [ ] Implement `boot-scene.js` with WS connect + transition
- [ ] Implement `menu-ui.js` with show/hide/populate/bind
- [ ] Implement `menu-scene.js` wiring eventBus to menuUi
- [ ] Update `game-config.js` scene list: [BootScene, MenuScene]
- [ ] Test: nickname flow end-to-end with running server
- [ ] Test: PVP room create + join flow
- [ ] Test: PVE game start flow
## Success Criteria
- [ ] BootScene connects and transitions to MenuScene on server prompt
- [ ] Nickname submission accepted by server, lobby appears
- [ ] PVP room creation shows waiting screen with room ID
- [ ] Room list populates with active rooms, join works
- [ ] PVE difficulty selection starts game immediately
- [ ] Error toasts show on join failures
- [ ] All files under 150 lines, JSDoc on exports
## Risk Assessment
- **DOM events not cleaned up on scene restart:** Each `menu-scene.js` shutdown must unbind eventBus listeners. Use named function refs (not anonymous) so `off()` works.
- **Race condition:** `CODE_GAME_STARTING` can arrive very quickly after `CODE_ROOM_JOIN_SUCCESS` in PVP (server auto-starts when 2 players join). MenuScene must handle both events without assuming a delay.
## Next Steps
- Phase 4: GameScene + board rendering (depends on this phase)
@@ -0,0 +1,202 @@
# Phase 4: Game Scene + Board Rendering
## Context Links
- [Plan overview](plan.md)
- [Phase 3: Boot + Menu](phase-03-boot-menu-scenes.md)
- Server move handler: `landlords-server/.../event/ServerEventListener_CODE_GAME_MOVE.java`
- Server game starting: `landlords-server/.../event/ServerEventListener_CODE_GAME_STARTING.java`
- Move success data: `{row, col, piece("BLACK"/"WHITE"), playerNickname, playerId}`
- Board size: 15x15 (from `Board.BOARD_SIZE`)
## Overview
- **Priority:** P1
- **Status:** Pending
- **Blocked by:** Phase 3
- **Description:** Render the Gomoku board using Phaser Graphics, handle click-to-place, display player info and turn indicator via DOM panel.
## Key Insights
- `CODE_GAME_STARTING` data: `{roomId, blackPlayerId, blackPlayerNickname, whitePlayerId, whitePlayerNickname, boardSize: 15}`
- Player determines their color by comparing `gameState.clientId` to `blackPlayerId`
- Turn alternates: black always first. Client tracks turn locally via move count (odd=black, even=white) or by checking `playerId` in move success
- `CODE_GAME_MOVE_SUCCESS` is broadcast to BOTH players (and spectators). Client must render the move regardless of who made it.
- Move errors (`OCCUPIED`, `OUT_OF_BOUNDS`, `NOT_YOUR_TURN`, `INVALID`) only sent to the player who attempted the invalid move
## Architecture
### Board Rendering (Phaser Graphics)
```
800x800 canvas
Margin: 40px each side -> play area: 720x720
Grid: 15x15 intersections (14 gaps)
Cell size: 720 / 14 = ~51.4px
Grid lines: Phaser.Graphics lines, color #8B7355 (wood brown)
Star points: 5 dots at standard Gomoku positions (3,3), (3,11), (7,7), (11,3), (11,11)
Stone: Phaser.Graphics circle, radius ~22px
BLACK: radial gradient fill #111 -> #333
WHITE: radial gradient fill #fff -> #ddd
Placement animation: scale tween 0 -> 1 over 150ms, ease 'Back.easeOut'
Last move indicator: small colored dot or ring on the most recent stone
Click detection: pointer event on canvas, snap to nearest intersection
- Calculate (row, col) from pointer position
- Reject if not player's turn (local check before sending)
- Send CODE_GAME_MOVE with {row, col}
```
### Coordinate Mapping
```
boardX(col) = MARGIN + col * CELL_SIZE
boardY(row) = MARGIN + row * CELL_SIZE
colFromX(x) = Math.round((x - MARGIN) / CELL_SIZE)
rowFromY(y) = Math.round((y - MARGIN) / CELL_SIZE)
Clamp to [0, 14] range
```
### DOM Game Panel (game-ui.js)
- Player info bar (top or side): black player name, white player name, highlight active turn
- Move history panel (optional, scrollable): list of moves with row,col
- Toast area for errors ("Not your turn!", "Position occupied")
## Related Code Files
### Files to Create
- `web-client/src/scenes/game-scene.js`
- `web-client/src/objects/board.js`
- `web-client/src/objects/stone.js`
- `web-client/src/ui/game-ui.js`
### Files to Modify
- `web-client/src/config/game-config.js` -- add GameScene to scene list
- `web-client/src/services/game-state-service.js` -- add board update methods
## Implementation Steps
### 1. `board.js` (~120 lines) -- Phaser GameObject
Board rendering class:
- `constructor(scene, config)` -- config has margin, cellSize, boardSize
- `drawGrid()` -- draw 15 horizontal + 15 vertical lines using `scene.add.graphics()`
- `drawStarPoints()` -- 5 filled circles at standard positions
- `drawLabels()` -- optional: row numbers (0-14) and column letters along edges
- `getIntersection(pointerX, pointerY)` -- returns `{row, col}` snapped to nearest intersection, or null if too far from any intersection (tolerance: cellSize * 0.4)
- `getCenterPosition(row, col)` -- returns `{x, y}` canvas coordinates
- Constants: `MARGIN = 40`, `CELL_SIZE`, `BOARD_SIZE = 15`
- Export class
### 2. `stone.js` (~60 lines) -- Phaser GameObject
Stone rendering:
- `constructor(scene, x, y, piece)` -- piece is "BLACK" or "WHITE"
- Draw filled circle with gradient-like effect:
- Main circle with fill color
- Smaller inner circle offset for 3D highlight effect
- `playPlaceAnimation()` -- scale tween from 0 to 1, 150ms, Back.easeOut
- `setLastMoveIndicator(show)` -- add/remove small red dot at center
- Export class
### 3. `game-scene.js` (~150 lines)
```
constructor: super({ key: 'GameScene' })
create():
- Read game info from gameState (set by MenuScene before transition)
- Create Board object
- Create stones container (empty initially)
- Setup game-ui.js DOM panel (player names, turn indicator)
- Register eventBus listeners:
- CODE_GAME_MOVE_SUCCESS: placeStone(data)
- CODE_GAME_MOVE_OCCUPIED: gameUi.showToast('Position occupied')
- CODE_GAME_MOVE_OUT_OF_BOUNDS: gameUi.showToast('Out of bounds')
- CODE_GAME_MOVE_NOT_YOUR_TURN: gameUi.showToast('Not your turn')
- CODE_GAME_MOVE_INVALID: gameUi.showToast('Invalid move')
- CODE_GAME_OVER: handled in Phase 5
- CODE_CLIENT_EXIT: opponent left, show message, return to menu
- CODE_CLIENT_KICK: kicked for idle, return to menu
- Setup pointer click handler on canvas
handleClick(pointer):
- If spectating, ignore
- If not my turn (local check), ignore (avoid unnecessary server round-trip)
- board.getIntersection(pointer.x, pointer.y) -> {row, col}
- If null (clicked too far from intersection), ignore
- If gameState.board[row][col] != null (local occupied check), ignore
- connectionService.send(CODE_GAME_MOVE, JSON.stringify({row, col}))
placeStone(data):
- {row, col, piece, playerNickname, playerId} = data
- pos = board.getCenterPosition(row, col)
- Create new Stone(scene, pos.x, pos.y, piece)
- stone.playPlaceAnimation()
- Remove last-move indicator from previous stone, add to this one
- gameState.board[row][col] = piece
- gameState.moves.push(data)
- Update turn: gameState.isMyTurn = (data.playerId !== gameState.clientId)
- gameUi.updateTurn(gameState.isMyTurn)
- gameUi.addMoveToHistory(data)
shutdown():
- Unsubscribe all eventBus listeners
- gameUi.hide()
```
### 4. `game-ui.js` (~100 lines)
DOM manipulation for in-game panels:
- `show(blackName, whiteName, isBlack)` -- create/show player info bar
- Two player cards: name + piece color indicator
- Highlight current turn
- `updateTurn(isMyTurn)` -- toggle highlight between player cards, show "Your turn" / "Opponent's turn"
- `addMoveToHistory(moveData)` -- append to scrollable move list
- `showToast(msg)` -- temporary error message, auto-fade after 2s
- `hide()` -- remove DOM elements
Add DOM containers to `index.html`:
```html
<div id="game-panel" class="hidden">
<div id="player-info"></div>
<div id="move-history"></div>
<div id="toast-container"></div>
</div>
```
### 5. Update `game-state-service.js`
Add methods:
- `initGame(startingData)` -- set roomId, player IDs/names, isBlack, isMyTurn (black goes first), init board 15x15 nulls
- `updateBoard(row, col, piece)` -- set board[row][col]
- `isMyTurn` getter based on current state
## Todo List
- [ ] Create `board.js` with grid, star points, coordinate mapping
- [ ] Create `stone.js` with circle rendering and placement tween
- [ ] Create `game-ui.js` with player info, turn indicator, toast
- [ ] Create `game-scene.js` wiring board + stones + events + clicks
- [ ] Add `#game-panel` DOM structure to `index.html`
- [ ] Update `game-config.js` scene list
- [ ] Update `game-state-service.js` with game init and board methods
- [ ] Test: click to place stone, see it appear after server confirms
- [ ] Test: opponent's move appears with animation
- [ ] Test: error toasts on invalid moves
## Success Criteria
- [ ] 15x15 board renders with grid lines and star points
- [ ] Clicking intersection sends move to server
- [ ] Confirmed moves appear as stones with placement animation
- [ ] Last move indicator visible
- [ ] Turn indicator updates correctly
- [ ] Error toasts display and auto-dismiss
- [ ] Spectator mode: board renders moves but clicks are ignored
- [ ] All files under 150 lines, JSDoc on exports
## Risk Assessment
- **Click precision on small screens:** `CELL_SIZE` may be small on mobile. Mitigation: Phaser `Scale.FIT` + generous snap tolerance (40% of cell size).
- **Rapid successive moves in PVE:** AI responds instantly, two `CODE_GAME_MOVE_SUCCESS` events arrive back-to-back. Ensure `placeStone` is idempotent and animation queue doesn't break. Each stone is an independent tween -- no sequential dependency needed.
- **Canvas vs DOM event conflict:** Phaser pointer events and DOM overlay events can conflict. Mitigation: hide DOM overlays when GameScene is active; game-panel is positioned outside the canvas clickable area (above or beside).
## Next Steps
- Phase 5: Game over + spectator (depends on this phase)
@@ -0,0 +1,227 @@
# Phase 5: Game Over + Spectator Mode
## Context Links
- [Plan overview](plan.md)
- [Phase 4: Game Scene](phase-04-game-scene-board.md)
- Server game over: `landlords-server/.../event/ServerEventListener_CODE_GAME_MOVE.java` lines 115-131
- Server ready handler: `landlords-server/.../event/ServerEventListener_CODE_GAME_READY.java`
- Server watch handler: `landlords-server/.../event/ServerEventListener_CODE_GAME_WATCH.java`
- Spectator event wrapper: `ClientEventListener_CODE_GAME_WATCH.java` -- `{code, data}` wrapping
## Overview
- **Priority:** P2
- **Status:** Pending
- **Blocked by:** Phase 4
- **Description:** Handle game end (win/lose/draw), rematch flow, opponent disconnect, and spectator mode viewing.
## Key Insights
### Game Over
- `CODE_GAME_OVER` data: `{result: "BLACK_WIN"|"WHITE_WIN"|"DRAW", winnerNickname: string, board: string}`
- Sent to both players and spectators
- After game over, players can send `CODE_GAME_READY` to signal rematch willingness
- `CODE_GAME_READY` response: `{clientNickName, status: "READY"|"NO_READY", clientId}`
- Ready is a toggle -- sending again toggles back to NO_READY
- When both players are READY, server auto-fires `CODE_GAME_STARTING` again (new game, same room)
### Opponent Disconnect
- `CODE_CLIENT_EXIT` data (to player): `{roomId, exitClientId, exitClientNickname}` -- room is destroyed server-side
- `CODE_CLIENT_KICK` data: client nickname string -- idle kick, room destroyed
- After either, client should return to lobby (server sends `CODE_SHOW_OPTIONS` after cleanup)
### Spectator Mode
- Spectator receives `CODE_GAME_WATCH` events wrapping inner events: `{code: "CODE_...", data: ...}`
- Inner codes: `CODE_ROOM_JOIN_SUCCESS`, `CODE_GAME_STARTING`, `CODE_GAME_MOVE_SUCCESS`, `CODE_CLIENT_EXIT`, `CODE_CLIENT_KICK`, `CODE_GAME_OVER`
- Spectator also receives `CODE_GAME_WATCH_SUCCESSFUL` directly: `{owner, status}`
- Spectator exits by sending `CODE_GAME_WATCH_EXIT`, then navigates back to menu
- Spectator CANNOT make moves, ready up, or interact with game
## Architecture
### Game Over Flow
```
CODE_GAME_OVER received
-> GameScene: disable click handler
-> Show GameOverScene (overlay or scene transition)
- Display: "You Win!" / "You Lose!" / "Draw!"
- Winner name
- Buttons: [Rematch] [Exit to Lobby]
[Rematch] clicked:
-> send CODE_GAME_READY
-> show "Waiting for opponent..." or "Both ready!"
-> on CODE_GAME_READY from opponent: update UI to show their ready status
-> on CODE_GAME_STARTING: clear overlay, reset board, start new game in GameScene
[Exit] clicked:
-> send CODE_CLIENT_EXIT
-> gameState.reset()
-> scene.start('MenuScene')
```
### Spectator Flow
```
MenuScene: user clicks Watch on a room
-> send CODE_GAME_WATCH with roomId
-> receive CODE_GAME_WATCH_SUCCESSFUL: {owner, status}
-> transition to GameScene with isSpectating=true
GameScene (spectator):
- Board renders normally
- Click handler disabled
- Player info shows "Spectating" badge
- Move events come wrapped in CODE_GAME_WATCH: {code, data}
- Unwrap and handle inner code normally (reuse placeStone, etc.)
- On game over: show result, offer [Exit Spectating] button
- Exit: send CODE_GAME_WATCH_EXIT, return to MenuScene
```
### Spectator Event Unwrapping (in game-scene.js)
```js
eventBus.on(ClientEventCode.GAME_WATCH, (wrapData) => {
const innerCode = wrapData.code;
const innerData = typeof wrapData.data === 'string'
? tryParseJson(wrapData.data)
: wrapData.data;
// Route to existing handlers
this.handleServerEvent(innerCode, innerData);
});
```
## Related Code Files
### Files to Create
- `web-client/src/scenes/game-over-scene.js`
### Files to Modify
- `web-client/src/scenes/game-scene.js` -- add game over, exit, kick, spectator event handlers
- `web-client/src/ui/game-ui.js` -- add game over overlay, spectator badge
- `web-client/src/config/game-config.js` -- add GameOverScene to scene list
- `web-client/index.html` -- add game-over DOM overlay structure
## Implementation Steps
### 1. Game Over DOM overlay (in `index.html`)
```html
<div id="game-over-overlay" class="hidden">
<div class="game-over-card">
<h1 id="game-result-text"></h1>
<p id="game-result-detail"></p>
<div id="rematch-status" class="hidden">
<p>You: <span id="my-ready-status">Not Ready</span></p>
<p>Opponent: <span id="opponent-ready-status">Not Ready</span></p>
</div>
<div class="game-over-buttons">
<button id="btn-rematch">Rematch</button>
<button id="btn-exit-game">Exit to Lobby</button>
</div>
</div>
</div>
```
### 2. `game-over-scene.js` (~80 lines)
Decision: use DOM overlay managed by game-ui.js rather than a separate Phaser scene. The board should remain visible behind the overlay. Rename this to game-over logic inside `game-ui.js` instead.
Actually -- implement as **methods in `game-ui.js`** rather than a separate scene. This avoids scene transition complexity and keeps the board visible.
- `showGameOver(result, winnerNickname, isWinner, isDraw)` -- populate and show overlay
- `showRematchStatus(myReady, opponentReady)` -- update ready indicators
- `hideGameOver()` -- hide overlay for new game
- `showSpectatorGameOver(result, winnerNickname)` -- simplified: result + exit button only
Remove `game-over-scene.js` from plan. Update `game-config.js` -- no extra scene needed.
### 3. Update `game-scene.js` -- Game Over Handling (~30 lines added)
```
on CODE_GAME_OVER:
- Parse result, determine if current player won
- Disable click handler
- gameUi.showGameOver(result, winnerNickname, isWinner, isDraw)
- Wire rematch button: send CODE_GAME_READY, show rematch status
on CODE_GAME_READY:
- Update rematch status display
- If both ready, server will send CODE_GAME_STARTING
on CODE_GAME_STARTING (during rematch):
- gameState.resetBoard()
- Clear all stone objects from scene
- gameUi.hideGameOver()
- Re-init board state (new black/white assignment)
- Re-enable click handler
```
### 4. Update `game-scene.js` -- Exit/Kick Handling (~15 lines added)
```
on CODE_CLIENT_EXIT:
- gameUi.showToast(exitClientNickname + ' left the game')
- After 2s delay: gameState.reset(), scene.start('MenuScene')
on CODE_CLIENT_KICK:
- gameUi.showToast('Kicked for being idle')
- gameState.reset(), scene.start('MenuScene')
```
### 5. Update `game-scene.js` -- Spectator Support (~25 lines added)
```
create():
- if gameState.isSpectating:
- Disable click handler
- gameUi.showSpectatorBadge()
- Register CODE_GAME_WATCH listener (unwrap + route)
on CODE_GAME_WATCH:
- Unwrap {code, data}
- Route to existing handlers (placeStone, game over, etc.)
Spectator exit button:
- send CODE_GAME_WATCH_EXIT
- gameState.reset()
- scene.start('MenuScene')
```
### 6. Update `game-ui.js` -- New Methods (~50 lines added)
- `showGameOver(...)` -- show overlay with result text
- `hideGameOver()` -- hide overlay
- `showRematchStatus(myReady, opponentReady)` -- toggle ready indicators
- `showSpectatorBadge()` -- add "SPECTATING" label to player info
- `showSpectatorExit()` -- add exit button for spectators
## Todo List
- [ ] Add game-over DOM overlay to `index.html`
- [ ] Add game over methods to `game-ui.js`
- [ ] Add `CODE_GAME_OVER` handler in `game-scene.js`
- [ ] Add rematch flow (`CODE_GAME_READY` send + receive) in `game-scene.js`
- [ ] Add rematch restart logic (clear board, re-init) in `game-scene.js`
- [ ] Add `CODE_CLIENT_EXIT` and `CODE_CLIENT_KICK` handlers in `game-scene.js`
- [ ] Add spectator event unwrapping (`CODE_GAME_WATCH`) in `game-scene.js`
- [ ] Add spectator UI elements (badge, exit button) to `game-ui.js`
- [ ] Test: win/lose/draw displays correctly
- [ ] Test: rematch flow -- both ready -> new game starts
- [ ] Test: opponent exits mid-game -> return to lobby
- [ ] Test: spectator sees moves and game over
## Success Criteria
- [ ] Game over overlay shows correct result (win/lose/draw)
- [ ] Rematch button sends ready signal, UI shows both players' ready state
- [ ] New game starts with cleared board when both ready
- [ ] Exit button sends `CODE_CLIENT_EXIT` and returns to lobby
- [ ] Opponent disconnect shows notification and returns to lobby after delay
- [ ] Spectator sees all moves in real-time, cannot interact
- [ ] Spectator exit sends `CODE_GAME_WATCH_EXIT` and returns to lobby
- [ ] `game-scene.js` stays under 200 lines total
- [ ] `game-ui.js` stays under 200 lines total
## Risk Assessment
- **Rematch player color swap:** Server re-assigns black/white in `CODE_GAME_STARTING`. Client must NOT assume same colors. Re-read `blackPlayerId` from new starting data.
- **Stale event listeners after rematch:** Board reset clears stones but scene is NOT restarted. Event listeners persist, which is correct -- no unbind/rebind needed.
- **Spectator joining mid-game:** Server sends `CODE_GAME_WATCH_SUCCESSFUL` with room status but does NOT replay past moves. Spectator sees the board from their join point onward. This is a server limitation -- document it, don't try to work around.
## Next Steps
- Phase 6: Polish + error handling (cross-cutting)
@@ -0,0 +1,170 @@
# Phase 6: Polish + Error Handling
## Context Links
- [Plan overview](plan.md)
- [Phase 2: Services](phase-02-services-layer.md) -- reconnect logic lives here
- [Phase 3: Menu](phase-03-boot-menu-scenes.md) -- toast system
- [Phase 4: Game](phase-04-game-scene-board.md) -- game-ui.js toast
## Overview
- **Priority:** P2
- **Status:** Pending
- **Blocked by:** Phase 5
- **Description:** Cross-cutting improvements: connection lost overlay, reconnect UX, hover effects, sound placeholders, input validation hardening, manual integration test checklist.
## Implementation Steps
### 1. Connection Lost Overlay (~20 lines in `game-ui.js`)
Add to `index.html`:
```html
<div id="connection-lost" class="hidden">
<div class="connection-card">
<h2>Connection Lost</h2>
<p>Reconnecting<span id="reconnect-dots">...</span></p>
</div>
</div>
```
- `eventBus.on('ws:disconnected')` -> show overlay (full-screen semi-transparent)
- `eventBus.on('ws:connected')` -> hide overlay
- If reconnect succeeds, server treats it as a new client (old room is gone). Redirect to BootScene.
### 2. Board Hover Effect (~15 lines in `game-scene.js`)
- On `pointermove`: calculate nearest intersection
- If valid + empty + my turn: draw a semi-transparent stone preview at that position
- On `pointerout` or click: clear preview
- Use a single reusable Graphics object for the preview (no object churn)
### 3. Input Validation Hardening
**Nickname input (`menu-ui.js`):**
- Trim whitespace
- Reject empty or >10 chars client-side before sending (avoid server round-trip)
- Disable submit button while waiting for server response
**Click debounce (`game-scene.js`):**
- After sending a move, set `awaitingResponse = true`
- On `CODE_GAME_MOVE_SUCCESS` or any move error: set `awaitingResponse = false`
- Reject clicks while `awaitingResponse` is true
- Prevents double-click sending duplicate moves
### 4. Toast System Consolidation
Both `menu-ui.js` and `game-ui.js` need toast capability. Extract shared toast logic:
- Add `#toast-container` to `index.html` at top level (outside scene-specific containers)
- Create `showToast(msg, type='error', duration=2500)` function in `game-ui.js` (reuse from menu too)
- Types: `error` (red), `info` (blue), `success` (green)
- Auto-dismiss with CSS fade-out animation
### 5. Keyboard Shortcuts
- `Escape` during game: show confirmation "Exit game?" dialog
- `Enter` on nickname input: submit (already handled if form has submit event)
### 6. WS URL Configuration
- Check `?ws=` URL parameter for custom server address
- Fallback: `ws://localhost:1025/ratel`
- Display connected server address in BootScene
- Example: `http://localhost:5173/?ws=ws://192.168.1.5:1025/ratel`
### 7. Responsive Layout
- Phaser `Scale.FIT` handles canvas scaling
- DOM overlays: use viewport-relative units (vh/vw) and max-width for panels
- Test at 800x800, 1920x1080, 1366x768 browser sizes
- Mobile: not a priority but should not break entirely
## Integration Test Checklist (Manual)
Run server: `java -jar landlords-server/target/landlords-server-*.jar -p 1024`
(WebSocket will be on port 1025)
Open client: `http://localhost:5173/?ws=ws://localhost:1025/ratel`
### Connection Flow
- [ ] Client connects, shows "Connecting..."
- [ ] After ~2s, nickname prompt appears
- [ ] Enter valid nickname (1-10 chars), lobby appears
- [ ] Enter invalid nickname (empty / >10), error shown, re-prompted
### PVP Flow
- [ ] Create room, waiting screen shows room ID
- [ ] Open second browser tab, join room by ID
- [ ] Game starts, board renders with player info
- [ ] Black player moves first, click places stone
- [ ] White player's turn, black player click rejected locally
- [ ] Play until 5-in-a-row, game over screen shows winner
- [ ] Both click Rematch, new game starts with cleared board
- [ ] One player exits, other returns to lobby
- [ ] Test room list refresh shows available rooms
### PVE Flow
- [ ] Select PVE, choose difficulty
- [ ] Game starts immediately (player is black)
- [ ] Place stone, AI responds automatically
- [ ] Play until game over
- [ ] Exit returns to lobby
### Spectator Flow
- [ ] Start a PVP game in two tabs
- [ ] Third tab: spectate the room
- [ ] Spectator sees moves in real-time
- [ ] Spectator cannot click to place stones
- [ ] Game over shown to spectator
- [ ] Spectator exits, returns to lobby
### Error Handling
- [ ] Close server: "Connection Lost" overlay appears
- [ ] Restart server: client reconnects, returns to nickname prompt
- [ ] Join non-existent room: error toast
- [ ] Join full room: error toast
- [ ] Rapid-click same position: no duplicate requests
## Todo List
- [ ] Add connection-lost overlay to `index.html` and wire in `game-ui.js`
- [ ] Add board hover preview in `game-scene.js`
- [ ] Add click debounce in `game-scene.js`
- [ ] Add client-side nickname validation in `menu-ui.js`
- [ ] Consolidate toast system in `game-ui.js`
- [ ] Add WS URL parameter support in `boot-scene.js`
- [ ] Add Escape key handler in `game-scene.js`
- [ ] Test responsive layout at multiple sizes
- [ ] Run full integration test checklist
## Success Criteria
- [ ] Connection lost overlay appears/disappears correctly
- [ ] Hover preview shows semi-transparent stone at valid positions
- [ ] No duplicate move requests on rapid clicks
- [ ] All integration test checklist items pass
- [ ] No console errors during normal gameplay
- [ ] All JS files remain under 200 lines
## Risk Assessment
- **Reconnect creates new identity:** Server has no session resumption. This is a known limitation. After reconnect, user must re-enter nickname and rejoin. Document this, don't over-engineer.
- **Hover performance:** Redrawing preview on every pointermove could lag. Mitigation: use a single Graphics object, clear+redraw only when intersection changes (cache last hover position).
## File Ownership Summary (All Phases)
| File | Owner Phase | Touched By |
|------|------------|------------|
| `package.json` | 1 | 1 only |
| `vite.config.js` | 1 | 1 only |
| `index.html` | 1 | 3, 4, 5, 6 (DOM additions) |
| `main.js` | 1 | 1 only |
| `game-config.js` | 1 | 3, 4 (scene list) |
| `protocol-constants.js` | 2 | 2 only |
| `event-bus.js` | 2 | 2 only |
| `connection-service.js` | 2 | 2, 6 (reconnect polish) |
| `game-state-service.js` | 2 | 2, 4, 5 (game methods) |
| `boot-scene.js` | 3 | 3, 6 (WS URL param) |
| `menu-scene.js` | 3 | 3 only |
| `menu-ui.js` | 3 | 3, 6 (validation) |
| `board.js` | 4 | 4 only |
| `stone.js` | 4 | 4 only |
| `game-scene.js` | 4 | 4, 5, 6 (game over, spectator, hover) |
| `game-ui.js` | 4 | 4, 5, 6 (game over overlay, toast, connection) |
Note: Phases are sequential so file ownership conflicts are not possible.
+101
View File
@@ -0,0 +1,101 @@
---
title: "Phaser 3 Web Client for Gomoku"
description: "Standalone Phaser 3 + Vite web client connecting to existing Netty server via WebSocket"
status: pending
priority: P1
effort: 12h
branch: master
tags: [web-client, phaser3, vite, gomoku, websocket]
created: 2026-04-10
---
# Phaser 3 Web Client for Gomoku
## Overview
Build a standalone web client using Phaser 3 (game engine) + Vite (build tool) + vanilla JavaScript (with JSDoc). Connects to existing Netty server at `ws://host:port/ratel`. Server owns all game logic; client is display + input only.
## Architecture
```
Browser
+-- Phaser 3 Game (canvas: board rendering, stones, animations)
+-- DOM Overlays (HTML/CSS: menus, forms, lobby, toasts)
+-- Services (JS modules, not Phaser-coupled)
+-- connection-service.js (WebSocket I/O + heartbeat)
+-- event-bus.js (pub/sub decoupling)
+-- game-state-service.js (clientId, roomId, turn, board state)
```
## Data Flow
```
User click -> GameScene -> connection-service.send(CODE_GAME_MOVE, {row, col})
|
v
WebSocket -> Server
|
v
Server -> WebSocket -> connection-service.onMessage -> event-bus.emit(code, data)
|
v
GameScene listener -> update board, play animation
```
## Phases
| # | Phase | Status | Effort | Files |
|---|-------|--------|--------|-------|
| 1 | [Project scaffold](phase-01-project-scaffold.md) | Pending | 1h | package.json, vite.config.js, index.html, main.js, game-config.js |
| 2 | [Services layer](phase-02-services-layer.md) | Pending | 2h | connection-service.js, event-bus.js, game-state-service.js, protocol-constants.js |
| 3 | [Boot + Menu scenes](phase-03-boot-menu-scenes.md) | Pending | 2.5h | boot-scene.js, menu-scene.js, menu-ui.js, styles in index.html |
| 4 | [Game scene + board](phase-04-game-scene-board.md) | Pending | 3h | game-scene.js, board.js, stone.js, game-ui.js |
| 5 | [Game over + spectator](phase-05-gameover-spectator.md) | Pending | 2h | DOM overlay in game-ui.js, updates to game-scene.js |
| 6 | [Polish + error handling](phase-06-polish-errors.md) | Pending | 1.5h | cross-cutting updates, reconnect logic, toast system |
## Dependency Graph
```
Phase 1 (scaffold)
+-> Phase 2 (services) -- no Phaser dependency, can start after scaffold
+-> Phase 3 (boot+menu) -- needs scaffold + services
+-> Phase 4 (game scene) -- needs menu to navigate + services for WS
+-> Phase 5 (game over + spectator) -- needs game scene
+-> Phase 6 (polish) -- cross-cutting, touches all
```
## Key Decisions
1. **DOM overlays for menus** -- Phaser text/buttons too limited for forms and tables. Standard Phaser practice.
2. **Plain WebSocket API** -- No socket.io. Server uses raw WS frames. `connection-service.js` wraps reconnect + heartbeat.
3. **Event bus decoupling** -- Scenes subscribe to game events via event-bus, not direct WS references. Testable, replaceable.
4. **No TypeScript** -- User preference. JSDoc `@typedef` for type documentation.
5. **15x15 board** -- Hardcoded from server `Board.BOARD_SIZE = 15`. Client reads `boardSize` from `CODE_GAME_STARTING` anyway.
6. **3 Phaser scenes only** -- BootScene, MenuScene, GameScene. Game over is a DOM overlay on GameScene (keeps board visible behind result card). No GameOverScene needed.
## Backwards Compatibility
- **Server: zero changes.** Client connects via existing WS endpoint `/ratel`.
- **Existing Java client: unaffected.** Web client is additive.
- **Protocol verified** from server source: `Msg{code, data, info}` JSON format.
## Rollback Plan
- `web-client/` is a standalone directory. `rm -rf web-client/` to revert.
- No server code modified. No migration needed.
## Test Strategy
- **Manual integration test** with running server (Phase 6)
- **Browser dev tools** for WS frame inspection
- No unit test framework initially (YAGNI -- thin UI client with server-owned logic)
- If needed later: Vitest for service modules
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| WS message format mismatch | Low | High | Protocol constants extracted from server source; verify with live server in Phase 2 |
| Phaser canvas sizing on different screens | Medium | Medium | Use Phaser `Scale.FIT` + responsive CSS container |
| Server heartbeat timeout (<60s idle) | Low | High | Client sends `CODE_CLIENT_HEAD_BEAT` every 50s via setInterval |
| DOM overlay z-index conflicts with Phaser | Medium | Low | Explicit z-index layering; hide overlays during game scene |