Skip to content

Repository files navigation

HexStacker Party

HexStacker Party gameplay banner

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/

Overview

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.

Architecture

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]
Loading

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
Loading

Features

  • 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

Quick Start

npm install                   # required once — the test suite needs esbuild too
npm run dev                   # serves individual scripts + restarts on change

npm 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
  1. Open http://localhost:4000 on a big screen.
  2. Players scan the QR code with their phones to join.
  3. The first player to join is the host and starts the game.
  4. Use touch gestures on your phone to control pieces. Last player standing wins.

Controller Gestures

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.

Project Structure

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).

Native TV ports

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.md

Renderer 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.

Pointing a TV app at a branch preview

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.com

A 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.

Configuration

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

Testing

# 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.js

Unit 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.

Tech Stack

  • 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.js and 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.js core
  • Testing: Node.js built-in test runner + Playwright
  • Runtime deps: none — the server ships zero npm dependencies

About

Browser-based multiplayer stacker where phones become controllers and a shared screen shows the action.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages