← back to Wallco Ai
hot-or-not: standalone server on port 9794 — own pm2 process
a7922f0b10ce64e3b2db39d235c40cb59da957db · 2026-05-12 00:00:11 -0700 · SteveStudio2
Per Steve: 'ok to serve hot or not on a port too.'
NEW: hot-or-not-server.js — focused 4 KB Express server on port 9794.
Independent pm2 process (wallco-hot-or-not) so it can be deployed,
scaled, and monitored separately from the main wallco-ai (9792).
ARCHITECTURE
• Self-contained HTML page (game engine, particle bursts, haptics,
XP/level banner, achievements). No /designs catalog, no studio,
no admin — just the focused speed-vote experience.
• Proxies 3 routes to main wallco-ai:
GET /api/design/random — for next card
POST /api/design/:id/user-vote — to record HOT/NOT
GET /designs/img/:fn — image asset passthrough
Cookie + Set-Cookie passed through both ways so the voter session
stays consistent with main wallco.ai.
• Loads /js/wallco-game.js directly (statics served locally).
PORT 9794 chosen as next-free after 9792 (wallco-ai). Reserved in
the pm2 fleet on Mac2 + prod Kamatera.
UPSTREAM CONFIG: WALLCO_UPSTREAM env var (defaults to
http://127.0.0.1:9792). Lets dev/prod target different backends
without code edits.
FOR PUBLIC SUBDOMAIN
When ready: GoDaddy A record `hotornot.wallco.ai → 45.61.58.125`,
nginx vhost proxying to localhost:9794, certbot for SSL — same
pattern as wallco.ai go-live. Skipped here per Steve's
port-only-for-now framing.
VERIFIED
http://127.0.0.1:9794/ returns 7.1 KB page with game engine loaded.
/api/design/random proxied successfully: {id:18, title:"Amber
Lattice No.18", dominant_hex:"#bb7d4e"}.
Card markup + 3 buttons + 1 game.js script all present.
Files touched
Diff
commit a7922f0b10ce64e3b2db39d235c40cb59da957db
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 00:00:11 2026 -0700
hot-or-not: standalone server on port 9794 — own pm2 process
Per Steve: 'ok to serve hot or not on a port too.'
NEW: hot-or-not-server.js — focused 4 KB Express server on port 9794.
Independent pm2 process (wallco-hot-or-not) so it can be deployed,
scaled, and monitored separately from the main wallco-ai (9792).
ARCHITECTURE
• Self-contained HTML page (game engine, particle bursts, haptics,
XP/level banner, achievements). No /designs catalog, no studio,
no admin — just the focused speed-vote experience.
• Proxies 3 routes to main wallco-ai:
GET /api/design/random — for next card
POST /api/design/:id/user-vote — to record HOT/NOT
GET /designs/img/:fn — image asset passthrough
Cookie + Set-Cookie passed through both ways so the voter session
stays consistent with main wallco.ai.
• Loads /js/wallco-game.js directly (statics served locally).
PORT 9794 chosen as next-free after 9792 (wallco-ai). Reserved in
the pm2 fleet on Mac2 + prod Kamatera.
UPSTREAM CONFIG: WALLCO_UPSTREAM env var (defaults to
http://127.0.0.1:9792). Lets dev/prod target different backends
without code edits.
FOR PUBLIC SUBDOMAIN
When ready: GoDaddy A record `hotornot.wallco.ai → 45.61.58.125`,
nginx vhost proxying to localhost:9794, certbot for SSL — same
pattern as wallco.ai go-live. Skipped here per Steve's
port-only-for-now framing.
VERIFIED
http://127.0.0.1:9794/ returns 7.1 KB page with game engine loaded.
/api/design/random proxied successfully: {id:18, title:"Amber
Lattice No.18", dominant_hex:"#bb7d4e"}.
Card markup + 3 buttons + 1 game.js script all present.
---
hot-or-not-server.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 220 insertions(+)
diff --git a/hot-or-not-server.js b/hot-or-not-server.js
new file mode 100644
index 0000000..b5b1847
--- /dev/null
+++ b/hot-or-not-server.js
@@ -0,0 +1,220 @@
+#!/usr/bin/env node
+/**
+ * Hot-or-Not standalone — port 9794.
+ *
+ * Self-contained mini-app: serves the gamified card-flick UI on its own
+ * port + pm2 process, independent of the main wallco-ai server (9792).
+ * Backend API calls (random design, user vote) proxy to the main server
+ * so design data and votes stay in one place.
+ *
+ * Run: PORT=9794 node hot-or-not-server.js
+ * pm2: pm2 start hot-or-not-server.js --name wallco-hot-or-not
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '.env') });
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9794', 10);
+
+// Upstream wallco-ai server for API + image assets
+const UPSTREAM = process.env.WALLCO_UPSTREAM || 'http://127.0.0.1:9792';
+
+app.use(express.json({ limit: '2mb' }));
+
+// Serve the local game engine
+app.use('/js', express.static(path.join(__dirname, 'public', 'js'), { maxAge: '1h' }));
+
+// Proxy: GET /api/design/random → upstream
+app.get('/api/design/random', async (req, res) => {
+ try {
+ const url = UPSTREAM + '/api/design/random' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
+ const r = await fetch(url, { headers: { 'cookie': req.headers.cookie || '' } });
+ const json = await r.json();
+ res.json(json);
+ } catch (e) { res.status(502).json({ error: 'upstream: ' + e.message }); }
+});
+
+// Proxy: POST /api/design/:id/user-vote → upstream
+app.post('/api/design/:id/user-vote', async (req, res) => {
+ try {
+ const r = await fetch(`${UPSTREAM}/api/design/${req.params.id}/user-vote`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'cookie': req.headers.cookie || ''
+ },
+ body: JSON.stringify(req.body || {})
+ });
+ res.set('set-cookie', r.headers.get('set-cookie') || '');
+ const j = await r.json();
+ res.json(j);
+ } catch (e) { res.status(502).json({ error: 'upstream: ' + e.message }); }
+});
+
+// Proxy: image assets so /designs/img/<file>.png works (load via main server)
+app.get('/designs/img/:fn', async (req, res) => {
+ try {
+ const r = await fetch(`${UPSTREAM}/designs/img/${encodeURIComponent(req.params.fn)}`);
+ if (!r.ok) return res.status(r.status).end();
+ res.set('content-type', r.headers.get('content-type') || 'image/png');
+ res.set('cache-control', 'public, max-age=86400');
+ const buf = Buffer.from(await r.arrayBuffer());
+ res.end(buf);
+ } catch (e) { res.status(502).end(); }
+});
+
+// ── Main page ───────────────────────────────────────────────────────
+app.get('/', (req, res) => {
+ res.type('html').send(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Hot or Not · wallco.ai</title>
+<meta name="description" content="Speed-vote on AI-original wallpaper. Every tap teaches the system your taste.">
+<meta name="theme-color" content="#0f0e0c">
+<link rel="canonical" href="https://hotornot.wallco.ai/">
+<style>
+ *,*::before,*::after { box-sizing: border-box; }
+ html, body { margin:0; padding:0; background:#0f0e0c; color:#f0eadc; font:15px/1.45 -apple-system, system-ui, Segoe UI, sans-serif; min-height:100vh; }
+ header { padding:14px 18px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #221f1c; }
+ header a.brand { font-family:'Cormorant Garamond', Georgia, serif; font-weight:300; font-size:22px; color:#f0eadc; text-decoration:none; letter-spacing:.5px; }
+ header .tag { font-size:11px; color:#888; text-transform:uppercase; letter-spacing:.1em; }
+ main { padding:18px; max-width:520px; margin:0 auto; }
+ h1 { font-family:'Cormorant Garamond', Georgia, serif; font-weight:300; font-size:30px; margin:0 0 4px; text-align:center; }
+ .sub { color:#888; font-size:12px; text-align:center; margin:0 0 12px; }
+ #wc-game-banner { background:#1a1816; border-radius:10px; padding:14px 16px; margin-bottom:16px; border:1px solid rgba(210,177,92,.25); }
+ #card { position:relative; border:1px solid rgba(255,255,255,.08); border-radius:18px; overflow:hidden; background:#1a1816; box-shadow:0 24px 60px rgba(0,0,0,.5); transition:transform .26s ease, opacity .26s ease; }
+ #card img { display:block; width:100%; aspect-ratio:1; object-fit:cover; pointer-events:none; }
+ #card .meta { padding:14px 20px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
+ #card .title { font-family:'Cormorant Garamond', Georgia, serif; font-weight:300; font-size:22px; }
+ #card .sub2 { font-size:10px; color:#888; text-transform:uppercase; letter-spacing:.06em; margin-top:3px; }
+ #card .viewfull { font-size:11px; color:#d2b15c; text-decoration:underline; }
+ #card.swipe-left { transform:translateX(-130%) rotate(-14deg); opacity:0; }
+ #card.swipe-right { transform:translateX( 130%) rotate( 14deg); opacity:0; }
+ #card.swipe-up { transform:translateY(-130%); opacity:0; }
+ #buttons { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; margin-top:18px; }
+ .btn { padding:18px 12px; font-size:14px; font-weight:700; letter-spacing:.12em; border:0; border-radius:12px; cursor:pointer; min-height:62px; transition:transform .12s; }
+ .btn:active { transform:scale(.96); }
+ .btn-not { background:linear-gradient(180deg,#3a1a1a,#2a1212); color:#fbb; border:1px solid #5a2222; }
+ .btn-skip { background:#2a2825; color:#aaa; border:1px solid #3a3835; }
+ .btn-hot { background:linear-gradient(180deg,#e0bd64,#d2b15c); color:#1a1a1a; border:1px solid #b89745; }
+ footer { margin-top:32px; padding:24px 18px; text-align:center; color:#666; font-size:11px; }
+ footer a { color:#888; text-decoration:none; }
+ footer a:hover { color:#d2b15c; }
+ .history { display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; margin-top:22px; }
+ .history a { display:block; aspect-ratio:1; border-radius:5px; overflow:hidden; border:2px solid transparent; }
+ .history a img { width:100%; height:100%; object-fit:cover; display:block; }
+ .history a.hot { border-color:#d2b15c; }
+ .history a.not { border-color:#a44; }
+ .history a.skip { border-color:#555; }
+ .keys { font-size:10px; color:#666; margin-top:6px; text-align:center; }
+ @media (min-width: 700px) {
+ main { padding:32px; max-width:560px; }
+ h1 { font-size:36px; }
+ }
+</style>
+</head>
+<body>
+<header>
+ <a class="brand" href="https://wallco.ai">wallco.ai</a>
+ <span class="tag">Hot or Not · v1</span>
+</header>
+<main>
+ <h1>Hot or Not</h1>
+ <p class="sub">Speed-vote AI-original wallpaper. Your taste teaches the system.</p>
+ <div id="wc-game-banner"></div>
+
+ <div id="card">
+ <img id="card-img" alt="design">
+ <div class="meta">
+ <div>
+ <div id="card-title" class="title"></div>
+ <div id="card-sub" class="sub2"></div>
+ </div>
+ <a id="card-view" class="viewfull" target="_blank">view full →</a>
+ </div>
+ </div>
+
+ <div id="buttons">
+ <button class="btn btn-not" data-action="not">NOT</button>
+ <button class="btn btn-skip" data-action="skip">SKIP</button>
+ <button class="btn btn-hot" data-action="hot">HOT</button>
+ </div>
+ <div class="keys">arrows: ← NOT · → HOT · space SKIP</div>
+
+ <div class="history" id="history"></div>
+</main>
+<footer>
+ <p>© <span id="yr"></span> SAND, LLC · part of <a href="https://wallco.ai">wallco.ai</a> / <a href="https://designerwallcoverings.com">Designer Wallcoverings</a></p>
+</footer>
+<script>document.getElementById('yr').textContent = new Date().getFullYear();</script>
+<script src="/js/wallco-game.js"></script>
+<script>
+var seen = [], picks = [];
+
+async function load(){
+ var url = '/api/design/random' + (seen.length ? '?exclude=' + seen.slice(-30).join(',') : '');
+ var r = await (await fetch(url)).json();
+ if (!r.id) return;
+ document.getElementById('card').className = '';
+ document.getElementById('card-img').src = r.image_url;
+ document.getElementById('card-title').textContent = r.title || '';
+ document.getElementById('card-sub').textContent = (r.category||'') + (r.dominant_hex ? ' · ' + r.dominant_hex : '');
+ document.getElementById('card-view').href = 'https://wallco.ai/design/' + r.id;
+ document.getElementById('card').dataset.id = r.id;
+ document.getElementById('card').dataset.title = r.title || '';
+ document.getElementById('card').dataset.image = r.image_url;
+ seen.push(r.id);
+}
+
+async function act(action, evt){
+ var card = document.getElementById('card');
+ var id = parseInt(card.dataset.id, 10);
+ if (!id) return;
+ var anim = action==='hot' ? 'swipe-right' : action==='not' ? 'swipe-left' : 'swipe-up';
+ card.classList.add(anim);
+
+ if (action === 'hot' || action === 'not') {
+ var score = action === 'hot' ? 5 : 1;
+ fetch('/api/design/' + id + '/user-vote', {
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({score: score, quick: true})
+ });
+ }
+ if (window.WC) WC.reward(action, evt);
+
+ picks.unshift({id: id, action: action, title: card.dataset.title, image_url: card.dataset.image});
+ picks = picks.slice(0, 6);
+ document.getElementById('history').innerHTML = picks.map(function(h){
+ return '<a class="' + h.action + '" href="https://wallco.ai/design/' + h.id + '" target="_blank" title="' + h.title + '"><img src="' + h.image_url + '"></a>';
+ }).join('');
+
+ setTimeout(load, 280);
+}
+
+document.querySelectorAll('.btn').forEach(function(b){
+ b.addEventListener('click', function(evt){ act(b.dataset.action, evt); });
+});
+document.addEventListener('keydown', function(e){
+ if (e.key === 'ArrowLeft') act('not');
+ else if (e.key === 'ArrowRight') act('hot');
+ else if (e.key === ' ' || e.key === 'ArrowUp') { e.preventDefault(); act('skip'); }
+});
+load();
+</script>
+</body>
+</html>`);
+});
+
+app.get('/health', (_req, res) => {
+ res.json({ ok: true, service: 'wallco-hot-or-not', port: PORT, upstream: UPSTREAM });
+});
+
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`wallco hot-or-not → http://0.0.0.0:${PORT} · upstream: ${UPSTREAM}`);
+});
← 70cab7c gamify: real game feel — XP, levels, combos, haptics, partic
·
back to Wallco Ai
·
wallco.ai · /designs "In a room ·N" filter chip — surface ro 354cb17 →