From 8c33d6c1523735c6c77b4a4006d5d9144f348416 Mon Sep 17 00:00:00 2001 From: Fadel Albayabi <121941280+fadelbay@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:07:11 +0300 Subject: [PATCH] Build FewStrokes MVP web+mobile-ready app and API --- .gitignore | 4 ++ README.md | 25 ++++++++++++ apps/api/src/server.mjs | 75 +++++++++++++++++++++++++++++++++++ apps/api/test/server.test.mjs | 39 ++++++++++++++++++ apps/client/app.js | 73 ++++++++++++++++++++++++++++++++++ apps/client/index.html | 30 ++++++++++++++ apps/client/styles.css | 8 ++++ infra/sql/schema.sql | 50 +++++++++++++++++++++++ package.json | 9 +++++ 9 files changed, 313 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/src/server.mjs create mode 100644 apps/api/test/server.test.mjs create mode 100644 apps/client/app.js create mode 100644 apps/client/index.html create mode 100644 apps/client/styles.css create mode 100644 infra/sql/schema.sql create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ac8ea1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..39fb79b --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# FewStrokes MVP + +FewStrokes is a mobile + web creativity game where players transform a random path with a strict stroke budget. + +## What is implemented + +- Single Node.js server that hosts both API and web client. +- Touch-friendly drawing board that works on mobile and desktop browsers. +- Daily deterministic challenge with start anchor, stroke limit, and ink budget. +- Attempt submission, voting, leaderboard, and reporting endpoints. +- PostgreSQL schema draft for production-ready persistence. + +## Run + +```bash +npm run dev +``` + +App and API are both served from `http://localhost:3001`. + +## Test + +```bash +npm test +``` diff --git a/apps/api/src/server.mjs b/apps/api/src/server.mjs new file mode 100644 index 0000000..5922488 --- /dev/null +++ b/apps/api/src/server.mjs @@ -0,0 +1,75 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const clientDir = join(__dirname, '../../client'); + +const attempts = []; +const reports = []; + +function seeded(seed){ let value = seed % 2147483647; return ()=>((value = value * 48271 % 2147483647) / 2147483647); } +function generateChallenge(seed){ + const rand = seeded(seed); const points = [{x:180,y:40}]; + for(let i=0;i<12;i++) points.push({x:40+rand()*280,y:60+i*36+rand()*18}); + return { id:`daily-${seed}`, title:'Daily FewStrokes Challenge', seed, startPoint:points[0], basePath:points, rules:{maxStrokes:3, inkBudget:900, startPointTolerance:30}, createdAt:new Date().toISOString() }; +} + +const daySeed = Number(new Date().toISOString().slice(0,10).replaceAll('-','')); +const challenge = generateChallenge(daySeed); + +function json(res, code, payload){ res.writeHead(code, {'content-type':'application/json','access-control-allow-origin':'*'}); res.end(JSON.stringify(payload)); } + +async function body(req){ const chunks=[]; for await (const c of req) chunks.push(c); return chunks.length?JSON.parse(Buffer.concat(chunks).toString()):{}; } + +const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (req.method === 'OPTIONS') return json(res, 200, {}); + + if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, {ok:true}); + if (req.method === 'GET' && url.pathname === '/challenges') return json(res, 200, [challenge]); + if (req.method === 'GET' && url.pathname === `/challenges/${challenge.id}`) return json(res, 200, challenge); + if (req.method === 'GET' && url.pathname === `/challenges/${challenge.id}/leaderboard`) { + const leaderboard = attempts.filter(a=>a.challengeId===challenge.id).sort((a,b)=>b.votes-a.votes || a.timeMs-b.timeMs).slice(0,10); + return json(res, 200, leaderboard); + } + + if (req.method === 'POST' && url.pathname === `/challenges/${challenge.id}/attempts`) { + const data = await body(req); + if (!data.challengeId || !Array.isArray(data.strokes)) return json(res, 400, {error:'invalid payload'}); + const attempt = { id: crypto.randomUUID(), challengeId:data.challengeId, userName:data.userName || 'guest', strokeCount:data.strokeCount, totalLength:data.totalLength, timeMs:data.timeMs, strokes:data.strokes, votes:0, createdAt:new Date().toISOString() }; + attempts.push(attempt); + return json(res, 201, attempt); + } + + if (req.method === 'POST' && url.pathname.startsWith('/attempts/') && url.pathname.endsWith('/votes')) { + const id = url.pathname.split('/')[2]; const attempt = attempts.find(a=>a.id===id); + if (!attempt) return json(res, 404, {error:'not found'}); + attempt.votes += 1; return json(res, 200, {votes:attempt.votes}); + } + + if (req.method === 'POST' && url.pathname.startsWith('/attempts/') && url.pathname.endsWith('/reports')) { + const id = url.pathname.split('/')[2]; const data = await body(req); + reports.push({id:crypto.randomUUID(), attemptId:id, reason:data.reason || 'other', createdAt:new Date().toISOString()}); + return json(res, 201, {status:'received'}); + } + + const filePath = url.pathname === '/' ? join(clientDir, 'index.html') : join(clientDir, url.pathname); + try { + const data = await readFile(filePath); + const ext = extname(filePath); + const type = ext === '.css' ? 'text/css' : ext === '.js' ? 'application/javascript' : 'text/html'; + res.writeHead(200, {'content-type': type}); + res.end(data); + } catch { + res.writeHead(404); res.end('Not found'); + } +}); + +if (process.env.NODE_ENV !== 'test') { + const port = Number(process.env.PORT || 3001); + server.listen(port, ()=> console.log(`fewstrokes running on http://localhost:${port}`)); +} + +export { server, challenge }; diff --git a/apps/api/test/server.test.mjs b/apps/api/test/server.test.mjs new file mode 100644 index 0000000..8f3e049 --- /dev/null +++ b/apps/api/test/server.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { server, challenge } from '../src/server.mjs'; + +let base; + +test.before(async () => { + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address(); + base = `http://127.0.0.1:${port}`; +}); + +test.after(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +test('returns challenge', async () => { + const data = await fetch(`${base}/challenges`).then((r) => r.json()); + assert.equal(data[0].id, challenge.id); +}); + +test('submits and votes attempt', async () => { + const created = await fetch(`${base}/challenges/${challenge.id}/attempts`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + challengeId: challenge.id, + userName: 'tester', + strokeCount: 1, + totalLength: 100, + timeMs: 2000, + strokes: [{ id: 's1', length: 100, points: [{ x: 1, y: 1, t: 1 }] }] + }) + }).then((r) => r.json()); + + await fetch(`${base}/attempts/${created.id}/votes`, { method: 'POST' }); + const leaderboard = await fetch(`${base}/challenges/${challenge.id}/leaderboard`).then((r) => r.json()); + assert.equal(leaderboard[0].votes, 1); +}); diff --git a/apps/client/app.js b/apps/client/app.js new file mode 100644 index 0000000..63d3ee8 --- /dev/null +++ b/apps/client/app.js @@ -0,0 +1,73 @@ +const API_BASE = window.location.origin; +const canvas = document.getElementById('canvas'); +const ctx = canvas.getContext('2d'); +const stats = document.getElementById('stats'); +const errorEl = document.getElementById('error'); +const leaderboardEl = document.getElementById('leaderboard'); +const nameInput = document.getElementById('name'); + +let challenge; +let strokes = []; +let active = null; +const startMs = Date.now(); + +const dist = (a,b)=>Math.hypot(a.x-b.x,a.y-b.y); +const pointFromEvent = (event) => { + const r = canvas.getBoundingClientRect(); + return { x: event.clientX - r.left, y: event.clientY - r.top, t: Date.now() - startMs }; +}; + +function draw() { + ctx.clearRect(0,0,canvas.width,canvas.height); + ctx.strokeStyle = '#778da9'; ctx.lineWidth = 4; ctx.beginPath(); + challenge.basePath.forEach((p,i)=> i===0 ? ctx.moveTo(p.x,p.y) : ctx.lineTo(p.x,p.y)); ctx.stroke(); + ctx.fillStyle = '#ff6b6b'; ctx.beginPath(); ctx.arc(challenge.startPoint.x, challenge.startPoint.y, 8, 0, Math.PI*2); ctx.fill(); + ctx.strokeStyle='#00b894'; ctx.lineWidth=5; + [...strokes, ...(active?[active]:[])].forEach(s=>{ ctx.beginPath(); s.points.forEach((p,i)=> i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y)); ctx.stroke(); }); + const usedInk = Math.round(strokes.reduce((a,s)=>a+s.length,0)+(active?.length||0)); + stats.textContent = `Strokes: ${strokes.length}/${challenge.rules.maxStrokes} · Ink: ${usedInk}/${challenge.rules.inkBudget}`; +} + +function setError(message=''){ errorEl.textContent = message; } + +canvas.addEventListener('pointerdown', (event)=>{ + if (strokes.length >= challenge.rules.maxStrokes) return setError('Stroke limit reached.'); + const p = pointFromEvent(event); + if (!strokes.length && dist(p, challenge.startPoint) > challenge.rules.startPointTolerance) return setError('First stroke must start near red point.'); + active = { id: crypto.randomUUID(), points:[p], length:0 }; setError(''); draw(); +}); +canvas.addEventListener('pointermove', (event)=>{ + if (!active) return; + const p = pointFromEvent(event); + const prev = active.points[active.points.length-1]; + const seg = dist(prev,p); + const ink = strokes.reduce((a,s)=>a+s.length,0) + active.length + seg; + if (ink > challenge.rules.inkBudget) return setError('Ink budget reached.'); + active.points.push(p); active.length += seg; draw(); +}); +window.addEventListener('pointerup', ()=>{ if (active){ strokes.push(active); active=null; draw(); }}); + +document.getElementById('clear').onclick = ()=>{ strokes=[]; active=null; setError(''); draw(); }; + +document.getElementById('submit').onclick = async ()=>{ + if (!strokes.length) return setError('Add at least one stroke.'); + await fetch(`/challenges/${challenge.id}/attempts`, { + method:'POST', headers:{'content-type':'application/json'}, + body: JSON.stringify({ challengeId: challenge.id, userName: nameInput.value || 'guest', strokeCount: strokes.length, totalLength: strokes.reduce((a,s)=>a+s.length,0), timeMs: Date.now()-startMs, strokes }) + }); + await loadLeaderboard(); +}; + +async function vote(id){ await fetch(`/attempts/${id}/votes`,{method:'POST'}); await loadLeaderboard(); } + +async function loadLeaderboard(){ + const list = await fetch(`/challenges/${challenge.id}/leaderboard`).then(r=>r.json()); + leaderboardEl.innerHTML = list.map(a=>`
${a.userName} · ${a.votes} votes · ${a.strokeCount} strokes
`).join('') || '

No attempts yet.

'; + leaderboardEl.querySelectorAll('button').forEach((button)=> button.onclick = ()=> vote(button.dataset.id)); +} + +(async()=>{ + challenge = await fetch('/challenges').then(r=>r.json()).then(l=>l[0]); + draw(); + await loadLeaderboard(); +})(); diff --git a/apps/client/index.html b/apps/client/index.html new file mode 100644 index 0000000..6654f72 --- /dev/null +++ b/apps/client/index.html @@ -0,0 +1,30 @@ + + + + + + FewStrokes + + + +
+

FewStrokes

+

Make it. In fewer strokes.

+ +
+ +

+

+
+ + +
+
+
+

Top attempts

+
+
+
+ + + diff --git a/apps/client/styles.css b/apps/client/styles.css new file mode 100644 index 0000000..7c3b85d --- /dev/null +++ b/apps/client/styles.css @@ -0,0 +1,8 @@ +body { margin: 0; display: flex; justify-content: center; font-family: Arial, sans-serif; background:#0d1b2a; color:#f8f9fa; } +main { width:min(100%,560px); padding:1rem; } +.tagline{ color:#a8dadc; } +canvas { width:100%; max-width:360px; background:#1b263b; border:2px solid #415a77; border-radius:12px; touch-action:none; } +.actions { display:flex; gap:.5rem; margin-top:.5rem; } +button { background:#00b894; color:#06281f; border:none; border-radius:8px; padding:.6rem .8rem; font-weight:700; } +.error { color:#ff6b6b; min-height:1em; } +.leaderboard article { border:1px solid #415a77; border-radius:10px; padding:.5rem; margin-bottom:.5rem; } diff --git a/infra/sql/schema.sql b/infra/sql/schema.sql new file mode 100644 index 0000000..9132ce2 --- /dev/null +++ b/infra/sql/schema.sql @@ -0,0 +1,50 @@ +create table if not exists users ( + id uuid primary key, + handle varchar(32) unique not null, + created_at timestamptz not null default now() +); + +create table if not exists challenges ( + id uuid primary key, + title text not null, + seed bigint not null, + rules jsonb not null, + base_path jsonb not null, + creator_id uuid references users(id), + voting_window_start timestamptz not null, + voting_window_end timestamptz not null, + created_at timestamptz not null default now() +); + +create table if not exists attempts ( + id uuid primary key, + challenge_id uuid not null references challenges(id), + user_id uuid not null references users(id), + stroke_count int not null, + total_length numeric not null, + time_ms int not null, + strokes jsonb not null, + votes int not null default 0, + created_at timestamptz not null default now() +); + +create table if not exists votes ( + id uuid primary key, + attempt_id uuid not null references attempts(id), + voter_id uuid not null references users(id), + vote_type varchar(16) not null, + created_at timestamptz not null default now(), + unique (attempt_id, voter_id) +); + +create table if not exists reports ( + id uuid primary key, + attempt_id uuid not null references attempts(id), + reporter_id uuid references users(id), + reason varchar(32) not null, + status varchar(16) not null default 'open', + created_at timestamptz not null default now() +); + +create index if not exists idx_attempts_challenge_votes on attempts (challenge_id, votes desc); +create index if not exists idx_reports_status on reports (status, created_at desc); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ccd9c82 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "fewstrokes", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "node apps/api/src/server.mjs", + "test": "NODE_ENV=test node --test apps/api/test/server.test.mjs" + } +}