# Project export: Flick Arena

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Scan, aim, flick and fight with friends - your phone is your controller.
- Devpost: https://devpost.com/software/flick-arena
- GitHub: https://github.com/lordronz/flick
- Demo: https://flick.ronz.workers.dev/
- Video: https://www.youtube.com/embed/Nm_pCQ4puHs?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Inspiration

We wanted to create a party game that feels physical and social without asking a group to buy controllers, install an app, create accounts, or configure complicated device pairing. In a shared room, the laptop becomes the arena, while the phones people already have become the controllers. The idea came from the energy of couch multiplayer and arcade game nights. The best moments are quick to understand, visible to everyone, and easy to laugh about, even for people who do not usually play games. Flick Arena brings that energy into the browser through a playful office battle where a paper ball, coffee mug, stapler, or desk bomb can become the winning shot. Our goal was to make the journey from opening the game to landing the first hit feel almost immediate: Create a room, scan the QR code, aim, flick, and play.

### What it does

Flick Arena is a browser-based local multiplayer party game for two to four players. A host opens the game on a laptop or shared display and creates a temporary room. Players scan the room’s QR code with their phones, no account or app installation is required. Each phone becomes a controller: drag to aim, then physically flick the phone forward to throw. A large THROW button is always available as a fallback. Players complete a short interactive tutorial, receive a color and player number, and mark themselves ready. The host starts a 60-second Office Mayhem round. Players bounce office objects through a physics-driven arena, hit opponents, destroy props, and compete for the highest score. Results show the final rankings, match statistics, and round awards. Play Again starts another match without requiring everyone to reconnect. The host screen carries the action, drama, and shared game state, while each phone remains a simple, glanceable controller. Players can focus on the arena and each other instead of staring at separate screens. A read-only Spectator Display can also be opened on a television, projector, tablet, or second laptop. It renders a synchronized version of the match while the original host remains authoritative and available for room management. Flick Arena also avoids making enhanced browser capabilities mandatory. If motion access is denied, blocked, or unsupported, the player can still compete using the on-screen Throw button. Motion sensing, haptics, audio, fullscreen, and wake lock improve the experience but are not required for the core game to function.

### How we built it

React, TanStack Start, and TanStack Router handle the landing page, host lobby, QR joining flow, phone controller, results, settings, spectator route, and user-facing error states. Phaser 4 renders the host-side game. Phaser Matter Physics handles thrown-object movement, rotation, bouncing, platforms, collisions, destructible props, and player hits at a fixed logical resolution. Cloudflare Workers serve the application and its HTTP and WebSocket routes from one deployment. Cloudflare Durable Objects coordinate temporary rooms, one host connection, up to four controller connections, player assignment, ready states, reconnection grace periods, spectator connections, and validated realtime message relay. Native WebSockets carry low-latency aim input, throw events, lobby state, connection updates, host feedback, and spectator synchronization. TypeScript and Zod keep the controller protocol, room messages, game bridge, spectator snapshots, and domain events typed and validated at trust boundaries. Procedural textures, local SVG-style assets, and generated WAV effects keep the core visual and audio assets local, original, and reproducible instead of depending on remote asset or font CDNs. Codex and GPT-5.6 were used throughout development to scaffold systems, implement gameplay features, migrate the prototype to Phaser, generate tests and sound effects, audit the product against its manifesto, debug browser-specific behavior, and iterate on the controller and visual experience. The architecture deliberately keeps responsibilities separated: React owns the product interface and browser networking. Phones own input capture, aim control, flick detection, and haptic feedback. Phaser owns gameplay simulation, rendering, audio, physics, effects, and match presentation. The host browser remains authoritative for collisions, scoring, timers, prop damage, and results. The Durable Object coordinates connections and relays validated messages; it does not run the game loop or physics. Spectator displays render synchronized snapshots and presentation events but cannot control or authoritatively change the match. This separation allowed us to redesign major interactions without replacing the multiplayer foundation.

### Challenges we ran into

