diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx
index ee50146..a0de5ec 100644
--- a/Frontend/src/App.tsx
+++ b/Frontend/src/App.tsx
@@ -1,3 +1,34 @@
-export function App() {
- return
Frontend placeholder
;
+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}
+
+ );
}
diff --git a/Frontend/src/LanderCanvas.tsx b/Frontend/src/LanderCanvas.tsx
new file mode 100644
index 0000000..10fd66f
--- /dev/null
+++ b/Frontend/src/LanderCanvas.tsx
@@ -0,0 +1,52 @@
+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 (
+
+ );
+}
diff --git a/Frontend/src/hooks/useAnimationLoop.ts b/Frontend/src/hooks/useAnimationLoop.ts
new file mode 100644
index 0000000..680a8d5
--- /dev/null
+++ b/Frontend/src/hooks/useAnimationLoop.ts
@@ -0,0 +1,17 @@
+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);
+ }, []);
+}