Inspiration

We wanted to make something a group of friends could start playing without installing an app or creating accounts. Everyone already has a phone, and a laptop can serve as the shared screen. Open a link, join the room, and start playing.

The idea took shape when we split the controls from the information. One player could steer but barely see where they were going. Their teammate could see the hazards but couldn't steer. They had to talk to get anywhere.

We set the game on a Gulf Coast beach on a red flag day, with a swimmer caught in a rip current and two lifeguard crews racing to reach them.

What it does

Shoreline is a five-device party game: one laptop displays the ocean, and four phones act as controllers. Players join by scanning a QR code or entering a four-letter room code.

Rescue Showdown, our main mode, is a 2v2 race. Both crews share one ocean and compete to rescue the same swimmer. Each crew has two roles:

  • The Driver controls the jet ski's throttle and steering. They can only see a small circle of water around the ski, with sun glare obscuring everything beyond it.
  • The Navigator uses a drone to find hazards and a radio to check signal strength across the map. They can mark locations and give directions, but they can't drive.

The Navigator might spot a sandbar before the Driver can see it. Getting around it means calling out directions clearly: "Land ahead, go left," or "Person at two o'clock, close."

Touching land sends the ski back to the dock with its hull intact. Jellyfish damage the hull, and reaching zero means a wreck and another trip back. The first boat to reach the swimmer scores a rescue. Another swimmer appears, and both crews race for that person next. The crew with the most rescues after 120 seconds wins.

How we built it

There were four of us, working in parallel for roughly twenty-four hours.

We started by agreeing on a shared TypeScript contract. It defined the types passed between modules and assigned each directory to one developer. That let us build our parts independently. When a playtest called for a new field, we added it through a small contract: pull request with placeholder implementations so everyone could keep building. We updated the contract three times across three playtest rounds.

The host laptop runs the simulation and holds the game state. Phones send typed actions and receive filtered views for their assigned roles. Keeping the rules on the host gave us one place to validate actions and avoided competing versions of the simulation on different devices.

The engine in src/game/engine/ is pure TypeScript. It doesn't read a clock, call Math.random(), access the DOM, or use the network. Randomness comes from a seeded generator, and time is passed in through a dtMs argument. Engine functions return new state rather than mutating their inputs.

The same seed and inputs produce the same match. That made it possible to reproduce bugs and test entire matches. The ocean is generated from one seed, so both crews face the same hazards and swimmer sequence.

Boat movement uses semi-implicit Euler integration. The rip current changes the boat's position without changing its heading, so the ski can drift sideways while still pointing in the direction the Driver chose:

$$ \begin{aligned} x_{t+1} &= x_t + \left(\sin(\theta)\,v + c_x\right)\Delta t \ y_{t+1} &= y_t + \left(-\cos(\theta)\,v + c_y\right)\Delta t \end{aligned} $$

Here, θ is the heading, measured clockwise from north; (v) is speed; and (c) is the current. Players have to account for that drift when choosing their approach.

For networking, we used Supabase Realtime Broadcast and Presence. There are no database tables or player accounts, and we don't run a separate game server. Each room has one channel with a single broadcast event, and presence is keyed by player ID. The host simulates at 30 Hz and broadcasts views at 10 Hz. A fixed-step accumulator accounts for elapsed time when the laptop drops a frame.

We also wanted more interesting hazard layouts than scattered obstacles. Sandbars and rocks form walls with gaps and horseshoe shapes. A flood-fill check makes sure both docks can still reach the swimmer. Layouts that block every route are discarded before the match starts.

Challenges we ran into

One of our earliest networking bugs came from two sessions sharing a channel. supabase-js returns the same channel object when the same client requests a topic twice. React StrictMode mounts effects twice during development, and one session's cleanup was tearing down the other's connection. The room would stop working without an obvious explanation. Giving each transport its own Supabase client fixed the collision.

We also found an HTTP flood hidden behind the Realtime API. Sending before a channel finishes joining doesn't throw an error: supabase-js falls back to an HTTP POST for each message. At ten messages a second across several devices, that added up quickly. We now drop outgoing messages until the channel has joined.

Reloading a phone caused another problem. Players were sent back to the name-entry screen and appeared to lose their roles, even though their identities were still stored. We were only reading that identity when the form was submitted. Reloads now reconnect with the existing player ID, and the host restores the same seat. We verified that this worked in under three seconds during a match.

We spent time testing messages we didn't want the host to accept. A player's seat comes from the host's roster, never from a seat claimed in an incoming message. Sender checks, per-player sequence numbers, and a bounded set of recent message IDs reject duplicate or replayed actions. Our tests include duplicate IDs with different payloads, reused sequence numbers, and stale view ticks.

Playtesting exposed problems that weren't obvious from the code. In the first round, people drove straight into sandbars and couldn't tell why they'd suddenly returned to the start. We used the waiting time before the whistle to explain the rules. Each role now gets five numbered steps that explain hazards before scoring, so players know what happened when they run aground.

Accomplishments that we're proud of

  • We built 238 automated tests across 58 suites, with zero failures. These include host-to-phone integration tests that run a full 120-second match over an in-memory transport.
  • We measured approximately 44 ms of device-to-device broadcast latency against a 300 ms budget.
  • Multiplayer works through Supabase Realtime Broadcast and Presence, without a separate game server or database tables.
  • All four developers were able to work in parallel through three rounds of contract and playtest changes.
  • Watching people play is funny. The laptop shows both crews racing while two Navigators shout directions across the room.

What we learned

Agreeing on the contract took time at the beginning, but it saved us from waiting on unfinished modules later. We could build against the same types and integrate smaller changes throughout the hackathon.

The pure engine also paid off quickly. When something went wrong, we could reproduce the same match with the same seed and action sequence. We could test the result without opening a browser or connecting to Supabase.

The biggest design changes came from playtesting. We changed how hazards work, how far the Driver can see, the boat's speed, the onboarding, and eventually the game's name. Watching someone get stuck gave us much more useful feedback than discussing what we thought they would do.

We also learned to be careful about giving players too much information. Every extra detail on the Driver's screen reduced the need to ask their Navigator for help. The limited view is what gets people talking, so we kept it.

What's next for Shoreline

We want to improve accessibility, especially for the Driver. The Navigator already uses discrete taps with generous cooldowns and little need for fast reactions. Steering still requires holding a control, and the throttle uses a fine-grained slider. Discrete rudder controls and audio feedback for running aground could make the Driver role more accessible, including for a blind player working with a sighted Navigator.

We'd also like to explain the current mechanics more clearly and connect them to basic rip-current awareness. Players already notice themselves drifting off course; the game could help them understand what they're seeing.

A replay of the final thirty seconds would give the room a chance to watch the closest approaches and last-second rescues again.

Built With

Share this project:

Updates

Submission history