From bef2bf8b2d4992ef1301fb713b286d806eadcd4d Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:45:33 +0000 Subject: [PATCH 1/7] Add `all` make target --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 6ece6c0..f657822 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +.PHONY: all +all: fix check test + .PHONY: install install: pnpm install From e4618b62e84b0e0b2dbf9643a91826bfb3d0f346 Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:49:03 +0000 Subject: [PATCH 2/7] Deal with `meta` earlier We let `executePlayerMove` and `executeBoardMove` deal with it directly, instead of it being a follow up step. --- packages/dev-server/src/App.tsx | 5 +- packages/dev-server/src/backend.ts | 19 ++--- packages/game/src/execution.ts | 107 +++++++++++++++++++++++------ packages/game/src/gameDef.ts | 1 + 4 files changed, 92 insertions(+), 40 deletions(-) diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 518341d..12cd778 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -137,13 +137,10 @@ const BoardForPlayer = ({ playerboards: { [userId]: optimisticBoards.current.playerboard }, secretboard: null, now: new Date().getTime(), - random: backend.random, onlyExecuteNow: true, // Note that technically we should not use anything from // `match.store` as this represents the DB. - matchData: backend.store.matchData, - gameData: backend.store.gameData, - meta: backend.store.meta, + meta: optimisticBoards.current.meta, isExpiration: false, }); } catch (e) { diff --git a/packages/dev-server/src/backend.ts b/packages/dev-server/src/backend.ts index b4779b4..d10bd78 100644 --- a/packages/dev-server/src/backend.ts +++ b/packages/dev-server/src/backend.ts @@ -17,7 +17,6 @@ import { Game_, MoveExecutionOutput, Random, - updateMetaWithTurnInfo, } from "@lefun/game"; import { User } from "@lefun/ui"; @@ -338,23 +337,13 @@ class Backend extends EventTarget { // Update the store. { - const { board, playerboards, secretboard } = result; + const { board, playerboards, secretboard, meta } = result; store.board = board; store.playerboards = playerboards; store.secretboard = secretboard; + store.meta = meta; } - // Update meta - const { beginTurn, endTurn } = result; - const { meta: newMeta, patches: metaPatches } = updateMetaWithTurnInfo({ - meta, - beginTurn, - endTurn, - now, - }); - - store.meta = newMeta; - // Send out patches to users. { const userIds = store.meta.players.allIds; @@ -366,12 +355,12 @@ class Backend extends EventTarget { const { patches } = result; separatePatchesByUser({ - patches: [...patches, ...metaPatches], + patches, userIds, patchesOut: patchesByUserId, }); - // Add the `meta` patches to everyone. + // Add the patches to everyone. for (const [userId, patches] of Object.entries(patchesByUserId)) { if (patches.length === 0) { diff --git a/packages/game/src/execution.ts b/packages/game/src/execution.ts index 722cb68..7647926 100644 --- a/packages/game/src/execution.ts +++ b/packages/game/src/execution.ts @@ -85,6 +85,7 @@ export type MoveExecutionOutput = { board: object; playerboards: Record; secretboard: object | null; + meta: Meta; patches: Patch[]; } & SideEffectResults; @@ -104,28 +105,55 @@ type BoardMoveExecutionInput = { isExpiration: boolean; }; -type PlayerMoveExecutionInput = - BoardMoveExecutionInput & { - userId: UserId; - onlyExecuteNow?: boolean; - }; +type PlayerMoveExecutionInput = { + name: string; + payload: unknown; + game: Game_; + board: GS["B"]; + playerboards: Record; + // `secretboard` is not strictly required for player moves, but we allow passing `null` already. + secretboard: GS["SB"]; + meta: Meta; + isExpiration: boolean; + userId: UserId; + now: number; +} & ( + | { + // If onlyExecuteNow is `false` (or missing), then we need everything. + onlyExecuteNow?: false; + matchData: unknown; + gameData: unknown; + random: Random; + } + | { + // If onlyExecuteNow is `true`, then the "execute"-only options are + // not required. + onlyExecuteNow: true; + // We still make them optional to make the calling more flexible. + matchData?: unknown; + gameData?: unknown; + random?: Random; + } +); + +export function executePlayerMove( + options: PlayerMoveExecutionInput, +): MoveExecutionOutput { + const { + name, + payload, + game, + matchData, + gameData, + userId, + now, + isExpiration, + random, + onlyExecuteNow = false, + } = options; + + let { board, playerboards, secretboard, meta } = options; -export function executePlayerMove({ - name, - payload, - game, - userId, - board, - playerboards, - secretboard, - matchData, - gameData, - now, - random, - onlyExecuteNow = false, - meta, - isExpiration, -}: PlayerMoveExecutionInput): MoveExecutionOutput { // This is a "normal" player move. const moveDef = game.playerMoves[name]; @@ -186,6 +214,7 @@ export function executePlayerMove({ }); }, ); + if (error) { throw error as Error; } @@ -196,6 +225,10 @@ export function executePlayerMove({ } if (execute && retValue !== false && !onlyExecuteNow) { + if (random === undefined) { + throw new Error('"random" is required to run "execute"'); + } + const { output, patches, error } = tryProduceWithPatches( { board, playerboards, secretboard }, ({ board, playerboards, secretboard }) => { @@ -223,10 +256,26 @@ export function executePlayerMove({ allPatches.push(...patches); } + // Deal with the turns which modify `meta`. + { + const { beginTurn, endTurn } = sideEffectResults; + + const { meta: newMeta, patches } = updateMetaWithTurnInfo({ + meta, + beginTurn, + endTurn, + now, + }); + + meta = newMeta; + allPatches.push(...patches); + } + return { board, playerboards, secretboard, + meta, patches: allPatches, ...sideEffectResults, }; @@ -461,10 +510,26 @@ export function executeBoardMove({ ({ board, playerboards, secretboard } = output); + // Deal with the turns which modify `meta`. + { + const { beginTurn, endTurn } = sideEffectResults; + + const { meta: newMeta, patches: metaPatches } = updateMetaWithTurnInfo({ + meta, + beginTurn, + endTurn, + now, + }); + + meta = newMeta; + patches.push(...metaPatches); + } + return { board, playerboards, secretboard, + meta, patches, ...sideEffectResults, }; diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index e833cc5..dcee6a6 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -275,6 +275,7 @@ export type InitialPlayerboardOptions = { gameData: any; matchData?: any; }; + /* * Object that `autoMove` can return to help train reinforcement learning models. */ From 6eecacde73c486717008795e1c607fd095a4e71a Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:50:35 +0000 Subject: [PATCH 3/7] Error in game1 when it's not the user's turn --- games/game1-v2.7.0/src/game.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/games/game1-v2.7.0/src/game.ts b/games/game1-v2.7.0/src/game.ts index 1e5ab5b..18abb7e 100644 --- a/games/game1-v2.7.0/src/game.ts +++ b/games/game1-v2.7.0/src/game.ts @@ -112,7 +112,10 @@ const goToNextPlayer = ({ }; const pass: PM = { - executeNow({ board, turns }) { + executeNow({ board, turns, userId }) { + if (getCurrentPlayer(board) !== userId) { + throw new Error("not your turn!"); + } goToNextPlayer({ board, turns }); }, }; From b75b70af776a395ad1deb16be8fa62c2f6bc2a1f Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:21:33 +0000 Subject: [PATCH 4/7] Fix useIsPlayer --- packages/ui/src/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 637d8ab..3521ca3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -124,13 +124,10 @@ export function makeUseMakeMove>() { /* * Util to check if the user is a player (if not they are a spectator). */ -export const useIsPlayer = () => { - // Currently, the user is a player iif its playerboard is defined. - const hasPlayerboard = useSelector((state: UIState) => { - return !!state.playerboard; +export const useIsPlayer = () => + useSelector((state: UIState) => { + return state.meta.players.byId[state.userId] !== undefined; }); - return hasPlayerboard; -}; type TimeAdjust = "none" | "after" | "before"; From e6227f0f6bac08199c1cca7003ac18565d5c91ff Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:57:30 +0000 Subject: [PATCH 5/7] Use `zustand` intead of `valtio` `valtio` created re-renders problems. `zustand` is closer to our `redux` mindset and works well with our `immer` pathes. --- games/game1-v2.7.0/index.html | 6 +++ games/game1-v2.7.0/src/Board.tsx | 52 +++++++++++-------- packages/dev-server/package.json | 1 - packages/dev-server/src/App.tsx | 82 +++++++++++++---------------- packages/dev-server/src/backend.ts | 2 +- packages/dev-server/src/moves.ts | 83 ++++++++++++++++++++---------- packages/dev-server/src/utils.ts | 11 ---- packages/game/src/utils.ts | 4 -- pnpm-lock.yaml | 27 ---------- 9 files changed, 130 insertions(+), 138 deletions(-) diff --git a/games/game1-v2.7.0/index.html b/games/game1-v2.7.0/index.html index 7e8e45f..e763f41 100644 --- a/games/game1-v2.7.0/index.html +++ b/games/game1-v2.7.0/index.html @@ -5,6 +5,12 @@ +
diff --git a/games/game1-v2.7.0/src/Board.tsx b/games/game1-v2.7.0/src/Board.tsx index d02b0cc..1bdf64a 100644 --- a/games/game1-v2.7.0/src/Board.tsx +++ b/games/game1-v2.7.0/src/Board.tsx @@ -87,26 +87,47 @@ const EndMatchCountDown = () => { return ; }; -function Board() { +const Sum = () => { + const sum = useSelector((state) => state.board.sum); + return
Sum: {sum}
; +}; + +const Buttons = () => { const makeMove = useMakeMove(); + const itsMyTurn = useSelector( + (state) => getCurrentPlayer(state.board) === state.userId, + ); + return ( + <> + + + + ); +}; + +function Board() { const players = useSelectorShallow((state) => Object.keys(state.board.players), ); const matchSettings = useSelector((state) => state.board.matchSettings); - const sum = useSelector((state) => state.board.sum); - - const itsMyTurn = useSelector( - (state) => getCurrentPlayer(state.board) === state.userId, - ); - return (
The template game -
Sum: {sum}
+ {Object.entries(matchSettings).map(([key, value]) => (
@@ -118,20 +139,7 @@ function Board() { ))}
- <> - - - +
); diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index fd34fb7..a479ce2 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -64,7 +64,6 @@ "classnames": "^2.5.1", "immer": "^10.1.1", "json-edit-react": "^1.13.3", - "valtio": "^2.1.5", "zustand": "^4.5.4" }, "postcss": { diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 12cd778..7cd1b8e 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -7,7 +7,9 @@ import classNames from "classnames"; import { enablePatches, Patch } from "immer"; import { JsonEditor } from "json-edit-react"; import { ReactNode, RefObject, useEffect, useRef, useState } from "react"; -import { proxy, snapshot, useSnapshot } from "valtio"; +import { useStore as useStoreZustand } from "zustand"; +import { shallow } from "zustand/shallow"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { GameId, @@ -15,7 +17,6 @@ import { GameSetting, GameSettings_, Locale, - Meta, UserId, } from "@lefun/core"; import { @@ -42,7 +43,7 @@ import { OptimisticBoards } from "./moves"; import { useStore } from "./store"; import { generateId } from "./utils"; -const LATENCY = 100; +const LATENCY = 200; enablePatches(); @@ -80,22 +81,20 @@ const BoardForPlayer = ({ }, [locale, messages]); const [loading, setLoading] = useState(true); + const { store: mainStore } = backend; + const { users } = mainStore; - // Here we use `valtio` as a test to see how well it works as a local state - // management solution. So far it's pretty good, perhaps we should also use it for the dev-server settings! const optimisticBoards = useRef( - proxy( - new OptimisticBoards({ - board: backend.store.board, - playerboard: backend.store.playerboards[userId] || null, - meta: backend.store.meta, - }), - ), + new OptimisticBoards({ + board: backend.store.board, + playerboard: backend.store.playerboards[userId] || null, + meta: backend.store.meta, + userId, + users, + }), ); useEffect(() => { - const { store: mainStore } = backend; - backend.addEventListener(patchesForUserEvent(userId), (event: any) => { if (!event) { return; @@ -127,20 +126,23 @@ const BoardForPlayer = ({ let result: MoveExecutionOutput | null = null; + const { board, playerboard, meta } = + optimisticBoards.current.store.getState(); + try { result = executePlayerMove({ name, payload, game: backend.game, userId, - board: optimisticBoards.current.board, - playerboards: { [userId]: optimisticBoards.current.playerboard }, + board, + playerboards: { [userId]: playerboard }, secretboard: null, now: new Date().getTime(), onlyExecuteNow: true, // Note that technically we should not use anything from // `match.store` as this represents the DB. - meta: optimisticBoards.current.meta, + meta, isExpiration: false, }); } catch (e) { @@ -161,26 +163,12 @@ const BoardForPlayer = ({ backend.makeMove({ userId, name, payload, moveId, isExpiration: false }); }); - const { users } = mainStore; - const _useSelector = (): UseSelector => { // We wrap it to respect the rules of hooks. const useSelector = ( selector: Selector, ): T => { - const snapshot = useSnapshot(optimisticBoards.current); - const board = snapshot.board; - const playerboard = snapshot.playerboard; - const meta = snapshot.meta as Meta; - return selector({ - board, - playerboard, - meta, - userId, - users, - timeDelta: 0, - timeLatency: 0, - }); + return useStoreZustand(optimisticBoards.current.store, selector); }; return useSelector; }; @@ -188,23 +176,27 @@ const BoardForPlayer = ({ setUseSelector(_useSelector); setUseStore(() => { - const playerboard = optimisticBoards.current.playerboard; - return { - board: snapshot(optimisticBoards.current.board), - playerboard: playerboard === null ? null : snapshot(playerboard), - meta: snapshot(optimisticBoards.current.meta) as Meta, - userId, - users, - timeDelta: 0, - timeLatency: 0, - }; + return optimisticBoards.current.store.getState(); }); - // As far as I know, valtio does not support shallow selectors, but if I understand correctly it's not a concern with Valtio. - setUseSelectorShallow(_useSelector); + const _useSelectorShallow = (): UseSelector => { + // We wrap it to respect the rules of hooks. + const useSelector = ( + selector: Selector, + ): T => { + return useStoreWithEqualityFn( + optimisticBoards.current.store, + selector, + shallow, + ); + }; + return useSelector; + }; + + setUseSelectorShallow(_useSelectorShallow); setLoading(false); - }, [userId, backend, gameId]); + }, [userId, backend, gameId, users]); if (loading) { return
Loading player...
; diff --git a/packages/dev-server/src/backend.ts b/packages/dev-server/src/backend.ts index d10bd78..07667e2 100644 --- a/packages/dev-server/src/backend.ts +++ b/packages/dev-server/src/backend.ts @@ -448,7 +448,7 @@ class Backend extends EventTarget { try { this._makeMove({ name, payload, userId, moveId, isExpiration }); } catch (e) { - console.error("There was an error executing move", name, e); + console.warn("BACKEND: There was an error executing move", name, e); this.dispatchEvent( new CustomEvent(REVERT_MOVE_EVENT, { detail: { moveId } }), ); diff --git a/packages/dev-server/src/moves.ts b/packages/dev-server/src/moves.ts index e62d3cc..708c698 100644 --- a/packages/dev-server/src/moves.ts +++ b/packages/dev-server/src/moves.ts @@ -1,9 +1,19 @@ import { applyPatches, Patch } from "immer"; +import { createStore, StoreApi } from "zustand"; -import { Meta } from "@lefun/core"; +import { Meta, UserId } from "@lefun/core"; import { GameStateBase } from "@lefun/game"; +import { User } from "@lefun/ui"; -import { deepCopy } from "./utils"; +type Store = { + board: GS["B"]; + playerboard: GS["PB"]; + meta: Meta; + userId: UserId; + users: Record; + timeDelta: number; + timeLatency: number; +}; export class OptimisticBoards { _confirmedBoard: GS["B"]; @@ -11,33 +21,51 @@ export class OptimisticBoards { _confirmedMeta: Meta; // The player's moves that have not been confirmed yet _pendingMoves: { moveId: string; patches: Patch[] }[]; - // ConfirmedBoard + pending moves updates: this is what we will display. - board: GS["B"]; - // `null` for spectators. Note that GS["PB"] can itself be `null` for games without playerboards. - playerboard: GS["PB"] | null; - meta: Meta; + store: StoreApi>; constructor({ board, playerboard, meta, + userId, + users, }: { board: GS["B"]; playerboard: GS["PB"] | null; meta: Meta; + userId: UserId; + users: Record; }) { - board = deepCopy(board); - playerboard = deepCopy(playerboard); - meta = deepCopy(meta); - this._confirmedBoard = board; this._confirmedPlayerboard = playerboard; this._confirmedMeta = meta; this._pendingMoves = []; - this.board = board; - this.playerboard = playerboard; - this.meta = meta; + + this.store = this._createStore({ userId, users }); + } + + /* Create the zustand store */ + _createStore({ + userId, + users, + }: { + userId: UserId; + users: Record; + }) { + const store = createStore>()(() => ({ + // These will contain the confirmed boards + patches from the pending moves. + board: this._confirmedBoard, + playerboard: this._confirmedPlayerboard, + meta: this._confirmedMeta, + // + userId, + users, + timeDelta: 0, + timeLatency: 0, + })); + + return store; } _getMoveIndex(moveId: string): number { @@ -51,18 +79,18 @@ export class OptimisticBoards { } _replay() { - let board = this._confirmedBoard; - let playerboard = this._confirmedPlayerboard; - let meta = this._confirmedMeta; - for (const { patches } of this._pendingMoves) { - ({ board, playerboard, meta } = applyPatches( - { board, playerboard, meta }, - patches, - )); - } - this.board = board; - this.playerboard = playerboard; - this.meta = meta; + this.store.setState((oldState) => { + let newState = { + board: this._confirmedBoard, + playerboard: this._confirmedPlayerboard, + meta: this._confirmedMeta, + }; + + for (const { patches } of this._pendingMoves) { + newState = applyPatches(newState, patches); + } + return { ...oldState, ...newState }; + }); } makeMove(moveId: string, patches: Patch[]) { @@ -86,12 +114,13 @@ export class OptimisticBoards { * have the `executeNow` patches. */ confirmMove({ moveId, patches }: { moveId?: string; patches: Patch[] }) { - // Start from the confirmed boards + // Add the patches to the confirmedBoard, changing at few fields as possible (using Immer). const { _confirmedBoard: board, _confirmedPlayerboard: playerboard, _confirmedMeta: meta, } = this; + { const state = applyPatches({ board, playerboard, meta }, patches); this._confirmedBoard = state.board; diff --git a/packages/dev-server/src/utils.ts b/packages/dev-server/src/utils.ts index 9f6c4e2..029bcc7 100644 --- a/packages/dev-server/src/utils.ts +++ b/packages/dev-server/src/utils.ts @@ -1,14 +1,3 @@ -/* - * Deep copy an object. - */ -export function deepCopy(obj: T): T { - if (obj === undefined) { - return obj; - } - - return JSON.parse(JSON.stringify(obj)); -} - let _counter = 0; export function generateId() { return `${new Date().getTime()}-${_counter++}`; diff --git a/packages/game/src/utils.ts b/packages/game/src/utils.ts index 576c1f9..4324e11 100644 --- a/packages/game/src/utils.ts +++ b/packages/game/src/utils.ts @@ -17,7 +17,3 @@ export function parseMove( const [name, payload] = move; return { name, payload }; } - -export function deepCopy(obj: T): T { - return JSON.parse(JSON.stringify(obj)); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb5eb68..4a246df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,9 +154,6 @@ importers: json-edit-react: specifier: ^1.13.3 version: 1.13.3(react@18.3.1) - valtio: - specifier: ^2.1.5 - version: 2.1.5(@types/react@18.3.3)(react@18.3.1) zustand: specifier: ^4.5.4 version: 4.5.4(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1) @@ -3977,9 +3974,6 @@ packages: protocols@2.0.1: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} - proxy-compare@3.0.1: - resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4599,18 +4593,6 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - valtio@2.1.5: - resolution: {integrity: sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - react: '>=18.0.0' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - vite-node@2.0.5: resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -9046,8 +9028,6 @@ snapshots: protocols@2.0.1: {} - proxy-compare@3.0.1: {} - proxy-from-env@1.1.0: {} pseudolocale@2.1.0: @@ -9725,13 +9705,6 @@ snapshots: validate-npm-package-name@5.0.1: {} - valtio@2.1.5(@types/react@18.3.3)(react@18.3.1): - dependencies: - proxy-compare: 3.0.1 - optionalDependencies: - '@types/react': 18.3.3 - react: 18.3.1 - vite-node@2.0.5(@types/node@20.14.10): dependencies: cac: 6.7.14 From 5fba0c91b5ac862fdc3fc06e860743069e518d11 Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Mon, 2 Mar 2026 03:17:11 +0000 Subject: [PATCH 6/7] Add `ts` to `executeNow` This enables optimistic UI for things that require the current timestamp. An estimated "server timestamp" will be used. Remember: when the move runs on the backend, it will get the "real" timestamp, and the move executed on the client will be reversed and the patches from the backend will be used. --- packages/game/src/execution.ts | 1 + packages/game/src/gameDef.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/game/src/execution.ts b/packages/game/src/execution.ts index 7647926..50bac68 100644 --- a/packages/game/src/execution.ts +++ b/packages/game/src/execution.ts @@ -209,6 +209,7 @@ export function executePlayerMove( playerboard: playerboards[userId]!, userId, payload, + ts: now, _: moveSideEffects, ...moveSideEffects, }); diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index dcee6a6..5741c88 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -147,8 +147,6 @@ export type MoveSideEffects< logMatchStat: LogMatchStat; }; -// TODO Add `ts` in the options here -// https://github.com/lefun-fun/lefun/issues/52 type ExecuteNowOptions< GS extends GameStateBase, P, @@ -162,6 +160,7 @@ type ExecuteNowOptions< // Assume that the game developer has defined the playerboard if they're using it. playerboard: GS["PB"]; payload: P; + ts: number; _: MoveSideEffects; } & MoveSideEffects; From c85b9a6d1f14a76d379027f822cfe56678eb1786 Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:47:21 +0000 Subject: [PATCH 7/7] Bigger delay in dev-server for server response This makes problems with optimistic UI easier to figure out. --- packages/dev-server/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 7cd1b8e..2c60f6e 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -43,7 +43,7 @@ import { OptimisticBoards } from "./moves"; import { useStore } from "./store"; import { generateId } from "./utils"; -const LATENCY = 200; +const LATENCY = 500; enablePatches();