← All projects

DUWA: Local-Multiplayer Arcade for the People in the Room

Cover Image for DUWA: Local-Multiplayer Arcade for the People in the Room

The problem

Multiplayer gaming has quietly become a long-distance activity. Matchmaking, accounts, friend codes, voice chat — all of it assumes the other player is somewhere else. But the most fun I've ever had with games was four people on one couch, and there's surprisingly little software built for the case where everyone is already in the same room.

I wanted the whole loop to take ten seconds: one person starts a room, everyone else joins, play. No account, no download for spectators-turned-players, and nothing that stops working when the internet does.

What I built

DUWA (Cebuano for "play") is a small arcade — three games, up to four players — that ships two ways:

  • An iOS app where phones find each other over the wifi you're already on (or Bluetooth) and talk directly, phone to phone. No server exists. It works on a plane, in a jeepney, or anywhere the signal has given up.
  • A browser version at duwa.pastelero.ph where a room is just a link: start one, send the URL, and whoever opens it is in. It also plays solo against bots.

The games

  • SNAKE — two to four snakes in one arena. Eat, grow, don't crash; running into someone's tail counts. Last one moving wins.
  • BLOKS — head-to-head falling blocks. Every line you clear dumps junk on your rival's side. Six pieces, eight columns, and one five-cell piece that ruins a tidy stack.
  • PADDLE — one ball, two paddles, first to eleven. Where the ball lands on your bat sets its exit angle, so rallies sharpen the longer they run.

Screenshots

SNAKE: two snakes in one arena on iPhone The lobby: start a room, others join from the list BLOKS: head-to-head falling blocks PADDLE: one ball, two paddles, first to eleven Attract mode: bots play the cabinet while nobody is driving

The one decision everything else follows from

Each game is a pure, deterministic state machine with no clock, no canvas, no DOM, and no network:

interface Engine<State, Input> {
  readonly ticksPerSecond: number
  create(seed: number, players: number): State
  step(state: State, inputs: ReadonlyMap<SeatId, Input>): State
  winner(state: State): SeatId | null
}

Randomness comes from a seeded mulberry32, never Math.random, and every step derives its RNG from seed + tick. Given the same seed and inputs, two machines produce byte-identical states. That's what lets one set of game rules run in three places with no second implementation:

             ┌─────────────────────────┐
             │      engine (pure)      │   step(state, inputs)
             └────────────┬────────────┘
        ┌─────────────────┼──────────────────┐
        ▼                 ▼                  ▼
  browser solo       Durable Object      iOS app
  you vs bots        the web room        host device
  steps locally      steps for all      steps for all

If a rule changes, it changes in the engine and all three follow. Nothing else in the codebase is allowed to know a game rule. The Swift port is pinned to the TypeScript reference by parity tests that replay the same seeds and inputs on both and compare states.

How the multiplayer works

On the web, a room is one Cloudflare Durable Object addressed by its own code — idFromName("ABCD") — so everyone who types ABCD lands in the same object with no lookup table and no database. The object is the authority: it owns the seats, runs the tick loop, and ships snapshots. Clients send what the player pressed and draw what comes back; they never simulate.

On iOS, there is no server at all. MultipeerConnectivity finds nearby phones over local wifi and Bluetooth, one phone hosts and runs the authoritative simulation, and the rest send inputs and render snapshots — the same host-authoritative shape as the Durable Object, with a phone in the object's chair.

Empty seats are filled by bots, so a two-player room of a four-player game still feels alive.

Tech stack

Layer Technology Why
Engines Pure TypeScript + Swift port Deterministic step(), parity-tested across languages
iOS SwiftUI + MultipeerConnectivity Phone-to-phone play with no server
Web Next.js 15 + React 19 Mostly static; only /room/[code] is dynamic
Rendering Canvas 2D, drawn by hand No game framework, no WebGL
Web rooms Cloudflare Workers + Durable Objects One object per room, addressed by room code
Testing node:test + Playwright Engine unit tests plus a real two-browser room E2E

The web runtime dependency list is next, react, react-dom — no state manager, no UI library, no CSS framework, no WebSocket library.

What it doesn't do

No account, no sign-in, no ads, nothing to buy. The iOS app makes no internet connections at all — it only talks to the phones beside it. Nothing is uploaded and nothing is stored anywhere but your own device.

What building this taught me

1. Determinism is a feature you buy once and spend everywhere

The seeded-RNG, pure-step rule felt like ceremony on day one. Then it paid for the bot player (bots are just another input source), the attract mode on the landing page (a replay with nobody driving), the two-language port (parity tests instead of faith), and multiplayer sync (send inputs, not state). Every hard feature fell out of one early constraint.

2. Host-authoritative beats clever

I never built rollback netcode or client prediction. For games at this tick rate on a local network, "one machine simulates, everyone else renders" is simple, cheat-proof by construction, and fast enough. The web room and the iOS session use the same shape, which meant one mental model instead of two.

3. The App Store's rules shape the product

"Tetris" and "Pong" appear nowhere in the app, the repo, or the store metadata — the games are renamed and restyled with their own palettes and piece shapes. Naming, age rating, privacy labels, and support pages were as much a part of shipping as the code.

Status

The web arcade is live. Version 1.0 of the iOS app is submitted and waiting on App Store review.

Links

Let's build something together.

Got an idea? I'm always up for a new challenge, whether it's a side project, a startup, or something in between.

© 2026 Cyrus David Pastelero. All rights reserved.

Built with Next.js, TypeScript & Tailwind CSS