Inspiration

Couch co-op games are at their best when everyone is yelling at each other. We wanted to push that further: what if two people had to share one character? In Skill Issue, each team is a single raccoon in a rain-soaked noir city, driven by two phones held sideways like controllers. The Runner tilts their phone to walk, flicks it to jump or crouch, and twists it to sprint. The Gunner turns their phone to aim and taps buttons to fire, aim down sights, reload and switch guns. Neither of you can play alone, and neither of you sees the whole picture. First team to 10 kills wins, or whoever leads when the five-minute clock runs out.

How we built it

  • Game: Godot 4.7, split-screen 3D on one shared screen, with a noir look: rainy city map, black-and-white menus, title-card lettering.
  • Phone controller: Expo / React Native. The app reads the phone's motion sensors (DeviceMotion) and turns them into movement, gestures and aim.
  • Networking: a Node.js WebSocket relay on Render (wss://relay.skillissue.vodka). The game opens a lobby through our Next.js control plane on Vercel and shows a six-character code. Phones only need that code to join from any network.
  • Website and data: the landing page at skillissue.vodka serves lobby provisioning and Relay helps with discovery
  • AI: after every match, Gemini reads all four players' stats and picks an MVP, then draws them a custom raccoon badge.

The shared protocol (packages/protocol) types every message, and the server, not the client, decides who owns which role. A Gunner can't move the body and a Runner can't shoot.

The motion math

Walking comes from how far you hold the phone tilted away from your neutral pose, with a deadzone and hysteresis so it doesn't flicker:

$$ v = \operatorname{sign}(\theta)\,\left(\operatorname{clamp}!\left(\frac{|\theta| - \theta_{\text{exit}}}{\theta_{\text{full}} - \theta_{\text{exit}}},\,0,\,1\right)\right)^{0.85}, \qquad \theta_{\text{enter}} = 5^\circ,\ \theta_{\text{exit}} = 3.5^\circ,\ \theta_{\text{full}} = 20^\circ $$

The hardest bug was geometric. Gravity only tells you two angles. With the phone tilted back about $45^\circ$, tipping an end down (strafe) and twisting it like a wheel (sprint) both rotate gravity in the screen plane: a full-speed $20^\circ$ strafe looks exactly like a $27^\circ$ twist. So we split each gyroscope sample $\boldsymbol\omega$ into a twist about the screen normal $\hat z$ and a strafe about the level "away" axis $\hat a$. The two axes aren't perpendicular, so with $c = \hat z \cdot \hat a$:

$$ \dot\tau = \frac{\omega_z - c\,(\boldsymbol\omega \cdot \hat a)}{1 - c^2}, \qquad |\tau| \le |\phi_{\text{screen}}| + 4^\circ $$

The bound uses the gravity angle $\phi_{\text{screen}}$, which can't drift, to keep the integrated twist $\tau$ honest. Sprint can't get stuck on, and players never have to recalibrate.

Challenges we ran into

  • "Joining your lobby..." forever. React Native's WebSocket.bufferedAmount is undefined, so our backpressure check silently dropped every message. One ?? 0 fixed it, after a lot of packet logging.
  • Phones on different networks. LAN-only play broke at a hackathon, so we moved every lobby onto a public relay and made the code the only thing a phone needs.
  • Inverted aim. iOS and Android report gyroscope axes in different orders, so we built a virtual phone simulator, PhoneRig, to test directions on both platforms without a device in hand.
  • Latency. We cut it everywhere: sending each sensor sample as soon as it's processed instead of on a 30 Hz timer, dropping a smoothing filter, firing flicks at the turn of the motion, and having the Gunner send exact degrees turned so the camera moves 1:1 with the phone.
  • Five people, one repo, one weekend: maps, pickups, HUD, networking, motion, art direction and the end screen all landing at once.

What we learned

  • Phone motion sensors are amazing and weird. The OS's fused gravity is clean and lag-free, but raw gyroscope readings drift, and every platform labels its axes differently.
  • Treat game input like a protocol: validate everything at the server, never trust the client about who it is.
  • Gemini's structured output (a JSON schema with an enum of valid player IDs) makes an LLM safe to put in the middle of a game loop: it can only answer with a real player.

What's next

Accounts and lifetime stats, more maps, ranked play, and a spectator view for the people watching from the couch.

Built With

Share this project:

Updates

Submission history