Moveet

July 31, 2026 ยท View on GitHub

CI License: MIT Node.js TypeScript Docker

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

๐Ÿ—บ Road-network agnosticIngests any GeoJSON/OSM-derived road graph โ€” swap the file to simulate a different city
๐ŸŒ Network CLIapps/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* pathfindingHaversine heuristic over bidirectional road segments; respects turn restrictions, roundabouts, and road-class access rules; incident-aware route cache
๐Ÿš— Vehicle typesFive 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 realismBPR congestion model (flow/capacity), time-of-day rush-hour/night demand multipliers, traffic-signal intersection delays, surface-smoothness speed factors
๐ŸŽจ Custom map rendererdeck.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 WebSocket100 ms batched broadcast with backpressure handling; streams vehicle positions, routes, heat zones, incidents, geofence events, fleet events, and replay frames
๐Ÿ”ฅ Heat zonesContour density map (green โ†’ red, 50 thresholds) derived from road-network intersection density
๐Ÿ”ฒ GeofencingDraw custom polygons on the map; monitor vehicles crossing zone boundaries; enter/exit events broadcast in real time
โš ๏ธ Incidents & reroutingOperator-created road incidents trigger live A* rerouting for all affected vehicles
๐ŸŽฌ Recording & replayNDJSON session recording; replay with pause, seek, and 1ร—/2ร—/4ร— speed controls and interpolated progress bar
๐Ÿš˜ Breadcrumb trailsPer-vehicle position history rendered as fading path overlays on the map
๐Ÿšฆ Fleet managementGroup vehicles into named, colour-coded fleets; assign/unassign at runtime
๐Ÿ“ฆ Job dispatch lifecyclePickup/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 injectionPer-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 searchTypeahead combining road names and points of interest; dispatches selected vehicles to result
๐Ÿ–ฅ Operator UIState-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 pluginsHot-swappable source and sink plugins; configure via env vars or REST API at runtime
๐Ÿ“Š ObservabilitySimulator 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-outWebSocket 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
ServiceURL
Dashboardhttp://localhost:5012
Simulator APIhttp://localhost:5010
Adapter APIhttp://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 derived WebSocketMessage, and WsDataMessageType) 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 the correlationId and errorHandler Express middleware, a pino logger factory with secret redaction, and a retrying httpClient.

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

CommandDescription
network downloadDownload country PBF from Geofabrik (cached after first run)
network extractClip a bounding box from the country PBF using osmium
network filterKeep only drivable road classes from the extracted PBF
network exportConvert filtered PBF to GeoJSON via osmium
network validateRun 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

MethodPathDescription
GET/statusSimulation state (running, ready, interval)
POST/startStart simulation (accepts options body)
POST/stopStop simulation
POST/resetReset to initial state
GET/optionsGet current simulation options
POST/optionsUpdate simulation options

Vehicles & routing

MethodPathDescription
GET/vehiclesList all vehicle DTOs
POST/directionDispatch one or more vehicles to a destination
GET/directionsGet active direction assignments
POST/find-nodeSnap a lat/lng to the nearest graph node
POST/find-roadSnap a lat/lng to the nearest road edge
POST/searchFull-text POI search

Map data

MethodPathDescription
GET/networkFull road-network GeoJSON
GET/roadsRoad segments GeoJSON
GET/poisPoints of interest
GET/heatzonesCurrent heat zone features
POST/heatzonesRegenerate heat zones

Fleets

MethodPathDescription
GET/fleetsList all fleets
POST/fleetsCreate a fleet
DELETE/fleets/:idDelete a fleet
POST/fleets/:id/assignAssign vehicles to a fleet
POST/fleets/:id/unassignUnassign 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.

MethodPathDescription
GET/jobsList every job on the board
POST/jobsCreate a job and assign it
POST/jobs/:id/assignRe-assign a job that has not been picked up yet
POST/jobs/:id/cancelCancel a live job and release its vehicle
DELETE/jobs/:idRemove 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.

MethodPathDescription
GET/faultsConfiguration plus a live device-state snapshot
POST/faultsUpdate the configuration at runtime
GET/faults/statusLive per-device state and trigger counts
POST/faults/resetClear latched device state, keeping the config
PUT/faults/vehicles/:idSet one vehicle's fault profile
DELETE/faults/vehicles/:idRemove 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

MethodPathDescription
GET/incidentsList active incidents
POST/incidentsCreate an incident (triggers rerouting)
DELETE/incidents/:idClear an incident
POST/incidents/randomCreate a random incident

Geofences

MethodPathDescription
GET/geofencesList all geofence zones
POST/geofencesCreate a geofence (GeoJSON polygon + metadata)
GET/geofences/:idGet a geofence
PUT/geofences/:idUpdate a geofence
DELETE/geofences/:idDelete a geofence
PATCH/geofences/:id/toggleEnable / disable a geofence

