Case file · Lykos Playable
VERSUS RACING SIMULATOR  //  TM4C123 + RASPBERRY PI 5

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.

Role
Mechanical design (CAD) · CAN comms
Team
Shawarma Inc · embedded systems
Platform
TM4C123GXL · Raspberry Pi 5
Stack
C · Python · KiCAD · OnShape
Built with · C CAN bus TM4C123 Python Pygame KiCAD OnShape Raspberry Pi 5
00

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.

01
Read driver inputs through interfaces that actually feel good to use
02
Write the renderer from scratch, no off-the-shelf engine
03
Implement the CAN protocol ourselves
04
Build a game engine that behaves like real physics
05
Make it a game people actually want to play
01

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.

▸ DECISION MATRIX · comms protocol TOTAL = Σ(SCORE × WEIGHT)
Criteria Weight CAN bus ESP32 MQTT SPI
Distance (span the rig)×3451
Speed / low latency×3425
Reliability / noise immunity×3532
Hardware complexity / cabling×1234
Software complexity×2324
Weighted total 473736

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.

Key decision
We put the whole rig on a CAN bus.

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.

The IMU · steering sense

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.

Pedal sense

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.

02

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.

▸ EMBED · SCHEMATIC
KiCAD schematic
drop your schematic export here
FIG.01
▸ EMBED · PCB LAYOUT
2-layer board layout
Gerber top / render
FIG.02

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.

MCU
TM4C123 · bare-metal C
SENSE
LSM6DS3TR-C · encoders
DRIVER I/O
Buttons · switches
DISPLAY
Center LCD · status LED
FEEDBACK
Speaker
BUS
MCP2551 CAN transceiver
03

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:

EQ.01 · Kinematic bicycle model
ẋ = v cos ψ,ẏ = v sin ψ
ψ̇ = vL tan δ// yaw rate rises with speed
vt+1 = (vt + a − b) · μ// μ = 0.99 coast-down
v speed · ψ heading · δ steer angle · L wheelbase · a,b accel/brake · μ friction

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.

Key decision
No floats anywhere. 1.0 is stored as 1000.

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.

▸ EMBED · CODE · game_engine.c / update() LST.01 · C
1 // kinematic bicycle model, ~16 ms/step, fixed-point (1.0 = 1000)
2 car->speed += accel - brakeForce;
3 car->speed = (car->speed * FRICTION) / SCALE; // 0.99 coast-down
4 if (car->speed > MAX_SPEED) car->speed = MAX_SPEED;
5 car->steerAngle += steering * STEER_RATE;
6 if (car->steerAngle > 30) car->steerAngle = 30; // steering lock
7 if (car->steerAngle < -30) car->steerAngle = -30;
8 int yawDelta = (car->speed * car->steerAngle * dt) / WHEELBASE;
9 car->yaw += yawDelta; // wider arc the faster you go
10 while (car->yaw < 0) car->yaw += 360; // index into lookup tables
11 while (car->yaw >= 360) car->yaw -= 360;
12 car->vx = (car->speed * cosTable[car->yaw]) / SCALE;
13 car->vy = (car->speed * sinTable[car->yaw]) / SCALE;
Handling the walls

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.

EQ.02 · Collision response
v′ = v − (1 + e)(v · n̂) n̂
n̂ wall normal · e = 0.3 restitution · tangent damped to 90%
▸ EMBED · CODE · handleCollisions() LST.02 · C
1 // project onto wall, compare squared dist
2 int t = ((cx*wx + cy*wy) * SCALE) / wallLen2;
3 if (t < 0) t = 0; if (t > SCALE) t = SCALE;
4 int dx = car->x - (w->x1 + (wx*t)/SCALE);
5 int dy = car->y - (w->y1 + (wy*t)/SCALE);
6 if (dx*dx + dy*dy < radius*radius) { // hit
7   int push = (radius - dist) * 1200 / SCALE; // 1.2x
8   car->x += nx*push/SCALE; car->y += ny*push/SCALE;
9   vTang = (vTang * 900) / SCALE; // tangent damp 90%
10 }
04

Input acquisition

Turning a hand on a wheel and a foot on a pedal into clean numbers the engine can trust.

Steering

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:

EQ.03 · Gyro integration
ψ = Σ (ωz − b) · Δtb = mean of 500 still samples

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.

Pedals + buttons

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.

