<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nishchal Singh</title>
    <description>The latest articles on DEV Community by Nishchal Singh (@nishchal-29).</description>
    <link>https://dev.to/nishchal-29</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3554529%2F8bdb6ecd-00c2-4786-b153-335664538555.jpg</url>
      <title>DEV Community: Nishchal Singh</title>
      <link>https://dev.to/nishchal-29</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nishchal-29"/>
    <language>en</language>
    <item>
      <title>GhostRunner: Turning Every Street into a Story</title>
      <dc:creator>Nishchal Singh</dc:creator>
      <pubDate>Sun, 11 Oct 2026 21:21:07 +0000</pubDate>
      <link>https://dev.to/nishchal-29/ghostrunner-turning-every-street-into-a-story-50i2</link>
      <guid>https://dev.to/nishchal-29/ghostrunner-turning-every-street-into-a-story-50i2</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GhostRunner&lt;/strong&gt;: &lt;em&gt;An open-source, local-first audio drama engine that turns the real streets under your feet into the set of a thriller.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;GhostRunner is a generative audio companion for runners and walkers. You pick a genre (zombie escape, cyberpunk courier, spy exfiltration), a distance and an intensity. GhostRunner plans a route over real streets, finds the real landmarks along it, and writes an audio drama in which &lt;strong&gt;your surroundings are the set&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Turn right onto Oak Street. See that old brick building on your left? That's where the drop is hidden."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then you put your phone away and run. The right line plays at the right place, in a radio-dispatcher voice, just before the turn it describes. If you speed up, the tone turns urgent and a pursuer closes in. If you flag, the story hands you a breather. If you take a wrong turn, the dispatcher notices, re-routes you and rewrites the rest of the mission.&lt;/p&gt;

&lt;p&gt;Three things make it more than a prompt wrapped around a map:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is spatially exact.&lt;/strong&gt; Every beat is anchored to a distance along the route, so a line about a turn plays before the turn, not after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is grounded.&lt;/strong&gt; The narrator can only name landmarks that exist in OpenStreetMap along your route. A validator rejects anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is fast, because nothing is generated while you run.&lt;/strong&gt; The story is compiled and voiced ahead of you. Playing a line is a lookup, not an inference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it runs on a laptop with a &lt;strong&gt;4 GB GPU&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/18aRVKpOGKQFeASiO6tCEzBHtIs-Ui2ch/view?usp=drive_link" rel="noopener noreferrer"&gt;Video Link&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F59qpjdeqrfwnh7u8bknm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F59qpjdeqrfwnh7u8bknm.png" alt=" " width="800" height="561"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3y0z7k0izq1mqb0zlij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3y0z7k0izq1mqb0zlij.png" alt=" " width="707" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/Nishchal-29/GhostRunner" rel="noopener noreferrer"&gt;https://github.com/Nishchal-29/GhostRunner&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;engine/     routing, chainage math, corridor POIs, tracker, trigger engine, pacing, safety
pipeline/   LangGraph compile_graph and runtime_graph
llm/        Ollama client (structured output)
audio/      TTS providers and clip cache
client/     mobile-first PWA (vanilla JS, Web Audio API, Leaflet)
tools/      LLM benchmark, GPX replayer, demo launcher
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;conda activate agents-env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;                 &lt;span class="c"&gt;# Valhalla (routing) + Postgres&lt;/span&gt;
python tools/setup_valhalla.py
ollama serve
python tools/demo.py                 &lt;span class="c"&gt;# compiles a demo loop and starts the app&lt;/span&gt;
&lt;span class="c"&gt;# open http://localhost:8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem: latency and trust
&lt;/h3&gt;

&lt;p&gt;A story that plays late is wrong, and a story that invents landmarks is worse. A runner moves roughly 3 m/s. An LLM on a small GPU takes seconds per line, and text-to-speech adds more. If you generate reactively, the line about the clocktower arrives after you have passed it. And small models hallucinate confidently.&lt;/p&gt;

&lt;p&gt;So I split the system by &lt;em&gt;when&lt;/em&gt; work happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;COMPILE (before and during the run)
Plan → Valhalla route → Overpass corridor POIs → route model (chainage)
     → Director (LLM, 1 call) → Beat planner → Beat writer (LLM, 3 variants/beat)
     → Validator → TTS pre-render (rolling window)

RUN (every tick, no LLM on the happy path)
GPS → pace classifier (TabPFN) → locate on route → trigger check → variant select → play
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two LangGraph graphs: &lt;code&gt;compile_graph&lt;/code&gt; carries the heavy work, &lt;code&gt;runtime_graph&lt;/code&gt; is a handful of deterministic nodes that finish in milliseconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The route is a coordinate system
&lt;/h3&gt;

