Project Lykos
Two drivers, two seats, two monitors, and one wire running between them. Lykos is a head-to-head racing sim where every input gets read off hardware we built, packed onto a CAN bus, and turned into a car moving through a world we render from scratch. Here is how it came together, from picking the parts to the one bug that almost sank the whole thing.
The brief
Two players in separate seats, each with their own wheel, pedals, and monitor, racing the same track against each other. Everything real-time lives on a central TM4C123GXL acting as the game engine, and a Raspberry Pi 5 sits between it and the two screens, doing nothing but drawing.
The wish list was short and we held ourselves to it. If it moved data, we wrote it. No off-the-shelf game engine, no dev board quietly doing the hard part.
Communication & sensing
The first real decision was how all the pieces would talk to each other. Get this wrong and nothing else matters.
Whatever we picked had to be fast, because slow data means a sluggish game and latency is the one thing a racer will feel instantly. It had to hold its integrity over at least a meter of wire per link. And it had to be simple enough in both hardware and software that we were not sinking the whole timeline into plumbing instead of the game. It also had to be reliable. This is a live race, so one dropped frame of steering is a driver swerving into a wall they never touched. We narrowed it to three real options and scored them.
| Criteria | Weight | CAN bus | ESP32 MQTT | SPI |
|---|---|---|---|---|
| Distance (span the rig) | ×3 | 4 | 5 | 1 |
| Speed / low latency | ×3 | 4 | 2 | 5 |
| Reliability / noise immunity | ×3 | 5 | 3 | 2 |
| Hardware complexity / cabling | ×1 | 2 | 3 | 4 |
| Software complexity | ×2 | 3 | 2 | 4 |
| Weighted total | 47 | 37 | 36 |
SPI was the fastest on paper, but it leans on a synchronous clock that falls apart after a couple of feet thanks to parasitic inductance and capacitance. Perfect for a short on-board hop, useless for spanning a whole rig. MQTT over an ESP32 killed the wiring entirely and could even let a wheel go fully wireless, but it traded that for unpredictable network latency and jitter, which is exactly what you cannot have in a head-to-head race.
Two wires, differential signaling that shrugs off the noise from a desk full of motors, and built-in arbitration so every node can talk without stepping on the others. If it is good enough to run a real car, it is good enough for our sim. For transceivers we used the MCP2551, mostly because it was already on the shelf and cost us nothing.
Adafruit LSM6DS3TR-C
This one barely counted as a decision. A 6-DoF accelerometer and gyro on a breakout that talks over SPI and, best of all, hands back an angle without us writing any raw-data conversion. It solved steering immediately and it is a chip I will happily reuse on the next project.
Rotary encoder, not force
You can measure the force on a pedal or the angle it travels. I went with angle, using an encoder mounted coaxially with the pedal. It made the mechanical side a little trickier, but it was available immediately and made everything downstream simpler, which was the trade I wanted.
Circuit & PCB
One custom board per wheel, carrying everything the driver touches and turning it into something the TM4C can read.
With the parts chosen, the whole circuit had to come together so the TM4C could actually interface with each of them. A lot of this was just living in datasheets, ours and each component's, making sure every chip landed on the right pin. The schematic came together in KiCAD.
The board had real constraints. The LCD had to sit dead center to work as the driver display, the whole thing had to stay compact enough to mount on the wheel, and the buttons and encoders had to end up somewhere a driver could reach at speed. None of it was hard alone, but we knew we would be debugging this board, so we designed with that in mind.
Layout comes down to the same three things every guide repeats: cut cross-talk, cut EMI, do not waste space. On a 2-layer board that mostly meant keeping the ground plane from forming loops.
I stuck to a simple rule to keep traces from fighting each other: vertical runs on the top layer, horizontal on the bottom. Boring, but it works, and it makes the board far easier to follow when you are the one probing it later.
Vehicle dynamics
The single source of truth for the whole game, running fixed-point physics on the central TM4C.
Inputs arrive over CAN from each cockpit. The wheel node integrates its gyro into a heading and sends that, the pedal node sends accelerator and brake encoder counts plus the button states. The engine takes those, steps the car physics forward on a fixed timestep, and ships the result over SPI to the Raspberry Pi. That split was deliberate: the microcontroller owns everything real-time and deterministic, the Pi owns everything pixel-heavy.
The car follows a kinematic bicycle model, the standard low-speed approximation that collapses the four wheels into two. Position advances along the heading, and the heading itself turns at a rate set by speed, steer angle, and wheelbase:
The important behavior falls right out of that middle equation. Because yaw rate scales with v, the same wheel angle carves a wider arc the faster you drive, exactly like a real car. Turn-in at a crawl is sharp, turn-in at top speed is lazy, and none of that had to be special-cased.
Part of it was forced on us: the CAN driver needs the TM4C's floating-point hardware turned off, so software floats would have crawled. But it is also just the right call for a real-time engine. Every multiply and divide takes a known number of cycles, results are bit-for-bit repeatable, and no rounding drift piles up in the car's position frame after frame. Trig is a 360-entry sine and cosine table, scaled by 1000, that the integer heading indexes straight into.
Track walls are line segments. Every frame the engine projects the car's center onto each segment to find the closest point, all in fixed-point dot products, and compares squared distances so it never pays for a square root unless something actually hit.
On a hit the velocity is split into a normal and a tangential part. The normal part reflects and scales by a bounce factor e, the tangential part is kept but damped. A glancing hit scrubs a little speed and slides you along the barrier, a head-on hit bounces you off.
Input acquisition
Turning a hand on a wheel and a foot on a pedal into clean numbers the engine can trust.
Steering comes off the LSM6DS3TR-C over a 2 MHz SPI bus, with a WHO_AM_I check at startup so we never trust a reading until we know the chip is answering. A hardware timer samples the Z-axis rate every 10 ms, converts it with the sensor's 8.75 mdps/LSB sensitivity, and integrates it into a running yaw in millidegrees.
Heading is just the accumulated integral of that rate, minus the bias we measure at startup:
Without subtracting b, drift slowly turns the wheel on its own. The yaw also saturates in software at full lock so it acts like an end-stop, with a button that re-zeros the heading if drift creeps in mid-race.
The encoders do not just count edges. Every edge interrupt folds the previous and current two-bit states into a 4-bit code and looks it up against the valid Gray-code transitions. Clockwise bumps a delta up, counterclockwise bumps it down, and anything invalid gets thrown out.
A detent only counts once the encoder settles back to rest with a consistent delta. That gives us contact-bounce rejection entirely in software, no RC filtering, and it cannot miscount from noise, because noise just produces invalid codes the table ignores.
The physics-to-pixels handoff
The one short, on-board hop where SPI wins. This is where physics becomes pixels.
The link from the engine to the Pi is SPI, with the Pi as master and the TM4C as slave, exactly the short on-board hop where SPI's speed wins and its distance problem never shows up. Rather than fight the hardware's slave mode, we did the slave in software: the TM4C watches the Pi's clock and chip-select lines on GPIO and shifts each byte out bit by bit on the clock edges it sees. That gave us full visibility into byte framing during bring-up, since every edge is steppable on a logic analyzer, and we bumped the data-out pin to 8 mA drive strength to keep the edges clean.
The framing is deliberately defensive. The Pi opens each exchange with a 0xFF sync byte, then the TM4C answers with a fixed 8-byte packet: a start sentinel 'S', the 16-bit position, heading, and speed, and a stop sentinel 'X'. Because every packet is bracketed by known bytes, the Pi can validate it, drop anything corrupted, and resync mid-stream instead of quietly drawing from misaligned bytes. We tested the whole scheme over UART against a Python parser first, so by the time we brought up the real SPI link the only unknowns left were electrical.
Real-time rendering
Where the signal finally becomes a world, drawn from scratch on the Raspberry Pi 5.
The renderer is split across two languages on purpose. All the per-pixel work is one C file compiled to a shared library, and Python with Pygame handles everything else: opening the display, polling SPI for fresh game state, and calling into the C through ctypes with a pointer to the frame buffer. The math is simple. At 1280x720 the renderer fills 921,600 pixels every frame, which a Python per-pixel loop cannot come close to doing in real time, while C fills the same buffer with room to spare.
The driver view uses the same trick behind corridor games like Wolfenstein 3D, which buys a convincing first-person view for a fraction of the cost of true 3D. The track is a 16×10 grid of 64-unit cells, each one open, a grey wall, or a red barrier. For every one of the 1280 columns on screen the renderer casts one ray, sweeping across a 60-degree field of view, and marches it to each grid line it crosses.
Two equations carry the whole look. Each ray's angle is offset from the player's heading across the field of view, and the sampled distance gets corrected to the perpendicular distance so flat walls do not bow outward at the screen edges. Wall height is then simply inverse to that distance, which is the 1/d falloff of a real perspective projection:
Everything that is not a wall gets painted procedurally, with no textures at all. The top half is a sky gradient. The bottom half is a perspective road, where each row below the horizon converges toward a vanishing point. That banding is not decoration, it is the entire sense of speed. With no textures, the only way you feel motion is watching those bands sweep under the car, and because every band's phase is tied to world position, the ground flows at a rate set by your actual speed. That is what makes braking and accelerating feel like they matter.
Two monitors, one surface
Both displays run off the one Pi over dual HDMI. Instead of two windows fighting to stay in sync, we treat the monitors as a single wide display: one surface spans both, the renderer runs once per driver, each result blits to its half, and one flip presents both. One loop, one present, so the two views can never drift apart.
A single source of truth
The exact same track grid is compiled into the wheel node's firmware, where it backs the little map on the wheel-mounted LCD. So the walls the physics collides against, the walls the renderer draws, and the outline on the driver display can never disagree. Change the track in one array and it propagates everywhere.
Mechanical design
Two things the driver actually holds: the pedal box and the wheel. Both designed in OnShape.
The pedals were the fussier of the two. The whole assembly had to be constrained so the pedal had a spring-actuated restoring force pushing back on the driver, stay adjustable for driver preference, and still let me measure its travel to send to the MCU.
What came out is a custom pedal with a rotary encoder reading its angle change, which is just how hard the driver is pressing. The trick was constraining only a few degrees of freedom at a time, part by part, so the thing stays easy to adjust and easy to reason about instead of turning into a wobbly mess.
The wheel was the simpler design, but the driver experience still came first. It had to hold the gyroscope properly, mount cleanly to the table, and route the button wiring without looking like a bird's nest.
The one real question was bushing versus bearing at the hub, which came down to how much play and friction I was willing to accept in the rotation. A bearing feels better and costs more room and money, a bushing is simpler and a little stickier.
Integration & debug
Everything worked alone. Then we put it together.
Start as basic as it gets
We began by sending [1, 2, 3, 4] from one microcontroller to another. It came through clean across two CAN transceivers, and that was enough to trust the physical layer before we built anything real on top of it.
Arbitration for free
Adding a node meant opening a channel for another CAN ID. We set the engine to ID 0, the players to 1 and 2. If both players transmit at the same instant, Player 2 backs off and resends a hair later, so effectively nothing is lost and the race stays smooth.
CAN and the gyro, fighting over the same pins
As we brought everything together, exactly one problem showed up. CAN transmitted perfectly on its own. The gyroscope read perfectly on its own. Put the two together and the screens stopped registering any rotation of the wheel at all. Nothing in either program looked wrong, which is the worst kind of bug.
So we stopped guessing and drew up a fishbone diagram, forcing ourselves through every possible cause instead of poking at the code and hoping. That is what finally isolated it. The CAN and gyroscope initialization were configuring the same pins, even though physically the two never actually interfered with each other.
The fix was almost anticlimactic. Swap the order of initialization and repair the pin setup so the two were not clobbering each other, and suddenly we could drive again. The lesson stuck harder than the bug: when two things work alone and break together, look at what they quietly share.
With everything finally running, we could stop engineering and just make it fun. We threw in random events, like a reverse control mode that flips steering and pedals mid-race, and obstacles dropped in the road to slow a driver down. Small stuff, but it is the difference between a tech demo and a game you actually want to beat your friend at.
Where it landed
The 1 Mbps bus kept steering feeling instant, the fixed-point engine held its physics steady without a single float, and the whole thing, hardware to renderer, came together into a game two people can sit down and actually race. If it moved data, we wrote it.
Lykos is not finished
Two milestones already in the car and a clear line of what is next. The bones are done, so what remains is polish and serviceability.
Playable v1
Two players, real-time, on a shared track with working collisions and road obstacles. This is the version you can sit down and race right now.
Procedural tracks
Random track generation, so every race runs a fresh circuit instead of the same loop. Already in and working.
Refine the renderer
Push the graphics past flat colors: real surface texturing, cleaner curbs, and a stronger read on speed coming out of the raycaster.
CAD for maintenance
Rework the wheel and pedal assemblies so they come apart and get serviced without a fight. Less glue, more fasteners.
Want the full teardown?
Happy to walk the CAN drivers, the fixed-point engine, or the CAD, wherever along the signal path you want to dig in.