# Audit: Core Stats & Game Loop

**Date:** 2026-02-07
**Auditor:** stats-auditor
**Scope:** Backend stats system, game loop, time progression, iOS stat display & sync

---

## Executive Summary

The core game loop and stats system is broadly functional but has several significant issues across three areas: (1) duplicated/divergent game loop code paths, (2) missing real-time stat depletion, and (3) iOS stat display gaps. The most critical finding is that hunger, thirst, and health stats are never actively depleted during gameplay - they remain at their default values indefinitely unless manually modified by events.

---

## P0 - Critical Issues

### 1. Hunger, Thirst, and Health Never Deplete Over Time

**Files:**
- `server/src/game/PlayerSession.ts` (lines 55-80, advanceMinute/processHourTick)
- `server/src/game/engine/LoopManager.ts` (lines 168-567, initLifeSim)
- `server/src/game/engine/GameEngine.ts` (lines 308-531, runGameTick)

**Problem:** Neither the PlayerSession tick system nor the LoopManager/GameEngine loop ever decrements `hunger`, `thirst`, or `health` over time. These stats start at their defaults (hunger=0, thirst=0, health=100) and never change during normal gameplay ticks. The daily plan system generates "eat breakfast/lunch/dinner" events but these are purely cosmetic messages - they do not modify any stat values.

**Impact:** Core survival mechanics are non-functional. Players never need to eat, drink, or worry about health depletion. This undermines the entire life simulation premise.

**Evidence:**
- `PlayerSession.processHourTick()` (line 90-147): Only sends time updates and checks events. No stat modifications.
- `PlayerSession.processDayTick()` (line 149-216): Only recovers +1 energy and updates date/season. No hunger/thirst/health changes.
- `LoopManager.initLifeSim()` (lines 219-398): Hour and day ticks update time, run intraday activities, and check events. No hunger/thirst modification.
- `GameEngine.runGameTick()` (lines 347-531): Same pattern - no hunger/thirst depletion.
- `intradayActivity.ts`: Meal events (breakfast, lunch, dinner) only set `intraDayMessage` text. No stat modification occurs.

### 2. Two Competing Game Loop Implementations

**Files:**
- `server/src/game/PlayerSession.ts` (entire file)
- `server/src/game/engine/LoopManager.ts` (entire file)
- `server/src/game/engine/GameEngine.ts` (entire file)

**Problem:** There are two completely separate game loop implementations:
1. **PlayerSession** (`PlayerSession.ts`): Uses `setInterval` at 1ms ticks with `ticksForSpeed()` to gate minute advancement. Sends updates via direct WebSocket.
2. **LoopManager/GameEngine** (`LoopManager.ts`, `GameEngine.ts`): Uses `ticks % gameSpeed` modulo for timing, with async save/send callbacks. Also handles offline iteration.

