I vibe-coded a party kart racer and self-hosted it on my homelab

Nikos Konomi 6 min read
Contents
I vibe-coded a party kart racer and self-hosted it on my homelab

HomeLab Kart is a party game. A TV or laptop shows a 2D top-down kart race and a QR code; up to four people scan it, and their phones become the controllers. No app to install. It runs as its own container on the same old office PC that serves this site, next to the other homelab services. And I didn't hand-write the code: I built it by vibe coding.

This post is the honest version of how that went: what the AI did, what I did, and what broke along the way.

What vibe coding meant here

Let me be clear about the split, because it is the point of this post.

The AI assistant (Claude) wrote most of the code, proposed the architecture options, and wrote automated tests for the game logic.

I chose the concept and the scope, decided between the options it proposed, played every build on real phones and a real TV, reported what felt wrong and what broke, and did all of the deployment and infrastructure myself: Docker, ZimaOS, the Cloudflare Tunnel, the networking, and the debugging over SSH.

The loop looked like this:

  1. I describe what I want.
  2. It builds it.
  3. I play it, on the actual devices.
  4. I report what is wrong, or what doesn't feel right.
  5. Repeat.

Step 3 turned out to matter more than I expected. Several of the bugs below only showed up when someone actually played the game.

The decisions that mattered

Most of the real decisions were about what not to build.

  • Local party, not online multiplayer. Everyone is in the same room, looking at the same screen.
  • 2D, not 3D. Top-down pixel art, generated entirely in code. There is not a single image file in the project.
  • One shared screen. Phones are only controllers; they render nothing.

The biggest one was where the physics runs. Proper online racing needs an authoritative server with prediction and reconciliation, and that is the hard part of the whole genre. A party game doesn't need any of it: there is one shared screen and everyone is in the same room. So the screen runs the game (physics, rendering, race rules) and the server is only a room manager and an input relay.

TV / laptop  ──── runs the game: physics, rendering, race rules
     ▲
     │ WebSocket
Node server  ──── rooms + input relay (containerised on ZimaOS)
     ▲
     │ WebSocket
Phones       ──── controllers only, they render nothing

The AI laid out the options. Picking this one, and cutting everything that came with the other one, was my call. That is the human part of vibe coding: deciding the shape of the thing.

The stack ended up small: Phaser 3 for the game, Node.js with ws for the server, qrcode for the join code, and Docker for packaging.

Phase by phase

  • Phase 0: driving feel first. One kart, one track, keyboard. Nothing else until driving was fun.
  • Phase 1: the core loop. QR code on the screen, phone becomes a controller, kart moves.
  • Phase 2: racing. Countdown, laps validated by checkpoints, live positions, and items: a Boost, a Banana you drop behind you, and a Bolt that slows everyone else.
  • Phase 3: championships. Cups of 3 or 5 races, points 10 / 6 / 3 / 1, standings between races and a champion screen. A cup visits every circuit in turn.
  • Phase 4: content as data. Four circuits (Palm Island, Forest Loop, Snow Ridge, Neon Circuit) and three karts (Balanced, Speedster, Gripper, trading speed, acceleration and grip) defined as plain data.
  • Phase 5: containerised and deployed on ZimaOS.
  • Phase 6: public. A solo mode against three AI drivers (TUX, BYTE and PIXEL, three laps, keyboard on desktop, on-screen touch controls on phones), server hardening, and a public tunnel.

What broke

This is the part I found most useful to write down.

The kart wouldn't move

Early on, the kart simply didn't drive. The code passed a syntax check, but nobody had actually driven it. A physics bug was discarding the acceleration on every frame.

We isolated the physics in a test, found the problem, and fixed it in one line. The lesson stuck with me for the rest of the project: "it runs" is not "it works". Test the behaviour, not just the code.

The AI drivers cut every corner

When solo mode arrived, the AI drivers were hopeless. Their look-ahead point was about 1000 px down the track, so they aimed at the far side of every corner and spent around 79% of the race off the track.

A parameter sweep fixed it. They now stay on the track 100% of the time, on all four circuits.

"Connecting…" forever over the internet

Once the game went public, party mode over the internet sat on "Connecting…" and never got further. The phone controller was still opening a ws:// connection from an https:// page. Browsers block that as mixed content, and they do it silently.

The fix was to use wss:// on https pages, and to make the QR link follow the page's own scheme. The idea, in a few lines:

// Follow the page's scheme: wss:// on https pages, ws:// on plain http
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${scheme}//${location.host}`);

A 151 MB container for a small game

The first image was 151 MB. 148 MB of that was the Phaser npm package, and the game needed exactly one 1.2 MB file from it. Keeping only the minified build brought the app payload down to about 4 MB.

Hosting it

The game runs as its own Docker container on ZimaOS, next to the other homelab services. The image is built on node:20-alpine as a multi-stage build, runs as a non-root user, and has a healthcheck.

Two infrastructure problems were mine to solve:

  • The ZimaOS app tile kept failing. The ZimaOS UI couldn't manage a locally built image and kept publishing it on a random port. I stopped fighting it and manage the container from the CLI instead.
  • The site couldn't reach the game. This website runs in a VM on the same ZimaOS box, and that VM can't reach its own host over the LAN, a VM-to-host "hairpin" limitation. Rather than fight the network, the game exposes itself from where it runs: it has its own Cloudflare Tunnel at kart.theprojecthomelab.org, separate from the site's tunnel.

Because the game is public, the server has limits: a maximum number of concurrent rooms, a maximum number of sockets per IP, a room-creation rate limit per IP, cleanup of idle rooms, a cap on message size, and server-side clamping and validation of controller input.

What I learned about vibe coding

  • You still need to know what "working" looks like. The AI wrote code quickly. It was much slower to notice when something only looked fine.
  • Real devices catch what tests miss. Tests were great for logic. Playing the game on a phone and a TV is what found the kart that wouldn't move and the controller stuck on "Connecting…".
  • The decisions are the job. Scope, architecture, what to cut: the AI offered options; choosing between them shaped the project more than any single piece of code.
  • Infrastructure stayed hands-on. Containers, ZimaOS, tunnels, networking and debugging over SSH were all on me. None of the hosting problems in this post were solved by writing more game code.

Try it

The showcase page, with both ways to play, is at /kart. The game itself lives at kart.theprojecthomelab.org: play solo against TUX, BYTE and PIXEL, or put it on a TV and hand the phones around.