▸ EMBED · CODE · rotary_encoder.c / EncoderRead() LST.03 · C
1 // fold old + new 2-bit states into one 4-bit transition code
2 newState = (GPIO_PORTB_DATA_R >> 1) & 0x03;
3 transition = (prevState << 2) | newState;
4 switch (transition) {
5   case 0b0001: case 0b0111: case 0b1110: case 0b1000: encoderDelta++; break; // CW
6   case 0b0010: case 0b1011: case 0b1101: case 0b0100: encoderDelta--; break; // CCW
7   default: break; // noise -> invalid code -> ignored
8 }
9 if (newState == 0x03) { // settled at rest = one detent
10   if (encoderDelta >= 2) { encoderCount++; encoderDelta = 0; }
11   if (encoderDelta <= -2) { encoderCount--; encoderDelta = 0; }
12 }
13 prevState = newState;
05

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.

SSTART
POS16-BIT
ANGLE16-BIT
SPEED16-BIT
XSTOP
▸ EMBED · CODE · spi.c / SPI_SendTM4C() LST.04 · C
1 // Pi is master; the TM4C answers each 0xFF sync with a framed packet
2 void SPI_SendTM4C(int32_t pos, uint16_t angle, int16_t speed) {
3   uint8_t sync = 0;
4   while (sync != 0xFF) sync = bitbangingbyte(0x00); // wait for open
5   bitbangingbyte('S'); // start sentinel
6   bitbangingbyte((pos>>8)&0xFF); bitbangingbyte(pos&0xFF);
7   bitbangingbyte((angle>>8)&0xFF); bitbangingbyte(angle&0xFF);
8   bitbangingbyte((speed>>8)&0xFF); bitbangingbyte(speed&0xFF);
9   bitbangingbyte('X'); // stop sentinel
10 }
Bit-banged on purpose, so every clock edge is visible and steppable while bringing the link up.
06

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.

$ gcc -O2 -shared -fPIC -o raycaster.so raycaster.c -lm
Key decision
Raycasting, not real 3D.

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:

EQ.04 · Raycast projection
θx = θ − FOV/2 + x · FOVW// ray per column x
d⊥ = d · cos (θx − θ)// fisheye correction
h = hwall · pd⊥// 1/d perspective falloff
θ heading · W screen width · d ray distance · d⊥ perpendicular dist · p projection scale
▸ EMBED · CODE · raycaster.c LST.05 · C · abbreviated
1 // 16x10 grid of 64-unit cells. 0 = open, 1 = grey wall, 2 = red barrier
2 static const uint8_t MAP[10][16] = {
3   {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
4   {1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1},
5   {1,0,0,0,0,0,2,0,0,2,0,0,0,0,0,1},
6   {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
7 }; // rows trimmed for space
8 // one ray per column, swept across a 60-deg FOV on the heading
9 for (int x = 0; x < width; x++) {
10   float ray = angle - half_fov + x * angle_step;
11   // ...DDA march to the next grid line...
12   int wallheight = (int)(height * pixelsize / perp); // 1/d falloff
13   int shade = (int)(255 - perp * 0.4f); // closer = brighter
14 }

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.

SKY
Vertical gradient, top half of the frame
ROAD
Half-width shrinks with depth to a vanishing point
CURBS
Red/white on the outer eighth, compressing toward the horizon
WALLS
Distance-shaded columns, red barriers vs grey walls
One Pi, two screens

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.

One map, everywhere

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.

▸ EMBED · GAMEPLAY VIDEO
▶
Live two-player race
drop the race clip or embed link here
FIG.03
07

Mechanical design

Two things the driver actually holds: the pedal box and the wheel. Both designed in OnShape.

The pedals

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.

▸ EMBED · CAD RENDER
Adjustable pedal box
OnShape · exploded + assembled
FIG.04
▸ EMBED · CAD RENDER
Steering-wheel assembly
OnShape · gyro mount + hub
FIG.05
The wheel

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.

08

Integration & debug

Everything worked alone. Then we put it together.

Test · CAN

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.

Test · multi-node

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.

The one that stumped us Resolved · driving again

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.

▸ EMBED · FISHBONE
Root-cause fishbone diagram
the one that pointed at pin init
FIG.06

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.

Fine tuning

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.

EVENTReverse controls, steering + pedals flipped
HAZARDRoad obstacles that slow you down
Telemetry

Where it landed

1 Mbps
CAN bus, steering that feels instant
2
Players racing, one shared track
921,600
Pixels drawn per frame, per view
0
Off-the-shelf engines or floats used

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.

Roadmap

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.

01
Done

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.

02
Done

Procedural tracks

Random track generation, so every race runs a fresh circuit instead of the same loop. Already in and working.

03
Up next

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.

04
Planned

CAD for maintenance

Rework the wheel and pedal assemblies so they come apart and get serviced without a fight. Less glue, more fasteners.

Open channel

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.