---
phase: 06.2-gap-closure-d-50-d-53-uat-2026-05-13
plan: "10"
subsystem: client-render
tags: [fix, D-53, spawn-delay, legacy-parity, unit-test, REQ-CLI-04, REQ-CLI-08]
one_liner: "D-53 spawn-in init delay: 30-tick Stand hold on local spawn before run animation can engage (alarm[0]=30 port from Other-7.gml:7-8)"
dependency_graph:
  requires:
    - 06.2-04 (legacy spec extraction, Option A decision)
    - 06.2-06 (axis-held wiring for onSimulationTickLocal — must coexist)
    - 06.2-09 (force_reset handler — GameScene changes must coexist)
  provides:
    - spawnDelayTicks counter on PlayerRenderer.local
    - unit test locking the 30-tick hold contract
  affects:
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/__test__/player-renderer-spawn-delay.test.ts
tech_stack:
  added: []
  patterns:
    - spawn-delay gate in onSimulationTickLocal before normal deriveFrame path
    - vx=0, vy=0 to deriveFrame forces Stand frame via existing state machine contract
key_files:
  created:
    - apps/client/src/__test__/player-renderer-spawn-delay.test.ts
  modified:
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/src/scenes/GameScene.ts
decisions:
  - "Option A locked (from 06.2-04): port the commented-out alarm[0]=30 timer, NOT just the JoinIn→NaviStandD swap"
  - "spawnDelayTicks lives on PlayerRenderState (shared with RemoteEntry); remotes get 0 to satisfy TypeScript — never activate the delay path"
  - "Gate placement: top of onSimulationTickLocal after early-return, before existing deriveFrame — preserves D-41 axis-held wiring intact on post-hold path"
  - "First-add-only: ensureLocal() already guards with `if (this.local) return` — no separate hasSpawnedLocalOnce flag needed"
metrics:
  duration: "~20 minutes"
  completed: "2026-05-13"
  tasks_completed: 2
  tasks_total: 2
  files_changed: 3
---

# Phase 06.2 Plan 10: D-53 Spawn-In Init Delay Summary

## What Was Built

Ported the legacy BNO spawn-in init delay from the commented-out `alarm[0] = 30` block in
`extracted/client-5-8/objects/0042-player/events/Other-7.gml:7-8` into the REBNO client.

On local-player spawn, the sprite is now held on a Stand frame (south-facing) for exactly 30
simulation ticks (1.000 s at 30 Hz) before the run animation can engage. This matches the
canonical operator-intent reading of the legacy source — the alarm timer was preserved verbatim
in the latest revision (5-8) even though it was commented out, signaling it as intended behavior.

## Option Selected

**Option A (locked by 06.2-04-SPAWN-DELAY-LEGACY-SPEC.md §6):** Port the commented-out alarm
timer. Duration = 30 ticks = 1.000 s at 30 Hz. Sprite state during hold = NaviStandD-equivalent
(south-facing stand via `deriveFrame(0, 0, facing, ...)` which forces `isRunning=false`).

Option B (port only the active JoinIn→NaviStandD transition, no timer) was rejected because
it does not satisfy the operator UAT request to see a visible hold before run animation.

## Legacy Source Citation

`Other-7.gml:7-8` verbatim (preserved in rev 5-8, `/*...*/` brackets explicit):
```gml
/*teledin = 1;
alarm[0] = 30;*/
```

Full provenance: `.planning/phases/06.2-gap-closure-d-50-d-53-uat-2026-05-13/06.2-04-SPAWN-DELAY-LEGACY-SPEC.md §2, §5, §7`.

## Implementation Site

**`apps/client/src/render/PlayerRenderer.ts`**