Health

MethodPathDescription
GET/healthUptime and subsystem status
GET/metricsPrometheus scrape endpoint (prom-client)

Recording & replay

MethodPathDescription
POST/recording/startStart recording the session
POST/recording/stopStop recording and save NDJSON file
GET/recordingsList saved recordings
POST/replay/startLoad and start a recording replay
POST/replay/pausePause replay
POST/replay/resumeResume replay
POST/replay/stopStop replay, return to live mode
POST/replay/seekSeek to a timestamp (ms)
POST/replay/speedSet playback speed multiplier
GET/replay/statusCurrent replay state

WebSocket Events

Connect to ws://localhost:5010. On connect the server sends a status and options snapshot.

EventDirectionPayload
vehiclesserver โ†’ clientArray of VehicleDTO (position, speed, heading, fleetId)
statusserver โ†’ clientSimulationStatus (running, ready, interval)
optionsserver โ†’ clientCurrent StartOptions
heatzonesserver โ†’ clientHeatZoneFeature[]
directionserver โ†’ clientActive route + ETA, with a reason (dispatch/waypoints/random/reroute)
waypoint:reachedserver โ†’ clientVehicle reached a waypoint
route:completedserver โ†’ clientVehicle completed its full route
resetserver โ†’ clientSimulation was reset
fleet:createdserver โ†’ clientNew fleet
fleet:deletedserver โ†’ clientFleet removed
fleet:assignedserver โ†’ clientVehicles assigned to fleet
job:createdserver โ†’ clientNew job, queued
job:updatedserver โ†’ clientJob lifecycle transition (assignment, status, SLA flag)
job:sla-breachserver โ†’ clientJob passed its SLA deadline unfinished
job:deletedserver โ†’ clientJob removed from the board
incident:createdserver โ†’ clientNew incident + affected vehicles
incident:clearedserver โ†’ clientIncident resolved
vehicle:reroutedserver โ†’ clientVehicle rerouted around incident
geofence:eventserver โ†’ clientVehicle entered or exited a geofence zone
faults:configserver โ†’ clientDevice fault-injection configuration changed

Adapter Plugins

Base URL: http://localhost:5011

Runtime configuration API

MethodPathDescription
GET/configCurrent source + sinks config
POST/config/sourceSwap the active source plugin
POST/config/sinksReplace the active sink list
DELETE/config/sinks/:typeRemove one sink
GET/vehiclesVehicles from the current source
GET/fleetsFleets from the current source
POST/syncPush a position update through all sinks
GET/healthHealth check
GET/metricsPrometheus 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
PluginKey config fields
staticcount (default 20)
graphqlurl, query, token, headers, vehiclePath, maxVehicles
resturl, token, headers, vehiclePath, maxVehicles
mysqlhost, port, user, password, database, query
postgreshost, 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)

VariableDefaultDescription
PORT5010HTTP / WebSocket port
GEOJSON_PATH./data/network.geojsonPath to the road-network GeoJSON file
VEHICLE_COUNT70Number of vehicles to spawn
UPDATE_INTERVAL500Position broadcast interval (ms)
MIN_SPEED20Minimum vehicle speed (km/h)
MAX_SPEED60Maximum vehicle speed (km/h)
ACCELERATION5Acceleration rate (km/h per tick)
DECELERATION7Deceleration rate (km/h per tick)
TURN_THRESHOLD30Bearing change (ยฐ) that triggers slowdown
SPEED_VARIATION0.1Random speed jitter factor [0, 1]
HEATZONE_SPEED_FACTOR0.5Speed multiplier inside heat zones
ADAPTER_URL(empty)Enable adapter sync (e.g. http://localhost:5011)
SYNC_ADAPTER_TIMEOUT5000Adapter sync timeout (ms)
WS_TRANSPORTinprocessWebSocket fan-out transport: inprocess or redis
REDIS_URL(empty)Redis bus URL; required when WS_TRANSPORT=redis

Adapter (apps/adapter/.env)

VariableDefaultDescription
PORT5011HTTP port
SOURCE_TYPEstaticActive 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

PackagePathTechPort
networkapps/network/Node.js 26 ยท Commander ยท osmium-tool (local install)CLI
simulatorapps/simulator/Node.js 26 ยท Express 4 ยท ws 8 ยท Turf.js 75010
adapterapps/adapter/Node.js 26 ยท Express 45011
uiapps/ui/React 19 ยท deck.gl 9 ยท Vite ยท TypeScript 6.0 ยท Tailwind CSS v45012

Shared workspace packages consumed by the apps:

PackagePathRole
@moveet/shared-typespackages/shared-types/Cross-app contracts: WebSocket message union + REST request/response DTOs
@moveet/server-kitpackages/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