&lt;p&gt;I use &lt;strong&gt;Valhalla&lt;/strong&gt; with pedestrian costing for routing. It returns an encoded polyline plus maneuvers with street names. I decode it (precision 6), project into the local &lt;strong&gt;UTM&lt;/strong&gt; zone with &lt;code&gt;pyproj&lt;/code&gt;, and densify to one point every ~5 m.&lt;/p&gt;

&lt;p&gt;Everything on the route is then addressed by &lt;strong&gt;chainage&lt;/strong&gt;: meters along the path. A turn is at 1,240 m. A fountain is at 1,310 m, on the left. This one choice does most of the work in the project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Triggers are &lt;em&gt;distance-based&lt;/em&gt;, so a pre-compiled beat stays valid at any pace. Pace only selects which variant plays.&lt;/li&gt;
&lt;li&gt;Side-of-route is a cross product in metric coordinates: with tangent &lt;strong&gt;T&lt;/strong&gt; and landmark vector &lt;strong&gt;L&lt;/strong&gt;, the sign of &lt;code&gt;Tx·Ly − Ty·Lx&lt;/code&gt; says left or right.&lt;/li&gt;
&lt;li&gt;Runtime snapping searches only &lt;code&gt;[last_chainage − 30 m, last_chainage + 200 m]&lt;/code&gt; of the densified path, so loops and out-and-back routes can't snap the runner onto the wrong leg.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some details I designed around, because they fail silently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Valhalla's &lt;code&gt;begin_shape_index&lt;/code&gt; is relative to each leg, so multi-leg routes need a running offset or every maneuver lands in the wrong place.&lt;/li&gt;
&lt;li&gt;Street detours make loops overshoot (a triangle at radius &lt;code&gt;D/4&lt;/code&gt; is ~0.93·D before detours). I generate three candidate loops from waypoints at 60°/180°/300°, score them by distance error and self-overlap, and recalibrate the radius once if the first pass is off by more than 15%.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Real landmarks, not imagined ones
&lt;/h3&gt;

&lt;p&gt;One &lt;strong&gt;Overpass&lt;/strong&gt; query along a simplified (≤150 point) buffer of the route pulls historic sites, artwork, fountains, places of worship, named buildings, parks, waterways, bridges and steps. I score each by kind and distance from the path, then apply greedy non-maximum suppression so no two story landmarks sit within 150 m of each other. Results are cached per H3 cell in Postgres, so a second run makes no Overpass calls.&lt;/p&gt;

&lt;p&gt;OSM coverage varies. When a route is landmark-poor, the story degrades gracefully into street names, turns, parks and water, and a hand-curated &lt;code&gt;demo_pois.json&lt;/code&gt; can add landmarks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The LLM narrates; it doesn't orchestrate
&lt;/h3&gt;

&lt;p&gt;Small models are unreliable at tool calling, and every tool-call round trip costs latency a runner can't spare. So all decisions are plain Python: which beat goes where, which landmark, what maneuver text, how far ahead to trigger. The LLM does two jobs only:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Director (one structured call):&lt;/strong&gt; produces three acts with chainage ranges, 3-5 objective landmarks (drop, checkpoint, extraction), an antagonist premise, and a &lt;strong&gt;story bible&lt;/strong&gt; (callsigns, motifs, tone).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beat writer:&lt;/strong&gt; for each beat, writes &lt;strong&gt;three variants&lt;/strong&gt; (cruising, accelerating, exhausted) of at most 30 words, from a structured brief. Each brief carries the story bible, a rolling summary of recent beats, and the landmarks already passed, so lines can call back to earlier ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A &lt;strong&gt;validator&lt;/strong&gt; enforces word count, an allowlist of landmark names, and safety rules (no crossing roads, no trespassing). On failure it retries once with the violation named, then falls back to a deterministic template.&lt;/p&gt;

&lt;p&gt;On a 4 GB GPU I chose the model by benchmarking rather than reputation: Gemma 2 2B as the baseline against Gemma 4 E2B/E4B and Qwen3 4B, measuring time-to-first-token, tokens per second, and constraint adherence on ten fixed briefs. The winner is &lt;code&gt;[model]&lt;/code&gt;, at &lt;code&gt;[x]&lt;/code&gt; tok/s with &lt;code&gt;[y]&lt;/code&gt;% constraint adherence. &lt;code&gt;[fill in from bench_llm.py]&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Trigger timing
&lt;/h3&gt;

