FewStrokes
+Make it. In fewer strokes.
+ +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=>`
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 @@ + + + + + +Make it. In fewer strokes.
+ +