Samuel
Ojo
← Back to Projects

Hybrid Cloud Reinforcement Learning Telemetry and Local LLM Race Engineer

A cloud dashboard and local LLM race engineer for a reinforcement-learning agent learning to drive Assetto Corsa

Live Demo: telemetry.samuelojo.tech →
Python Streamlit Amazon EC2 (t4g.micro / Graviton) Amazon S3 Apache Parquet Ollama (Llama 3.1) Tailscale PyTorch / SAC RTX 3080 (local compute)

Executive Summary

A reinforcement-learning agent trains on my PC to drive a GT3 car around Monza in Assetto Corsa. Every lap it completes is a telemetry file, and I wanted anyone, technical or not, to be able to follow the learning from anywhere. This project is the cloud platform around that agent. Lap data streams to Amazon S3, and a Streamlit dashboard on a small EC2 instance visualises it live. A Llama 3.1 model translates each run into plain English.

Two constraints shaped the design from the start. All the heavy compute, meaning training and the language model, runs locally on my RTX 3080. The cloud side is deliberately as light as possible, running on a t4g.micro.

The training code is built on the open-source Assetto Corsa Gym benchmark (SAC / DisCor), which I credit and did not write. My work is the platform around it: the incremental uploader, the S3 data layout, the dashboard and its performance, the private LLM link, session scoping, the deploy path and the security fixes along the way.

Design Goals

Run the heavy work locally

Training the agent and running the LLM both happen on my own RTX 3080. There are no GPU instances to rent and no per-token API fees, and the telemetry and AI conversations stay on my own hardware. The cloud only stores and displays results.

Keep the cloud side as light as possible

The dashboard runs on a t4g.micro (2 vCPU, 1 GiB RAM, Arm-based Graviton). It never trains or runs a model. It only reads Parquet files and draws charts, which is why efficiency mattered so much. The instance is also started on demand by the launch script, so it only runs while I’m training.

Make the AI useful to non-technical people

The model is prompted to act as an engineer explaining the run in everyday language. Each debrief is a short, two-sentence summary that compares the run with earlier ones, without telemetry jargon. The chat answers questions in the same plain style, using a rolling memory of the last 10 runs to explain whether the driver is improving, struggling or repeatedly hitting walls.

Architecture & Workflow Decisions

  1. One-command launch: A PowerShell script starts Ollama if it isn’t running, starts the EC2 instance on demand and waits for it, launches the S3 uploader in its own window, then starts training. The cloud side only runs when I’m training.
  2. Incremental telemetry ingestion: The agent writes one Parquet file per lap. A watcher uploads only new or changed files to S3 and skips files still being written, so the dashboard never reads a half-written lap.
  3. Cached, column-pruned reads built for a 1 GiB instance: The Streamlit app on EC2 lists the bucket with a single call, then reads only the Parquet columns each chart needs straight from S3. The file’s modification time is in the cache key, so finished laps are cached and live ones are re-read.
  4. Live-aware session scoping: The dashboard infers from S3 timestamps whether a simulation is currently running. “Current session” is only offered while it is live, and counters reset for each training restart.
  5. Local LLM on the 3080, reached privately over Tailscale: Debriefs and chat come from Llama 3.1 running on my own GPU, so the cloud instance carries no inference load. EC2 reaches it over Tailscale rather than the public internet, streams the reply token by token, and degrades gracefully if the PC is offline. The debrief is written for a non-technical reader.
  6. Pull-based deployment: A watchdog on the server polls the repository, pulls new commits on main and restarts the app. No deploy credential has to be stored in GitHub, and a push to main is the deploy.

The reward (`get_reward`)

The agent is paid for going fast, but only while it stays close to a reference racing line, and it can be charged a small penalty for jerky inputs. It is never told where to brake or turn in. It discovers that by finding out which behaviour keeps the reward stream going.

AssettoCorsaEnv/ac_env.pyPython
r = speed_kmh                                       # faster is better
r *= 1.0 - abs(gap) / 12.0                          # fades to zero 12 m from the racing line
r /= 300.0                                          # normalise
r -= action_difference_penalty * coef               # optional: discourage twitchy inputs

The Dashboard & Its AI Engineer

The dashboard shows the numbers, but most people can’t read a speed trace. So next to the charts sits an AI “race engineer” that explains what the driver is doing in plain English. It has two parts, an automatic debrief for each run and a chat, and both run on Llama 3.1 on my own GPU.

The live Assetto Corsa telemetry dashboard after 144 training runs, showing run statistics, a speed trace, an automated engineer brief and a pit radio chat
The live dashboard after 144 training runs: headline statistics and telemetry traces on the left, with the engineer’s brief and the pit-radio chat on the right.

