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.
Contents
- Features
- Quick start
- Architecture
- Network CLI
- Simulator API
- WebSocket events
- Adapter plugins
- Configuration
- Testing
- Docker
- Contributing
Features
| 🗺 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) |
Quick Start
Prerequisites
- Node.js ≥ 26, npm ≥ 9 (workspace root)
- Docker (optional)
Run locally
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 :5011
To prepare a road network for a new city:
cd apps/network npm run dev -- prepare nairobi # or any region in regions.json
Architecture
flowchart 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.
Simulator internals
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.
Shared packages
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.
Network CLI
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.
One-command setup
cd apps/network npm run dev -- prepare nairobi # interactive wizard if region omitted npm run dev -- prepare --output apps/simulator/data/network.geojson
The prepare command runs the full pipeline: download → extract → filter → export → validate.
Individual commands
| 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.
Simulator API
Base URL:
http://localhost:5010
Simulation control
| 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 |
Vehicles & routing
| 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 |
Map data
| 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 |
Fleets
| 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 |
Jobs
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.
Device faults
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.
Incidents
| 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 |
Geofences
| 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 |
Health
| Method | Path | Description |
|---|---|---|
GET |
/health |
Uptime and subsystem status |
GET |
/metrics |
Prometheus scrape endpoint (prom-client) |
Recording & replay
| 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 |
WebSocket Events
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 |
Adapter Plugins
Base URL:
http://localhost:5011
Runtime configuration API
| 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) |
Source plugins
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 |
Sink plugins
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"}'
Configuration
Simulator (apps/simulator/.env)
| 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 |
Adapter (apps/adapter/.env)
| 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 |
Testing
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 CLI
Simulator 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.
Docker
Pull and run (no build needed)
curl -O https://raw.githubusercontent.com/ivannovazzi/moveet/main/docker-compose.ghcr.yml docker compose -f docker-compose.ghcr.yml up
The 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
Build from source
All three images build from the single workspace-aware root Dockerfile (targets:
simulator, adapter, ui). From the repo root:
docker compose up --build
To 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
Project Structure
| 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.
Contributing
Please read CONTRIBUTING.md before opening a PR.
Security
See SECURITY.md for the vulnerability disclosure policy.
License
MIT © Ivan Novazzi