The hardest problem was making a phone motion controller feel understandable and consistent across devices. Our first implementation used phone orientation for aiming. In practice, players were unsure whether they should tilt, rotate, point, or swing their phones, and different devices and browsers reported sensor axes differently. We replaced that interaction with a clearer hybrid control scheme: players drag directly on the phone screen to aim and use a short forward phone flick only for throwing. The new approach preserves the physical identity of Flick Arena while making aiming precise and immediately understandable. The trajectory on the shared screen reacts directly to the phone’s aim surface, and the Throw button ensures that denied or unsupported motion access never prevents someone from playing. We also had to make a realtime browser game feel reliable in a room. Player identity must survive an accidental refresh, a temporary disconnect should not immediately surrender a player slot, and malformed or stale input must never reach the host simulation. Durable Object sessions, server-assigned identities, a short reconnection grace period, Zod schemas, input timestamps, role-specific permissions, and strict room boundaries handle those cases. The spectator display introduced a different synchronization challenge. The host remains the only authoritative simulation, while spectator screens receive periodic snapshots and discrete presentation events. Interpolation keeps projectile and character movement smooth without video streaming, duplicating the physics simulation, or allowing spectators to influence gameplay. Another challenge was balancing readable chaos with satisfying physics. Multiple bouncing projectiles, destructible office props, particles, character reactions, floating scores, audio, camera shake, and a 60-second timer all need to feel energetic without hiding the arena or making it difficult to identify players and scores. Finally, mobile-browser behavior varies widely. Motion permissions, vibration, audio autoplay, wake lock, fullscreen support, background-tab behavior, and sensor availability differ across iPhone Safari, Android Chrome, Brave, and individual devices. We treated these capabilities as progressive enhancements and built explicit fallbacks rather than assuming every browser behaves identically.

### Accomplishments we're proud of

A complete room-to-round experience with no accounts, downloads, Bluetooth pairing, or dedicated gaming hardware. A polished two-to-four-player office arena running on one authoritative host display. A clear hybrid controller: drag to aim, flick to throw, and tap THROW if motion is unavailable. A short onboarding flow that teaches the controls through interaction instead of long instructions. Stable player colors and numbers across the controller, host arena, lobby, scoreboard, spectator display, and results screen. Matter-powered projectiles with different weight, speed, rotation, bounce, impact, and scoring characteristics. Destructible office props with damage reactions, particles, debris, score feedback, and object-specific sound effects. Immediate action feedback through trajectory previews, character anticipation, projectile trails, hit-stop, camera shake, floating scores, audio, particles, and haptics where supported. A read-only spectator display that can show the match on another laptop, television, tablet, or projector without transferring authority away from the host. Fast replay flow that resets the round without forcing players to scan the QR code or reconnect. Graceful degradation through touch and keyboard fallbacks, optional motion permission, reconnection recovery, wake-lock recovery, muted-by-default spectator audio, and clear browser error states. A typed and validated realtime protocol that keeps controller, host, and spectator permissions separate. A reproducible local asset pipeline for procedural textures and generated WAV sound effects. A public browser-first experience that works without a custom native application. We are also proud of the development process itself. Codex allowed us to move quickly across networking, Phaser gameplay, visual systems, sound generation, automated testing, and browser debugging, while we retained responsibility for the product decisions: simplifying the controls, limiting the first release to one game mode, prioritizing fallbacks, and focusing on replayability instead of adding unnecessary scope.

### What we learned

The most important lesson was that “using a phone as a controller” is not the same as “putting game controls on a phone.” The phone interface should remain simple and glanceable. The shared screen should carry the game state, spectacle, competition, and emotional feedback. If players spend the entire match studying their phones, the social experience has failed. We also learned that physical controls must be designed around clear intent rather than raw sensor availability. A technically impressive motion system is not useful when players cannot understand what movement the game expects. Separating direct touch aiming from physical throwing made both actions easier to learn and more reliable. Browser capabilities are best treated as optional layers. Motion sensors, vibration, audio autoplay, wake lock, fullscreen, and stable network conditions vary by browser and device. A signature interaction can use those capabilities, but the game still needs to function when one of them is unavailable. On the engineering side, separating the React interface, typed game bridge, host-authoritative Phaser simulation, Durable Object relay, and read-only spectator renderer made iteration much safer. We could replace orientation aiming, redesign the controller, introduce spectators, and improve presentation without rewriting the room system or allowing multiple sources of gameplay truth. We also learned that game feel is a chain rather than a single effect. A throw only feels satisfying when the controller responds, the character anticipates, the projectile launches clearly, the sound matches its weight, the collision is readable, the target reacts, and the score confirms the result. Missing one part can make an otherwise correct mechanic feel weak. Finally, using Codex effectively required more than asking it to generate code. The best results came from giving it clear product constraints, breaking work into verifiable milestones, asking it to inspect rendered output, preserving known-good architecture, and combining its implementation speed with real human playtesting and product judgment.

### What's next

