Multiplayer hex stacker where phones become controllers and a shared screen shows the action.
Play now at hexstacker.com
Gallery: main.hexstacker.com/gallery — reference pages for the display, the phone, and piece rotations · cross-platform comparison (web vs tvOS vs Android TV): main.hexstacker.com/gallery-tv/
HexStacker Party supports 1 to 8 players on a single shared display. One browser window acts as the game screen (TV, monitor, or laptop), while each player joins by scanning a QR code with their phone. The display runs the authoritative game engine; the Node.js server only serves static files.
The display also ships as native apps for Apple TV and Android TV, which run the same engine bundle. See Native TV ports.
Game events — display broadcasts state to all controllers via the relay.
graph LR
D[Display Browser] -- "game events (WebSocket)" --> R[Party-Sockets Relay]
R -- "game events (WebSocket)" --> P[Phone Controllers]
Controller input — after WebRTC negotiation, input goes directly to the display over a DataChannel; the relay serves as signaling channel and input fallback.
graph LR
P[Phone Controllers] <-- "signaling (WebSocket)" --> R[Party-Sockets Relay]
R <-- "signaling (WebSocket)" --> D[Display Browser]
P -- "input fallback (WebSocket)" --> R
R -- "input fallback (WebSocket)" --> D
P -- "input (DataChannel)" --> D
linkStyle 2 stroke:#94a3b8,stroke-dasharray:4,stroke-width:1px
linkStyle 3 stroke:#94a3b8,stroke-dasharray:4,stroke-width:1px
linkStyle 4 stroke:#22c55e,stroke-width:2px
- 1–8 players on one shared screen
- QR code join – scan and play, no app install
- Touch gesture controls with haptic feedback (Android)
- Hexagonal grid with line clears and garbage attack
- Per-player level selection (1–15) and color picker in lobby
- Controller settings: audio, haptics, touch sensitivity
- Localized UI (11 languages)
- AirConsole platform support
npm install # required once — the test suite needs esbuild too
npm run dev # serves individual scripts + restarts on changenpm run dev is the inner loop: no bundling step, and node --watch restarts the
server on every edit, so a browser reload is enough to see a change.
For a production-style run, build the bundles first and start in production mode:
npm run build # esbuild: one hashed bundle per app + dist/partycore.js (~0.3s)
APP_ENV=production node server/index.js- Open
http://localhost:4000on a big screen. - Players scan the QR code with their phones to join.
- The first player to join is the host and starts the game.
- Use touch gestures on your phone to control pieces. Last player standing wins.
| Gesture | Action |
|---|---|
| Drag left/right | Move piece horizontally (ratcheting at 44 px steps) |
| Tap | Rotate clockwise |
| Flick down | Hard drop |
| Drag down + hold | Soft drop (variable speed based on drag distance) |
| Flick up | Hold piece |
All gestures provide haptic feedback on supported devices. The controller uses axis locking so horizontal and vertical movements do not interfere with each other.
server/ # Game engine modules (isomorphic UMD, used by display + tests)
# + core-entry.js (native HexCore bundle entry)
public/
display/ # Display client: game authority, Canvas renderer
controller/# Phone touch controller
shared/ # Protocol, relay connection, colors, theme, shared UI
partyplug/ # Reusable party-game transport/room kit (own README + tests)
appletv/ # Native tvOS display port (Swift, see appletv/README.md)
android/ # Native Android TV display port (Kotlin/Compose, Gradle)
scripts/ # build.js (esbuild bundles), asset-manifest.js (canonical script
# load order), AirConsole HTML generator, gallery/
tests/ # Unit tests (node:test) and Playwright E2E
dist/ # Build output (gitignored): partycore.js + web-manifest.json
artwork/ # Banner, favicon, icon and cover art generators (Playwright)
Browser script load order lives in scripts/asset-manifest.js; the app index.html
files use <!--CONTROLLER_SCRIPTS--> / <!--DISPLAY_SCRIPTS--> placeholders that the
server expands to either one hashed bundle (production / SERVE_BUNDLES=1) or the
individual files (dev).
Apple TV and Android TV have no usable browser for the display, so both rebuild the
display natively while reusing the engine verbatim: server/*.js +
partyplug/RoomFlow.js are bundled by npm run build:core into dist/partycore.js
and executed in JavaScriptCore (tvOS) / QuickJS (Android). Controllers stay web pages,
so nothing changes for players.
npm run build:core # Android's :tv preBuild needs dist/partycore.js
(cd android && ./gradlew :core:jvmTest :tv:assembleDebug)
(cd appletv && swift test) # rebuilds the bundle itself; see appletv/README.mdRenderer differences between the three platforms are caught by the cross-platform
screen gallery (scripts/gallery/), not by the engine, which is byte-exact across
all three per the frame-golden conformance tests.
The web display derives its QR/join origin from window.location, so a preview
deploy retargets itself; the TV apps have no origin to read and default to
https://hexstacker.com. Both take a debug-only override at launch. The relay is
untouched, so the preview phone and the TV still meet in the same room:
# Apple TV: Edit Scheme > Run > Environment Variables
HEXHOST=preview-<branch>.hexstacker.com
# Android TV (debuggable builds only; a launcher start is always production)
adb shell am start -n com.hexstacker.tv/.MainActivity \
--es hexHost preview-<branch>.hexstacker.comA bare host, a full origin, or a pasted join URL all work (the origin is kept, the rest dropped). The lobby shows the host next to the room code, so which build the phones are about to load is visible on screen.
The display and controllers connect to a Party-Sockets WebSocket relay for message forwarding. The relay URL is set in public/shared/protocol.js. If you run your own relay, update this value and the CSP connect-src directive in server/index.js.
| Environment Variable | Default | Description |
|---|---|---|
PORT |
4000 |
HTTP server port |
BASE_URL |
Auto-detected LAN IP | Base URL for join links and QR codes |
APP_ENV |
development |
production serves the hashed web bundles (immutable cache) instead of individual files |
SERVE_BUNDLES |
– | 1 forces bundle serving without full production mode (used by e2e; keeps the dev CSP the AirConsole mock needs) |
GIT_SHA |
– | Git commit SHA shown in version endpoint |
Build-time only:
| Environment Variable | Default | Description |
|---|---|---|
BUILD_COMPRESSION |
fast | max spends brotli quality 11 on the precompressed .br siblings: ~9% smaller, for roughly double the build wall time. Set by the Dockerfile, since the image's bundles are the ones users download; every other build takes the fast tier |
# Unit tests (~2s, no build needed)
npm test
npm run test:watch # same suite, re-run on save
# Both E2E projects — prefer this over running the two separately, which
# rebuilds and re-boots the test server twice
npm run test:e2e:all
# ...or one project at a time
npm run test:e2e # lifecycle
npm run test:e2e:airconsole # AirConsole (mock SDK)
# Relay load test (k6 — requires k6 installed)
k6 run scripts/relay-loadtest.k6.jsUnit tests use Node.js's built-in node:test runner with node:assert/strict — no test framework dependency, and no build step: the couple of tests that need a bundle build it in-memory. They do need npm install, since that bundling goes through esbuild.
E2E tests use Playwright against a live server on port 4100, which the Playwright config builds and boots itself (PW_PORT moves it if the port is taken). Both projects share that one server, which is why test:e2e:all is meaningfully faster than two separate runs. UI regressions are caught via the live gallery at /gallery. The relay load test models 5-client rooms (1 display + 4 controllers) against the configured relay URL; see the script header for environment knobs.
- Runtime: Node.js
- Relay: Party-Sockets WebSocket relay (signaling + game events)
- P2P: WebRTC DataChannels for low-latency controller input
- Frontend: Vanilla JavaScript, Canvas API
- QR codes: qrcode-generator by Kazuhiko Arase (MIT), vendored at
public/shared/qrcode-generator.jsand run client-side. The native ports use their platform's own encoder (CoreImage on tvOS, ZXing on Android TV) - Native TV: Swift + SpriteKit (tvOS), Kotlin + Compose/Canvas (Android TV), both running the shared engine bundle
- Bundling: esbuild (build-time devDep) for production web bundles and the native
dist/partycore.jscore - Testing: Node.js built-in test runner + Playwright
- Runtime deps: none — the server ships zero npm dependencies
