Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.log
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
75 changes: 75 additions & 0 deletions apps/api/src/server.mjs
Original file line number Diff line number Diff line change
@@ -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 };
39 changes: 39 additions & 0 deletions apps/api/test/server.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
73 changes: 73 additions & 0 deletions apps/client/app.js
Original file line number Diff line number Diff line change
@@ -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=>`<article><strong>${a.userName}</strong> · ${a.votes} votes · ${a.strokeCount} strokes <button data-id="${a.id}">👍 Vote</button></article>`).join('') || '<p>No attempts yet.</p>';
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();
})();
30 changes: 30 additions & 0 deletions apps/client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FewStrokes</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main>
<h1>FewStrokes</h1>
<p class="tagline">Make it. In fewer strokes.</p>
<label>Player name <input id="name" maxlength="32" value="guest" /></label>
<section class="board">
<canvas id="canvas" width="360" height="540"></canvas>
<p id="stats"></p>
<p id="error" class="error"></p>
<div class="actions">
<button id="clear">Clear</button>
<button id="submit">Submit attempt</button>
</div>
</section>
<section class="leaderboard">
<h2>Top attempts</h2>
<div id="leaderboard"></div>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
8 changes: 8 additions & 0 deletions apps/client/styles.css
Original file line number Diff line number Diff line change
@@ -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; }
50 changes: 50 additions & 0 deletions infra/sql/schema.sql
Original file line number Diff line number Diff line change
@@ -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);
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}