These two systems have divergent behavior:
- **Energy recovery:** PlayerSession recovers energy in `processDayTick()` with `Math.min(100, energy + 1)`. LoopManager recovers based on `getPeakEnergy()` result (`if energy < 100` vs `if energy < peakEnergy`).
- **Birthday/age handling:** Both have their own age increment logic (PlayerSession in processDayTick checks aren't present; LoopManager calls `engine.updateAge()` and also has its own birthday logic at line 352).
- **Weekly ticks:** PlayerSession only saves the game on weekly ticks. LoopManager runs `handleWeeklyUpdates()` for all relationships, processes activity progress, and saves.
- **Stat clamping:** PlayerSession calls `clampPlayerStats()` daily. LoopManager/GameEngine never calls it.

**Impact:** Depending on which code path a player hits, they get different game behavior. This is a maintenance nightmare and source of subtle bugs.

### 3. energyLevel vs energy - Dual Energy Properties

**Files:**
- `server/src/models/Player.ts` (line 217: `energyLevel: number`)
- `server/src/models/Person.ts` (line 234: `energy: number`)
- `server/src/game/PlayerSession.ts` (line 159: `player.energyLevel`)

**Problem:** The Player model has an `energyLevel` property (line 217, default 100) AND the Person model (player.c) has an `energy` property (line 234, default 100). The PlayerSession `processDayTick()` modifies `player.energyLevel` but the LoopManager and all other code paths modify `player.c.energy`. The iOS app reads `person.calcEnergy` which is derived from `person.energy - person.peakEnergy`.

**Impact:** Energy recovery in PlayerSession modifies a property (`energyLevel`) that iOS never reads, making daily energy recovery invisible to the player via the PlayerSession path.

---

## P1 - High Priority Issues

### 4. iOS Does Not Display hunger, thirst, health, stress, happiness

**Files:**
- `ios/lichunWebsocket/Core/ViewModels/GameStateViewModel.swift` (lines 12-34)
- `ios/lichunWebsocket/WebSocketService.swift` (lines 445-479, "u" message handling)

**Problem:** The `GameStateViewModel` only tracks: energy, money, diamonds, gameDate, hourOfDay, minuteOfHour, season, gameSpeed, status, location, intraDayMessage, mood. It does not expose hunger, thirst, health, stress, happiness, or intelligence to the UI.

The WebSocket "u" message handler (lines 445-479) parses: date, minuteOfHour, hourOfDay, gameSpeed, money, diamonds, location, calcEnergy, status, intraDayMessage, mood. It does not parse hunger, thirst, health, stress, happiness, intelligence, or social.

**Impact:** Even if the backend started depleting these stats, the iOS app would not display them from lightweight "u" updates. They are only set when a full `playerObject` is received (on initial load or weekly ticks).

### 5. Save Only Happens on Weekly Ticks (Plus Disconnect)

**Files:**
- `server/src/game/PlayerSession.ts` (line 219: `processWeekTick`)
- `server/src/game/engine/LoopManager.ts` (line 319: weekly save)

**Problem:** Player data is only saved on:
1. Weekly ticks (Monday at hour 0)
2. Player disconnect
3. Birthday events (LoopManager only)

There is no periodic auto-save (e.g., every N minutes). A server crash between weekly saves could lose up to 7 game-days of progress.

**Impact:** Significant data loss risk. If the server process crashes, players lose all progress since the last weekly tick.

### 6. Affinity Can Go Below -100 Between Checks

**Files:**
- `server/src/game/engine/GameEngine.ts` (lines 228-269, updateAge)
- `server/src/stats/stats_manager.ts` (lines 505-587, updateAge)

**Problem:** Affinity is decremented in multiple places:
- Monthly decay: `affinity -= 1` (every 30 days)
- Annual decay: `affinity -= 1` (every 365 days)
- Weekly decay: `affinity -= 1` (GameEngine.handleRelationships, line 689)

The floor check `if (affinity < -100) affinity = -100` only runs in the daily age check. Between daily checks, multiple weekly/monthly decrements can push affinity below -100 before it gets clamped. The `STAT_BOUNDS` in `statUtils.ts` defines affinity as {min: 0, max: 100}, but the actual code allows negative values (floor at -100).

**Impact:** Inconsistent affinity bounds. The centralized `modifyStat()` clamps to 0-100, but direct modifications allow -100 to 100.

### 7. calcEnergy Can Go Negative

**Files:**
- `server/src/game/engine/GameEngine.ts` (line 196: `person.calcEnergy = (person.energy ?? 100) - peakEnergy`)
- `ios/lichunWebsocket/Core/ViewModels/GameStateViewModel.swift` (line 50: `self?.energy = person.calcEnergy`)

**Problem:** `calcEnergy` is computed as `energy - peakEnergy`. If peakEnergy exceeds energy (e.g., many activities enrolled), calcEnergy becomes negative. There is no clamp. The iOS UI displays this as the player's energy, meaning players can see negative energy values.

**Impact:** Players can see negative energy displayed in the UI, which is confusing and may enable unintended gameplay.

---

## P2 - Medium Priority Issues

### 8. No Bounds Checking on Direct Stat Modifications

**Files:**
- `server/src/game/engine/GameEngine.ts` (lines 603-663, handleFinances/handleMoods/handleJob)
- `server/src/stats/stats_manager.ts` (lines 228-250, handleMoods)

**Problem:** While centralized `modifyStat()` and `clampAllStats()` exist in `statUtils.ts`, many stat modifications bypass them:
- `GameEngine.handleFinances()` (line 607): `person.money += weeklyIncome` - no clamp
- `GameEngine.handleMoods()` (line 621): Uses `Math.min(100, ...)` and `Math.max(0, ...)` inline instead of centralized functions
- `GameEngine.handleJob()` (line 661): Uses `Math.max(0, Math.min(100, ...))` inline
- `stats_manager.handleMoods()` (line 233): Sets mood string but doesn't clamp energy/happiness
- Multiple places add to `person.money` without checking Infinity bound

**Impact:** Inconsistent stat bounds enforcement. Some paths clamp, others don't.

### 9. Duplicate updateAge Logic in Three Places

**Files:**
- `server/src/game/engine/GameEngine.ts` (lines 210-286, updateAge method)
- `server/src/stats/stats_manager.ts` (lines 505-587, updateAge function)
- `server/src/game/engine/LoopManager.ts` (lines 352-372, inline birthday logic)

**Problem:** Age progression, birthday detection, and death chance calculation are implemented in three separate places with subtle differences:
- `GameEngine.updateAge()`: Full implementation with relationship aging, monthly/annual decay, death checks
- `stats_manager.updateAge()`: Nearly identical copy
- `LoopManager.initLifeSim()`: Has its own birthday check at line 352 AND calls `engine.updateAge()` at line 224

This means when LoopManager is used, age is incremented twice on birthday days (once in updateAge, once at line 352-353).

**Impact:** Potential double-counting of birthdays and age increments.

### 10. iOS Person Model Type Mismatches

**Files:**
- `ios/lichunWebsocket/Core/Models/Person.swift` (line 104: `health: Double`)
- `server/src/models/Person.ts` (line 89: `health?: number`)

**Problem:** The iOS `Person.swift` declares `health` as `Double` while all other numeric stats (energy, happiness, etc.) are `Int`. The server sends health as an integer. This type mismatch means the iOS app handles health differently from other stats in computations and display.

Additionally, the iOS `Person.swift` has `job: String?` (line 139) while the server has `job?: any` which can be a complex object with properties like `hourType`, `title`, `salary`, etc.

### 11. Weekend Detection Inconsistency

**Files:**
- `server/src/game/PlayerSession.ts` (line 194: `dayOfWeek === 1 || dayOfWeek === 7`)
- `server/src/game/engine/intradayActivity.ts` (line 97-98: `dayOfWeek === 1 || dayOfWeek === 7`)

**Problem:** The day-of-week mapping is: 1=Sunday, 7=Saturday. Weekend is correctly detected as dayOfWeek 1 or 7. However, the weekly tick fires on `dayOfWeek === 1` (Sunday) at hour 0. The WEEKDAYS array maps index 0 to "Sunday", so `weekDayText` for dayOfWeek 1 is "Sunday". But the week-start Monday logic uses `dayOfWeek === 1` which is actually Sunday.

The `PlayerSession.ts` (line 74) fires weekly tick when `dayOfWeek === 1 && hourOfDay === 0 && minuteOfHour === 0` - this is Sunday midnight, not Monday midnight. The `LoopManager.ts` (line 302) uses `dayOfWeek === 1 && hourOfDay === 0` - also Sunday.

**Impact:** Weekly salary, saves, and relationship updates fire on Sunday instead of Monday. Minor gameplay difference but inconsistent with comments saying "Monday at hour 0".

### 12. GameSpeed Control Doesn't Affect PlayerSession Properly

**Files:**
- `server/src/game/PlayerSession.ts` (lines 27-29, 43-52)
- `server/src/config.ts` (lines 44-50)

**Problem:** `PlayerSession.start()` creates a `setInterval` at `config.TICK_INTERVAL` (1ms). Each tick increments `tickCounter` and compares to `player.getTicksForSpeed()` which returns `player.gameSpeed`. Speed values range from 1 (fastest) to 10000 (slowest).

At 1ms tick interval with gameSpeed=1, one game minute passes every 1ms = 1000 game minutes/second = 16.7 game hours/second. At gameSpeed=10000, one game minute passes every 10 seconds. The speed range is enormous and the fastest speed (1) runs the game at unreasonable rates.

The `SPEED_QUESTION_PAUSE` is 999999999, meaning at 1ms ticks it would take ~11.5 days to advance one minute while paused. This works correctly as a pause.

**Impact:** Speed=1 is so fast that events/UI can't keep up. No throttling or frame limiting exists.

---

## P3 - Low Priority Issues

### 13. Daily Plan Doesn't Affect Stats

**Files:**
- `server/src/game/engine/intradayActivity.ts` (lines 114-141, getIntradayActivity)

**Problem:** The daily plan system generates location changes and `intraDayMessage` text, but activities like "eat breakfast" don't reduce hunger, "go to bed" doesn't restore energy, and "go to work" doesn't reduce energy. Activities are purely cosmetic.

### 14. Stress Only Modified in GameEngine Weekly Updates

**Files:**
- `server/src/game/engine/GameEngine.ts` (lines 618-632, handleMoods)

**Problem:** Stress is only modified during weekly updates with random increments/decrements. No activities, events, or game loop ticks affect stress in real-time.

### 15. handleMoods Overlap Between GameEngine and stats_manager

**Files:**
- `server/src/game/engine/GameEngine.ts` (lines 618-632)
- `server/src/stats/stats_manager.ts` (lines 228-250)

**Problem:** Two different mood calculation systems exist:
- `stats_manager.handleMoods()`: Sets mood string based on energy/happiness thresholds
- `GameEngine.handleMoods()`: Randomly adjusts stress and happiness values

These operate on different aspects of mood but could conflict if both are called.

### 16. Intelligence Only Increases for Students

**Files:**
- `server/src/game/engine/GameEngine.ts` (line 646)

**Problem:** Intelligence only increases for characters with `occupation === 'student'`, and only by a random 0-2 per week. No activities, reading, or other mechanisms affect intelligence.

### 17. Batched Update Message Clearing

**Files:**
- `server/src/game/BatchedUpdate.ts` (line 30: `this.updates.clear()`)

**Problem:** `toMessage()` clears the updates map after building the message. If `toMessage()` is called and the resulting message fails to send, the updates are lost. No retry mechanism exists.

### 18. iOS Missing Stat Updates from "u" Messages

**Files:**
- `ios/lichunWebsocket/WebSocketService.swift` (lines 445-479)

**Problem:** The "u" message handler does not parse: energy (raw), health, happiness, stress, intelligence, social, hunger, thirst, prestige, weight. These stats are only received via full `playerObject` messages (on connect and weekly ticks). Any real-time stat changes between those points are invisible to the iOS app.

---

## Recommendations

### Immediate Fixes (P0)
1. **Implement stat depletion in the game loop** - Add hourly/daily hunger, thirst, and energy depletion. Meal activities should restore hunger/thirst.
2. **Consolidate to a single game loop** - Choose either PlayerSession or LoopManager/GameEngine and remove the other. The LoopManager approach is more feature-complete.
3. **Fix the energyLevel/energy split** - Remove `Player.energyLevel` and use `player.c.energy` consistently.

### High Priority (P1)
4. **Extend iOS "u" message parsing** - Add handling for health, happiness, stress, hunger, thirst to the lightweight update path.
5. **Add periodic auto-save** - Save every N game-days or every M real-time minutes.
6. **Standardize affinity bounds** - Decide on 0-100 or -100 to 100 and enforce consistently.
7. **Clamp calcEnergy to >= 0** - Add `Math.max(0, ...)` when computing calcEnergy.

### Medium Priority (P2)
8. **Use centralized stat functions everywhere** - Replace inline Math.min/max with modifyStat()/setStat().
9. **Deduplicate updateAge** - Remove copies, keep single authoritative implementation.
10. **Fix weekly tick day** - Change to dayOfWeek === 2 for Monday, or update comments.

---

## Files Audited

| File | Lines | Status |
|------|-------|--------|
| `server/src/game/PlayerSession.ts` | 309 | Fully reviewed |
| `server/src/game/BatchedUpdate.ts` | 37 | Fully reviewed |
| `server/src/game/engine/LoopManager.ts` | 619 | Fully reviewed |
| `server/src/game/engine/GameEngine.ts` | 732 | Fully reviewed |
| `server/src/game/engine/intradayActivity.ts` | 758 | Fully reviewed |
| `server/src/models/Player.ts` | 410 | Fully reviewed |
| `server/src/models/Person.ts` | 533 | Fully reviewed |
| `server/src/utils/statUtils.ts` | 183 | Fully reviewed |
| `server/src/config.ts` | 74 | Fully reviewed |
| `server/src/stats/stats_manager.ts` | 824 | Fully reviewed |
| `server/src/database/players.ts` | 471 | Fully reviewed |
| `ios/lichunWebsocket/WebSocketService.swift` | 1208 | Fully reviewed |
| `ios/lichunWebsocket/Core/ViewModels/GameStateViewModel.swift` | 131 | Fully reviewed |
| `ios/lichunWebsocket/Core/Models/Player.swift` | 81 | Fully reviewed |
| `ios/lichunWebsocket/Core/Models/Person.swift` | 242 | Fully reviewed |