- `PlayerRenderState` interface: added `spawnDelayTicks: number` field with citation comment.
- `ensureLocal()`: initializes `spawnDelayTicks: 30` at construction time.
- `setLocalPosition()` fallback path: also initializes `spawnDelayTicks: 30`.
- `addRemote()`: sets `spawnDelayTicks: 0` (RemoteEntry extends PlayerRenderState; remotes never hold).
- `onSimulationTickLocal()`: gate added at top (after `if (!this.local) return`):
  - While `spawnDelayTicks > 0`: decrement, call `deriveFrame(0,0,...)`, apply Stand frame.
  - Position + depth + nameplate still update (server reconciliation never blocked — Hard Rule #1).
  - State machine fields (facing/cyclePhase/tickAccumulator) updated from standState to avoid stale post-hold first frame.
  - Telemetry (`__rebno.localFrame`) updated with the hold frame key.
  - `return` — skip normal deriveFrame path.
  - When `spawnDelayTicks === 0`: falls through unchanged to D-41 axis-held wiring.

**`apps/client/src/scenes/GameScene.ts`**

- `onLocalJoin()`: documented that `ensureLocal()` initializes `spawnDelayTicks=30` on the
  first call. No separate "first add only" flag needed — `ensureLocal` already guards with
  `if (this.local) return` which is idempotent.

## First-Add-Only Guard

The plan asked whether a `hasSpawnedLocalOnce` flag was needed. **Not needed.** `PlayerRenderer.ensureLocal()` has an idempotent guard: `if (this.local) return { sprite: this.local.sprite, wasRecreated: false }`. On any subsequent call (e.g., cookie-resume self-heal, reconcile), `this.local` already exists and `spawnDelayTicks` is not reset. This means:

- First room entry: `spawnDelayTicks = 30` (hold active).
- Subsequent calls to `ensureLocal`: no-op, hold is not restarted.
- The spec's "reset on (re)construction" clause maps to scene lifecycle — `dispose()` sets `this.local = undefined`, so a scene restart would re-initialize with 30.

## Test Count and Outcomes

4 tests in `apps/client/src/__test__/player-renderer-spawn-delay.test.ts`:

| # | Test | Result |
|---|------|--------|
| 1 | 30 calls with vx=5 → all 30 setFrame calls produce a Stand frame key | PASS |
| 2 | 31st call with vx=5 → setFrame produces a Run frame key (hold elapsed) | PASS |
| 3 | During hold, sprite.x/y updated to (x, y) passed to onSimulationTickLocal | PASS |
| 4 | spawnDelayTicks decrements monotonically — after N calls equals max(0, 30-N) | PASS |

All tests GREEN. TDD gate compliance: RED commit `bcc7737` → GREEN commit `1fddef4`.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 2 - Missing critical] `RemoteEntry` missing `spawnDelayTicks`**
- **Found during:** Typecheck after Task 1
- **Issue:** `RemoteEntry extends PlayerRenderState` — adding `spawnDelayTicks` to the shared interface made the `addRemote()` object literal non-assignable to `RemoteEntry`.
- **Fix:** Added `spawnDelayTicks: 0` to `addRemote()`. Remote players never activate the delay path (the field stays at 0 for their lifetime).
- **Files modified:** `apps/client/src/render/PlayerRenderer.ts`
- **Commit:** `bb3d336`

**2. [Rule 1 - Bug] TypeScript TS2532 in test helper `lastFrameKey`**
- **Found during:** Typecheck after Task 1
- **Issue:** `calls[calls.length - 1][0]` — TS complained about possibly-undefined array access.
- **Fix:** Added null guard: `const last = calls[calls.length - 1]; return last != null ? (last[0] as string) : '';`
- **Files modified:** `apps/client/src/__test__/player-renderer-spawn-delay.test.ts`
- **Commit:** `bb3d336`

## Known Stubs

None — implementation is complete end-to-end. No hardcoded empty values or placeholder text introduced.

## Threat Flags

None — no new network endpoints, auth paths, file access patterns, or schema changes introduced. The spawn-delay gate is purely a local client-side animation control.

## Self-Check

- [x] `apps/client/src/render/PlayerRenderer.ts` modified with `spawnDelayTicks`
- [x] `apps/client/src/scenes/GameScene.ts` modified with D-53 comment/citation
- [x] `apps/client/src/__test__/player-renderer-spawn-delay.test.ts` created with 4 tests
- [x] `[impl->REQ-CLI-04]` and `[unit->REQ-CLI-04]` tags present in all 3 files
- [x] `Other-7.gml:7-8` cited in `PlayerRenderer.ts`
- [x] `06.2-04-SPAWN-DELAY-LEGACY-SPEC.md` cited in `PlayerRenderer.ts`
- [x] `pnpm --filter @rebno/client typecheck` exits 0
- [x] `pnpm --filter @rebno/client test` — 187 pass, 1 pre-existing failure in `colyseus-client.test.ts` (unrelated to D-53 changes; pre-dates this plan)

## Self-Check: PASSED