The AI debrief: one run in two sentences

Pressing Generate AI Debrief summarises the selected run. Python first gathers the facts: lap time, peak and average speed, maximum cornering G, whether the car left the track, and the Monza corner where most off-track frames happened. That corner comes from a lookup table that maps the car’s progress around the lap to named sections of the circuit. It also adds a rolling memory of the previous 10 runs, so the model can say whether things are improving.

The prompt casts the model as an R&D engineer and asks for exactly two sentences in everyday language. It forbids filler openers such as “Sure, here is a summary”, calls the agent “the driver”, requires specific figures, and limits it to the single most important corner. The result is saved on the server beside the run, so it appears instantly next time.

The pit-radio chat

Visitors can ask the engineer anything about the session, such as “is it getting faster?” or “where does it keep crashing?”. Each question builds the same fact block as the debrief, adds a system prompt that asks for short, friendly, non-technical answers with exact numbers and real corner names, and sends the last 10 messages of the conversation so follow-up questions make sense.

Replies stream back token by token, so the first words appear almost immediately rather than after the whole answer is written. The model also stays loaded on the GPU for 30 minutes between questions, so follow-ups don’t pay the model-loading delay again.

Facts from code, words from the model

The model is never asked to calculate anything. Python computes every statistic from the Parquet data, and the prompt hands those figures over as plain text. The model’s job is only to turn them into a readable explanation, and it is instructed to quote the exact figures it was given. The arithmetic stays in code, where it is reliable.

Built to fail gracefully

The model lives on my PC, which is sometimes off. A cached health check every 20 seconds shows Engineer online or Engineer offline under the chat, calls use a timeout long enough to cover a cold model load, and any error becomes a friendly message in the chat instead of a broken page. The chat is also drawn first and never waits on S3, so it is available the moment the page loads.

Example debrief from the live dashboard “The driver is currently learning and improving its driving skills. In the last few runs, the driver’s peak speed has increased from 6.0 m/s to 20.7 m/s, and it has completed a lap in 1 minute and 41 seconds, but it still goes off-track, particularly at Parabolica corner, and its average speed is around 9.0 m/s.”

The Facts the Model Is Given (`build_context`)

Both the debrief and the chat receive the same block, built fresh from the telemetry each time they are used.

dashboard_v2.pyPrompt context
[ACTIVE RUN METRICS]
- Total Training Runs So Far: {len(runs)}
- Completed Lap Time: {lap_str}
- Peak Speed: {speed.max():.1f} m/s (Average Speed: {speed.mean():.1f} m/s)
- Max Cornering G-Force: {g:.2f} G
- Went Off Track: {off_track} (Primary Off-Track Corner: {corner})

[LAST 10 RUNS ROLLING MEMORY]
{history}

Streaming Replies From the Local GPU

Tokens are streamed from Ollama on my PC to the EC2 app and straight to the page, with the model kept warm between questions.

dashboard_v2.pyPython
def stream_reply(messages):
    for chunk in ollama_client().chat(
            model=OLLAMA_MODEL, messages=messages,
            stream=True, keep_alive="30m"):
        yield chunk["message"]["content"]

# the chat sends the system prompt plus the last 10 messages
reply = st.write_stream(stream_reply([system_prompt] + history))

How the Agent Learns

The agent uses reinforcement learning: it drives, gets a score for how well it drove, and gradually adjusts its behaviour to score higher. The algorithm is Soft Actor-Critic (SAC) with an optional DisCor correction. It comes from the open-source Assetto Corsa Gym benchmark (Remonda et al., NeurIPS 2024), whose SAC code builds on toshikwa/discor.pytorch. I didn’t write it. I ran it on my own machine and built the tooling to watch it learn, so I needed to understand it well enough to read what the dashboard was telling me.

The driving loop

Twenty-five times a second the agent receives telemetry, such as speed and position on the track, a short history of recent frames, and look-ahead features describing the curvature and target speed of the road ahead. It outputs three numbers between −1 and 1: steering, throttle and brake. These are relative nudges with rate limits rather than absolute positions, so the wheel can’t snap from lock to lock in one tick. An episode ends if the car leaves the track, or if it stays slower than 1 m/s for 10 seconds.

Soft Actor-Critic

An actor network picks the controls as a probability distribution, so sampling from it makes the car try slightly different things. Two critic networks predict how much reward will follow from a situation and an action, and the lower of the two estimates is used so the agent doesn’t become over-optimistic. Slow-moving copies of the critics are nudged 0.5% toward the real ones each step, which keeps the learning targets stable. An entropy bonus, tuned automatically, rewards staying a little unpredictable: the agent explores widely early on and commits as it learns.

