diff --git a/docs/superpowers/plans/2026-07-17-04-frontend.md b/docs/superpowers/plans/2026-07-17-04-frontend.md
new file mode 100644
index 0000000..23d7a89
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-17-04-frontend.md
@@ -0,0 +1,825 @@
+# React Frontend Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the browser-side of the design: a React app that opens a WebSocket to `ws://localhost:5100/ws/game`, streams mouse-position updates to the backend at ~50 Hz, receives ship state each tick, and renders it on an HTML canvas. Reference the design spec at `docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md` §4.
+
+**Architecture:** Vite + React + TypeScript. Three focused hooks (`useGameSocket`, `useCursorSender`, `useAnimationLoop`) + one canvas component. No routing, no state management library, no game engine — just the platform.
+
+**Tech Stack:** Node.js 20+, Vite, React 18+, TypeScript. Runtime browser API only (`WebSocket`, `Canvas 2D`, `requestAnimationFrame`, `MouseEvent`).
+
+---
+
+## Prerequisites
+
+- **Node.js 20+** available as `node`. Verify with `node --version`.
+- **Plan 3 backend** works — this frontend expects `ws://localhost:5100/ws/game` to reply. That backend must be startable on demand for manual smoke.
+
+---
+
+## File structure
+
+```
+Experiment_ReinforcementLearning/
+├── Frontend/
+│ ├── package.json
+│ ├── tsconfig.json
+│ ├── tsconfig.node.json
+│ ├── vite.config.ts
+│ ├── index.html
+│ └── src/
+│ ├── main.tsx
+│ ├── App.tsx
+│ ├── LanderCanvas.tsx
+│ ├── hooks/
+│ │ ├── useGameSocket.ts
+│ │ ├── useCursorSender.ts
+│ │ └── useAnimationLoop.ts
+│ ├── protocol.ts # TS types for wire messages
+│ └── render.ts # pure canvas draw functions
+```
+
+Each hook has one responsibility. `render.ts` is pure functions — testable without a DOM.
+
+---
+
+## Task 1: Scaffold the Vite + React + TypeScript project
+
+**Files:**
+- Create: `Frontend/package.json` (via Vite template)
+- Create: `Frontend/vite.config.ts`
+- Create: `Frontend/tsconfig.json`
+- Create: `Frontend/index.html`
+- Create: `Frontend/src/main.tsx`
+
+- [ ] **Step 1: Check Node is available**
+
+Run: `node --version`
+Expected: `v20.x` or higher. If missing, STOP and report BLOCKED.
+
+- [ ] **Step 2: Scaffold with Vite**
+
+Run from the repo root:
+
+```bash
+npm create vite@latest Frontend -- --template react-ts
+```
+
+This may prompt "Ok to proceed? (y)" — pass `--yes` or reply `y`.
+
+- [ ] **Step 3: Install dependencies**
+
+```bash
+cd Frontend
+npm install
+cd ..
+```
+
+Expected: `Frontend/node_modules/` created, no errors.
+
+- [ ] **Step 4: Clean out template placeholders**
+
+The Vite template creates a demo counter component. Delete files we won't use:
+
+```bash
+rm -f Frontend/src/App.css
+rm -f Frontend/src/index.css
+rm -f Frontend/src/assets/react.svg
+rm -f Frontend/public/vite.svg
+```
+
+Overwrite `Frontend/src/App.tsx` with a placeholder (Task 6 rewrites it):
+
+```tsx
+export function App() {
+ return
Frontend placeholder
;
+}
+```
+
+Overwrite `Frontend/src/main.tsx` (removing CSS imports the template added):
+
+```tsx
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+
+const root = document.getElementById('root')!;
+createRoot(root).render(
+
+
+
+);
+```
+
+Overwrite `Frontend/index.html`:
+
+```html
+
+
+
+
+
+ Cursor-Following Lander
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 5: Configure Vite dev proxy for the backend WebSocket**
+
+Overwrite `Frontend/vite.config.ts`:
+
+```ts
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/ws': {
+ target: 'ws://localhost:5100',
+ ws: true,
+ changeOrigin: true,
+ },
+ },
+ },
+});
+```
+
+This lets the frontend reach the backend via `ws://localhost:5173/ws/game` (same origin as the dev server), which Vite forwards to `localhost:5100`.
+
+- [ ] **Step 6: Verify it builds and serves**
+
+```bash
+cd Frontend
+npm run build
+```
+
+Expected: `dist/` directory created, no TypeScript errors.
+
+Dev server smoke (in background):
+
+```bash
+npm run dev &
+DEV_PID=$!
+sleep 3
+curl -sf http://localhost:5173/ | head -5
+kill $DEV_PID
+wait $DEV_PID 2>/dev/null
+cd ..
+```
+
+Expected: the served HTML contains `` and the placeholder script tag.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add Frontend/package.json Frontend/package-lock.json Frontend/vite.config.ts \
+ Frontend/tsconfig.json Frontend/tsconfig.node.json Frontend/tsconfig.app.json \
+ Frontend/index.html Frontend/src/
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): scaffold Vite + React + TypeScript project"
+```
+
+---
+
+## Task 2: `protocol.ts` — wire message TypeScript types
+
+**Files:**
+- Create: `Frontend/src/protocol.ts`
+
+Mirror the backend's WireMessages exactly. This is the source of truth for what the frontend expects on the wire.
+
+- [ ] **Step 1: Write `Frontend/src/protocol.ts`**
+
+```ts
+// Wire messages exchanged with the backend over WebSocket.
+// Mirror of Backend/WireMessages.cs.
+
+export interface CursorMessage {
+ type: 'cursor';
+ x: number;
+ y: number;
+}
+
+export interface InitFrame {
+ type: 'init';
+ world: [number, number];
+}
+
+export interface StateFrame {
+ type: 'state';
+ x: number;
+ y: number;
+ angle: number;
+ engine: number;
+ target: [number, number];
+ step: number;
+}
+
+export type ServerFrame = InitFrame | StateFrame;
+
+export function isInit(frame: ServerFrame): frame is InitFrame {
+ return frame.type === 'init';
+}
+
+export function isState(frame: ServerFrame): frame is StateFrame {
+ return frame.type === 'state';
+}
+```
+
+- [ ] **Step 2: Verify TypeScript compiles**
+
+```bash
+cd Frontend && npx tsc --noEmit && cd ..
+```
+
+Expected: no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Frontend/src/protocol.ts
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): wire message TypeScript types"
+```
+
+---
+
+## Task 3: `useGameSocket` hook
+
+**Files:**
+- Create: `Frontend/src/hooks/useGameSocket.ts`
+
+Opens the WebSocket on mount, parses inbound frames, exposes `{init, state, connected, send}` as React state.
+
+- [ ] **Step 1: Write `Frontend/src/hooks/useGameSocket.ts`**
+
+```ts
+import { useEffect, useRef, useState } from 'react';
+import type { InitFrame, ServerFrame, StateFrame, CursorMessage } from '../protocol';
+import { isInit, isState } from '../protocol';
+
+export interface GameSocketState {
+ connected: boolean;
+ init: InitFrame | null;
+ state: StateFrame | null;
+ sendCursor: (x: number, y: number) => void;
+}
+
+/**
+ * Opens a WebSocket to the given URL, tracks the latest init/state frames.
+ * Auto-reconnects with exponential backoff on close.
+ */
+export function useGameSocket(url: string): GameSocketState {
+ const [connected, setConnected] = useState(false);
+ const [init, setInit] = useState
(null);
+ const [state, setState] = useState(null);
+ const wsRef = useRef(null);
+
+ useEffect(() => {
+ let closed = false;
+ let backoffMs = 500;
+ let reconnectTimer: number | null = null;
+
+ const open = () => {
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setConnected(true);
+ backoffMs = 500; // reset backoff on successful connect
+ };
+ ws.onclose = () => {
+ setConnected(false);
+ wsRef.current = null;
+ if (!closed) {
+ reconnectTimer = window.setTimeout(open, backoffMs);
+ backoffMs = Math.min(backoffMs * 2, 8000);
+ }
+ };
+ ws.onerror = () => { /* let onclose handle it */ };
+ ws.onmessage = (ev) => {
+ let frame: ServerFrame;
+ try {
+ frame = JSON.parse(ev.data) as ServerFrame;
+ } catch {
+ return;
+ }
+ if (isInit(frame)) setInit(frame);
+ else if (isState(frame)) setState(frame);
+ };
+ };
+
+ open();
+ return () => {
+ closed = true;
+ if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
+ wsRef.current?.close();
+ };
+ }, [url]);
+
+ const sendCursor = (x: number, y: number) => {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const msg: CursorMessage = { type: 'cursor', x, y };
+ ws.send(JSON.stringify(msg));
+ };
+
+ return { connected, init, state, sendCursor };
+}
+```
+
+- [ ] **Step 2: Verify TypeScript compiles**
+
+```bash
+cd Frontend && npx tsc --noEmit && cd ..
+```
+
+Expected: no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Frontend/src/hooks/useGameSocket.ts
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): useGameSocket hook with auto-reconnect"
+```
+
+---
+
+## Task 4: `useCursorSender` hook
+
+**Files:**
+- Create: `Frontend/src/hooks/useCursorSender.ts`
+
+Attaches a `mousemove` listener to a target element (the canvas). Converts pixel coords → world coords in `[0,1] × [0,1]` using letterbox mapping. Throttles sends to ~50 Hz using a `requestAnimationFrame` timestamp gate.
+
+- [ ] **Step 1: Write `Frontend/src/hooks/useCursorSender.ts`**
+
+```ts
+import { useEffect, useRef } from 'react';
+
+/**
+ * Convert a viewport point to world coords in [0,1] × [0,1] using
+ * letterbox mapping (preserves world aspect ratio 1:1).
+ */
+export function viewportToWorld(
+ viewportPx: { x: number; y: number },
+ viewportSize: { w: number; h: number },
+): { x: number; y: number } {
+ // World is 1×1. Fit inside viewport with black bars on the wider axis.
+ const scale = Math.min(viewportSize.w, viewportSize.h);
+ const offsetX = (viewportSize.w - scale) / 2;
+ const offsetY = (viewportSize.h - scale) / 2;
+ return {
+ x: Math.max(0, Math.min(1, (viewportPx.x - offsetX) / scale)),
+ y: Math.max(0, Math.min(1, (viewportPx.y - offsetY) / scale)),
+ };
+}
+
+const SEND_INTERVAL_MS = 20; // 50 Hz cap
+
+export function useCursorSender(
+ targetRef: React.RefObject,
+ onSend: (x: number, y: number) => void,
+) {
+ const lastCursorRef = useRef<{ x: number; y: number } | null>(null);
+ const lastSentAtRef = useRef(0);
+ const rafRef = useRef(null);
+
+ useEffect(() => {
+ const el = targetRef.current;
+ if (!el) return;
+
+ const handleMove = (ev: MouseEvent) => {
+ const rect = el.getBoundingClientRect();
+ const world = viewportToWorld(
+ { x: ev.clientX - rect.left, y: ev.clientY - rect.top },
+ { w: rect.width, h: rect.height },
+ );
+ lastCursorRef.current = world;
+ };
+
+ const tick = (now: number) => {
+ const c = lastCursorRef.current;
+ if (c && now - lastSentAtRef.current >= SEND_INTERVAL_MS) {
+ onSend(c.x, c.y);
+ lastSentAtRef.current = now;
+ }
+ rafRef.current = requestAnimationFrame(tick);
+ };
+
+ el.addEventListener('mousemove', handleMove);
+ rafRef.current = requestAnimationFrame(tick);
+
+ return () => {
+ el.removeEventListener('mousemove', handleMove);
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ };
+ }, [targetRef, onSend]);
+}
+```
+
+- [ ] **Step 2: Verify TypeScript compiles**
+
+```bash
+cd Frontend && npx tsc --noEmit && cd ..
+```
+
+Expected: no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Frontend/src/hooks/useCursorSender.ts
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): useCursorSender hook with letterbox mapping and 50 Hz throttle"
+```
+
+---
+
+## Task 5: `render.ts` — pure canvas draw functions
+
+**Files:**
+- Create: `Frontend/src/render.ts`
+
+Pure functions that draw the scene on a `CanvasRenderingContext2D`. Testable without a DOM (Vitest is not being set up in this plan — this is more about keeping the module isolated for correctness).
+
+- [ ] **Step 1: Write `Frontend/src/render.ts`**
+
+```ts
+import type { StateFrame } from './protocol';
+
+/** Ship as a filled triangle, drawn at (x,y) rotated by angle. Size in canvas px. */
+export function drawShip(
+ ctx: CanvasRenderingContext2D,
+ worldX: number,
+ worldY: number,
+ angle: number,
+ engine: number,
+ scale: number,
+ offsetX: number,
+ offsetY: number,
+) {
+ const px = offsetX + worldX * scale;
+ const py = offsetY + worldY * scale;
+ const size = scale * 0.04;
+
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(angle);
+
+ // Body: triangle pointing up (angle=0 → up).
+ ctx.fillStyle = '#eee';
+ ctx.beginPath();
+ ctx.moveTo(0, -size);
+ ctx.lineTo(size * 0.7, size * 0.6);
+ ctx.lineTo(-size * 0.7, size * 0.6);
+ ctx.closePath();
+ ctx.fill();
+
+ // Flame if an engine is firing.
+ if (engine !== 0) {
+ ctx.fillStyle = '#ff9d3d';
+ ctx.beginPath();
+ if (engine === 2) {
+ // Main engine: flame under the ship.
+ const flame = size * 0.9 + Math.random() * size * 0.4;
+ ctx.moveTo(-size * 0.4, size * 0.6);
+ ctx.lineTo(size * 0.4, size * 0.6);
+ ctx.lineTo(0, size * 0.6 + flame);
+ } else if (engine === 1) {
+ // Left thruster: flame on right side of body.
+ ctx.moveTo(size * 0.7, -size * 0.2);
+ ctx.lineTo(size * 0.7, size * 0.2);
+ ctx.lineTo(size * 1.3, 0);
+ } else if (engine === 3) {
+ // Right thruster: flame on left side of body.
+ ctx.moveTo(-size * 0.7, -size * 0.2);
+ ctx.lineTo(-size * 0.7, size * 0.2);
+ ctx.lineTo(-size * 1.3, 0);
+ }
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ ctx.restore();
+}
+
+/** Draw a crosshair at the target world position. */
+export function drawTarget(
+ ctx: CanvasRenderingContext2D,
+ worldX: number,
+ worldY: number,
+ scale: number,
+ offsetX: number,
+ offsetY: number,
+) {
+ const px = offsetX + worldX * scale;
+ const py = offsetY + worldY * scale;
+ const r = 8;
+
+ ctx.strokeStyle = '#5cf';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.arc(px, py, r, 0, Math.PI * 2);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(px - r * 1.5, py);
+ ctx.lineTo(px + r * 1.5, py);
+ ctx.moveTo(px, py - r * 1.5);
+ ctx.lineTo(px, py + r * 1.5);
+ ctx.stroke();
+}
+
+/** Layout: fit the 1×1 world into the canvas with black letterbox bars. */
+export function layout(canvas: HTMLCanvasElement) {
+ const scale = Math.min(canvas.width, canvas.height);
+ const offsetX = (canvas.width - scale) / 2;
+ const offsetY = (canvas.height - scale) / 2;
+ return { scale, offsetX, offsetY };
+}
+
+/** Render one frame: clear + world background + target + ship. */
+export function renderFrame(
+ canvas: HTMLCanvasElement,
+ state: StateFrame | null,
+) {
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ // Clear
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const { scale, offsetX, offsetY } = layout(canvas);
+
+ // World background (subtle dark band so the play area is visible).
+ ctx.fillStyle = '#0a0a12';
+ ctx.fillRect(offsetX, offsetY, scale, scale);
+
+ if (!state) return;
+
+ drawTarget(ctx, state.target[0], state.target[1], scale, offsetX, offsetY);
+ drawShip(ctx, state.x, state.y, state.angle, state.engine, scale, offsetX, offsetY);
+}
+```
+
+- [ ] **Step 2: Verify TypeScript compiles**
+
+```bash
+cd Frontend && npx tsc --noEmit && cd ..
+```
+
+Expected: no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Frontend/src/render.ts
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): pure canvas draw functions (ship + flame + target + layout)"
+```
+
+---
+
+## Task 6: `useAnimationLoop`, `LanderCanvas`, `App` — wire it all up
+
+**Files:**
+- Create: `Frontend/src/hooks/useAnimationLoop.ts`
+- Create: `Frontend/src/LanderCanvas.tsx`
+- Modify: `Frontend/src/App.tsx`
+
+- [ ] **Step 1: Write `Frontend/src/hooks/useAnimationLoop.ts`**
+
+```ts
+import { useEffect, useRef } from 'react';
+
+/** Calls `callback` once per browser frame. Runs while mounted. */
+export function useAnimationLoop(callback: (now: number) => void) {
+ const cbRef = useRef(callback);
+ cbRef.current = callback;
+
+ useEffect(() => {
+ let rafId = 0;
+ const loop = (now: number) => {
+ cbRef.current(now);
+ rafId = requestAnimationFrame(loop);
+ };
+ rafId = requestAnimationFrame(loop);
+ return () => cancelAnimationFrame(rafId);
+ }, []);
+}
+```
+
+- [ ] **Step 2: Write `Frontend/src/LanderCanvas.tsx`**
+
+```tsx
+import { useEffect, useRef } from 'react';
+import type { StateFrame } from './protocol';
+import { renderFrame } from './render';
+import { useAnimationLoop } from './hooks/useAnimationLoop';
+import { useCursorSender } from './hooks/useCursorSender';
+
+export interface LanderCanvasProps {
+ state: StateFrame | null;
+ onCursor: (x: number, y: number) => void;
+}
+
+/**
+ * Full-viewport canvas. Redraws every animation frame reading the latest
+ * `state` prop (no interpolation). Mouse movement is captured on the canvas
+ * and forwarded through `onCursor`.
+ */
+export function LanderCanvas({ state, onCursor }: LanderCanvasProps) {
+ const canvasRef = useRef(null);
+ const stateRef = useRef(state);
+ stateRef.current = state;
+
+ // Keep the canvas's backing store in sync with its CSS size + devicePixelRatio.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const resize = () => {
+ const dpr = window.devicePixelRatio ?? 1;
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = Math.floor(rect.width * dpr);
+ canvas.height = Math.floor(rect.height * dpr);
+ };
+ resize();
+ window.addEventListener('resize', resize);
+ return () => window.removeEventListener('resize', resize);
+ }, []);
+
+ useAnimationLoop(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ renderFrame(canvas, stateRef.current);
+ });
+
+ useCursorSender(canvasRef as React.RefObject, onCursor);
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 3: Overwrite `Frontend/src/App.tsx`**
+
+```tsx
+import { useGameSocket } from './hooks/useGameSocket';
+import { LanderCanvas } from './LanderCanvas';
+
+// Vite dev server proxies `/ws` → `ws://localhost:5100`. In prod the backend
+// serves the built static files, so same-origin works there too.
+function buildWsUrl(): string {
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${proto}//${window.location.host}/ws/game`;
+}
+
+export function App() {
+ const { connected, state, sendCursor } = useGameSocket(buildWsUrl());
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+interface StatusBarProps { connected: boolean; step: number; }
+function StatusBar({ connected, step }: StatusBarProps) {
+ return (
+
+ ●{' '}
+ {connected ? 'connected' : 'disconnected'} · step {step}
+
+ );
+}
+```
+
+- [ ] **Step 4: Verify TypeScript compiles and build passes**
+
+```bash
+cd Frontend && npx tsc --noEmit && npm run build && cd ..
+```
+
+Expected: no TypeScript errors, `dist/` produced.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Frontend/src/hooks/useAnimationLoop.ts Frontend/src/LanderCanvas.tsx Frontend/src/App.tsx
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): LanderCanvas + App wiring cursor and state to WebSocket"
+```
+
+---
+
+## Task 7: End-to-end manual verification
+
+**Files:** (none — verification only)
+
+This task launches everything at once (backend + frontend) and confirms the ship renders and moves.
+
+- [ ] **Step 1: Kill any stale processes**
+
+```bash
+pkill -f "dotnet.*Backend" 2>/dev/null; sleep 1
+pkill -f "vite" 2>/dev/null; sleep 1
+```
+
+- [ ] **Step 2: Publish CLI (idempotent)**
+
+```bash
+dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
+```
+
+- [ ] **Step 3: Start the backend in the background**
+
+```bash
+ASPNETCORE_URLS=http://localhost:5100 dotnet run --project Backend --no-launch-profile > /tmp/backend.log 2>&1 &
+BACKEND_PID=$!
+sleep 5
+grep -q "Loaded PPO policy" /tmp/backend.log && echo "backend loaded ONNX" || (echo "backend startup failed"; cat /tmp/backend.log; exit 1)
+curl -sf http://localhost:5100/ && echo
+```
+
+Expected: `backend loaded ONNX` and health returns `GameCli Backend`.
+
+- [ ] **Step 4: Start the Vite dev server**
+
+```bash
+cd Frontend
+npm run dev > /tmp/vite.log 2>&1 &
+VITE_PID=$!
+cd ..
+sleep 5
+curl -sf http://localhost:5173/ | grep -q "Cursor-Following Lander" && echo "vite serving index"
+```
+
+Expected: `vite serving index`.
+
+- [ ] **Step 5: WebSocket handshake test through Vite's proxy**
+
+Use a small Node script or curl to prove the WS proxies through. Node one-liner (assumes `ws` is available; if not, skip this step and rely on the browser check below).
+
+Simpler: just use `curl` to hit the frontend and confirm no 500 errors. The real WS test is the browser check.
+
+- [ ] **Step 6: Browser check (manual — the deliverable)**
+
+Open `http://localhost:5173/` in a browser. You should see:
+- Black background with a slightly-lighter square (the world).
+- A blue crosshair follows your mouse.
+- A small white triangular ship appears somewhere in the world, twitching / firing engines as the (smoke-trained) PPO policy tries to control it.
+- Top-left corner shows `● connected · step N` with N ticking up.
+
+If the ship never appears or the socket says `disconnected`, capture what's on screen and the browser DevTools Network → WS tab for the `ws/game` connection to diagnose.
+
+- [ ] **Step 7: Teardown**
+
+```bash
+kill $VITE_PID $BACKEND_PID 2>/dev/null
+wait $VITE_PID $BACKEND_PID 2>/dev/null
+```
+
+- [ ] **Step 8: No commit — verification only**
+
+---
+
+## Definition of done for Plan 4
+
+- `npx tsc --noEmit` in `Frontend/` reports 0 errors.
+- `npm run build` produces `Frontend/dist/`.
+- With backend running on 5100 and Vite dev server on 5173, opening `http://localhost:5173/` shows a canvas with a ship that responds to the cursor.
+- Every source file has one clear responsibility; no file exceeds ~150 lines.
+
+## Notes on real training vs the smoke model
+
+The ship's motion quality directly reflects how well-trained the PPO policy is. The current `models/ppo_lander.onnx` was trained for only 10k steps in Plan 2 — it's essentially random with a slight bias. To see actual cursor-following behavior:
+
+```bash
+source .venv/bin/activate
+python Training/train.py --steps 2000000 --n-envs 8
+python Training/export_onnx.py \
+ --checkpoint checkpoints/ppo_lander_final.zip \
+ --out models/ppo_lander.onnx
+```
+
+Then restart the backend so it re-loads the new ONNX. Training takes on the order of an hour on CPU.