Imported from asemfateen/air-playstation (
AGENTS.md). Install upstream withnpx skills add asemfateen/air-playstation. Copyright stays with the author.
AGENTS.md — Air Playstation
Guidelines for any agent working in this repository. Read this before changing anything.
Purpose
Air Playstation is an AirConsole-style party-game platform: the game runs on a big screen (a browser on a TV/laptop) and each player's smartphone becomes the gamepad. Phones and screen talk over WebSocket via a small Node.js relay server. No hardware needed.
The product name/branding is "Air Playstation" (do not rename back to "Air Console").
Repository layout
Air playstation/
├── server/ Node.js relay: HTTP static hosting + WebSocket relay (single file, port 8080)
│ ├── index.js the whole server
│ └── package.json depends only on `ws`
├── web/ Big-screen UI (static HTML/JS, no build step) — served by server at /
│ └── index.html game-library carousel menu + Snake game (all in one file)
└── app/ Expo / React Native phone controller
├── App.js navigation (stack: Menu -> Controller)
├── app.json Expo config (name "Air Playstation", portrait)
└── src/screens/
├── MenuScreen.js fake game-tile grid (tapping a tile -> Controller)
└── ControllerScreen.js D-pad + PRESS button + WebSocket client
How to run
1. Server (must be running first — serves the web UI AND the WebSocket)
cd server && npm install && npm start # listens on 0.0.0.0:8080
- Web UI:
http://localhost:8080(open on the TV/laptop = the "screen") - WebSocket:
ws://localhost:8080 - Server is ESM (
"type": "module"), depends only onws.
2. Phone controller (Expo)
cd app && npm install && npm start # then press `a` for Android / `w` for web
- Run on the Android emulator via Expo Go (pressed
a). Expo auto-installs Expo Go on the emulator — no gradle build, so any Java works. - Emulator networking: the app reaches the host server through
ws://10.0.2.2:8080(10.0.2.2= host machine's localhost inside the Android emulator). This is hardcoded inapp/src/screens/ControllerScreen.jsviaPlatform.OS === 'android' ? 'ws://10.0.2.2:8080' : 'ws://localhost:8080'. - On a physical phone, change that constant to your machine's LAN IP (e.g.
ws://192.168.1.186:8080).
3. Android emulator (this machine)
- SDK lives at
~/Android/Sdk.adbis NOT on PATH — use$ANDROID_HOME/platform-tools/adb. - One AVD exists:
Medium_Phone. - Start it (single instance only — never spawn a second emulator, it eats RAM):
export ANDROID_HOME=$HOME/Android/Sdk nohup $ANDROID_HOME/emulator/emulator -avd Medium_Phone -no-boot-anim -no-snapshot-save > /tmp/emulator.log 2>&1 & $ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 2; done; echo BOOTED' - To tap the screen programmatically:
adb shell input tap X Y.
4. Verify the app bundles
cd app && npx expo export --platform android --output-dir /tmp/expo-export-check
No errors = the bundle compiles. (Metro dev server = port 8081, never conflicts with 8080.)
Message protocol (the contract between app and web)
All messages are JSON over one WebSocket. Server relays controller → screen (and screen → controllers).
Client → server
| type | payload | meaning |
|---|---|---|
hello |
{ type:'hello', role:'screen'|'controller' } |
register device, on connect |
message |
{ type:'message', data:{...} } |
arbitrary payload to relay |
Server → client
| type | payload | meaning |
|---|---|---|
welcome |
{ type:'welcome', deviceId, role } |
reply to hello |
deviceJoined |
{ type:'deviceJoined', deviceId, role } |
another device joined (screen gets controllers, controllers get screen) |
deviceGone |
{ type:'deviceGone', deviceId, role } |
another device left |
message |
{ type:'message', deviceId, data } |
relayed payload, deviceId = sender |
Input data events (sent by the phone, consumed by the web)
data is { event: <name>, at: <ms timestamp> }.
| event | web behavior |
|---|---|
nav-left |
move menu selection left |
nav-right |
move menu selection right |
select |
launch selected game / exit back to menu / restart game |
up / down / left / right |
snake steering |
press |
logged to the press log |
IMPORTANT: event name strings must match EXACTLY between app/src/screens/ControllerScreen.js (sender) and web/index.html (receiver). They were mismatched once (web expected nav+dir, app sent nav-right) and broke navigation — when adding events, update both sides in the same change.
Files in detail
server/index.js
- Node http server +
WebSocketServeron the same port (8080). - Serves
../web/statically (path-traversal guarded viastartsWith(WEB_DIR)). - Tracks devices in a
Map(id -> { ws, role }), monotonically increasingnextId. helloassigns role + id, replieswelcome, broadcastsdeviceJoined.messagefrom a controller relays to all screens; from a screen relays to all controllers (excludethe sender).- Logs every join/leave/message to stdout.
web/index.html (screen + game host)
- Menu: horizontal carousel of 6 fake games (
GAMESarray: pong, quiz, snake, buzzer, tetris, racer — id/name/icon/color). Selected card is centered + scaled via CSS transform on.track.update()computestranslateX. - Playing overlay:
#playingfullscreen; generic games show icon+name;snakeis a real game:- Canvas 420×420, 21×21 grid (
N=21,CELL=canvas.width/N). startSnake()/stopSnake()/tickSnake()(interval, starts 160ms, speeds up with score, floor 80ms) /randFood()/snakeInput(dir)/drawSnake().- Direction input is queued (avoids 180° reversals and double-tick turning).
- Game over (wall or self) draws "GAME OVER — PRESS to restart" overlay.
- Canvas 420×420, 21×21 grid (
- Gotcha fixed already:
snake.food = randFood()must be assigned AFTERsnake = {...}— calling it inside the object literal crashes becausesnakeis stillnullinsiderandFood(). - To add a real game: extend the
GAMESarray, add aselect()branch, hook the relevantdataevents.
app/App.js
NavigationContainerwithDarkThemeoverridden to#0f1220background.- Native stack:
Menu(header hidden) →Controller(title "Air Playstation").
app/src/screens/MenuScreen.js
- Scrollable grid of
GAMEStiles. Every tilenavigates('Controller')(fake menu — game selection is really driven from the web screen's carousel).
app/src/screens/ControllerScreen.js
- WebSocket client (
ws://10.0.2.2:8080on Android, elselocalhost). - Sends
helloon open; connection banner (green Connected / red Disconnected). - D-pad layout: ▲ ◀ [PRESS center] ▶ ▼. Each button sends a
messagewith anevent. send(event)setslastSendfor the hint line.
Visual conventions
- Background
#0f1220, card/panel#1a1f33, text#e8eaf6, muted#9fa8da. - Primary action / PRESS button red
#e53935(pressed#b71c1c). - Connected green
#1b5e20, disconnected red#7f1d1d. - Snake accent green
#43a047, food red#e53935, game-over yellow#ffd54f. - Fonts:
system-ui; web carousel card = 240×300, radius 20.
Code conventions
- Web: single-file vanilla HTML/CSS/JS, no build step, no framework. Keep it that way.
- App: Expo SDK 57 / React Native 0.86 / React 19 / React Navigation 7. Install new Expo-compatible deps with
npx expo install <pkg>(never plain npm for native modules). - Do not add comments unless they explain a non-obvious gotcha (e.g. the
randFoodordering). - No feature creep — match existing structure when adding games/screens.
app/AGENTS.md(created by Expo template) notes that Expo API surface changes — consult https://docs.expo.dev/versions/v57.0.0/ before relying on an Expo API.
Testing / verification
- Simulate a phone without the emulator (Node script, run from repo root):
const WebSocket = require('./server/node_modules/ws'); const ws = new WebSocket('ws://localhost:8080'); const send = (d) => ws.send(JSON.stringify({ type: 'message', data: d })); ws.on('open', () => { ws.send(JSON.stringify({ type: 'hello', role: 'controller' })); setTimeout(() => send({ event: 'nav-right' }), 300); setTimeout(() => send({ event: 'nav-right' }), 500); setTimeout(() => send({ event: 'select' }), 700); setTimeout(() => send({ event: 'up' }), 1100); setTimeout(() => ws.close(), 1800); }); - Web UI: verify with the Playwright browser tools against
http://localhost:8080(DOM snapshot shows carousel cards + selected ▶; console errors must be 0). Canvas games are verified by reading pixels viagetContext('2d').getImageData(...)— aGAME OVERoverlay dims colors, so sample while the game is running to see live cells. - App:
npx expo export --platform androidto confirm the bundle compiles; visual check in the emulator via Expo Go. - There is no test framework and no linter configured — do not invent one without being asked.
Git
- Remote:
git@github.com:asemfateen/air-playstation.git, branchmain. - Repo state should be kept pushed after meaningful work. Only commit/push when the user asks.
- Commit message style (existing history):
Initial commit: Air Console phone-as-controller platform.Rename project branding to Air Playstation.
Environment gotchas
- Node v22.
adbnot on PATH — full path via~/Android/Sdk/platform-tools/adb. /dev/kvmexists (hardware accel) so the emulator runs fast.- Never start a second emulator instance — only
Medium_Phone, one at a time. - Ports in use: 8080 (server), 8081 (Metro). Don't collide.
- Server log:
/tmp/air-server.logwhen launched with nohup. Emulator log:/tmp/emulator.log. .playwright-mcp/is gitignored (Playwright artifacts).