DisCor (optional)

Learned values are built from other learned values, so errors can compound. DisCor trains extra error networks that estimate how unreliable each training target is, then down-weights the unreliable samples so the critics concentrate on data they can trust. It is switched on with --algo discor, and the default run uses plain SAC.

The training schedule

The first 2,000 steps are random, only to fill a replay buffer that can hold up to 8 million steps. From then on the agent learns while it drives, from random batches of 128 remembered steps. Because it reuses old experience, it learns from far less real-time simulation than methods that discard data after one use. It looks 3 steps ahead when scoring decisions, with a discount of 0.992 that reaches about five seconds into the future. Checkpoints are saved regularly, and evaluation uses the mean action with no exploration noise. Every episode is written out as a Parquet file, which is where my pipeline begins.

Connecting the Agent to Assetto Corsa with vJoy

Assetto Corsa is built for human players. It has no official way for a program to send steering and pedal commands. The benchmark’s answer is vJoy, a virtual joystick driver for Windows. To the game, vJoy looks like a real controller. To my code, it is a set of axes that can be set from Python. Getting that bridge working on my own machine was a prerequisite for everything else on this page.

1. PolicyOutputs steer, throttle and brake each step
2. Gym clientThe training process, sending a small JSON control message
3. vJoyVirtual joystick whose axes are set from Python
4. Assetto CorsaReads vJoy as its active controller; the car responds
↻ The sensors_par plugin inside the game streams telemetry back to the client over TCP, and the loop repeats.

Reading the car: a plugin and sockets

A small Python app called sensors_par runs inside Assetto Corsa. It opens TCP sockets: one that streams the car’s state at 25 Hz, downsampled from the game’s 50 Hz tick, and one for simulation management, such as resetting the car and fetching track and car information. The Gym environment connects as a client, so the agent’s observations arrive over a local network connection.

Driving the car: vJoy

Each step, the client converts the agent’s three outputs into joystick axis positions: steering from the range −1 to 1 onto 0 to 2, and each pedal onto 0 to 1. It then writes them to the vJoy device through the driver’s DLL with ctypes. Assetto Corsa is set to use the vJoy device as its controller, so as far as the game is concerned, a steering wheel and pedals are being moved.

What the setup involved

Installing the vJoy driver, copying the plugin into the game’s apps/python folder, installing the controller preset and socket libraries, and installing the Custom Shaders Patch through Content Manager so the car can be restarted in place. Then, in the game: enable the app, select vJoy as the controller, run a Hotlap session, switch ABS, traction and stability control off with automatic gearbox on, and cap the frame rate at 50 FPS to match the 50 Hz tick.

Everything on one machine

My PC runs the game, the training process and the language model, all on the same RTX 3080. vJoy commands are therefore applied locally by the training process (vjoy_executed_by_server: False, 50 Hz sampling). The benchmark also supports running the game on one machine and the agent on another, with vJoy applied on the game machine at 100 Hz, which I didn’t need.

Turning Agent Outputs into Joystick Axes (`car_control.py`, `vjoy.py`)

The agent works in the range −1 to 1. The virtual joystick expects positive axis ranges, so every command is shifted and clamped before it reaches vJoy.

car_control.py and vjoy.pyPython
self.steer = self.steer_cmd + 1                    # [-1, 1] -> [0, 2], clamped
self.acc   = (self.pedal_cmd + 1) / 2              # [-1, 1] -> [0, 1], clamped
self.brake = (self.brake_cmd + 1) / 2              # [-1, 1] -> [0, 1], clamped

# vJoy driver DLL, loaded with ctypes
self.dll = ctypes.CDLL("C:\\Program Files\\vJoy\\x64\\vJoyInterface.dll")
self.dll.AcquireVJD(self.reference)              # claim the virtual joystick

Infrastructure & Code Highlights

Incremental, Safe Uploads (`sync_outputs_v2.py`)

Uploads each lap exactly once, ignores files that are still being written, and reports failures instead of swallowing them. Keeping S3 timestamps meaningful is what lets the dashboard tell whether a run is live.

sync_outputs_v2.pyPython
if time.time() - st.st_mtime < SETTLE_SECONDS:
    continue                       # still being written

sig = (st.st_size, st.st_mtime_ns)
if uploaded.get(path) == sig:
    continue                       # unchanged since last upload

key = "outputs/" + os.path.relpath(path, LOCAL_DIR).replace(os.sep, "/")
s3.upload_file(path, BUCKET_NAME, key)
uploaded[path] = sig

Column-Pruned, Cache-Aware S3 Reads (`dashboard_v2.py`)

