A physically-grounded driving simulator that teaches real-world driving skills through force feedback.
DriveCoach pairs a Logitech G920 racing wheel with a physics-accurate Honda Civic simulation to teach learners how to actually feel a car — the self-aligning torque that tells you the tires are gripping, the lightening that warns you're about to understeer, the viscous resistance of real power steering. It scores you against the California Vehicle Code in real time, coaches you through corners, and gives you an analytics report when you're done.
Built in a single weekend for a hackathon. One Python file. No game engine. Runs natively on Apple Silicon.
38,000 people die on US roads every year. The #1 cause is driver error — not mechanical failure, not weather, not road design. New drivers learn by reading a handbook and circling a parking lot. They never feel what a car does at 65 mph in a curve until they're on a real highway with real consequences.
Professional racing drivers train on simulators with force feedback wheels because steering feel is the primary channel through which a driver perceives grip. When you feel the wheel go light in a corner, that's the front tires telling you they're running out of traction — a 200-millisecond warning before the car pushes wide. No textbook teaches this. No parking-lot session covers this. But a force feedback simulator can.
DriveCoach brings that same principle to driver education:
- Ghost lap mode: The wheel physically moves your hands through the correct steering inputs for each corner while you feel the forces. Motor-memory learning — the same technique fighter pilots use in simulator training.
- Your turn mode: You drive the same road with real physics FFB. The car rewards smooth inputs with a planted, heavy wheel and punishes overdriving with a lightening feel at the limit.
- Real-time scoring: Every red light, every stop sign, every instance of tailgating or lane departure is caught and penalized — exactly like a DMV examiner sitting in the passenger seat.
The entire simulator is companion/drive3d.py — ~3,300 lines of Python. No Unity, no Unreal, no Godot. The rendering uses pygfx (a wgpu-based graphics library) which compiles directly to Metal on macOS. This means:
- Zero OpenGL dependency — critical on Apple Silicon where OpenGL is deprecated and crashes on some configurations
- Native GPU pipeline — wgpu → Metal with no translation layer
- ~60 FPS on a MacBook with a procedural city, cockpit model, and 16 NPC vehicles
The Logitech G920 on macOS is essentially undocumented. There is no official SDK, no DirectInput (that's Windows), and no Linux ff-memless layer. We wrote a complete FFB driver from scratch:
companion/wheel.py — 600 lines that implement:
-
Input via GameController.framework (PyObjC) — macOS sees the G920 as a
_GCLogitechRacingWheeland parses steering angle (900-degree range), three analog pedals, paddle shifters, and 11 buttons through the native HID stack. -
Output via HID++ 2.0 protocol over
IOHIDDeviceSetReport— ported from the Linux kernel driver (hid-logitech-hidpp.c). At connect time:1. Root GET_FEATURE(0x8123) → discovers FFB feature index (0x0b on G920) 2. RESET_ALL → clears stale effects from prior sessions 3. DOWNLOAD_EFFECT(CONSTANT | AUTOSTART, force=0) → uploads a constant-force effect into the hardware slot the device assignsAfter handshake,
set_torque(nm)converts Nm to int16 and a 100 Hz output loop re-sends the effect with the new force value. The G920 has a 200ms hardware watchdog — if we stop sending packets, the motor zeros out automatically (safety feature). Our output loop re-sends even when the value hasn't changed to keep the watchdog fed. -
macOS-specific quirk handling — the G920 answers HID++ long reports (0x11) on the very-long report ID (0x12). Standard HID libraries don't expect this. We register a raw input-report callback and parse both report types to correctly receive handshake replies.
The car physics in SimCar.update() implements a kinematic bicycle model with:
-
Friction-circle grip limiting — longitudinal and lateral forces share a tire's total grip budget. Hard braking reduces available cornering grip (and vice versa). This produces natural understeer: ask for more yaw rate than the front tires can deliver, and the car pushes wide.
-
Load transfer — braking shifts weight to the front axle (increasing front grip, decreasing rear), acceleration shifts it back. CG height, wheelbase, and weight distribution are tuned to a real Honda Civic (62/38 front/rear, 0.55m CG height).
-
Engine model with gear ratios — six-speed manual transmission with realistic torque curves per gear. Engine braking in higher gears, rev-matching feel through the pedals.
-
Aerodynamic drag + rolling resistance —
F_drag = C_rr + C_d * v^2. At partial throttle, drag balances drive force and the car settles at a realistic cruise speed. 30% throttle in 4th gear holds ~45 mph — just like a real Civic.
This is the core innovation. Most sim-racing FFB implementations use a centering spring — a force proportional to wheel angle that pushes the wheel back to center. This is physically wrong and creates a motor-position feedback loop that oscillates ("wobble").
DriveCoach uses the same force model as a real hydraulic power steering system:
# The ONLY forces sent to the G920 motor:
SAT_GAIN = 0.70 # Self-aligning torque — from tire lateral force
RF_VISCOUS_DAMP = 0.35 # Viscous damping — from hydraulic steering fluid
RF_COULOMB_NM = 0.06 # Coulomb friction — dry rack friction for textureSelf-Aligning Torque (SAT) — When the front tires generate lateral force (cornering), the force acts behind the wheel's steering axis through the pneumatic trail. This creates a torque that tries to straighten the wheel. The magnitude is proportional to cornering force, and critically, it fades as the tire approaches its grip limit (pneumatic trail collapse). This is the "wheel goes light" feeling that warns a real driver they're about to lose grip:
front_lat_per_mass = self.lat_accel * (L_R / WHEELBASE)
pt_scale = max(0.0, 1.0 - min(1.0, understeer * SAT_TRAIL_COLLAPSE))
sat = -SAT_GAIN * front_lat_per_mass * pt_scaleViscous Damping — Opposes wheel velocity, not position. This is physically what hydraulic power steering fluid does — it resists rapid wheel movements while allowing slow, deliberate inputs. Because it depends on velocity (not position), it cannot create a feedback oscillation with the motor:
viscous = -RF_VISCOUS_DAMP * d_wheel_filteredNo Centering Spring — Real power steering has no spring. The SAT provides all the centering force naturally. Removing the spring eliminated the wobble problem entirely while producing more realistic feel.
The ghost autopilot uses a pure pursuit algorithm with multi-segment waypoint lookahead:
LOOKAHEAD_TIME = 1.40 # seconds of preview
LOOKAHEAD_MIN = 18.0 # metres minimum (tight turns)
LOOKAHEAD_MAX = 65.0 # metres maximum (highway)It walks forward along the waypoint chain by Ld = speed * lookahead_time metres, finds the target point, and computes the ideal steering angle:
ideal_wheel = atan2(2 * WHEELBASE * sin(alpha), L_eff)This is the same algorithm used by autonomous vehicles for path following. The servo then drives the G920 motor to match this angle with a PD controller:
servo = K * (ideal_angle - actual_angle) - D * angular_velocityThe learner's hands rest on the wheel and feel it move through each turn — building muscle memory for correct steering inputs before they ever take control.
The Coach class watches the player against the California Vehicle Code in real time:
| Rule | Code | What It Catches |
|---|---|---|
| Speed limit | CVC 22350 | Exceeding posted limit + 5 mph grace |
| Stop signs | CVC 22450 | Rolling through without fully stopping |
| Red lights | CVC 21453 | Entering intersection on red |
| Following distance | CVC 21703 | < 1.6 second gap to vehicle ahead |
| Lane departure | — | Drifting past lane edge for > 0.7s |
| Wrong side | — | Driving in oncoming lane for > 0.5s |
| Hard braking | — | Panic stop (> 8.5 m/s^2 deceleration) |
| Understeer | — | Pushing past tire grip limit |
Each violation deducts points from a starting score of 100, displays a real-time HUD warning with the CVC section number, and logs to a session record. When the player presses Space to end their turn, an analytics summary identifies their top violation category and gives a specific improvement tip:
═══ DRIVE REPORT ═══
Score: 78/100
Drive time: 2:34
Violations: 4
Breakdown:
SPEEDING: 2x
LANE DRIFT: 1x
HARD BRAKE: 1x
Focus area: SPEEDING
Tip: Watch the speed limit — ease off the gas earlier approaching slower zones
The procedural map models a 3.2 km loop through seven road sections with diverse driving challenges:
| Section | Speed | Lanes | Challenge |
|---|---|---|---|
| I-280 East | 65 mph | 6 | Highway merge, sustained speed |
| Exit Ramp | 35 mph | 2 | Deceleration, tight right curve |
| Oak Lane | 25 mph | 2 | Tight S-curves, stop signs |
| Sunnyvale Collector | 35 mph | 4 | Sweeping curves, speed transitions |
| El Camino Real | 45 mph | 4 | Broad curves, lane discipline |
| I-280 Freeway | 65 mph | 6 | High-speed sweepers |
| I-280 West | 65 mph | 6 | Return highway, merge home |
Highway sections feature paved shoulders, concrete jersey barriers, and green verge strips. Local roads have sidewalks, parked cars, street trees, and buildings. The Exit Ramp has metal guardrails. All road markings follow MUTCD standards (dashed white lane dividers at 10ft/30ft spacing, double yellow centerlines on two-way roads, white median stripes on divided highways).
288 waypoints define the route at ~11-13m spacing for smooth curve rendering. The route was generated procedurally using a turtle-graphics approach with arc and line primitives to ensure no sharp discontinuities.
The SoundEngine class generates all audio in real-time on a background thread via sounddevice — no pre-recorded samples, no audio files. Every sound reacts to live car physics:
# Engine: 4-cylinder harmonic profile driven by RPM
fund_hz = rpm / 60.0 * 2.0 # 4-cyl fires twice per rev
engine = (sin(phase) * 1.0 # fundamental
+ sin(phase * 2) * 0.55 # 2nd harmonic
+ sin(phase * 3) * 0.25 # 3rd harmonic
+ sin(phase * 4) * 0.12) # 4th harmonic
# Volume: idle hum + throttle response + rev-based rise
volume = 0.06 + throttle * 0.30 + rpm_fraction * 0.15| Sound | Source | Trigger |
|---|---|---|
| Engine | Phase-continuous sine oscillator with 4 harmonics | Always — pitch from RPM, volume from throttle |
| Tire screech | Bandpass-modulated noise at 1800 Hz | Lateral accel > 3 m/s^2 or understeer detected |
| Wind | Highpass-filtered noise | Speed > 27 mph, scales with velocity |
| Road rumble | Lowpass-filtered noise at 120 Hz | Always — volume proportional to speed |
All filters are fully vectorised with numpy — no Python loops in the audio callback. Phase is continuous across blocks to prevent clicks. Volume changes are smoothed with exponential moving averages (50-150ms time constants) to eliminate pops.
- macOS (Apple Silicon or Intel with Metal support)
- Python 3.9+
- Logitech G920 steering wheel (USB)
pip install pygfx numpy pyobjc-framework-GameController pyobjc-framework-Cocoa
# Switch G920 from Xbox compatibility mode to native HID mode
python companion/g920_native_switch.py
# Run the simulator
python companion/drive3d.py| Input | Action |
|---|---|
| G920 wheel | Steering |
| Gas pedal | Throttle (pressure-sensitive) |
| Brake pedal | Brake (pressure-sensitive) |
| Right paddle | Upshift |
| Left paddle | Downshift |
| Space | Restart (shows drive report) |
| Escape | Quit |
| T | Toggle performance trace |
The simulator starts in Ghost Lap mode — watch and feel the wheel move through correct steering inputs for 45 seconds. Then it switches to Your Turn — drive the same route yourself while the coach scores you.
It's not a game. There are no points for drifting, no boost pads, no finish lines. The scoring penalizes exactly the behaviors that cause real accidents — running red lights, following too close, overcorrecting on the steering, panic braking. Every metric maps directly to California DMV evaluation criteria.
The physics are real. The bicycle model, friction circle, load transfer, and SAT-based FFB are the same models used in professional driving simulators. The tire feel — heavy in a corner, light at the limit — is the actual mechanism through which experienced drivers perceive grip. Teaching this feel to new drivers before they encounter it at 65 mph on I-280 is the entire point.
It runs on a laptop. No $50,000 motion platform. No dedicated sim rig. A $250 G920 clamped to a desk and a MacBook. The wgpu/Metal rendering pipeline means no GPU compatibility issues on any Mac made after 2020.
The FFB driver is open source and correct. There is no other working implementation of G920 force feedback on macOS that we're aware of. The HID++ 2.0 protocol implementation in wheel.py handles the macOS-specific report ID mismatch, the 200ms hardware watchdog, and the full handshake sequence. It could be extracted as a standalone library for any macOS application that needs G920 FFB.
Copyright 2026 DriveCoach. All rights reserved.
This source code is proprietary and confidential. No part of this codebase may be reproduced, distributed, or transmitted in any form without prior written permission.
Built at a hackathon in Sunnyvale, California. The roads are modeled after real streets.