Files
Experiment_ReinforcementLea…/docs/superpowers/plans/2026-07-17-04-frontend.md
2026-07-17 18:23:17 +00:00

23 KiB
Raw Permalink Blame History

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:

npm create vite@latest Frontend -- --template react-ts

This may prompt "Ok to proceed? (y)" — pass --yes or reply y.

  • Step 3: Install dependencies
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:

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):

export function App() {
  return <div style={{ padding: 20 }}>Frontend placeholder</div>;
}

Overwrite Frontend/src/main.tsx (removing CSS imports the template added):

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

const root = document.getElementById('root')!;
createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Overwrite Frontend/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Cursor-Following Lander</title>
    <style>
      html, body, #root { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
      body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #eee; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • Step 5: Configure Vite dev proxy for the backend WebSocket

Overwrite Frontend/vite.config.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
cd Frontend
npm run build

Expected: dist/ directory created, no TypeScript errors.

Dev server smoke (in background):

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 <div id="root"> and the placeholder script tag.

  • Step 7: Commit
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
// 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
cd Frontend && npx tsc --noEmit && cd ..

Expected: no errors.

  • Step 3: Commit
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
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<InitFrame | null>(null);
  const [state, setState] = useState<StateFrame | null>(null);
  const wsRef = useRef<WebSocket | null>(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
cd Frontend && npx tsc --noEmit && cd ..

Expected: no errors.

  • Step 3: Commit
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
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<HTMLElement>,
  onSend: (x: number, y: number) => void,
) {
  const lastCursorRef = useRef<{ x: number; y: number } | null>(null);
  const lastSentAtRef = useRef<number>(0);
  const rafRef = useRef<number | null>(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
cd Frontend && npx tsc --noEmit && cd ..

Expected: no errors.

  • Step 3: Commit
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
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
cd Frontend && npx tsc --noEmit && cd ..

Expected: no errors.

  • Step 3: Commit
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

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
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<HTMLCanvasElement>(null);
  const stateRef = useRef<StateFrame | null>(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<HTMLElement>, onCursor);

  return (
    <canvas
      ref={canvasRef}
      style={{ display: 'block', width: '100vw', height: '100vh', cursor: 'crosshair' }}
    />
  );
}
  • Step 3: Overwrite Frontend/src/App.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 (
    <>
      <LanderCanvas state={state} onCursor={sendCursor} />
      <StatusBar connected={connected} step={state?.step ?? 0} />
    </>
  );
}

interface StatusBarProps { connected: boolean; step: number; }
function StatusBar({ connected, step }: StatusBarProps) {
  return (
    <div style={{
      position: 'fixed', top: 8, left: 8, padding: '4px 10px',
      background: 'rgba(0,0,0,0.5)', borderRadius: 4, fontSize: 12,
      pointerEvents: 'none',
    }}>
      <span style={{ color: connected ? '#5f5' : '#f55' }}></span>{' '}
      {connected ? 'connected' : 'disconnected'} · step {step}
    </div>
  );
}
  • Step 4: Verify TypeScript compiles and build passes
cd Frontend && npx tsc --noEmit && npm run build && cd ..

Expected: no TypeScript errors, dist/ produced.

  • Step 5: Commit
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
pkill -f "dotnet.*Backend" 2>/dev/null; sleep 1
pkill -f "vite" 2>/dev/null; sleep 1
  • Step 2: Publish CLI (idempotent)
dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
  • Step 3: Start the backend in the background
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
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
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:

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.