Reads only the columns a chart needs and keys the cache on the file’s modification time. Finished laps are served from memory, and a lap still being written is refreshed.

dashboard_v2.pyPython
def read_cols(path, wanted):
    with get_fs().open(path, "rb") as f:
        pf = pq.ParquetFile(f)
        names = set(pf.schema_arrow.names)
        cols = [c for c in wanted if c in names]
        return pf.read(columns=cols).to_pandas()

@st.cache_data(ttl=300, show_spinner=False, max_entries=64)
def load_run(path, mtime):
    # mtime is part of the cache key: a run still uploading is re-read, finished runs are not
    return read_cols(path, NEEDED_COLS)

The learning
journey.

Recordings from training, in the order they were captured, from the first steps to a full lap. Click a clip to pause it.

Pause
01

The agent’s first steps

The earliest footage in the series: the first time the model is let loose on Monza behind the wheel of a GT3 car. The sim flags a track cut early on.

Monza · BMW Z4 GT3 · 23 s

Pause
02

Learning to drive in a straight line

Before corners come the basics. The agent works on holding a steady line down a straight.

Monza · BMW Z4 GT3 · 28 s

Pause
03

Unsure about a corner

A corner the agent hasn’t worked out yet.

Monza · BMW Z4 GT3 · 36 s

Pause
04

First attempt at Turn 1

The first run at Turn 1. The sim flags a track cut, one of the mistakes the agent has to learn to avoid.

Monza · BMW Z4 GT3 · 33 s

Pause
05

Trying Turn 1

Another go at Turn 1 as the agent keeps iterating.

Monza · BMW Z4 GT3 · 35 s

Pause
06

Learning to stay off the grass

A short clip of the agent learning to keep the car on the tarmac.

Monza · BMW Z4 GT3 · 6 s

Pause
07

Ascari, attempt 1

The first attempt at the Ascari chicane.

Monza · BMW Z4 GT3 · 20 s

Pause
08

Ascari, attempt 2

A second run through Ascari.

Monza · BMW Z4 GT3 · 22 s

Pause
09

Ascari, attempt 3

A third run through Ascari.

Monza · BMW Z4 GT3 · 13 s

Pause
10

Ascari, attempt 4

The fourth attempt at the chicane.

Monza · BMW Z4 GT3 · 11 s

Pause
11

Turn 1, attempt 2

Back to Turn 1 for another go.

Monza · BMW Z4 GT3 · 20 s

Pause
12

A full lap

A complete lap of Monza, start to finish, driven by the trained agent.

Monza · BMW Z4 GT3 · full lap

Key Technical Challenges Resolved

A Dashboard That Couldn’t Keep Up

The original dashboard re-listed the bucket and re-read whole Parquet files on every refresh, and the AI chat sat behind the slow work so it never appeared. I collapsed the listing into one cached call, pruned reads to the needed columns, and moved heavy statistics into a background thread pool. I also rendered the chat first so it appears immediately. On a 1 GiB instance there was no headroom to brute-force it, so the fix was efficiency, which is why it now runs comfortably on the smallest Graviton size.

The Hidden Restart Loop

Even after the code fixes, the live site kept resetting. The cause was on the server: the deploy watchdog tracked a master branch while the repository used main, so every pull failed and the app restarted every 30 seconds. I found it by comparing process uptimes with the watchdog log, corrected the script, and confirmed the app then stayed up.

An Uploader That Broke “Live” Detection

The first sync script re-uploaded every file every 10 seconds, which saturated the upload link and reset every object’s last-modified time, so the dashboard could not tell which run was newest or whether a simulation was running. Switching to change-detected, settle-aware uploads restored trustworthy timestamps and made the live indicator possible.

Secret Hygiene & Deployment Credentials

A private key had ended up in the repository. I rotated the EC2 key pair, replaced the server’s authorized keys, audited login history and found no unauthorised access, and added ignore rules so keys can’t be committed again. I then retired the CI step that deployed over SSH in favour of the pull-based watchdog, so no deploy key is stored in GitHub at all.

Splitting Compute Between a Local GPU and a Tiny Cloud Node

Keeping inference on my own 3080 while serving a public dashboard from AWS meant connecting the two safely. Ollama was only listening on loopback, so I rebound it and linked the machines over Tailscale instead of exposing a port to the internet. The dashboard checks the model’s status, streams replies, and carries on working if the PC is switched off.

What I’d Do Next

Run the dashboard and watchdog as systemd services so they survive a reboot. Define the S3 bucket, EC2 instance and security group in Terraform. Restrict SSH in the security group to a single trusted address, or remove it in favour of SSM Session Manager.