create-remix-game 1.1.13 → 1.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-remix-game",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "CLI for scaffolding Remix games",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -1,11 +1,8 @@
1
1
  import { initRemix } from "@insidethesim/remix-dev"
2
- import { initializeSDKMock } from "@insidethesim/remix-dev/mocks"
3
2
  import { GameScene } from "./scenes/GameScene"
4
3
  import GameSettings from "./config/GameSettings"
5
4
 
6
- // Initialize SDK mock BEFORE creating the game
7
- // This ensures window.FarcadeSDK exists when scenes are created
8
- await initializeSDKMock()
5
+ // SDK mock is automatically initialized by the framework (dev-init.ts)
9
6
 
10
7
  // Game configuration
11
8
  const config: Phaser.Types.Core.GameConfig = {
@@ -33,6 +30,9 @@ const config: Phaser.Types.Core.GameConfig = {
33
30
  // Create the game instance
34
31
  const game = new Phaser.Game(config)
35
32
 
33
+ // Store globally for performance monitoring and HMR cleanup
34
+ ;(window as any).game = game
35
+
36
36
  // Initialize Remix framework after game is created
37
37
  game.events.once("ready", () => {
38
38
  initRemix(game, {
@@ -1,9 +1,13 @@
1
+ import type { FarcadeSDK, GameInfo } from '@farcade/game-sdk'
1
2
  import GameSettings from '../config/GameSettings'
2
3
 
3
- // Declare the FarcadeSDK type on window
4
+ // Extend the Player type from SDK
5
+ type Player = GameInfo['players'][number]
6
+
7
+ // Declare the FarcadeSDK on window
4
8
  declare global {
5
9
  interface Window {
6
- FarcadeSDK: any
10
+ FarcadeSDK: FarcadeSDK
7
11
  debugLogs: string[]
8
12
  }
9
13
  }
@@ -18,14 +22,10 @@ interface Ball {
18
22
 
19
23
  export class DemoScene extends Phaser.Scene {
20
24
  private balls: Ball[] = []
21
- private clickCount: number = 0
22
- private clickText?: Phaser.GameObjects.Text
23
25
  private gameOver: boolean = false
24
26
  private elementsCreated: boolean = false
25
27
 
26
- // Color selection state
27
- private selectedColor: 'green' | 'blue' | 'red' = 'green'
28
- private colorSwatches: Phaser.GameObjects.Container | undefined
28
+ // Color values
29
29
  private colorValues = {
30
30
  green: 0x33ff00,
31
31
  blue: 0x0099ff,
@@ -34,10 +34,27 @@ export class DemoScene extends Phaser.Scene {
34
34
 
35
35
  // Multiplayer support
36
36
  private isMultiplayer: boolean = false
37
- private players: Array<{ id: string; name: string; imageUrl?: string }> = []
37
+ private players: Player[] = []
38
38
  private meId: string = '1'
39
- private otherPlayerClicks: number = 0
40
- private allClickCounts: { [key: string]: number } = {}
39
+
40
+ // Per-player state (works for both single and multiplayer)
41
+ private playerStates: {
42
+ [playerId: string]: {
43
+ color: 'green' | 'blue' | 'red'
44
+ score: number
45
+ }
46
+ } = {}
47
+
48
+ // Turn-based multiplayer state
49
+ private currentTurnPlayerId: string = '1'
50
+ private roundNumber: number = 0
51
+ private lastSentStateId?: string // Track last state we sent to avoid processing our own updates
52
+
53
+ // UI Elements
54
+ private player1ScoreText?: Phaser.GameObjects.Text
55
+ private player2ScoreText?: Phaser.GameObjects.Text
56
+ private turnIndicatorText?: Phaser.GameObjects.Text
57
+ private colorSwatches?: Phaser.GameObjects.Container
41
58
 
42
59
  constructor() {
43
60
  super({ key: 'DemoScene' })
@@ -45,6 +62,121 @@ export class DemoScene extends Phaser.Scene {
45
62
 
46
63
  preload(): void {}
47
64
 
65
+ // ========== Helper Methods ==========
66
+
67
+ private initializePlayerState(playerId: string): void {
68
+ if (!this.playerStates[playerId]) {
69
+ // In multiplayer, assign fixed colors based on player order
70
+ let assignedColor: 'green' | 'blue' | 'red' = 'green'
71
+ if (this.isMultiplayer && this.players.length >= 2) {
72
+ // Player 1 (players[0]) = green, Player 2 (players[1]) = red
73
+ const playerIndex = this.players.findIndex((p) => p.id === playerId)
74
+ assignedColor = playerIndex === 1 ? 'red' : 'green'
75
+ }
76
+
77
+ this.playerStates[playerId] = {
78
+ color: assignedColor,
79
+ score: 0,
80
+ }
81
+ }
82
+ }
83
+
84
+ private getMyState() {
85
+ this.initializePlayerState(this.meId)
86
+ return this.playerStates[this.meId]
87
+ }
88
+
89
+ private getPlayerState(playerId: string) {
90
+ this.initializePlayerState(playerId)
91
+ return this.playerStates[playerId]
92
+ }
93
+
94
+ private isMyTurn(): boolean {
95
+ return !this.isMultiplayer || this.currentTurnPlayerId === this.meId
96
+ }
97
+
98
+ private getOtherPlayerId(): string | null {
99
+ if (!this.isMultiplayer || this.players.length < 2) return null
100
+ return this.players.find((p) => p.id !== this.meId)?.id || null
101
+ }
102
+
103
+ private switchTurn(): void {
104
+ if (!this.isMultiplayer) return
105
+
106
+ const otherPlayerId = this.getOtherPlayerId()
107
+ if (otherPlayerId) {
108
+ this.currentTurnPlayerId = otherPlayerId
109
+ this.roundNumber++
110
+ }
111
+ }
112
+
113
+ private getTurnText(): string {
114
+ if (!this.isMultiplayer) return ''
115
+ const currentPlayer = this.players.find((p) => p.id === this.currentTurnPlayerId)
116
+ return this.isMyTurn() ? '⭐ YOUR TURN ⭐' : `${currentPlayer?.name || 'Opponent'}'s Turn`
117
+ }
118
+
119
+ private updateUI(): void {
120
+ if (this.isMultiplayer && this.players.length >= 2) {
121
+ const player1 = this.players[0]
122
+ const player2 = this.players[1]
123
+
124
+ if (this.player1ScoreText) {
125
+ const p1State = this.getPlayerState(player1.id)
126
+ this.player1ScoreText.setText(`${player1.name}\nScore: ${p1State.score}/3`)
127
+ }
128
+
129
+ if (this.player2ScoreText) {
130
+ const p2State = this.getPlayerState(player2.id)
131
+ this.player2ScoreText.setText(`${player2.name}\nScore: ${p2State.score}/3`)
132
+ }
133
+
134
+ if (this.turnIndicatorText) {
135
+ this.turnIndicatorText.setText(this.getTurnText())
136
+ }
137
+ } else if (this.player1ScoreText) {
138
+ this.player1ScoreText.setText(`Score: ${this.getMyState().score}/3`)
139
+ }
140
+ }
141
+
142
+ private loadStateFromData(state: any): void {
143
+ if (!state) return
144
+
145
+ if (this.isMultiplayer) {
146
+ // Multiplayer: load per-player states and turn info
147
+ if (state.playerStates) {
148
+ this.playerStates = state.playerStates
149
+ }
150
+ if (state.currentTurnPlayerId) {
151
+ this.currentTurnPlayerId = state.currentTurnPlayerId
152
+ }
153
+ if (typeof state.roundNumber === 'number') {
154
+ this.roundNumber = state.roundNumber
155
+ }
156
+ if (typeof state.gameOver === 'boolean') {
157
+ this.gameOver = state.gameOver
158
+ }
159
+ } else {
160
+ // Single-player: load only color preference (score always starts at 0)
161
+ const myState = this.getMyState()
162
+ if (state.color) {
163
+ myState.color = state.color
164
+ }
165
+ }
166
+ }
167
+
168
+ private updateBallColors(): void {
169
+ // Update ball colors to match current player's color
170
+ const myColor = this.getMyState().color
171
+ const colorValue = this.colorValues[myColor]
172
+
173
+ this.balls.forEach((ball) => {
174
+ if (!ball.isPopped) {
175
+ ball.sprite.setFillStyle(colorValue)
176
+ }
177
+ })
178
+ }
179
+
48
180
  create(): void {
49
181
  // Initialize SDK first and wait for it to be ready before creating game elements
50
182
  this.initializeSDK()
@@ -57,21 +189,39 @@ export class DemoScene extends Phaser.Scene {
57
189
  }
58
190
  this.elementsCreated = true
59
191
 
60
- // Add instructional text
192
+ console.log(
193
+ '[DemoScene] Creating game elements, isMultiplayer:',
194
+ this.isMultiplayer,
195
+ 'players:',
196
+ this.players.length
197
+ )
198
+
199
+ // Initialize my player state
200
+ this.initializePlayerState(this.meId)
201
+
202
+ // Add title
61
203
  const title = this.add
62
- .text(GameSettings.canvas.width / 2, GameSettings.canvas.height / 2 - 100, 'Remix SDK Demo', {
63
- fontSize: '64px',
64
- color: '#ffffff',
65
- fontFamily: 'Arial',
66
- })
204
+ .text(
205
+ GameSettings.canvas.width / 2,
206
+ GameSettings.canvas.height / 2 - 100,
207
+ this.isMultiplayer ? 'Turn-Based Demo' : 'Remix SDK Demo',
208
+ {
209
+ fontSize: '64px',
210
+ color: '#ffffff',
211
+ fontFamily: 'Arial',
212
+ }
213
+ )
67
214
  .setOrigin(0.5)
68
215
  .setDepth(100)
69
216
 
217
+ // Instruction text
70
218
  const instruction = this.add
71
219
  .text(
72
220
  GameSettings.canvas.width / 2,
73
221
  GameSettings.canvas.height / 2 - 20,
74
- 'Pop 3 balls to trigger Game Over!',
222
+ this.isMultiplayer
223
+ ? 'Take turns popping balls!\nFirst to 3 wins!'
224
+ : 'Pop 3 balls to trigger Game Over!',
75
225
  {
76
226
  fontSize: '32px',
77
227
  color: '#ffffff',
@@ -82,22 +232,65 @@ export class DemoScene extends Phaser.Scene {
82
232
  .setOrigin(0.5)
83
233
  .setDepth(100)
84
234
 
85
- // Add click counter text (left-aligned)
86
- this.clickText = this.add
87
- .text(50, 50, 'Score: 0/3', {
88
- fontSize: '36px',
89
- color: '#ffffff',
90
- fontFamily: 'Arial',
91
- })
92
- .setOrigin(0, 0.5)
93
- .setDepth(100)
235
+ if (this.isMultiplayer && this.players.length >= 2) {
236
+ // Multiplayer: Show both players' scores
237
+ const player1 = this.players.find((p) => p.id === '1') || this.players[0]
238
+ const player2 = this.players.find((p) => p.id === '2') || this.players[1]
94
239
 
95
- // Create color swatch selector in top right
96
- this.createColorSwatches()
240
+ // Initialize both player states
241
+ this.initializePlayerState(player1.id)
242
+ this.initializePlayerState(player2.id)
97
243
 
98
- // Update UI to reflect loaded state
99
- if (this.clickText && this.clickCount > 0) {
100
- this.clickText.setText(`Score: ${this.clickCount}/3`)
244
+ // Player 1 score (left side)
245
+ this.player1ScoreText = this.add
246
+ .text(50, 50, `${player1.name}\nScore: ${this.getPlayerState(player1.id).score}/3`, {
247
+ fontSize: '28px',
248
+ color: player1.id === this.meId ? '#00ff00' : '#ffffff',
249
+ fontFamily: 'Arial',
250
+ })
251
+ .setOrigin(0, 0.5)
252
+ .setDepth(100)
253
+
254
+ // Player 2 score (right side)
255
+ this.player2ScoreText = this.add
256
+ .text(
257
+ GameSettings.canvas.width - 50,
258
+ 50,
259
+ `${player2.name}\nScore: ${this.getPlayerState(player2.id).score}/3`,
260
+ {
261
+ fontSize: '28px',
262
+ color: player2.id === this.meId ? '#00ff00' : '#ffffff',
263
+ fontFamily: 'Arial',
264
+ align: 'right',
265
+ }
266
+ )
267
+ .setOrigin(1, 0.5)
268
+ .setDepth(100)
269
+
270
+ // Turn indicator (center top)
271
+ this.turnIndicatorText = this.add
272
+ .text(GameSettings.canvas.width / 2, 40, this.getTurnText(), {
273
+ fontSize: '32px',
274
+ color: '#ffff00',
275
+ fontFamily: 'Arial',
276
+ })
277
+ .setOrigin(0.5)
278
+ .setDepth(100)
279
+ } else {
280
+ // Single player: Show one score
281
+ this.player1ScoreText = this.add
282
+ .text(50, 50, `Score: ${this.getMyState().score}/3`, {
283
+ fontSize: '36px',
284
+ color: '#ffffff',
285
+ fontFamily: 'Arial',
286
+ })
287
+ .setOrigin(0, 0.5)
288
+ .setDepth(100)
289
+ }
290
+
291
+ // Create color swatch selector in top right (single-player only)
292
+ if (!this.isMultiplayer) {
293
+ this.createColorSwatches()
101
294
  }
102
295
 
103
296
  // Add removal instructions at bottom
@@ -120,10 +313,32 @@ export class DemoScene extends Phaser.Scene {
120
313
  // Create bouncing balls
121
314
  this.createBalls(15)
122
315
 
123
- // Don't save state immediately - wait for SDK to be ready
124
- // The state will be saved after SDK initialization
316
+ // Add global click handler for background clicks (in multiplayer)
317
+ if (this.isMultiplayer) {
318
+ this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
319
+ // Only process if it's your turn and game is not over
320
+ if (!this.gameOver && this.isMyTurn()) {
321
+ // Check if we clicked on a ball by seeing if any ball was clicked
322
+ let clickedBall = false
323
+ this.balls.forEach((ball) => {
324
+ const distance = Phaser.Math.Distance.Between(
325
+ pointer.x,
326
+ pointer.y,
327
+ ball.sprite.x,
328
+ ball.sprite.y
329
+ )
330
+ if (distance <= ball.radius && !ball.isPopped) {
331
+ clickedBall = true
332
+ }
333
+ })
125
334
 
126
- // Remove global click listener - clicks will be handled per ball
335
+ // If we didn't click a ball, it's a miss - still counts as turn
336
+ if (!clickedBall) {
337
+ this.handleMiss()
338
+ }
339
+ }
340
+ })
341
+ }
127
342
  }
128
343
 
129
344
  private createBalls(count: number): void {
@@ -132,8 +347,9 @@ export class DemoScene extends Phaser.Scene {
132
347
  const x = Phaser.Math.Between(radius, GameSettings.canvas.width - radius)
133
348
  const y = Phaser.Math.Between(radius, GameSettings.canvas.height - radius)
134
349
 
135
- // Use selected color
136
- const color = this.colorValues[this.selectedColor]
350
+ // Use my player's selected color
351
+ const myColor = this.getMyState().color
352
+ const color = this.colorValues[myColor]
137
353
  const ball = this.add.circle(x, y, radius, color)
138
354
  ball.setStrokeStyle(2, 0x000000)
139
355
  ball.setInteractive()
@@ -149,6 +365,12 @@ export class DemoScene extends Phaser.Scene {
149
365
  // Add click handler to this specific ball
150
366
  ball.on('pointerdown', () => {
151
367
  if (!this.gameOver && !ballData.isPopped) {
368
+ // In multiplayer, only allow clicks on your turn
369
+ if (this.isMultiplayer && !this.isMyTurn()) {
370
+ // Visual feedback that it's not your turn
371
+ this.cameras.main.shake(100, 0.002)
372
+ return
373
+ }
152
374
  this.popBall(ballData)
153
375
  }
154
376
  })
@@ -265,26 +487,24 @@ export class DemoScene extends Phaser.Scene {
265
487
  }
266
488
 
267
489
  // Determine multiplayer mode based on build configuration
268
- // In production, GAME_MULTIPLAYER_MODE will be replaced with true/false by build script
269
- try {
270
- // @ts-ignore - This will be replaced at build time
271
- this.isMultiplayer = GAME_MULTIPLAYER_MODE
272
- } catch (error) {
273
- // If GAME_MULTIPLAYER_MODE is not defined, we're in a Remix environment
274
- // Since package.json says multiplayer: false, we should use single-player mode
275
- this.isMultiplayer = false
276
- }
490
+ // GAME_MULTIPLAYER_MODE is set by vite-plugin based on package.json
491
+ // @ts-ignore - This is defined by Vite's define config
492
+ this.isMultiplayer =
493
+ typeof GAME_MULTIPLAYER_MODE !== 'undefined' ? GAME_MULTIPLAYER_MODE : false
494
+ console.log('[DemoScene] Multiplayer mode:', this.isMultiplayer)
277
495
 
278
496
  // Set up SDK event listeners
279
497
  window.FarcadeSDK.on('play_again', () => {
280
- this.restartGame()
281
- // Send reset state to other player after restart
282
- if (this.isMultiplayer) {
283
- // Small delay to ensure state is reset before sending
284
- setTimeout(() => {
285
- this.sendGameState()
286
- }, 10)
498
+ console.log('[DemoScene] play_again event received, isMultiplayer:', this.isMultiplayer)
499
+ // In single-player, handle reset locally
500
+ if (!this.isMultiplayer) {
501
+ console.log('[DemoScene] Single-player mode, calling restartGame()')
502
+ this.restartGame()
503
+ } else {
504
+ console.log('[DemoScene] Multiplayer mode, waiting for game_state_updated(null)')
287
505
  }
506
+ // In multiplayer, the SDK mock will send game_state_updated(null)
507
+ // which triggers setupNewGame() via the game_state_updated listener
288
508
  })
289
509
 
290
510
  window.FarcadeSDK.on('toggle_mute', (data: { isMuted: boolean }) => {
@@ -304,66 +524,58 @@ export class DemoScene extends Phaser.Scene {
304
524
  if (this.isMultiplayer) {
305
525
  // Multiplayer setup - Set up listeners BEFORE calling ready
306
526
  window.FarcadeSDK.on('game_state_updated', (gameState: any) => {
527
+ console.log('[DemoScene] game_state_updated event received:', gameState)
307
528
  // Handle it exactly like chess.js does
308
529
  if (!gameState) {
530
+ console.log('[DemoScene] Null state received, calling setupNewGame()')
309
531
  this.setupNewGame()
310
532
  } else {
311
533
  this.handleGameStateUpdate(gameState)
312
534
  }
313
535
  })
314
536
 
315
- // Add listener for state loading
316
- window.FarcadeSDK.on('load_state', (state: any) => {
317
- if (state) {
318
- if (state.selectedColor) {
319
- this.selectColor(state.selectedColor)
320
- }
321
- if (typeof state.clickCount === 'number') {
322
- this.clickCount = state.clickCount
323
- if (this.clickText) {
324
- this.clickText.setText(`Score: ${this.clickCount}/3`)
325
- }
326
- }
327
- }
328
- })
329
-
330
- // Also listen for restore_game_state events
331
- window.FarcadeSDK.on('restore_game_state', (data: any) => {
332
- if (data?.gameState) {
333
- const state = data.gameState
334
- if (state.selectedColor) {
335
- this.selectColor(state.selectedColor)
336
- }
337
- if (typeof state.clickCount === 'number') {
338
- this.clickCount = state.clickCount
339
- if (this.clickText) {
340
- this.clickText.setText(`Score: ${this.clickCount}/3`)
341
- }
342
- }
343
- }
344
- })
537
+ // State updates come through game_state_updated event only
345
538
 
346
539
  // Call multiplayer ready and await the response
347
540
  try {
348
- const data = await window.FarcadeSDK.multiplayer.actions.ready()
349
- if (data.players) {
350
- this.players = data.players
541
+ const gameInfo = await window.FarcadeSDK.multiplayer.actions.ready()
542
+
543
+ // gameInfo structure: { players, player, viewContext, initialGameState }
544
+ // Extract player data from gameInfo
545
+ if (gameInfo.players) {
546
+ this.players = gameInfo.players
351
547
  }
352
- if (data.meId) {
353
- this.meId = data.meId
548
+ if (gameInfo.player?.id) {
549
+ this.meId = gameInfo.player.id
354
550
  }
355
- if (data.initialGameState?.gameState) {
356
- const state = data.initialGameState.gameState
357
- if (state.selectedColor) {
358
- this.selectedColor = state.selectedColor
551
+
552
+ // Load initial game state if it exists
553
+ if (gameInfo.initialGameState?.gameState) {
554
+ const state = gameInfo.initialGameState.gameState
555
+ // Check if state is from wrong mode (single-player state in multiplayer)
556
+ if (!state.playerStates && state.color) {
557
+ console.log('[DemoScene] Detected single-player state in multiplayer mode, ignoring...')
558
+ // Don't load it, will send fresh multiplayer state below
559
+ this.currentTurnPlayerId = this.players[0]?.id || '1'
560
+ } else {
561
+ this.loadStateFromData(state)
359
562
  }
563
+ } else {
564
+ // No existing state - Player 1 starts first turn
565
+ this.currentTurnPlayerId = this.players[0]?.id || '1'
360
566
  }
567
+
361
568
  // Now create game elements after state is loaded
362
569
  this.createGameElements()
363
- // Send initial state after ready, like chess.js does in setupNewGame
364
- setTimeout(() => {
365
- this.sendGameState()
366
- }, 100)
570
+
571
+ // Only Player 0 (first player) sends initial state to avoid infinite loops
572
+ // Other players will receive the state via game_state_updated event
573
+ if (gameInfo.initialGameState === null && this.meId === this.players[0]?.id) {
574
+ // No existing state and I'm Player 0 - send initial state
575
+ setTimeout(() => {
576
+ this.sendGameState()
577
+ }, 100)
578
+ }
367
579
  } catch (error) {
368
580
  console.error('Failed to initialize multiplayer SDK:', error)
369
581
  // Create game elements anyway if there's an error
@@ -372,14 +584,27 @@ export class DemoScene extends Phaser.Scene {
372
584
  } else {
373
585
  // Single player - call ready and await it
374
586
  try {
375
- const data = await window.FarcadeSDK.singlePlayer.actions.ready()
376
-
377
- // Handle any response, including null/undefined
378
- if (data?.initialGameState?.gameState) {
379
- const state = data.initialGameState.gameState
380
- if (state.selectedColor) {
381
- this.selectedColor = state.selectedColor
587
+ const gameInfo = await window.FarcadeSDK.singlePlayer.actions.ready()
588
+
589
+ // gameInfo structure: { players, player, viewContext, initialGameState }
590
+ // initialGameState is the GameStateEnvelope or null
591
+ if (gameInfo.initialGameState?.gameState) {
592
+ const state = gameInfo.initialGameState.gameState
593
+ // Check if state is from wrong mode (multiplayer state in single-player)
594
+ if (state.playerStates || state.currentTurnPlayerId) {
595
+ console.log('[DemoScene] Detected multiplayer state in single-player mode, clearing...')
596
+ // Don't load it, send fresh single-player state instead
597
+ setTimeout(() => {
598
+ this.saveGameState()
599
+ }, 100)
600
+ } else {
601
+ this.loadStateFromData(state)
382
602
  }
603
+ } else {
604
+ // No initial state - send our default state
605
+ setTimeout(() => {
606
+ this.saveGameState()
607
+ }, 100)
383
608
  }
384
609
  } catch (error) {
385
610
  console.error('Failed to initialize single player SDK:', error)
@@ -398,144 +623,120 @@ export class DemoScene extends Phaser.Scene {
398
623
  return
399
624
  }
400
625
 
401
- const otherPlayerId = this.players.find((p) => p.id !== this.meId)?.id
626
+ const otherPlayerId = this.getOtherPlayerId()
402
627
 
403
- // Include both players' click counts and selected color
628
+ // Complete game state with per-player data and turn information
629
+ // IMPORTANT: Deep clone to avoid reference issues
404
630
  const stateData = {
405
- players: this.players,
406
- clickCounts: {
407
- [this.meId]: this.clickCount,
408
- [otherPlayerId || '2']: this.otherPlayerClicks,
409
- },
410
- selectedColor: this.selectedColor,
631
+ playerStates: JSON.parse(JSON.stringify(this.playerStates)),
632
+ currentTurnPlayerId: this.currentTurnPlayerId,
633
+ roundNumber: this.roundNumber,
411
634
  gameOver: this.gameOver,
412
635
  }
413
636
 
414
- // Call updateGameState directly, no defensive checks - like chess.js
415
- window.FarcadeSDK.multiplayer.actions.updateGameState({
416
- data: stateData,
637
+ console.log('[DemoScene] Sending state:', stateData)
638
+
639
+ // Use saveGameState instead of updateGameState (this is the SDK 0.2 pattern)
640
+ // alertUserIds tells the SDK to notify the other player
641
+ window.FarcadeSDK.multiplayer.actions.saveGameState({
642
+ gameState: stateData,
417
643
  alertUserIds: otherPlayerId ? [otherPlayerId] : [],
418
644
  })
645
+
646
+ // Store the state data signature to detect our own updates
647
+ this.lastSentStateId = JSON.stringify(stateData)
419
648
  }
420
649
 
421
650
  private setupNewGame(): void {
651
+ console.log('[DemoScene] setupNewGame called')
422
652
  this.restartGame()
423
653
  // Send initial state
424
654
  if (this.isMultiplayer) {
425
- this.sendGameState()
655
+ console.log(
656
+ '[DemoScene] Multiplayer: checking if should send initial state, meId:',
657
+ this.meId,
658
+ 'player0:',
659
+ this.players[0]?.id
660
+ )
661
+ // Only Player 1 sends initial state
662
+ if (this.meId === this.players[0]?.id) {
663
+ console.log('[DemoScene] I am Player 1, sending initial state')
664
+ this.sendGameState()
665
+ } else {
666
+ console.log('[DemoScene] I am not Player 1, waiting for state from Player 1')
667
+ }
426
668
  }
427
669
  }
428
670
 
429
- private handleGameStateUpdate(gameState: any): void {
430
- // Handle the game state exactly like chess.js does
431
- if (!gameState) {
671
+ private handleGameStateUpdate(envelope: any): void {
672
+ // Handle state updates from other players
673
+ if (!envelope) {
432
674
  this.setupNewGame()
433
675
  return
434
676
  }
435
677
 
436
- // Chess.js expects { id: string, data: { players, moves } }
437
- // We have { id: string, data: { players, clickCounts, gameOver } }
438
- const { id, data } = gameState
678
+ // The envelope structure is: { id, gameState, alertUserIds }
679
+ const { id, gameState } = envelope
439
680
 
440
- if (!data) {
681
+ if (!gameState) {
441
682
  this.setupNewGame()
442
683
  return
443
684
  }
444
685
 
445
- // Update game state from data
446
- if (data.players) {
447
- this.players = data.players
448
- }
449
-
450
- this.handleStateUpdate(data)
451
- }
452
-
453
- private handleStateUpdate(data: any): void {
454
- if (!data) {
455
- this.restartGame()
686
+ // Ignore our own state updates (prevents infinite loops)
687
+ const incomingStateSignature = JSON.stringify(gameState)
688
+ if (incomingStateSignature === this.lastSentStateId) {
689
+ console.log('[DemoScene] Ignoring own state update')
456
690
  return
457
691
  }
458
692
 
459
- // Update selected color if provided
460
- if (data.selectedColor && data.selectedColor !== this.selectedColor) {
461
- this.selectColor(data.selectedColor)
462
- }
693
+ console.log('[DemoScene] Processing state update from other player:', gameState)
463
694
 
464
- // Update all click counts
465
- if (data.clickCounts) {
466
- this.allClickCounts = { ...data.clickCounts }
695
+ // Check for game over BEFORE loading state (otherwise this.gameOver will already be true)
696
+ const wasGameOver = this.gameOver
697
+ const incomingGameOver = gameState.gameOver === true
467
698
 
468
- // Update other player's count specifically
469
- if (this.players && this.players.length > 0) {
470
- const otherPlayerId = this.players.find((p) => p.id !== this.meId)?.id
471
- if (otherPlayerId && data.clickCounts[otherPlayerId] !== undefined) {
472
- this.otherPlayerClicks = data.clickCounts[otherPlayerId]
473
- }
474
- }
699
+ // Check if this is a reset state (all scores at 0, gameOver false, round 0)
700
+ const isResetState =
701
+ gameState.gameOver === false &&
702
+ gameState.roundNumber === 0 &&
703
+ Object.values(gameState.playerStates || {}).every((state: any) => state.score === 0)
475
704
 
476
- // Also update our own count if it's different (in case of sync issues)
477
- if (
478
- data.clickCounts[this.meId] !== undefined &&
479
- data.clickCounts[this.meId] !== this.clickCount
480
- ) {
481
- // Only update if the other player has a higher count (they clicked more recently)
482
- if (data.clickCounts[this.meId] > this.clickCount) {
483
- this.clickCount = data.clickCounts[this.meId]
484
- if (this.clickText) {
485
- this.clickText.setText(`Score: ${this.clickCount}/3`)
486
- }
487
- }
488
- }
705
+ if (isResetState) {
706
+ console.log('[DemoScene] Received reset state from play_again')
707
+ // Don't just load state - fully restart to ensure balls and UI are reset
708
+ this.restartGame()
709
+ return
489
710
  }
490
711
 
491
- // Check game state changes
492
- if (data.gameOver === true && !this.gameOver) {
493
- // Store the scores before marking game over
494
- if (data.clickCounts) {
495
- // Update our knowledge of all click counts
496
- const otherPlayerId = this.players?.find((p) => p.id !== this.meId)?.id
497
- if (otherPlayerId && data.clickCounts[otherPlayerId] !== undefined) {
498
- this.otherPlayerClicks = data.clickCounts[otherPlayerId]
499
- }
500
- // Also ensure our own count is up to date
501
- if (data.clickCounts[this.meId] !== undefined) {
502
- this.clickCount = data.clickCounts[this.meId]
503
- }
504
- }
712
+ // Load the state
713
+ this.loadStateFromData(gameState)
505
714
 
506
- // Mark game over locally
507
- this.gameOver = true
715
+ // Update UI to reflect new state
716
+ this.updateUI()
508
717
 
509
- // Trigger game over in SDK for this player too (with the same scores)
510
- // This ensures both players see the game over screen
511
- if (window.FarcadeSDK) {
512
- if (this.isMultiplayer && this.players && this.players.length === 2) {
513
- // Build the complete click counts from the received data
514
- const scores = this.players.map((player) => ({
515
- playerId: player.id,
516
- score: data.clickCounts?.[player.id] || 0,
517
- }))
718
+ // Update ball colors based on current player's color
719
+ this.updateBallColors()
518
720
 
519
- window.FarcadeSDK.multiplayer.actions.gameOver({ scores })
520
- } else {
521
- // Fallback for single player mode
522
- window.FarcadeSDK.singlePlayer.actions.gameOver({ score: this.clickCount })
523
- }
524
- }
721
+ // Trigger game over if incoming state has game over and we didn't already have it
722
+ if (incomingGameOver && !wasGameOver) {
723
+ this.triggerGameOver()
525
724
  }
526
725
  }
527
726
 
528
727
  private handleClick(): void {
529
- this.clickCount++
530
- if (this.clickText) {
531
- this.clickText.setText(`Score: ${this.clickCount}/3`)
532
- }
728
+ // Increment current player's score
729
+ const myState = this.getMyState()
730
+ myState.score++
533
731
 
534
732
  // Check if this click triggers game over
535
- if (this.clickCount >= 3) {
733
+ if (myState.score >= 3) {
536
734
  // Set game over state BEFORE sending state update
537
735
  this.gameOver = true
538
736
 
737
+ // Update UI (score changed, game over)
738
+ this.updateUI()
739
+
539
740
  // Send final state with gameOver = true
540
741
  if (this.isMultiplayer) {
541
742
  this.sendGameState()
@@ -546,64 +747,91 @@ export class DemoScene extends Phaser.Scene {
546
747
  this.triggerGameOver()
547
748
  }, 50)
548
749
  } else {
549
- // Normal click - just send updated count
750
+ // Normal click - switch turns and send updated state
751
+ if (this.isMultiplayer) {
752
+ this.switchTurn()
753
+ }
754
+
755
+ // Update UI AFTER switching turns (so turn indicator shows correct player)
756
+ this.updateUI()
757
+
758
+ // Send state update to other player
550
759
  if (this.isMultiplayer) {
551
760
  this.sendGameState()
552
761
  }
553
762
  }
554
763
  }
555
764
 
556
- private triggerGameOver(): void {
557
- // gameOver is already set in handleClick
765
+ private handleMiss(): void {
766
+ // Missing a ball counts as a turn in multiplayer
767
+ if (!this.isMultiplayer) return
768
+
769
+ // Visual feedback for miss
770
+ this.cameras.main.flash(100, 255, 100, 100)
771
+
772
+ // Switch turns
773
+ this.switchTurn()
774
+
775
+ // Update UI
776
+ this.updateUI()
777
+
778
+ // Send state update
779
+ this.sendGameState()
780
+ }
558
781
 
559
- // Use SDK to trigger game over - simplified like chess.js
782
+ private triggerGameOver(): void {
560
783
  if (!window.FarcadeSDK) return
561
784
 
562
785
  if (this.isMultiplayer) {
563
- // Build scores array for multiplayer - exactly like chess.js does
786
+ // Build scores array for multiplayer
564
787
  const scores: Array<{ playerId: string; score: number }> = []
565
788
 
566
- // Ensure we have both players
789
+ // Get scores for all players
567
790
  if (this.players && this.players.length >= 2) {
568
- scores.push({
569
- playerId: this.players[0].id,
570
- score: this.players[0].id === this.meId ? this.clickCount : this.otherPlayerClicks,
571
- })
572
- scores.push({
573
- playerId: this.players[1].id,
574
- score: this.players[1].id === this.meId ? this.clickCount : this.otherPlayerClicks,
575
- })
576
- } else {
577
- // Fallback with default IDs
578
- scores.push({
579
- playerId: this.meId || '1',
580
- score: this.clickCount,
581
- })
582
- scores.push({
583
- playerId: this.meId === '1' ? '2' : '1',
584
- score: this.otherPlayerClicks,
791
+ this.players.forEach((player) => {
792
+ scores.push({
793
+ playerId: player.id,
794
+ score: this.getPlayerState(player.id).score,
795
+ })
585
796
  })
586
797
  }
587
798
 
588
799
  window.FarcadeSDK.multiplayer.actions.gameOver({ scores })
589
800
  } else {
590
801
  // Single player
591
- window.FarcadeSDK.singlePlayer.actions.gameOver({ score: this.clickCount })
802
+ window.FarcadeSDK.singlePlayer.actions.gameOver({ score: this.getMyState().score })
592
803
  }
593
804
  }
594
805
 
595
806
  private restartGame(): void {
596
- this.clickCount = 0
597
- this.otherPlayerClicks = 0
807
+ // Reset all player states
808
+ Object.keys(this.playerStates).forEach((playerId) => {
809
+ // In multiplayer, preserve assigned colors (Player 1 = green, Player 2 = red)
810
+ let assignedColor: 'green' | 'blue' | 'red' = 'green'
811
+ if (this.isMultiplayer && this.players.length >= 2) {
812
+ const playerIndex = this.players.findIndex((p) => p.id === playerId)
813
+ assignedColor = playerIndex === 1 ? 'red' : 'green'
814
+ } else {
815
+ // Single player - keep current color
816
+ assignedColor = this.playerStates[playerId]?.color || 'green'
817
+ }
818
+
819
+ this.playerStates[playerId] = {
820
+ color: assignedColor,
821
+ score: 0,
822
+ }
823
+ })
824
+
598
825
  this.gameOver = false
599
- this.selectedColor = 'green' // Reset to default color
826
+ this.roundNumber = 0
600
827
 
601
- if (this.clickText) {
602
- this.clickText.setText('Score: 0/3')
828
+ // Player 1 starts first
829
+ if (this.isMultiplayer && this.players.length >= 2) {
830
+ this.currentTurnPlayerId = this.players[0].id
603
831
  }
604
832
 
605
- // Reset color selection UI
606
- this.selectColor('green')
833
+ // Update UI
834
+ this.updateUI()
607
835
 
608
836
  // Reset all balls to new positions and unpop them
609
837
  this.balls.forEach((ball) => {
@@ -617,6 +845,9 @@ export class DemoScene extends Phaser.Scene {
617
845
  ball.velocityY = Phaser.Math.Between(-300, 300)
618
846
  })
619
847
 
848
+ // Update ball colors
849
+ this.updateBallColors()
850
+
620
851
  // Focus the canvas to enable keyboard input
621
852
  this.game.canvas.focus()
622
853
  }
@@ -630,9 +861,10 @@ export class DemoScene extends Phaser.Scene {
630
861
  colors.forEach((colorName, index) => {
631
862
  const x = index * 45
632
863
 
633
- // Create circle swatch, highlighting the currently selected color
864
+ // Create circle swatch
634
865
  const swatch = this.add.circle(x, 0, 18, this.colorValues[colorName])
635
- swatch.setStrokeStyle(3, colorName === this.selectedColor ? 0xffffff : 0x666666)
866
+ const myColor = this.getMyState().color
867
+ swatch.setStrokeStyle(3, colorName === myColor ? 0xffffff : 0x666666)
636
868
  swatch.setInteractive()
637
869
  swatch.setData('color', colorName)
638
870
 
@@ -656,11 +888,13 @@ export class DemoScene extends Phaser.Scene {
656
888
  }
657
889
 
658
890
  private selectColor(color: 'green' | 'blue' | 'red'): void {
659
- this.selectedColor = color
891
+ // Update my player's color
892
+ const myState = this.getMyState()
893
+ myState.color = color
660
894
 
661
895
  // Update swatch borders to show selection
662
896
  if (this.colorSwatches) {
663
- this.colorSwatches.list.forEach((obj) => {
897
+ this.colorSwatches.list.forEach((obj: any) => {
664
898
  const swatch = obj as Phaser.GameObjects.Arc
665
899
  const swatchColor = swatch.getData('color')
666
900
  swatch.setStrokeStyle(3, swatchColor === color ? 0xffffff : 0x666666)
@@ -668,37 +902,42 @@ export class DemoScene extends Phaser.Scene {
668
902
  }
669
903
 
670
904
  // Update all existing balls to new color
671
- this.balls.forEach((ball) => {
672
- ball.sprite.setFillStyle(this.colorValues[color])
673
- })
905
+ this.updateBallColors()
674
906
 
675
907
  // Save state after color change
676
908
  this.saveGameState()
677
909
  }
678
910
 
679
911
  private saveGameState(): void {
680
- // Save state to emulate SDK state saving
681
- const gameState = {
682
- selectedColor: this.selectedColor,
683
- timestamp: Date.now(),
684
- }
912
+ // Save state through SDK only - no localStorage
913
+ if (this.isMultiplayer) {
914
+ // Multiplayer: save full per-player state with turn info
915
+ const gameState = {
916
+ playerStates: this.playerStates,
917
+ currentTurnPlayerId: this.currentTurnPlayerId,
918
+ roundNumber: this.roundNumber,
919
+ timestamp: Date.now(),
920
+ }
685
921
 
686
- // Save through SDK only - no localStorage
687
- if (window.FarcadeSDK?.singlePlayer?.actions?.saveGameState) {
688
- window.FarcadeSDK.singlePlayer.actions.saveGameState({ gameState })
689
- } else if (window.FarcadeSDK?.multiplayer?.actions?.saveGameState && this.isMultiplayer) {
690
- // For multiplayer mode
691
- window.FarcadeSDK.multiplayer.actions.saveGameState({
692
- gameState,
693
- alertUserIds: this.players?.filter((p) => p.id !== this.meId).map((p) => p.id) || [],
694
- })
695
- }
696
- }
922
+ if (window.FarcadeSDK?.multiplayer?.actions?.saveGameState) {
923
+ const otherPlayerId = this.getOtherPlayerId()
924
+ window.FarcadeSDK.multiplayer.actions.saveGameState({
925
+ gameState,
926
+ alertUserIds: otherPlayerId ? [otherPlayerId] : [],
927
+ })
928
+ }
929
+ } else {
930
+ // Single-player: save only color preference (score is session-only)
931
+ const myState = this.getMyState()
932
+ const gameState = {
933
+ color: myState.color,
934
+ timestamp: Date.now(),
935
+ }
697
936
 
698
- private loadGameState(): void {
699
- // Don't load from localStorage - only from SDK events
700
- // The SDK will send us restore_game_state events when needed
701
- // Waiting for SDK restore_game_state event
937
+ if (window.FarcadeSDK?.singlePlayer?.actions?.saveGameState) {
938
+ window.FarcadeSDK.singlePlayer.actions.saveGameState({ gameState })
939
+ }
940
+ }
702
941
  }
703
942
 
704
943
  private popBall(ball: Ball): void {