A real-time vehicle fleet simulator that runs vehicles on actual road networks with A* pathfinding, realistic motion physics, BPR traffic congestion, time-of-day patterns, geofencing, incident-based rerouting, session recording, and a custom WebGL map rendering engine — no map tile provider required.
- Features
- Quick start
- Architecture
- Network CLI
- Simulator API
- WebSocket events
- Adapter plugins
- Configuration
- Testing
- Docker
- Contributing
| 🗺 Road-network agnostic | Ingests any GeoJSON/OSM-derived road graph — swap the file to simulate a different city |
| 🌐 Network CLI | apps/network pipeline: download OSM data from Geofabrik, extract a bbox, filter road classes, export GeoJSON, validate topology, and diff versions — one prepare command does it all |
| 🔀 A* pathfinding | Haversine heuristic over bidirectional road segments; respects turn restrictions, roundabouts, and road-class access rules; incident-aware route cache |
| 🚗 Vehicle types | Five types (car, truck, motorcycle, ambulance, bus) with distinct speed profiles, acceleration curves, road restrictions, and special behaviours (e.g. ambulances ignore heat-zone penalties) |
| 🚦 Traffic realism | BPR congestion model (flow/capacity), time-of-day rush-hour/night demand multipliers, traffic-signal intersection delays, surface-smoothness speed factors |
| 🎨 Custom map renderer | deck.gl + luma.gl WebGL scene (Web Mercator viewport, pan/zoom, fly-to); GPU layers for roads, vehicles, POIs, heat-zone contours, incident markers, geofences, breadcrumb trails, and dispatch routes — no Leaflet or Mapbox |
| 📡 Real-time WebSocket | 100 ms batched broadcast with backpressure handling; streams vehicle positions, routes, heat zones, incidents, geofence events, fleet events, and replay frames |
| 🔥 Heat zones | Contour density map (green → red, 50 thresholds) derived from road-network intersection density |
| 🔲 Geofencing | Draw custom polygons on the map; monitor vehicles crossing zone boundaries; enter/exit events broadcast in real time |
| Operator-created road incidents trigger live A* rerouting for all affected vehicles | |
| 🎬 Recording & replay | NDJSON session recording; replay with pause, seek, and 1×/2×/4× speed controls and interpolated progress bar |
| 🚘 Breadcrumb trails | Per-vehicle position history rendered as fading path overlays on the map |
| 🚦 Fleet management | Group vehicles into named, colour-coded fleets; assign/unassign at runtime |
| 📦 Job dispatch lifecycle | Pickup/dropoff jobs assigned by nearest / best-ETA / named vehicle, driven through the full status lifecycle with per-leg ETAs and SLA-breach tracking; placed with two map clicks |
| 🔌 Device fault injection | Per-vehicle device faults injected in the simulator — frozen GPS, clock skew/drift, duplicate and out-of-order messages, battery death, teleport/spoofing — reproducible under a fixed seed, editable at runtime, visible on the WebSocket feed and the adapter push |
| 🔍 POI + road search | Typeahead combining road names and points of interest; dispatches selected vehicles to result |
| 🖥 Operator UI | State-adaptive bottom dock (live/replay transport + Fleet · Monitor · Session · Settings sections), a left-edge icon rail for map-layer visibility and vehicle-type filters, corner health lamps, and a ⌘K command palette |
| 🔌 Adapter plugins | Hot-swappable source and sink plugins; configure via env vars or REST API at runtime |
| 📊 Observability | Simulator and adapter each expose a Prometheus /metrics endpoint (prom-client); an x-request-id correlation id flows end to end (simulator → adapter → telemetry envelope correlation_id / trace_id) |
| 📈 Optional scale-out | WebSocket fan-out runs in-process by default, or moves to a standalone ws-gateway process over a Redis pub/sub bus via WS_TRANSPORT=redis (compose scale profile, off by default) |
- Node.js ≥ 26, npm ≥ 9 (workspace root)
- Docker (optional)
git clone https://github.com/ivannovazzi/moveet.git
cd moveet
npm install
npm run dev # starts all three services via Turborepo| Service | URL |
|---|---|
| Dashboard | http://localhost:5012 |
| Simulator API | http://localhost:5010 |
| Adapter API | http://localhost:5011 |
Or start services individually:
npm run dev:sim # simulator only :5010
npm run dev:ui # UI only :5012
npm run dev:adapter # adapter only :5011To prepare a road network for a new city:
cd apps/network
npm run dev -- prepare nairobi # or any region in regions.jsonflowchart TD
NET["<b>apps/network</b><br/>OSM CLI pipeline<br/>(offline, one-time)"]
UI["<b>apps/ui</b><br/>React 19 · deck.gl · Vite<br/>:5012"]
SIM["<b>apps/simulator</b><br/>Express · ws · Turf.js<br/>:5010"]
ADP["<b>apps/adapter</b><br/>Express · plugin manager<br/>:5011"]
EXT["External system<br/><i>GraphQL · Kafka · REST · …</i>"]
NET -- "GeoJSON road network" --> SIM
UI -- "REST + WebSocket" --> SIM
SIM -- "GET /vehicles<br/>POST /sync" --> ADP
ADP -- "source / sink plugins" --> EXT
Network is an offline CLI that turns raw OpenStreetMap data into a simulator-ready GeoJSON road network. Run it once per city; the output drops straight into apps/simulator/data/.
Simulator is the core — it builds a routable graph from GeoJSON, runs vehicles with per-vehicle interval timers, and serves a REST API + WebSocket feed. It works completely standalone.
UI is a React app that renders everything on a WebGL canvas using deck.gl + luma.gl over a Web Mercator viewport. It has no map-tile dependency — roads, routes, heat-zone contours, POIs, incidents, geofences, breadcrumb trails, and vehicles are all drawn from GeoJSON/API data.
Adapter is optional — only needed when you want to push data to an external fleet management system. It hot-swaps source and sink plugins at runtime via its own REST API.
flowchart LR
GJ[GeoJSON<br/>road network] --> RN[RoadNetwork<br/>graph + A*]
RN --> VM[VehicleManager<br/>movement · routing · types]
VM --> SC[SimulationController<br/>start · stop · options]
SC --> RM[RecordingManager]
SC --> RP[ReplayManager]
SC --> IM[IncidentManager<br/>rerouting]
SC --> FM[FleetManager]
VM --> JM[JobManager<br/>assignment · lifecycle · SLA]
SC --> GF[GeoFenceManager<br/>enter / exit events]
SC --> TM[TrafficManager<br/>BPR · time-of-day]
SC --> WS[WebSocketBroadcaster<br/>buffer + flush]
WS --> TR{WS_TRANSPORT}
TR -- inprocess (default) --> CF[ClientFanout<br/>per-client fan-out]
TR -- redis --> RB[(Redis pub/sub)]
RB --> GW[ws-gateway<br/>standalone process]
GW --> CF
The WebSocketBroadcaster keeps the de-duping buffer and 10 Hz flush timer, then delegates egress to a BroadcastTransport. By default (WS_TRANSPORT=inprocess) it fans out to clients on the simulation thread. Setting WS_TRANSPORT=redis publishes serialized envelopes onto a Redis bus that a standalone ws-gateway process consumes, running the same ClientFanout engine against its own WS server so client count scales independently of the simulator. See apps/simulator/CLAUDE.md for the transport seam details.
Cross-app code lives in packages/:
@moveet/shared-types: the single source of truth for the cross-app contracts. It owns the WebSocket message union (WsMessageMap, the derivedWebSocketMessage, andWsDataMessageType) and the REST request/response DTOs. The simulator's broadcaster is typed against the union (broadcast<K extends WsDataMessageType>(type, data)), so producer and consumer derive from one definition and a payload-shape change fails to compile on the other side.@moveet/server-kit: shared server runtime infra used by both Node services, namely thecorrelationIdanderrorHandlerExpress middleware, a pinologgerfactory with secret redaction, and a retryinghttpClient.
apps/network is a standalone CLI that turns raw OpenStreetMap data into a simulator-ready GeoJSON road network. It requires a locally installed osmium-tool (≥ 1.14) and runs entirely offline after the initial Geofabrik download.
cd apps/network
npm run dev -- prepare nairobi # interactive wizard if region omitted
npm run dev -- prepare --output apps/simulator/data/network.geojsonThe prepare command runs the full pipeline: download → extract → filter → export → validate.
| Command | Description |
|---|---|
network download |
Download country PBF from Geofabrik (cached after first run) |
network extract |
Clip a bounding box from the country PBF using osmium |
network filter |
Keep only drivable road classes from the extracted PBF |
network export |
Convert filtered PBF to GeoJSON via osmium |
network validate |
Run topology checks: orphan nodes, duplicate edges, disconnected components |
network diff <old> <new> |
Compare two network GeoJSON files and report changes |
network prepare [region] |
Full pipeline in one step |
Regions are defined in regions.json (covers major cities globally). Pass --bbox w,s,e,n for a custom area or --geofabrik <path> for a Geofabrik sub-path.
Base URL:
http://localhost:5010
| Method | Path | Description |
|---|---|---|
GET |
/status |
Simulation state (running, ready, interval) |
POST |
/start |
Start simulation (accepts options body) |
POST |
/stop |
Stop simulation |
POST |
/reset |
Reset to initial state |
GET |
/options |
Get current simulation options |
POST |
/options |
Update simulation options |
| Method | Path | Description |
|---|---|---|
GET |
/vehicles |
List all vehicle DTOs |
POST |
/direction |
Dispatch one or more vehicles to a destination |
GET |
/directions |
Get active direction assignments |
POST |
/find-node |
Snap a lat/lng to the nearest graph node |
POST |
/find-road |
Snap a lat/lng to the nearest road edge |
POST |
/search |
Full-text POI search |
| Method | Path | Description |
|---|---|---|
GET |
/network |
Full road-network GeoJSON |
GET |
/roads |
Road segments GeoJSON |
GET |
/pois |
Points of interest |
GET |
/heatzones |
Current heat zone features |
POST |
/heatzones |
Regenerate heat zones |
| Method | Path | Description |
|---|---|---|
GET |
/fleets |
List all fleets |
POST |
/fleets |
Create a fleet |
DELETE |
/fleets/:id |
Delete a fleet |
POST |
/fleets/:id/assign |
Assign vehicles to a fleet |
POST |
/fleets/:id/unassign |
Unassign vehicles from a fleet |
Pickup/dropoff work orders. Creating a job also assigns it: the simulator picks a
free vehicle (nearest, best_eta, or a named one), routes it through both
stops, and advances the job through pending → assigned → en_route → on_scene → transporting → complete off the vehicle's own routing events, tracking ETA and
SLA breach along the way.
| Method | Path | Description |
|---|---|---|
GET |
/jobs |
List every job on the board |
POST |
/jobs |
Create a job and assign it |
POST |
/jobs/:id/assign |
Re-assign a job that has not been picked up yet |
POST |
/jobs/:id/cancel |
Cancel a live job and release its vehicle |
DELETE |
/jobs/:id |
Remove a finished job from the board |
Naming a vehicleId that is not in the fleet answers 404; one that is already
carrying another job answers 409 naming that job. A vehicle taken off its job by
something else (an operator dispatch, a scenario) re-queues the job if the load was
not yet collected, and fails it — rather than reporting a delivery — if it was.
In the UI the board is the Fleet dock panel's Jobs tab: place a pickup/dropoff with two map clicks, watch the SLA countdown, reassign a job that has not been picked up, and see which unit is carrying what in the vehicle list and inspector.
Faults injected as properties of the simulated device (frozen GPS, clock skew,
duplicate and out-of-order messages, battery death, teleport/spoofing), as opposed
to the adapter's realism engine, which degrades the transport. Off by default;
reproducible under a fixed seed. See apps/simulator/README.md for the profile
shape and per-fault semantics.
| Method | Path | Description |
|---|---|---|
GET |
/faults |
Configuration plus a live device-state snapshot |
POST |
/faults |
Update the configuration at runtime |
GET |
/faults/status |
Live per-device state and trigger counts |
POST |
/faults/reset |
Clear latched device state, keeping the config |
PUT |
/faults/vehicles/:id |
Set one vehicle's fault profile |
DELETE |
/faults/vehicles/:id |
Remove one vehicle's fault profile |
The UI surfaces this as Monitor → Faults: arm the layer, set a seed, apply a profile preset fleet-wide or to one device, watch the live device counters, and clear latched state. Vehicles whose device is misbehaving carry a fault badge in the vehicle list, and the inspector shows the active faults, remaining battery and clock skew for the selected one.
| Method | Path | Description |
|---|---|---|
GET |
/incidents |
List active incidents |
POST |
/incidents |
Create an incident (triggers rerouting) |
DELETE |
/incidents/:id |
Clear an incident |
POST |
/incidents/random |
Create a random incident |
| Method | Path | Description |
|---|---|---|
GET |
/geofences |
List all geofence zones |
POST |
/geofences |
Create a geofence (GeoJSON polygon + metadata) |
GET |
/geofences/:id |
Get a geofence |
PUT |
/geofences/:id |
Update a geofence |
DELETE |
/geofences/:id |
Delete a geofence |
PATCH |
/geofences/:id/toggle |
Enable / disable a geofence |
| Method | Path | Description |
|---|---|---|
GET |
/health |
Uptime and subsystem status |
GET |
/metrics |
Prometheus scrape endpoint (prom-client) |
| Method | Path | Description |
|---|---|---|
POST |
/recording/start |
Start recording the session |
POST |
/recording/stop |
Stop recording and save NDJSON file |
GET |
/recordings |
List saved recordings |
POST |
/replay/start |
Load and start a recording replay |
POST |
/replay/pause |
Pause replay |
POST |
/replay/resume |
Resume replay |
POST |
/replay/stop |
Stop replay, return to live mode |
POST |
/replay/seek |
Seek to a timestamp (ms) |
POST |
/replay/speed |
Set playback speed multiplier |
GET |
/replay/status |
Current replay state |
Connect to ws://localhost:5010. On connect the server sends a status and options snapshot.
| Event | Direction | Payload |
|---|---|---|
vehicles |
server → client | Array of VehicleDTO (position, speed, heading, fleetId) |
status |
server → client | SimulationStatus (running, ready, interval) |
options |
server → client | Current StartOptions |
heatzones |
server → client | HeatZoneFeature[] |
direction |
server → client | Active route + ETA, with a reason (dispatch/waypoints/random/reroute) |
waypoint:reached |
server → client | Vehicle reached a waypoint |
route:completed |
server → client | Vehicle completed its full route |
reset |
server → client | Simulation was reset |
fleet:created |
server → client | New fleet |
fleet:deleted |
server → client | Fleet removed |
fleet:assigned |
server → client | Vehicles assigned to fleet |
job:created |
server → client | New job, queued |
job:updated |
server → client | Job lifecycle transition (assignment, status, SLA flag) |
job:sla-breach |
server → client | Job passed its SLA deadline unfinished |
job:deleted |
server → client | Job removed from the board |
incident:created |
server → client | New incident + affected vehicles |
incident:cleared |
server → client | Incident resolved |
vehicle:rerouted |
server → client | Vehicle rerouted around incident |
geofence:event |
server → client | Vehicle entered or exited a geofence zone |
faults:config |
server → client | Device fault-injection configuration changed |
Base URL:
http://localhost:5011
| Method | Path | Description |
|---|---|---|
GET |
/config |
Current source + sinks config |
POST |
/config/source |
Swap the active source plugin |
POST |
/config/sinks |
Replace the active sink list |
DELETE |
/config/sinks/:type |
Remove one sink |
GET |
/vehicles |
Vehicles from the current source |
GET |
/fleets |
Fleets from the current source |
POST |
/sync |
Push a position update through all sinks |
GET |
/health |
Health check |
GET |
/metrics |
Prometheus scrape endpoint (prom-client) |
flowchart LR
SRC["Source plugin"] --> MGR["Plugin Manager"]
subgraph Sources
static["<b>static</b><br/>synthetic vehicles"]
graphql_s["<b>graphql</b><br/>GraphQL query"]
rest_s["<b>rest</b><br/>HTTP GET"]
mysql["<b>mysql</b>"]
postgres["<b>postgres</b>"]
end
Sources --> SRC
| Plugin | Key config fields |
|---|---|
static |
count (default 20) |
graphql |
url, query, token, headers, vehiclePath, maxVehicles |
rest |
url, token, headers, vehiclePath, maxVehicles |
mysql |
host, port, user, password, database, query |
postgres |
host, port, user, password, database, query |
flowchart LR
MGR["Plugin Manager"] --> SINK["Sink plugin(s)"]
subgraph Sinks
console["<b>console</b><br/>stdout"]
graphql_k["<b>graphql</b><br/>GraphQL mutation"]
rest_k["<b>rest</b><br/>HTTP POST"]
redpanda["<b>redpanda</b><br/>Kafka / Redpanda"]
redis["<b>redis</b>"]
webhook["<b>webhook</b><br/>HTTP fire-and-forget"]
end
SINK --> Sinks
Multiple sinks run simultaneously. Configure via env vars or the runtime API:
# Env-var example: Redpanda + webhook
SOURCE_TYPE=graphql
SOURCE_CONFIG='{"url":"https://api.example.com/graphql","token":"..."}'
SINK_TYPES=redpanda,webhook
SINK_REDPANDA_CONFIG='{"brokers":"localhost:9092","topic":"fleet-updates"}'
SINK_WEBHOOK_CONFIG='{"url":"https://hooks.example.com/fleet"}'| Variable | Default | Description |
|---|---|---|
PORT |
5010 |
HTTP / WebSocket port |
GEOJSON_PATH |
./data/network.geojson |
Path to the road-network GeoJSON file |
VEHICLE_COUNT |
70 |
Number of vehicles to spawn |
UPDATE_INTERVAL |
500 |
Position broadcast interval (ms) |
MIN_SPEED |
20 |
Minimum vehicle speed (km/h) |
MAX_SPEED |
60 |
Maximum vehicle speed (km/h) |
ACCELERATION |
5 |
Acceleration rate (km/h per tick) |
DECELERATION |
7 |
Deceleration rate (km/h per tick) |
TURN_THRESHOLD |
30 |
Bearing change (°) that triggers slowdown |
SPEED_VARIATION |
0.1 |
Random speed jitter factor [0, 1] |
HEATZONE_SPEED_FACTOR |
0.5 |
Speed multiplier inside heat zones |
ADAPTER_URL |
(empty) | Enable adapter sync (e.g. http://localhost:5011) |
SYNC_ADAPTER_TIMEOUT |
5000 |
Adapter sync timeout (ms) |
WS_TRANSPORT |
inprocess |
WebSocket fan-out transport: inprocess or redis |
REDIS_URL |
(empty) | Redis bus URL; required when WS_TRANSPORT=redis |
| Variable | Default | Description |
|---|---|---|
PORT |
5011 |
HTTP port |
SOURCE_TYPE |
static |
Active source plugin |
SOURCE_CONFIG |
{} |
JSON config for the source plugin |
SINK_TYPES |
(empty) | Comma-separated sink plugin names |
SINK_<TYPE>_CONFIG |
{} |
JSON config per sink, e.g. SINK_REDPANDA_CONFIG |
Tests use Vitest across all four packages. CI enforces 50 % coverage thresholds.
npm test # all packages via Turborepo
cd apps/simulator && npm test # simulator
cd apps/ui && npm test # UI
cd apps/adapter && npm test # adapter
cd apps/network && npm test # network CLISimulator test coverage includes: road-network graph, A* pathfinding, vehicle types and profiles, turn restrictions, BPR traffic manager, time-of-day clock, geofence manager, heat zones, fleet management, incident rerouting, recording/replay lifecycle, geospatial helpers, serializer, config validation, and SimulationController lifecycle.
curl -O https://raw.githubusercontent.com/ivannovazzi/moveet/main/docker-compose.ghcr.yml
docker compose -f docker-compose.ghcr.yml upThe simulator image does not bundle a road network: place a simulator-ready GeoJSON at ./apps/simulator/data/network.geojson (see Network CLI) or edit the volume in the compose file.
Open http://localhost:5012.
Images (published on every release via GitHub Container Registry):
ghcr.io/ivannovazzi/moveet-simulator
ghcr.io/ivannovazzi/moveet-adapter
ghcr.io/ivannovazzi/moveet-ui
All three images build from the single workspace-aware root Dockerfile (targets:
simulator, adapter, ui). From the repo root:
docker compose up --buildTo scale the WebSocket fan-out onto a standalone ws-gateway process backed by Redis, enable the optional scale profile (off by default):
WS_TRANSPORT=redis REDIS_URL=redis://redis:6379 docker compose --profile scale up --build| Package | Path | Tech | Port |
|---|---|---|---|
| network | apps/network/ |
Node.js 26 · Commander · osmium-tool (local install) | CLI |
| simulator | apps/simulator/ |
Node.js 26 · Express 4 · ws 8 · Turf.js 7 | 5010 |
| adapter | apps/adapter/ |
Node.js 26 · Express 4 | 5011 |
| ui | apps/ui/ |
React 19 · deck.gl 9 · Vite · TypeScript 6.0 · Tailwind CSS v4 | 5012 |
Shared workspace packages consumed by the apps:
| Package | Path | Role |
|---|---|---|
| @moveet/shared-types | packages/shared-types/ |
Cross-app contracts: WebSocket message union + REST request/response DTOs |
| @moveet/server-kit | packages/server-kit/ |
Shared server runtime: correlation-id + error middleware, pino logger, retrying HTTP client |
Each package has its own README with deeper architecture notes.
Please read CONTRIBUTING.md before opening a PR.
See SECURITY.md for the vulnerability disclosure policy.
MIT © Ivan Novazzi