Skip to content
This repository was archived by the owner on Apr 24, 2026. It is now read-only.

Commit 0b10380

Browse files
lanmowerclaude
andcommitted
refactor: xstate TickSystem + document xstate preference in CLAUDE.md
- TickSystem: replace running/_reloadLocked booleans with xstate machine (stopped→running→paused). pauseForReload/resumeAfterReload use actor.send. - CLAUDE.md: add xstate preference section documenting when to use xstate vs boolean, import patterns (Node/browser/isomorphic), and complete list of all 7 xstate-backed systems in the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ff359c commit 0b10380

2 files changed

Lines changed: 52 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,36 @@ After 3 consecutive reload failures, module stops auto-reloading until server re
368368

369369
---
370370

371+
## xstate Preference
372+
373+
**All state machines in this codebase MUST use xstate.** Any component with 3+ states or non-trivial transitions must use `createMachine`/`createActor` from xstate v5.
374+
375+
### When to use xstate
376+
- 3+ distinct states with defined transitions
377+
- States that guard against invalid transitions (e.g., can't RESUME from running)
378+
- Reconnection/retry logic with backoff
379+
- UI mode switching (editor gizmo, XR teleport)
380+
- Per-instance state tracking (reload manager per-module actors)
381+
382+
### When a boolean is sufficient
383+
- Single on/off flag with no transition logic (e.g., `connected = true/false`)
384+
- Re-entrancy guard (`_inProgress` protecting a synchronous block)
385+
- Simple toggle with no guards
386+
387+
### Import pattern
388+
- **Node.js (server):** `import { createMachine, createActor } from 'xstate'`
389+
- **Browser (client):** `import { createMachine, createActor } from '/node_modules/xstate/dist/xstate.esm.js'`
390+
- **Isomorphic (shared):** `const _isNode = typeof process !== 'undefined' && process.versions?.node; const { createMachine, createActor } = await import(_isNode ? 'xstate' : '/node_modules/xstate/dist/xstate.esm.js')`
391+
392+
### xstate-backed systems
393+
- **App lifecycle** (`apps/_lib/lifecycle.js`): idle→setting_up→ready→error→destroyed
394+
- **Animation locomotion** (`client/AnimationStateMachine.js`): IdleLoop/WalkLoop/JogFwdLoop/SprintLoop/CrouchIdleLoop/CrouchFwdLoop/JumpLoop/JumpLand/Death
395+
- **Reconnect** (`src/client/ReconnectManager.js`): idle→connected→waiting→reconnecting→destroyed
396+
- **Hot reload** (`src/sdk/ReloadManager.js`): per-module watching→debouncing→reloading→disabled
397+
- **Editor gizmo** (`client/EditorMachine.js`): translate/rotate/scale with drag sub-states
398+
- **XR teleport fade** (`client/XRWidgets.js`): idle→fadeIn→delay→fadeOut
399+
- **Tick system** (`src/netcode/TickSystem.js`): stopped→running→paused
400+
371401
## Misc Engine Details
372402

373403
- **WORLD_DEF strips entities**: `ServerHandlers.onClientConnect()` removes the `entities` array before sending `MSG.WORLD_DEF`. Pattern: `const { entities: _ignored, ...worldDefForClient } = ctx.currentWorldDef`.

src/netcode/TickSystem.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,37 @@
1+
import { createMachine, createActor } from 'xstate'
2+
3+
const machine = createMachine({
4+
id: 'tick',
5+
initial: 'stopped',
6+
states: {
7+
stopped: { on: { START: 'running' } },
8+
running: { on: { STOP: 'stopped', PAUSE: 'paused' } },
9+
paused: { on: { RESUME: 'running', STOP: 'stopped' } }
10+
}
11+
})
12+
113
export class TickSystem {
214
constructor(tickRate = 128) {
315
this.tickRate = tickRate
416
this.tickDuration = 1000 / tickRate
517
this.currentTick = 0
618
this.lastTickTime = 0
719
this.callbacks = []
8-
this.running = false
9-
this._reloadLocked = false
20+
this._actor = createActor(machine)
21+
this._actor.start()
1022
this._reloadResolve = null
1123
this._tickInProgress = false
1224
}
1325

26+
get running() { return this._actor.getSnapshot().value === 'running' }
27+
1428
onTick(callback) {
1529
this.callbacks.push(callback)
1630
}
1731

1832
start() {
1933
if (this.running) return
20-
this.running = true
34+
this._actor.send({ type: 'START' })
2135
this.lastTickTime = Date.now()
2236
this.loop()
2337
}
@@ -28,7 +42,8 @@ export class TickSystem {
2842
let elapsed = now - this.lastTickTime
2943
let steps = 0
3044
const maxSteps = 4
31-
while (elapsed >= this.tickDuration && !this._reloadLocked && steps < maxSteps) {
45+
const isPaused = this._actor.getSnapshot().value === 'paused'
46+
while (elapsed >= this.tickDuration && !isPaused && steps < maxSteps) {
3247
this._tickInProgress = true
3348
this.currentTick++
3449
this.lastTickTime += this.tickDuration
@@ -52,17 +67,17 @@ export class TickSystem {
5267
}
5368

5469
pauseForReload() {
55-
this._reloadLocked = true
70+
this._actor.send({ type: 'PAUSE' })
5671
if (!this._tickInProgress) return Promise.resolve()
5772
return new Promise(resolve => { this._reloadResolve = resolve })
5873
}
5974

6075
resumeAfterReload() {
61-
this._reloadLocked = false
76+
this._actor.send({ type: 'RESUME' })
6277
}
6378

6479
stop() {
65-
this.running = false
80+
this._actor.send({ type: 'STOP' })
6681
}
6782

6883
getTick() {

0 commit comments

Comments
 (0)