The immediate next step is broader real-world playtesting with groups using the phones and browsers we intend to support, especially iPhone Safari and Android Chrome over HTTPS. We want to measure: How long it takes a new player to join and land their first throw Whether the controls are understood without verbal explanation How reliable physical flick detection feels across devices Whether players look primarily at the shared screen Whether scoring and projectile differences are obvious Whether the arena remains readable with four active players Whether players voluntarily choose Play Again Based on those sessions, we will tune flick sensitivity, projectile balance, aim ranges, round pacing, audio levels, visual hierarchy, destruction effects, and the final countdown. We also want to continue replacing procedural placeholder artwork with stronger authored visuals while preserving the cohesive office-arcade style and gameplay readability. After the first mode is consistently fun, reliable, and easy to understand, we would explore carefully selected additions that preserve its immediacy: More interactions inside the Office Mayhem arena Additional projectile and prop behaviors Stronger round-to-round variation Improved spectator presentation for events and larger displays More accessibility settings and control alternatives Additional local party arenas and modes Event and venue-focused presentation tools We would keep the focus on one excellent local multiplayer experience before considering persistent progression, public matchmaking, remote online play, or a broader game platform.

## README (from the GitHub repository)

# Flick Arena

Flick Arena is a local multiplayer office battle. One laptop renders the Phaser game; two to four phones join the room over WebSockets and become drag-and-flick controllers.

The host owns the match, physics, scoring, and results. The Durable Object only owns room membership, player slots, reconnection grace, and validated message relay.

> [!IMPORTANT]
> **Testing with real phones requires an HTTPS URL that the phones can reach.** Deploying the app is the recommended path. The local `http://localhost:3000` workflow below is intended for testing with additional desktop tabs; a phone cannot use the laptop's `localhost`, and physical flick sensors are not reliably available over plain HTTP. An HTTPS development tunnel is also supported if you do not want to deploy yet.

## Local desktop test

```bash
pnpm install
pnpm dev
```

1. Open `http://localhost:3000` and click **Create Game**.
2. Open the controller URL in two to four additional desktop tabs.
3. Complete the short controller tutorial: drag the aim marker, then tap **THROW** for a practice throw.
4. Mark at least two controllers ready, then start the round on the host.
5. Test dragging, the 3-2-1-FLICK countdown, the 60-second timer, the scoreboard, and **Play Again**.

Keyboard fallback:

- Player 1: `A` / `D` aim, `W` or `Space` throw
- Player 2: `Left` / `Right` aim, `Arrow Up` throw
- Player 3: `J` / `L` aim, `I` throw
- Player 4: `Numpad 4` / `Numpad 6` aim, `Numpad 8` throw

Development builds also allow a one-player demo round.

## Test with iPhone or Android controllers

Use the deployed HTTPS app for real-phone testing. Motion sensors require a secure context, and the URL must be reachable from both the laptop and each phone. Alternatively, expose the local app through an HTTPS development tunnel.

```bash
pnpm cf-typegen
pnpm deploy
```

1. Open the deployed HTTPS URL on the laptop and create a game.
2. Scan the host QR code with each phone’s camera.
3. Drag the marker left and right to aim. Release without firing.
4. Tap **Enable Physical Flick** if you want motion throws; permission is optional because **THROW** always works.
5. Hold the phone securely and make a short forward motion, or tap **THROW**.
6. Confirm the assigned number/color, then tap **Mark Me Ready**.
7. Start the round on the host. Drag to aim and flick to throw.

The controller stores a room-scoped session ID in `localStorage`, so a refresh can reclaim the same player slot during the approximately 15-second reconnect grace period. A fifth simultaneous controller is rejected.

## Commands

```bash
pnpm dev
pnpm build
pnpm typecheck
pnpm test
pnpm generate:sfx
pnpm cf-typegen
pnpm deploy
```

The host game uses Phaser `4.2.1` with Phaser Matter Physics. Phaser is imported and initialized only from the client-side effect in `src/components/PhaserGame.tsx`; it is not part of the SSR execution path.

## AI-assisted development

Flick Arena was built through a human-directed, AI-assisted workflow using GPT-5.6 in two complementary environments.

**GPT-5.6 Sol in ChatGPT** was used as a collaborative thinking partner during the early and iterative design stages. It helped explore the initial concept, brainstorm game mechanics and player interactions, compare technical approaches, identify edge cases, and turn ideas into practical implementation plans. This included reasoning about the host/controller experience, multiplayer room behavior, mobile-device constraints, and the overall structure of the game.

**GPT-5.6 in Codex** was used for hands-on implementation inside the codebase. Working from those ideas and plans, Codex created and modified the application code, connected the game and controller flows, implemented supporting systems, investigated problems, and ran relevant development checks while refining the result.

The process was iterative rather than a single generated output: ideas developed in ChatGPT informed the implementation in Codex, and discoveries made during implementation fed back into further planning and refinement. The project remained human-guided throughout—its goals, creative direction, constraints, trade-offs, and final decisions were directed and reviewed by the developer.

## Local art and audio