&lt;p&gt;For each beat, the line should &lt;em&gt;finish&lt;/em&gt; as the runner reaches the anchor. The engine dead-reckons position between 3-5 s GPS fixes and fires a beat when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;due&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chainage_est&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration_s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;est_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;lead_m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dur&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# finish speaking as the runner arrives
&lt;/span&gt;    &lt;span class="n"&gt;start_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor_chainage_m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lead_m&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;start_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;chainage_est&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_chainage_m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Durations come from the &lt;strong&gt;measured length of the rendered audio clip&lt;/strong&gt;, so the lead distance is accurate to the clip. A priority queue (navigation &amp;gt; checkpoint &amp;gt; landmark &amp;gt; challenge &amp;gt; tension) plays one beat at a time, enforces a minimum gap between non-navigation beats, and skips expired ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pace awareness with TabPFN
&lt;/h3&gt;

&lt;p&gt;I treat pace as a small tabular classification problem. Each tick builds features: rolling speed over 30 s / 2 min / 5 min, speed delta and variance, grade, time since last stop, and current speed relative to a personal baseline from the first two minutes. &lt;strong&gt;TabPFN&lt;/strong&gt; classifies the runner as &lt;code&gt;CRUISING&lt;/code&gt;, &lt;code&gt;ACCELERATING&lt;/code&gt;, &lt;code&gt;EXHAUSTED&lt;/code&gt; or &lt;code&gt;STOPPED&lt;/code&gt;, with a 3-tick hysteresis filter so the story doesn't flip-flop. It runs on CPU, throttled to once every 5 s, leaving all VRAM to the LLM.&lt;/p&gt;

&lt;p&gt;One honest caveat: there is no labeled dataset for this, so the training labels come from a transparent heuristic over simulated run profiles. TabPFN's value here is a fast, calibrated, retrain-in-seconds classifier. If it fails to load, a rules classifier takes over automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Voice
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt; voices every line, rendered ahead of the runner through a rolling window of the next three triggers. Clips are content-addressed (&lt;code&gt;sha256(voice + text)&lt;/code&gt;), so repeated lines are free. Reactive moments (off route, pick up the pace, checkpoint countdown) use a pre-rendered &lt;strong&gt;tier-C bank&lt;/strong&gt; that plays from disk in under 50 ms while any dynamic line renders in the background. On the client, Web Audio adds a bandpass-and-distortion radio effect, and the ambient drones and sirens are synthesized procedurally, so there are no audio assets to ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  Product details that make it feel real
&lt;/h3&gt;

&lt;p&gt;A mobile-first PWA with a briefing screen (route preview, premise, objectives, safety acknowledgement, headphone check), a countdown, a run HUD, and a debrief with a shareable card. Every compiled mission is saved, so any mission &lt;strong&gt;replays with the LLM, router and TTS all switched off&lt;/strong&gt;. Wi-Fi at a venue is the one thing a demo can't count on, so the whole experience can run without it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;p&gt;Python, FastAPI, WebSockets, LangGraph (Postgres checkpointer), Valhalla, Overpass/OpenStreetMap, shapely, pyproj, H3, Ollama with Gemma, TabPFN, ElevenLabs, Sentry, Postgres, vanilla JS with the Web Audio API and Leaflet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;GhostRunner is built almost entirely on open pieces, and that is not incidental. It is why the product can exist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open data makes every street a stage.&lt;/strong&gt; OpenStreetMap is a community-built map of the world. Valhalla and Overpass turn it into routes and landmarks. A proprietary map would have limited what I could query, cache and replay, and would have made the offline-replay mode impossible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open weights make location privacy real.&lt;/strong&gt; Your GPS trail is among the most sensitive data a phone produces. Because Gemma runs through Ollama on my own machine, the story is written locally, and the app doesn't send your coordinates to a model provider. Raw trails are deleted when a session ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open weights make the cost structure work.&lt;/strong&gt; A run generates dozens of lines. With a local model, a 5 km mission costs electricity, not tokens. That is the difference between a toy and something you can offer to anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small open models reward good engineering.&lt;/strong&gt; A 2-4B model won't improvise a perfect story, which pushed me toward a design that is better for it: deterministic structure, grounded briefs, a validator and pre-generation. Constraints produced the architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open tooling compounds.&lt;/strong&gt; LangGraph, TabPFN, Ollama and Valhalla let one person assemble in days what would otherwise be a platform team's job, and anyone can fork GhostRunner to add a genre, a city, or a language.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of ElevenLabs.&lt;/strong&gt; Every line is voiced in a per-genre radio-dispatcher voice, with an optional second voice for the antagonist. Rolling pre-rendering, content-hash caching and measured clip durations are what let the audio land at the right place, and a pre-rendered reactive bank gives sub-50 ms responses to off-route and pace events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of TabPFN.&lt;/strong&gt; A TabPFN classifier turns streaming telemetry features into a runner state (cruising / accelerating / exhausted / stopped) that selects the story variant in real time, with hysteresis and a rules fallback.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
