A few months ago I shipped the first version of Moveet — an open-source real-time vehicle fleet simulator that runs vehicles on actual road networks using A* pathfinding, streams positions over WebSocket, and renders everything in a custom D3 SVG map without touching Leaflet or Mapbox.
Since then a lot has landed. Here's what's new.
What shipped
🚦 Fleet management
Vehicles can now be grouped into named, colour-coded fleets. You create a fleet, assign vehicles to it, and the UI colours routes and markers per fleet. Useful when you want to simulate multiple operators or companies sharing the same road network.
REST API:
POST /fleets
GET /fleets
DELETE /fleets/:id
POST /fleets/:id/assign
POST /fleets/:id/unassign
Fleet events are broadcast over WebSocket in real time (fleet:created, fleet:deleted, fleet:assigned).
⚠️ Incidents and dynamic rerouting
You can now drop a road incident anywhere on the network. Every vehicle currently routing through the affected segment gets rerouted live — A* reruns from their current position, routing around the blocked edge.
POST /incidents # create incident, triggers rerouting
GET /incidents # list active
DELETE /incidents/:id # clear it, vehicles return to normal paths
POST /incidents/random # random incident for testing
Incident markers appear on the map. The vehicle:rerouted WebSocket event fires for each affected vehicle.
🎬 Session recording and replay
Every simulation session can be recorded to a timestamped NDJSON file. The format is a header line followed by one event per line — direction assignments, vehicle snapshots, incidents, everything.
POST /recording/start
POST /recording/stop
GET /recordings
Replay loads any recording file and plays it back with full control:
POST /replay/start { "file": "path/to/recording.ndjson" }
POST /replay/pause
POST /replay/resume
POST /replay/stop
POST /replay/seek { "timestamp": 12000 }
POST /replay/speed { "speed": 2 }
GET /replay/status
Playback supports 1×, 2×, and 4× speed. The UI has an interpolated progress bar that advances smoothly between server ticks, so it doesn't jump every 500 ms.
🖥 UI redesign: icon rail + panel sidebar
The controls were previously a floating overlay. They're now an icon rail on the left edge — a vertical strip of icon buttons that each toggle a panel.
Panels:
| Icon | Panel |
|---|---|
| �� | Vehicles — list, filter, select |
| 🗂 | Fleets — create, assign, colour |
| ⚠️ | Incidents — active list with badge count |
| ⏺ | Recordings — start/stop/browse/replay |
| 👁 | Visibility — toggle map layers |
| ⚡ | Speed — simulation speed controls |
| ⚙️ | Adapter — hot-swap source/sink plugins |
The bottom dock holds live simulation controls (play/stop/reset/record) and collapses to a replay transport bar during playback.
All panel components are built from a shared set of primitives (PanelShell, PanelHeader, PanelSection) and a unified theme token set, so every panel looks consistent without per-component ad-hoc styling.
✅ Test coverage: from 410 to 502 tests
The simulator test suite grew significantly. New files added:
| File | What it covers |
|---|---|
rateLimiter.test.ts |
Window enforcement, 429 responses, per-IP tracking, cleanup interval |
helpers.test.ts |
calculateBearing, interpolatePosition, calculateDistance, nonCircularRouteEdges, estimateRouteDuration
|
serializer.test.ts |
serializeVehicle with and without fleet assignment |
config.test.ts |
verifyConfig — missing file, port range, speed ordering, all numeric constraints |
SimulationController.test.ts |
Full lifecycle: start, stop, getStatus, setOptions, getVehicles, getInterval, all replay methods |
The pattern for testing SimulationController is worth noting: construction stubs VehicleManager.prototype.setRandomDestination to prevent A* calls on the tiny test-network fixture, then restores it. Replay tests write a minimal NDJSON file to a temp dir before each test. Everything cleans up in afterEach.
📡 WebSocket: 100 ms batching + backpressure
Vehicle position updates are now batched at 100 ms before broadcast. If a client's write buffer is backed up, the broadcaster skips it rather than growing an unbounded queue. This makes the simulator usable with 50–100 vehicles without the connection degrading.
All event types the simulator broadcasts:
vehicles – batched position array (100ms window)
status – simulation state (running / ready / interval)
options – current StartOptions
heatzones – HeatZoneFeature[]
direction – active dispatch assignment
waypoint:reached – vehicle reached a waypoint
route:completed – vehicle completed its full route
reset – simulation was reset
fleet:created / fleet:deleted / fleet:assigned
incident:created / incident:cleared
vehicle:rerouted – live A* reroute triggered
🔧 CI improvements
The GitHub Actions workflow previously ran npm ci three times in parallel — once per job. It now has a dedicated setup job that installs once and caches node_modules keyed on package-lock.json. Lint, test, and build jobs restore from cache. The build job also caches .turbo/ with a per-commit key and branch-level restore keys.
Architecture diagram (updated)
┌──────────────────────────────────────┐
│ apps/ui │
│ React 19 · D3 7 · Vite · TS 5.8 │
│ Mercator SVG map, 1×–15× zoom │
│ Icon rail · Panel sidebar · Dock │
└──────────────┬───────────────────────┘
│ REST + WebSocket
▼
┌──────────────────────────────────────┐
│ apps/simulator │
│ Express 4 · ws 8 · Turf.js 7 │
│ GeoJSON graph · A* · LRU cache │
│ WS broadcaster · 100ms batching │
└──────────────┬───────────────────────┘
│ GET /vehicles · POST /sync
▼
┌──────────────────────────────────────┐
│ apps/adapter (optional) │
│ Source plugins: static / graphql / │
│ rest / mysql / postgres │
│ Sink plugins: console / graphql / │
│ rest / redpanda / redis / webhook │
└──────────────────────────────────────┘
Try it
curl -O https://raw.githubusercontent.com/ivannovazzi/moveet/main/docker-compose.ghcr.yml
docker compose -f docker-compose.ghcr.yml up
Open http://localhost:5012. No config, no API keys.
Repo: github.com/ivannovazzi/moveet
Happy to answer questions about the A* implementation, the D3 renderer, or the recording format in the comments.

Top comments (0)