Arena sprites, props, projectiles, debris, and impact particles are generated locally at boot by `src/game/art/createProceduralTextures.ts`. The UI uses system fonts and CSS shapes, so the game does not depend on remote art or font CDNs.

The UI, countdown, throw, impact, and destruction cues in `public/assets/audio` are deterministic WAV files generated by `scripts/generate-sfx.mjs`. Regenerate the same set with `pnpm generate:sfx`, or choose another repeatable variation with `pnpm generate:sfx -- --seed=1234`. Keep the supplied `public/soundtrack.mp3` separate: it is an external project asset, not generated by that script.

Host audio controls expose master, music, and effects levels plus mute. Settings persist in `localStorage`; playback unlocks after user interaction, pools overlapping effects, fades the soundtrack between phases, and pauses music when the page is hidden.

## Device limitations and known rough edges

- iPhone Safari requires motion permission from a direct user gesture and normally requires HTTPS. Motion permission is only used for physical flick detection; it is not required to aim or use the Throw button.
- The controller requests Screen Wake Lock automatically over HTTPS so supported browsers do not dim or auto-lock during play. Battery saver, low power mode, older browsers, or a browser policy can reject or revoke it; the controller will show a warning and remain usable, but you must then disable auto-lock manually or keep the phone awake.
- Manually locking the phone or sending the browser to the background pauses reliable motion and WebSocket control. Unlock or return to the controller page to let it reacquire the wake lock; a native app would be required for guaranteed locked-screen control.
- Some iPhone browsers and embedded browsers do not expose `navigator.vibrate`; haptic feedback is optional and fails silently.
- Android motion event names and sensor values vary by browser and device. Chrome over HTTPS is the recommended Android test target; unsupported or denied sensors fall back to the Throw button.
- Sound and haptics are enhancement layers. If autoplay is blocked or audio is muted, visual effects and controls remain functional.
- Procedural art keeps the project self-contained. Its shapes and materials are intentionally arcade-styled rather than production illustration assets; character silhouette and destruction tuning remain the best candidates for a future authored-art pass.

No physical-device test is claimed by this README; verify iPhone and Android behavior on the actual devices you intend to support.


## Detected evidence (automated analysis)

