Command & Conquer-style WebGL interface prototype. Units are displayed on a 3D map layer with real-time movement data provided by a backend simulation server.
- 3D vehicle models — two-part GLB drones (body + spinning propellers) rendered via deck.gl ScenegraphLayer
- Real-time simulation — 20Hz server-side tick with state machine (landed → takeoff → holding → goto → land)
- Smooth interpolation — 60fps client-side lerp of position and heading between 5Hz server polls
- Vehicle selection — click, rubber-band drag-select, double-click to select all in viewport
- Goto with spread formation — multi-vehicle goto fans targets into a circle to prevent stacking
- Follow camera — lock the map view to a single selected vehicle
- Fog of war — PixiJS-based reveal system that uncovers the map as vehicles move
- Configurable settings — vehicle count, model scale, propeller speed, anti-aliasing, fog of war, map style
- Stats HUD — live FPS (color-coded), total/landed/flying/selected vehicle counts
nvm use
pnpm install
cp .env.example .env
# Add your Mapbox token to .envThe frontend and backend run as separate processes. Start both in separate terminals:
# Terminal 1 — Vite dev server (frontend)
pnpm dev
# Terminal 2 — Express API server (port 3001)
pnpm serverThe Vite dev server proxies /api requests to the backend automatically.
| Variable | Default | Description |
|---|---|---|
VITE_MAPBOX_TOKEN |
— | Mapbox GL access token (required) |
VITE_MAPBOX_STYLE |
mapbox://styles/mapbox/standard |
Mapbox style URL |
VITE_VEHICLE_SCALE |
10 |
Vehicle model scale coefficient |
| Variable | Default | Description |
|---|---|---|
PORT |
3001 |
Server listen port |
TICK_MS |
50 |
Simulation tick interval (20Hz) |
HORIZONTAL_SPEED |
5 |
Vehicle horizontal speed (m/s) |
VERTICAL_SPEED |
2 |
Vehicle vertical speed (m/s) |
ROTATION_SPEED |
90 |
Vehicle rotation speed (deg/s) |
| Input | Action |
|---|---|
| Right-click drag | Pan the map |
| Middle-click drag up/down | Tilt (pitch) the map |
| Middle-click drag left/right | Rotate (bearing) the map |
| Scroll wheel | Zoom in/out |
| Left-click on vehicle | Select that vehicle (deselects others) |
| Left-click on empty space | Deselect all |
| Left-click drag | Drag-select (rubber band) all vehicles in the rectangle |
| Double-click anywhere | Select all vehicles in viewport |
| Key | Action |
|---|---|
W / A / S / D |
Pan map up / left / down / right (hold for continuous) |
O |
Reset pitch to top-down view |
T |
Takeoff selected vehicle(s) |
L |
Land selected vehicle(s) |
F |
Toggle follow camera on single selected vehicle |
| Input | Action |
|---|---|
Hold G + left-click |
Send selected vehicle(s) to the clicked position (multi-vehicle goto uses spread formation) |
| Layer | Technology |
|---|---|
| Frontend | Vue 3, Vite 7, TypeScript, Tailwind CSS v4 |
| Map | Mapbox GL JS |
| 3D overlays | deck.gl (ScenegraphLayer, PathLayer, PolygonLayer, etc.) |
| 2D effects | PixiJS (fog of war) |
| Gestures | Hammer.js |
| Backend | Node.js, Express 5 |
| Icons | Font Awesome 7 |
Ghost Deck uses a three-layer rendering stack, each layer handling a specific concern.
Layer 1: Mapbox GL (base map). Provides the satellite/terrain imagery. Custom mouse bindings replace the defaults (right-click to pan, middle-click for pitch/bearing). WASD keyboard panning runs on its own requestAnimationFrame loop for smooth continuous movement.
Layer 2: deck.gl (3D geometry). Integrated as a MapboxOverlay, which keeps the deck.gl camera in sync with Mapbox. This is the key architectural decision. Instead of rendering vehicles through Mapbox's WebGL context (which forces a full tile re-render on every update), deck.gl sits as an independent layer. The map only re-renders when the viewport changes, not when vehicles move.
Layer 3: PixiJS (2D effects). An absolutely positioned canvas with pointer-events: none, used for fog of war. Renders on top of everything else.
Mapbox GL's rendering pipeline is optimized for cartographic data, not real-time object animation. When you add custom WebGL content through its API, every position update triggers a full re-render of the map tiles. With many vehicles updating at 20Hz, this becomes the bottleneck.
deck.gl solves this by maintaining its own WebGL context layered above the map. Vehicle position updates, model rendering, path overlays, all of it happens without touching the map tiles. The map is just a background.
Each vehicle is a low-poly drone model (~800 triangles) split into two GLB files: body and rotor/hub assembly.
The body renders once per vehicle as a static ScenegraphLayer. The rotor assembly renders four times per vehicle (one per propeller arm), each with an independent spin angle computed per frame. Rotation directions alternate CW/CCW across the four arms. Spin speeds are randomized per vehicle on creation (720 to 1800 deg/s) and persist across page reloads via localStorage.
This is performant because deck.gl reuses the same model instance. Rendering one body and four rotors per vehicle is nearly the same cost as rendering just the body. It's basically geometry instancing at the application level.
spinning-model.mp4
(If the video is no longer loading due to Github garbage collecting the upload, you can find it here: assets/media/spinning-model.mp4)
The server simulation runs at 20Hz. The client polls at 5Hz (200ms intervals). To keep movement smooth at 60fps, the client interpolates between the last two known positions using linear interpolation gated by requestAnimationFrame.
deck.gl's built-in transition system wasn't suitable for this. It's designed for data visualization transitions (like morphing a bar chart), not continuous real-time position updates where new data arrives before the previous transition completes. Custom lerp was the only viable option.
Heading interpolation uses a shortest-angle-difference function to handle the 350-to-10 degree wraparound correctly, so vehicles always rotate through the shortest path.
The RAF loop only runs while there are active interpolations. When all vehicles are stationary, it stops, avoiding unnecessary CPU usage.
Three independent AA strategies, each toggleable and persisted to localStorage:
| Option | What it does |
|---|---|
| WebGL MSAA | Hardware multi-sample anti-aliasing at 4x (if GPU supports it) |
| Device Pixel Ratio | Renders at native screen density, capped at 2x to avoid GPU overload on high-DPI displays |
| FXAA | Post-process fast approximate anti-aliasing via luma.gl's shader effect |
Each one adds rendering cost. All three active together is the most expensive configuration.
PixiJS renders a semi-transparent black overlay across the entire viewport. A render texture acts as an inverse mask: white areas reveal the map, transparent areas stay fogged.
As vehicles move, radial gradient sprites (64px, white center fading to transparent) are stamped along their trail paths. A 2m deduplication threshold prevents over-stamping when vehicles are moving slowly. Sprites are pooled and reused rather than allocated fresh, and off-screen stamps are culled with margin.
The pixel size of each reveal stamp is recalculated dynamically based on map zoom level, converting a fixed 80m reveal radius into screen pixels.
This stack handles 3D model rendering with spinning animated propellers at display refresh rate on machines with integrated GPUs. For 2D use cases (which is the actual production target), there's even more headroom since we drop the 3D model overhead entirely.
The only scenario that causes frame drops: Mapbox 3D building extrusions visible on screen while constantly panning/tilting, all three AA layers active, and fog of war enabled simultaneously. Each of those is fine individually. They only become a problem when stacked together, and the fog of war implementation specifically has room for optimization.
Vehicle count is configurable at runtime (up to 10,000) via the settings panel, making it easy to find the performance ceiling for any given hardware.