Indexed codebase: 82 recognized source files, 1272 KB.
- CSS (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Cursor — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (99 of 99)

```
.cta.json
.cursorrules
.gitignore
.nvmrc
.vscode/settings.json
biome.json
components.json
flick-arena-control-refinement.md
flick-arena-next.md
flick-manifesto.md
flick-spectator.md
messages/de.json
messages/en.json
package.json
pnpm-workspace.yaml
PRODUCT.md
project.inlang/settings.json
public/manifest.json
public/robots.txt
public/site.webmanifest
README.md
scripts/generate-sfx.mjs
src/cloudflare-env.ts
src/components/BrandMark.tsx
src/components/PhaserGame.tsx
src/components/ui/button.tsx
src/components/ui/input.tsx
src/components/ui/label.tsx
src/components/ui/select.tsx
src/components/ui/slider.tsx
src/components/ui/switch.tsx
src/components/ui/textarea.tsx
src/controller/aim-input.test.ts
src/controller/aim-input.ts
src/controller/controller.css
src/controller/flick-detector.test.ts
src/controller/flick-detector.ts
src/controller/screen-wake-lock.test.ts
src/controller/screen-wake-lock.ts
src/durable-objects/FlickRoom.test.ts
src/durable-objects/FlickRoom.ts
src/env.ts
src/game/art/createProceduralTextures.ts
src/game/art/tokens.ts
src/game/audio/ArcadeAudio.test.ts
src/game/audio/ArcadeAudio.ts
src/game/bridge/game-events.ts
src/game/bridge/GameBridge.test.ts
src/game/bridge/GameBridge.ts
src/game/config/arena.ts
src/game/config/controls.ts
src/game/config/projectiles.ts
src/game/config/props.ts
src/game/createGameConfig.ts
src/game/effects/ArcadeEffects.ts
src/game/FlickGame.ts
src/game/physics.test.ts
src/game/scenes/ArenaScene.ts
src/game/scenes/BootScene.ts
src/game/scenes/PreloadScene.ts
src/game/solo-bot.test.ts
src/game/solo-bot.ts
src/game/systems/aim.test.ts
src/game/systems/aim.ts
src/game/systems/math.ts
src/game/systems/results.test.ts
src/game/systems/results.ts
src/game/systems/scoring.test.ts
src/game/systems/scoring.ts
src/game/types.ts
src/lib/utils.ts
src/realtime/input-validation.test.ts
src/realtime/input-validation.ts
src/realtime/player-slots.test.ts
src/realtime/player-slots.ts
src/realtime/protocol.test.ts
src/realtime/protocol.ts
src/realtime/spectator-policy.test.ts
src/realtime/spectator-policy.ts
src/realtime/websocket-client.ts
src/router.tsx
src/routes/__root.tsx
src/routes/controller.tsx
src/routes/host.tsx
src/routes/index.tsx
src/routes/watch.tsx
src/routeTree.gen.ts
src/server.ts
src/spectator/link.test.ts
src/spectator/link.ts
src/spectator/sync.test.ts
src/spectator/sync.ts
src/styles.css
tsconfig.json
tsr.config.json
vite.config.ts
vitest.config.ts
worker-configuration.d.ts
wrangler.jsonc
```

### Dependencies

- package.json: @biomejs/biome@2.4.5, @cloudflare/vite-plugin@^1.44.0, @inlang/paraglide-js@^2.13.1, @rolldown/plugin-babel@^0.2.3, @t3-oss/env-core@^0.13.10, @tailwindcss/typography@^0.5.16, @tailwindcss/vite@^4.1.18, @tanstack/devtools-vite@latest, @tanstack/react-devtools@latest, @tanstack/react-form@latest, @tanstack/react-router@latest, @tanstack/react-router-devtools@latest, @tanstack/react-router-ssr-query@latest, @tanstack/react-start@latest, @tanstack/router-cli@^1.132.0, @tanstack/router-plugin@^1.132.0, @testing-library/dom@^10.4.1, @testing-library/react@^16.3.0, @types/node@^22.10.2, @types/qrcode@^1.5.6, @types/react@^19.2.0, @types/react-dom@^19.2.0, @vitejs/plugin-react@^6.0.1, babel-plugin-react-compiler@^1.0.0, class-variance-authority@^0.7.1, clsx@^2.1.1, jsdom@^28.1.0, lucide-react@^0.577.0, nitro@npm:nitro-nightly@latest, phaser@^4.2.1, qrcode@^1.5.4, radix-ui@^1.6.2, react@^19.2.0, react-dom@^19.2.0, tailwind-merge@^3.0.2, tailwindcss@^4.1.18, tw-animate-css@^1.3.6, typescript@^6.0.2, vite@^8.0.0, vitest@^4.1.5, wrangler@^4.110.0, zod@^4.3.6

### Recent commits (newest first)

- feat: android motion guide
- docs: explain AI-assisted development workflow
- feat: add solo bot opponent
- feat: add join code room in home
- chore: remove package lock and add .nvmrc
- feat: adjust home styling
- docs: plans and manifesto
- feat: adjust controller style
- feat: better gameplay for real
- feat(controller): save flick sensitivity
- feat: detector enhancements
- feat: game enhancements
- fix: keep throws in play and polish aim control
- feat: add synchronized spectator display
- fix: soundtrack
- feat: polish arcade presentation
- feat: refine drag and flick controls
- feat: keep controller screen awake
- feat: migrate host game to Phaser arena
- feat: first game iter

## Key source files (fetched from GitHub, selected and truncated for size)

### PRODUCT.md

```markdown
# Flick Arena

## Register

product

## Users

Casual in-person groups of two to four players: friends, families, students, coworkers, event attendees, and people who may not identify as gamers. A host uses a laptop or shared display while each player uses their own phone as a controller. The primary job is to join quickly, understand the controls without verbal coaching, and share a physical, social game around one screen.

## Product Purpose

Flick Arena turns ordinary phones into browser-based motion controllers for a shared office-battle party game. A group should move from opening the host page to a first throw in under one minute, without downloads, accounts, controller pairing, or special hardware. Success means the technology disappears, inputs feel immediate and trustworthy, the match remains readable amid the chaos, and players choose Play Again.

## Brand Personality

Playful, physical, and instantly understandable. The tone is competitive without hostility: bright arcade chaos in a friendly stylized office, with exaggerated reactions and clear feedback rather than realistic violence.

## Anti-references

- Engineering dashboards, sensor readouts, raw protocol details, or developer-placeholder UI in the normal player experience.
- Dark military styling, realistic violence, gritty destruction, or anything that makes the office battle feel threatening.
- Dense or excessively detailed backgrounds, tiny text, thin controls, particle overload, or effects that obscure players, aim paths, projectiles, and scores.
- Traditional-controller assumptions, lengthy instructions, forced motion permissions, and setup flows that require host coaching.
- Feature sprawl, persistent progression, online matchmaking, and unfinished extra modes that delay one polished Office Mayhem experience.

## Design Principles

1. **Get to the first throw fast.** Every screen should shorten or clarify the path from room creation to play.
2. **Make every input undeniable.** Aim, accepted throws, hits, cooldowns, scoring, and connection changes need immediate, redundant feedback.
3. **Keep eyes on the shared arena.** The phone is a glanceable controller, not a second game screen or diagnostic console.
4. **Create readable chaos.** Exaggerate action while preserving each player's identity, trajectory, projectile, score, and match status.
5. **Degrade gracefully.** Touch throwing, visual feedback, reconnection, and human-readable errors keep the game playable when sensors, haptics, audio, or networking are imperfect.

## Accessibility & Inclusion

Target WCAG 2.2 AA for the React interface. Keep large touch targets, high contrast, visible keyboard focus, a large Throw-button fallback, and clear errors. Never rely on color alone: pair player color with number, label, and station identity. Physical motion is optional, short, and safety-conscious. The experience must remain understandable without vibration or sound and should honor reduced-motion preferences where feasible.

```

### flick-arena-control-refinement.md

```markdown
Continue developing the existing **Flick Arena** project.

The current controller scheme uses phone orientation to aim and a physical phone flick to throw. In real-device testing, motion-based aiming is confusing, unstable, and difficult to understand across different phones.

Replace the primary aiming interaction with a clearer hybrid control scheme:

> **Drag on the phone screen to aim. Physically flick the phone forward to throw.**

Do not re-scaffold the application or replace the existing architecture.

# Main objective

Make the controller immediately understandable:

* Drag horizontally to adjust aim.
* Release the drag without firing.
* Physically flick the phone forward to throw using the current aim.
* Provide a large **Throw** button as a fallback.
* Show the same aim direction on both the phone and host screen.
* Do not require phone orientation for normal aiming.

The player should understand the controls within five seconds.

# Preserve existing architecture

Keep:

* TanStack Start
* React
* TypeScript
* Phaser
* Cloudflare Workers
* Cloudflare Durable Objects
* Native WebSockets
* Existing room and player assignment
* Existing physical flick detection
* Existing projectile systems
* Existing match lifecycle
* Existing Zod validation
* Existing phone motion permission flow where needed for flick detection

Do not introduce:

* New backend services
* WebRTC
* Socket.IO
* Native applications
* Additional game frameworks
* AI features
* A virtual joystick library

# New control scheme

## Aim

Aim using a horizontal touch surface on the phone controller.

The controller should contain a large dedicated area labeled:

```text
DRAG TO AIM
```

Behavior:

* Drag left to aim further left.
* Drag right to aim further right.
* The aim remains at its last position after the finger is released.
* Releasing the finger must not throw.
* Multi-touch gestures should be ignored.
* Prevent the page from scrolling or zooming while interacting with the aim surface.
* Use Pointer Events when available so the same implementation supports touch and mouse debugging.

The controller sends normalized aim values:

```ts
type AimInputMessage = {
  type: "aim";
  normalizedAim: number;
  timestamp: number;
};
```

Where:

```text
-1 = minimum allowed angle
 0 = center/default angle
 1 = maximum allowed angle
```

Clamp all values to `-1` through `1`.

Do not send raw screen coordinates to the host.

## Throw

Throw using either:

1. A physical forward phone flick
2. A large on-screen **THROW** button

Both should use the current aim.

The physical flick remains the signature interaction.

The Throw button is a fully supported fallback, not merely a hidden debug tool.

The Throw button should:

* Be large enough to press with one thumb.
* Be visually disabled during cooldown.
* Display cooldown progress.
* Trigger the same message and game behavior as a physical flick.
* Use a default intensity such as `0.75`.
* Provide immediate visual feedback.
* Vibrate wh
[truncated — 14970 more characters]
```

### package.json

```
{
  "name": "flick",
  "private": true,
  "type": "module",
  "imports": {
    "#/*": "./src/*"
  },
  "scripts": {
    "dev": "vite dev --port 3000",
    "generate-routes": "tsr generate",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run --config vitest.config.ts",
    "typecheck": "tsc --noEmit",
    "cf-typegen": "wrangler types",
    "deploy": "pnpm cf-typegen && pnpm build && wrangler deploy",
    "format": "biome format",
    "lint": "biome lint",
    "check": "biome check"
	,"generate:sfx": "node scripts/generate-sfx.mjs"
  },
  "dependencies": {
    "@t3-oss/env-core": "^0.13.10",
    "@tailwindcss/vite": "^4.1.18",
    "@tanstack/react-devtools": "latest",
    "@tanstack/react-form": "latest",
    "@tanstack/react-router": "latest",
    "@tanstack/react-router-devtools": "latest",
    "@tanstack/react-router-ssr-query": "latest",
    "@tanstack/react-start": "latest",
    "@tanstack/router-plugin": "^1.132.0",
    "@types/qrcode": "^1.5.6",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "lucide-react": "^0.577.0",
    "nitro": "npm:nitro-nightly@latest",
    "phaser": "^4.2.1",
    "qrcode": "^1.5.4",
    "radix-ui": "^1.6.2",
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "tailwind-merge": "^3.0.2",
    "tailwindcss": "^4.1.18",
    "tw-animate-css": "^1.3.6",
    "zod": "^4.3.6"
  },
  "devDependencies": {
    "@biomejs/biome": "2.4.5",
    "@cloudflare/vite-plugin": "^1.44.0",
    "@inlang/paraglide-js": "^2.13.1",
    "@rolldown/plugin-babel": "^0.2.3",
    "@tailwindcss/typography": "^0.5.16",
    "@tanstack/devtools-vite": "latest",
    "@tanstack/router-cli": "^1.132.0",
    "@testing-library/dom": "^10.4.1",
    "@testing-library/react": "^16.3.0",
    "@types/node": "^22.10.2",
    "@types/react": "^19.2.0",
    "@types/react-dom": "^19.2.0",
    "@vitejs/plugin-react": "^6.0.1",
    "babel-plugin-react-compiler": "^1.0.0",
    "jsdom": "^28.1.0",
    "typescript": "^6.0.2",
    "vite": "^8.0.0",
    "vitest": "^4.1.5",
    "wrangler": "^4.110.0"
  }
}

```

### src/server.ts

```typescript
import handler from '@tanstack/react-start/server-entry'
import { roomCodeSchema } from './realtime/protocol'
import type { FlickEnv } from './cloudflare-env'
import { FlickRoom } from './durable-objects/FlickRoom'

export { FlickRoom }

export default {
  async fetch(request: Request, env: FlickEnv) {
    const url = new URL(request.url)
    const socketMatch = url.pathname.match(/^\/api\/rooms\/([^/]+)\/socket$/)
    if (socketMatch) {
      if (request.method !== 'GET') return new Response('Method not allowed.', { status: 405 })
      const roomCode = socketMatch[1]?.toUpperCase() ?? ''
      if (!roomCodeSchema.safeParse(roomCode).success) return new Response('Invalid room code.', { status: 400 })
      if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return new Response('WebSocket upgrade required.', { status: 426 })
      return env.FLICK_ROOM.getByName(roomCode).fetch(request)
    }
    if (url.pathname === '/api/rooms' && request.method === 'POST') return createRoom(request, env)
    return handler.fetch(request)
  },
}

const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'

async function createRoom(request: Request, env: FlickEnv) {
  for (let attempt = 0; attempt < 8; attempt += 1) {
    const bytes = new Uint8Array(6)
    crypto.getRandomValues(bytes)
    const roomCode = roomCodeSchema.parse(Array.from(bytes, (byte) => ALPHABET[byte % ALPHABET.length]).join(''))
    const initResponse = await env.FLICK_ROOM.getByName(roomCode).fetch(new Request(`${new URL(request.url).origin}/__init`, { method: 'POST' }))
    if (initResponse.status === 409) continue
    if (!initResponse.ok) return Response.json({ error: 'Could not initialize room.' }, { status: 503 })
    return Response.json({ roomCode })
  }
  return Response.json({ error: 'Could not find an available room code.' }, { status: 503 })
}

```

### src/routes/index.tsx

```typescript
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Github } from "lucide-react";
import { type FormEvent, useState } from "react";
import { BrandMark } from "../components/BrandMark";
import { playUiCue } from "../game/audio/ArcadeAudio";
import { roomCodeSchema } from "../realtime/protocol";

export const Route = createFileRoute("/")({ component: Home });

function Home() {
	const navigate = useNavigate();
	const [isCreating, setIsCreating] = useState(false);
	const [isJoining, setIsJoining] = useState(false);
	const [roomCode, setRoomCode] = useState("");
	const [error, setError] = useState("");

	async function createGame() {
		setIsCreating(true);
		setError("");
		try {
			const response = await fetch("/api/rooms", { method: "POST" });
			const payload = (await response.json()) as {
				roomCode?: string;
				error?: string;
			};
			if (!response.ok || !payload.roomCode)
				throw new Error(payload.error ?? "Could not create room.");
			await navigate({ to: "/host", search: { room: payload.roomCode } });
		} catch (cause) {
			playUiCue("ui-error");
			setError(
				cause instanceof Error ? cause.message : "Could not create room.",
			);
		} finally {
			setIsCreating(false);
		}
	}

	async function joinGame(event: FormEvent<HTMLFormElement>) {
		event.preventDefault();
		const normalizedRoomCode = roomCode.trim().toUpperCase();
		setRoomCode(normalizedRoomCode);
		setError("");

		if (!roomCodeSchema.safeParse(normalizedRoomCode).success) {
			playUiCue("ui-error");
			setError("Enter a valid 6-character room code.");
			return;
		}

		setIsJoining(true);
		try {
			await navigate({
				to: "/controller",
				search: { room: normalizedRoomCode },
			});
		} catch (cause) {
			playUiCue("ui-error");
			setError(cause instanceof Error ? cause.message : "Could not join room.");
			setIsJoining(false);
		}
	}

	return (
		<main className="landing-shell">
			<section className="landing-copy">
				<div className="brand-mark">
					<BrandMark />
				</div>
				<p className="eyebrow">PHONE AS CONTROLLER</p>
				<h1>
					Drag the aim.
					<br />
					<em>Flick the shot.</em>
				</h1>
				<p className="lede">
					Drag on your phone to aim, then flick or tap Throw to fire at the
					office arena on your laptop.
				</p>
				<button
					className="primary-button"
					type="button"
					onClick={createGame}
					onMouseEnter={() => playUiCue("ui-hover")}
					onFocus={() => playUiCue("ui-hover")}
					disabled={isCreating}
				>
					{isCreating ? "Creating room…" : "Create Game"}
					<span aria-hidden="true">↗</span>
				</button>
				{error ? (
					<p className="error-text" id="room-code-error" role="alert">
						{error}
					</p>
				) : null}
				<form className="join-form" onSubmit={joinGame}>
					<label htmlFor="room-code">Have a room code?</label>
					<div className="join-form-row">
						<input
							id="room-code"
							name="roomCode"
							type="text"
							value={roomCode}
							onChange={(event) => setRoomCode(event.target.value)}
							placeholder="ABC123"
							maxLength={6}
							autoCapitalize="characters"
							autoComplete="off"
							spellCheck={false}
							aria-describedby={error ? "room-code-error" : undefined}
						/>
						<button
							className="join-button"
							type="submit"
							onMouseEnter={() => playUiCue("ui-hover")}
							onFocus={() => playUiCue("ui-hover")}
							disabled={isJoining}
						>
							{isJoining ? "Joining…" : "Join"}
						</button>
					</div>
				</form>
				<p className="microcopy">No account. One laptop. Two to four phones.</p>
				<a
					className="github-link"
					href="https://github.com/lordronz/flick"
					target="_blank"
					rel="noreferrer"
					aria-label="View Flick Arena on GitHub"
				>
					<Github aria-hidden="true" />
					<span>View on GitHub</span>
				</a>
			</section>
			<section className="landing-preview" aria-label="Flick game preview">
				<div className="preview-topline">
					<span>OFFICE MAYHEM</span>
				</div>
				<div className="preview-canvas">
					<div className="preview-windows">
						<i />
						<i />
						<i />
					</div>
					<div className="preview-city">
						<i />
						<i />
						<i />
						<i />
						<i />
					</div>
					<div className="preview-desk" />
					<div className="preview-mascot mascot-blue">
						<span>P1</span>
					</div>
					<div className="preview-mascot mascot-red">
						<span>P2</span>
					</div>
					<div className="preview-projectile" />
					<div className="preview-impact">BONK!</div>
				</div>
				<div className="preview-stats">
					<div>
						<span>AIM</span>
						<strong>DRAG</strong>
					</div>
					<div>
						<span>THROW</span>
						<strong>FLICK</strong>
					</div>
					<div>
						<span>GOAL</span>
						<strong>HIT TARGET</strong>
					</div>
				</div>
			</section>
		</main>
	);
}

```

### pnpm-workspace.yaml

```yaml
allowBuilds:
  esbuild: true
  sharp: true
  workerd: true

```

### vitest.config.ts

```typescript
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
  },
})

```

### vite.config.ts

```typescript
import { defineConfig } from 'vite'
import { cloudflare } from '@cloudflare/vite-plugin'
import { paraglideVitePlugin } from '@inlang/paraglide-js'

import { tanstackStart } from '@tanstack/react-start/plugin/vite'

import viteReact, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite'

const config = defineConfig({
  resolve: { tsconfigPaths: true },
  plugins: [
    cloudflare({ viteEnvironment: { name: 'ssr' } }),
    paraglideVitePlugin({
      project: './project.inlang',
      outdir: './src/paraglide',
      strategy: ['url', 'baseLocale'],
    }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
    babel({ presets: [reactCompilerPreset()] }),
  ],
})

export default config

```

### src/cloudflare-env.ts

```typescript
export type FlickEnv = Env

```

### src/router.tsx

```typescript
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  const router = createTanStackRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
  })

  return router
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof getRouter>
  }
}

```

[68 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]