Web 4 trò chơi cho sự kiện CLB. Mỗi lượt tối đa 5 người (đổi được cho từng trò, xem MAX_PLAYERS), chơi xong hiện dashboard của đúng nhóm đó.
| Game | Thời lượng | Cách chơi |
|---|---|---|
| Tính nhanh | 90 giây | Chọn 1 trong 4 đáp án. Điểm = chuỗi đúng dài nhất |
| Vẽ hình nhanh | 15 giây | Vẽ theo từ khoá rồi bấm NỘP BÀI để AI chấm — hết giờ thì tự nộp |
| Nhớ nhanh | 60 giây | Nhìn chuỗi ô nháy sáng rồi lặp lại. Điểm = cấp cao nhất vượt được |
| Ô khác màu | 45 giây | Tìm ô lệch màu trong lưới. Mỗi cấp lưới dày thêm và màu sát nhau hơn. Điểm = cấp cao nhất vượt được |
![]() Trang chủ người chơi chỉ nhập tên |
![]() Phòng chờ cập nhật realtime |
![]() Đang chơi một đồng hồ chung cả lượt |
![]() Bảng hạng hiện ngay khi hết giờ |
Trang quản trị — chọn trò đang mở, chỉnh số người mỗi lượt, tạo lượt, in mã QR
Ảnh do
npm run screenshotssinh ra: script tự bật server ở cổng riêng, cho vài người chơi giả vào chơi thật rồi chụp lại. Sửa giao diện xong chạy lại một lệnh là có bộ ảnh mới — xemdocs/screenshots/README.md.
Một process Node.js duy nhất chạy trên máy BTC. Không database, không Docker, không Python, không service trung gian.
Điện thoại người chơi MÁY BTC — 1 process Node.js
┌──────────────────┐ ┌──────────────────────────────────┐
│ quét QR → nhập │ HTTP / SSE │ Fastify 5 │
│ tên → vào lượt │ ──────────────────► │ ├── /api/rounds/* (lobby) │
│ │ │ ├── /api/math/* (game 1) │
│ ┌────────────┐ │ │ ├── /api/draw/* (game 2) │
│ │ React 19 │ │ ◄────────────────── │ ├── /api/memory/* (game 3) │
│ │ + Vite │ │ SSE realtime │ ├── /api/leaderboard/stream │
│ └────────────┘ │ │ └── /* static build React │
└──────────────────┘ │ │
│ State các lượt: Map (RAM) │
│ Model ONNX: nóng sẵn trong RAM │
└──────────────────────────────────┘
Ba quyết định định hình toàn bộ source code:
- Một process, cả ba game. Model AI (20MB) nằm thường trú trong RAM tiến trình server, inference ~3ms. Tách thành service riêng chỉ thêm một chặng HTTP và một thứ nữa để sập — không đổi lại được gì ở quy mô một sự kiện.
- Không database. Mỗi lượt chơi là dữ liệu phù du: 5 người vào, chơi 90 giây, xem kết quả, xong. Không có gì cần truy vấn lịch sử, nên state nằm trong
Mapsống trong RAM.store/chính là database. - Server là nguồn sự thật duy nhất. Đồng hồ, điểm số, thứ tự câu hỏi, từ khoá cần vẽ, chuỗi ô cần nhớ — tất cả do server giữ. Client chỉ là màn hình + bàn phím.
| # | Nguyên tắc | Hệ quả thực tế |
|---|---|---|
| 1 | Phụ thuộc một chiều: routes → services → store |
Không bao giờ import ngược lại. Xem bảng phân tầng bên dưới. |
| 2 | store/ là database |
Muốn thêm dữ liệu? Thêm field vào Round/Player. Đừng thêm ORM, đừng thêm SQL. |
| 3 | Hằng số đã đo thì bất khả xâm phạm | dtype: 'fp32', lineWidth: 1.5, boxFrac: 0.85 — đều đã đo thực nghiệm, đổi là hỏng. Luôn kèm comment nói rõ. |
| 4 | Client không bao giờ gửi điểm | API không có field score. Server tự đếm. Đây là ràng buộc thiết kế, không phải chi tiết cài đặt. |
✅ = đã có · ⬜ = dự kiến · 🔒 = nền tảng, đã đóng băng · 🅰️/🅱️ = thuộc track nào
🔒 = file nền tảng. Đừng sửa — nếu thấy cần sửa, đó là dấu hiệu hợp đồng thiếu field: dừng lại và trao đổi với người còn lại (xem plan
split-2-tracksmục 3).
Test đặt ngay cạnh code (store.test.ts, lobby.test.ts, dashboard.test.ts) — chạy npm test.
ClubDay/
│
├── apps/
│ ├── server/ ── Backend: API + AI + state các lượt
│ │ ├── src/
│ │ │ ├── index.ts ✅ 🔒 bootstrap: đọc .env, khôi phục snapshot, listen
│ │ │ ├── app.ts ✅ 🔒 factory Fastify — đăng ký sẵn CẢ 2 route
│ │ │ ├── config.ts ✅ 🔒 PORT, ADMIN_TOKEN, đường dẫn cache
│ │ │ ├── model.ts ✅ 🔒 hằng số model — GHIM dtype fp32
│ │ │ │
│ │ │ ├── store/ ── "DATABASE" — state trong RAM
│ │ │ │ ├── types.ts ✅ 🔒 Round, Player — HỢP ĐỒNG ĐÓNG BĂNG
│ │ │ │ ├── store.ts ✅ 🔒 Map + CRUD + pub/sub cho SSE + dọn rác
│ │ │ │ ├── state.ts ✅ 🔒 RoundState — view-model cho REST + SSE
│ │ │ │ ├── lobby.ts ✅ 🔒 join / start / skip / đồng hồ nền
│ │ │ │ ├── dashboard.ts ✅ 🔒 xếp hạng 5 người
│ │ │ │ └── snapshot.ts ✅ 🔒 ghi JSON chống mất dữ liệu khi restart
│ │ │ │
│ │ │ ├── routes/ ── HTTP: chỉ validate + gọi nghiệp vụ
│ │ │ │ ├── rounds.ts ✅ 🔒 join / start / state / dashboard
│ │ │ │ ├── stream.ts ✅ 🔒 SSE (tự quản socket, có keep-alive)
│ │ │ │ ├── admin.ts ✅ 🔒 tạo lượt, bỏ qua, reset
│ │ │ │ ├── admin-guard.ts ✅ 🔒 xác thực header x-admin-token
│ │ │ │ ├── math.ts ✅ 🅰️ API Tính nhanh (đã xong)
│ │ │ │ ├── draw.ts ✅ 🅱️ API Vẽ (đã xong)
│ │ │ │ ├── memory.ts ✅ 🅲 API Nhớ nhanh (đã xong)
│ │ │ │ └── spot.ts ✅ 🅳 API Ô khác màu (đã xong)
│ │ │ │
│ │ │ ├── services/ ── NGHIỆP VỤ: không biết gì về HTTP
│ │ │ │ ├── math-gen.ts ✅ 🅰️ sinh câu hỏi (PRNG seed, chống trùng liền kề)
│ │ │ │ ├── math-session.ts✅ 🅰️ trọng tài Tính nhanh (đồng hồ, spam, chấm điểm)
│ │ │ │ ├── raster.ts ✅ 🅱️ nét → 28×28 (hằng số ĐÃ ĐO — đừng đổi)
│ │ │ │ ├── classifier.ts ✅ 🅱️ model singleton, ghim fp32, hàng đợi tuần tự
│ │ │ │ ├── labels.ts ✅ 🅱️ từ khoá + tên tiếng Việt + luật chấp nhận
│ │ │ │ ├── allowlist.generated.ts ✅ 🅱️ SINH TỰ ĐỘNG — đừng sửa tay
│ │ │ │ ├── draw-session.ts✅ 🅱️ trọng tài Vẽ (đồng hồ, spam, seq, chấm điểm)
│ │ │ │ ├── memory-gen.ts ✅ 🅲 sinh chuỗi ô (PRNG seed, chặn 3 ô trùng liền)
│ │ │ │ ├── memory-session.ts ✅ 🅲 trọng tài Nhớ nhanh (đồng hồ, spam, chống bot)
│ │ │ │ ├── spot-gen.ts ✅ 🅳 sinh bàn Ô khác màu (lệch ĐỘ SÁNG, không lệch tông)
│ │ │ │ └── spot-session.ts ✅ 🅳 trọng tài Ô khác màu
│ │ │ │
│ │ │ └── lib/ ── tiện ích thuần
│ │ │ ├── prng.ts ✅ 🔒 mulberry32 (dùng chung 2 track)
│ │ │ ├── id.ts ✅ 🔒 mã lượt 6 ký tự (bỏ I/O/0/1)
│ │ │ ├── rate-limit.ts ✅ 🔒 tooFast(last, now, minGap)
│ │ │ └── health.ts ✅ 🔒 thanh ghi health-check
│ │ │
│ │ ├── package.json ✅
│ │ └── tsconfig.json ✅
│ │
│ └── web/ ── Frontend: React + Vite + Tailwind CSS 4
│ ├── src/
│ │ ├── main.tsx ✅ điểm khởi động React
│ │ ├── App.tsx ✅ 🔒 router — KHÔNG cần sửa khi làm game
│ │ ├── styles.css ✅ Tailwind v4 + khối @theme
│ │ ├── routes/ ── 1 file = 1 màn hình
│ │ │ ├── Home.tsx ✅ 🔒 chọn game + nhập tên (cả / và /r/:code)
│ │ │ ├── Lobby.tsx ✅ 🔒 5 slot, chờ BTC
│ │ │ ├── Play.tsx ✅ 🔒 dispatcher → MathGame | DrawGame
│ │ │ ├── Dashboard.tsx ✅ 🔒 bảng hạng 5 người
│ │ │ ├── Admin.tsx ✅ 🔒 màn hình BTC (tạo lượt, bắt đầu, mã QR)
│ │ │ ├── MathGame.tsx ✅ 🅰️ màn hình Tính nhanh (đã xong)
│ │ │ ├── DrawGame.tsx ✅ 🅱️ màn hình Vẽ (frame = gợi ý, nút NỘP BÀI)
│ │ │ ├── MemoryGame.tsx ✅ 🅲 màn hình Nhớ nhanh (xem chuỗi → lặp lại)
│ │ │ └── SpotGame.tsx ✅ 🅳 màn hình Ô khác màu (tìm ô lệch màu)
│ │ ├── components/
│ │ │ ├── Shell.tsx ✅ 🔒 khung màn hình
│ │ │ ├── Countdown.tsx ✅ 🔒 đồng hồ + thanh tiến độ
│ │ │ ├── RankTable.tsx ✅ 🔒 bảng hạng
│ │ │ ├── ChoicePad.tsx ✅ 🅰️ 4 nút đáp án (server sinh + xáo trộn)
│ │ │ ├── DrawCanvas.tsx ✅ 🅱️ canvas + giữ pointer (setPointerCapture)
│ │ │ ├── MemoryPad.tsx ✅ 🅲 bàn 4 ô nháy sáng (màu + hình, phím 1–4)
│ │ │ ├── Avatar.tsx ✅ ô chữ cái đầu tên người chơi
│ │ │ ├── Chips.tsx ✅ nhãn game, kết nối, trạng thái lượt, spinner
│ │ │ ├── Logo.tsx ✅ logo DSC (src/assets/logo-dsc.png, luôn trên nền trắng)
│ │ │ └── Toast.tsx ✅ thông báo ở góc màn hình, tự tắt sau 1,2 giây
│ │ └── lib/ ✅ api · sse · useCountdown · session · types · format (🔒)
│ │ ✅ game-theme.ts — icon + màu nhận diện từng game
│ │ ✅ 🅰️ api-math.ts — client riêng của Track A
│ │ ✅ 🅱️ api-draw.ts — client riêng của Track B
│ │ ✅ 🅲 api-memory.ts — client riêng của Track C
│ │ ✅ 🅱️ strokes.ts — gom nét từ pointer event
│ ├── index.html ✅
│ ├── vite.config.ts ✅ (proxy /api → :8787 khi dev)
│ ├── package.json ✅
│ └── tsconfig.json ✅
│
├── scripts/ ── Công cụ vận hành — KHÔNG thuộc runtime
│ ├── prefetch-model.ts ✅ tải model về ./models để chạy offline
│ ├── check-offline.ts ✅ chặn internet, xác nhận model vẫn load được
│ ├── smoke-test.mjs ✅ chạy thử end-to-end (71 kiểm tra)
│ ├── eval-model.ts ✅ 🅱️ đo accuracy 345 class → SINH RA allowlist
│ ├── load-test.mjs ✅ giả lập N người vẽ đồng thời + stress → JSON
│ └── report-loadtest.mjs ✅ JSON đó → báo cáo HTML tự chứa
│
├── .hermes/plans/ ── Tài liệu thiết kế (plan v2 là bản chuẩn)
├── README.md ✅ file này
├── package.json ✅ npm workspaces + script gốc
├── tsconfig.base.json ✅ cấu hình TS dùng chung
└── models/ 📦 cache ONNX (~20MB) — KHÔNG commit
Đây là phần quan trọng nhất cần hiểu. Mỗi tầng chỉ được biết những gì trong cột "Biết":
| Tầng | Thư mục | Biết | KHÔNG được biết |
|---|---|---|---|
| HTTP | routes/ |
req/reply, zod schema, mã lỗi |
luật chơi, Map, cấu trúc Round |
| Nghiệp vụ | services/ |
luật chơi, thời gian, model AI | req/reply, Fastify, HTTP status |
| Trạng thái | store/ |
Round/Player, Map, GC |
HTTP, luật chơi, model AI |
| Tiện ích | lib/ |
tham số đầu vào | mọi thứ khác (hàm thuần) |
Phép thử nhanh: nếu bạn cần import fastify bên trong services/ hoặc store/, tức là code đang đặt sai tầng.
Lợi ích cụ thể: services/math-session.ts nhận now như một tham số thay vì gọi Date.now() bên trong — nhờ vậy test chống gian lận có thể giả lập "đã hết giờ" mà không cần chờ 90 giây thật.
| Tầng | Thư mục | Vai trò |
|---|---|---|
| Màn hình | routes/ |
1 file = 1 màn hình. Ghép component + gọi API. |
| UI | components/ |
Component tái dùng, không gọi API, nhận props. |
| Tiện ích | lib/ |
api.ts (fetch), sse.ts (EventSource), strokes.ts (bắt nét vẽ), useCountdown.ts. |
Giao diện dùng Tailwind CSS 4. Theme được khai báo ngay trong apps/web/src/styles.css
bằng khối @theme — không có tailwind.config.js (đây là cách của Tailwind v4).
Muốn thêm màu hay token mới thì thêm biến --color-* vào khối đó, rồi dùng như utility bình
thường (bg-primary, text-muted, …). Hạn chế viết CSS rời; chỉ viết tay khi thật cần
(ví dụ touch-action: none cho canvas vẽ).
Phong cách neo-brutalism, bản nền tối. Bốn luật, và chúng giải thích gần như mọi dòng
trong styles.css:
- Không bo góc — mọi
--radius-*đặt về 0, nênrounded-xlrải khắp code cũng ra góc vuông. Muốn quay lại kiểu bo tròn thì sửa đúng chỗ đó, không phải sửa 200 chỗ dùng. - Viền trắng dày 3px, đục hoàn toàn — viền là thứ dựng nên hình khối, không phải bóng mờ.
- Bóng đổ đặc, lệch chéo (
6px 6px 0), không blur. Bấm xuống thì khối trượt vào chỗ bóng. Khối vốn đã trắng (logo, mã QR) thì bóng đổi sang màu vàng, vì bóng trắng sau lưng nó không ai thấy. - Màu phẳng, rực, không gradient. Nền gần đen để màu neon nảy hẳn lên.
Class dùng chung khai báo trong @layer components của styles.css: .btn (+ btn-primary,
btn-accent, btn-correct, btn-ghost, btn-sm, btn-lg), .card, .field, .stage-bg.
Mỗi game có màu nhận diện riêng (math cyan, draw hồng, memory lime, spot cam) — xem
lib/game-theme.ts.
Một họ chữ duy nhất: Be Vietnam Pro 400→900 (tiêu đề dùng 800/900), tự host qua
@fontsource — không dùng Google Fonts CDN vì ngày sự kiện có thể không có internet. Bản cũ
dùng Baloo 2 cho tiêu đề; chữ bo tròn mũm mĩm là thứ brutalism không bao giờ dùng, và
Be Vietnam Pro vốn được thiết kế cho tiếng Việt nên dấu không vỡ ở cỡ lớn.
Icon dùng lucide-react (SVG, đóng gói vào bundle), không dùng emoji làm icon.
Nền tảng cung cấp sẵn 3 điểm cắm. Mỗi track chỉ gọi, không cần sửa file dùng chung:
| Điểm cắm | Ở đâu | Dùng thế nào |
|---|---|---|
registerStartHook(game, fn) |
store/lobby.ts |
Chạy khi BTC bấm BẮT ĐẦU. A sinh câu hỏi, B chọn từ khoá. |
registerHealth(name, fn) |
lib/health.ts |
Báo tình trạng ở /api/health. B báo model: ready. |
Play.tsx (dispatcher) |
apps/web/src/routes/ |
Đọc round.game → render MathGame hoặc DrawGame. |
Track A thêm vào routes/math.ts:
registerStartHook('math', (round, now) => {
round.questions = generateQuestions(seed, 60);
});Track C thêm vào routes/memory.ts:
registerStartHook('memory', (round) => {
round.sequence = generateSequence(seedFromRoundId(round.id));
});Track B thêm vào routes/draw.ts:
registerStartHook('draw', (round) => {
round.target = pickTarget();
});
registerHealth('model', () => (isModelReady() ? 'ready' : 'not-loaded'));Cả hai đều không đụng app.ts, lobby.ts, hay App.tsx — đó là mục đích của các điểm cắm này.
Tính nhanh — mọi con số quyết định đều ở server:
Client Server
│ │
├── POST /api/rounds/join ──────►│ gán playerId, thêm vào Round
│◄── { playerId, endsAt } ───────│ (chưa bắt đầu — chờ BTC)
│ │
│ … BTC bấm BẮT ĐẦU … │ startedAt=now, endsAt=now+90s
│ │ sinh sẵn 60 câu hỏi
│ │
├── POST /:id/answer ───────────►│ ① còn trong giờ? ② ≥250ms từ câu trước?
│ { playerId, idx, value } │ ③ đúng câu hiện tại? → tự chấm điểm
│◄── { correct, question kế } ───│ (KHÔNG có field score trong request)
│ │
├── GET /:id/stream (SSE) ─────►│ đẩy trạng thái mỗi khi round.version đổi
│◄── dashboard 5 người ──────────│ khi now > endsAt
Vẽ hình — ảnh không bao giờ đi qua mạng, và ĐIỂM CHỈ SINH RA KHI NỘP:
Client Server
│ │
├── POST /:id/frame (mỗi ~1s) ────────────►│ ① còn trong 15s? ② ≥1000ms từ frame trước?
│ { strokes: [[[x,y],…],…] } │ ③ rasterize(strokes) → 28×28
│ │ ④ classify() → top-3 nhãn
│◄── { hint, top: […] } ───────────────────│ ⬅ GỢI Ý thôi: KHÔNG cộng điểm
│ │ (server giữ lại nét mới nhất)
│ │
│ … người chơi bấm NỘP BÀI … │
├── POST /:id/submit ─────────────────────►│ ① chưa nộp? ② còn trong quãng ân hạn?
│ { strokes } — bỏ trống = nét server giữ │ ③ rasterize → classify → so từ khoá
│◄── { matched, score, reason } ───────────│ ④ score = 150 − số giây (kẹp ở 15)
│ │
│ … hết 15 giây … │ hook kết thúc lượt: ai CHƯA nộp thì
│ │ server tự chấm bằng nét cuối nó giữ
Client gửi toạ độ nét thô, không gửi ảnh. Server tự vẽ lại ở 28×28 — nhờ vậy client không thể gửi một bức ảnh có sẵn, và payload nhẹ hơn base64 PNG khoảng 10–30 lần.
Điểm không phụ thuộc vào việc AI có đọc ra hình trong lúc đang vẽ hay không: frame chỉ để hiện "AI nghĩ: …". Nếu hễ model đọc ra là cộng điểm thì người đang vẽ dở cũng bị tính là đã thắng — đó là lỗi cũ, và có test canh để nó không quay lại.
Nhớ nhanh — server chỉ nhả chuỗi của ĐÚNG cấp đang chơi:
Client Server
│ │
├── GET /:id/sequence ────────────────►│ cấp n → n ô ĐẦU của chuỗi cả lượt
│◄── { level: n, sequence: […n ô] } ────│ ⏱ đóng dấu "đã gửi lúc nào"
│ │ (phần sau của chuỗi KHÔNG gửi)
│ … nháy lại n ô, mất n × 600ms … │
│ … người chơi chạm lặp lại … │
│ │
├── POST /:id/replay ──────────────────►│ ① còn trong giờ? ② đúng cấp của mình?
│ { level, taps: [1,3,0,…] } │ ③ ≥250ms từ lượt trước?
│ │ ④ ĐÃ ĐỦ THỜI GIAN XEM CHƯA?
│◄── { correct, score, cấp kế } ────────│ ⑤ so với chuỗi server giữ → chấm
Chốt ④ là thứ thay cho "chống spam" của hai game kia: muốn lặp đúng n ô thì phải
xem hết n ô đã, mà xem hết mất n × 600ms. Bot nhận chuỗi rồi bấm lại ngay sẽ
đúng từng ô nhưng về sớm hơn quãng đó — và bị chặn.
| Thành phần | Trạng thái |
|---|---|
| Môi trường, build, 2 workspace | ✅ chạy được |
| Model AI tải + chạy offline | ✅ đã verify (npm run check:offline) |
| Phase F — nền tảng (store, lobby, SSE, admin, router, 2 stub) | ✅ XONG |
math-gen, math-session, math-options, ChoicePad) |
✅ XONG — đã merge main |
raster, classifier, labels, draw-session) |
✅ XONG — đã merge main |
✅ XONG trên nhánh feat/draw-submit |
|
🅲 Track C — Nhớ nhanh (memory-gen, memory-session, MemoryPad) |
✅ XONG |
🅳 Track D — Ô khác màu (spot-gen, spot-session, SpotGrid) |
✅ XONG |
| Tích hợp + load test + diễn tập | ⬜ Wave 3 |
Chạy kiểm tra bất cứ lúc nào:
npm test # toàn bộ unit test (server + web)
npm run smoke # 86 kiểm tra end-to-end (tự bật server rồi tắt, có nạp model thật)
# ⏱ chậm hơn trước ~20s: có kiểm tra phải chờ hết 15 giây thật của lượt VẽTest của game Tính nhanh tập trung vào chống gian lận: hết giờ không cộng điểm dù đúng, chặn trả lời nhanh hơn 250ms, không cho nhảy câu hay trả lời lại, và đáp án không bao giờ được gửi ra client (có regression test riêng cho việc này).
Điểm là chuỗi đúng dài nhất, không phải tổng số câu đúng. Trả lời sai làm chuỗi hiện tại về 0 nhưng không lấy đi chuỗi dài nhất đã lập — người vừa mất chuỗi vẫn còn lý do trả lời tiếp thay vì buông xuôi 90 giây.
Hệ quả cần nhớ: player.score mỗi game một nghĩa — Tính nhanh là chuỗi dài nhất, Vẽ hình
là 150 − số giây. dashboard.ts là nơi duy nhất đọc nó để xếp hạng. Bằng điểm là chuyện
thường gặp ở Tính nhanh, nên khi bằng thì xếp theo số câu đúng nhiều hơn; nếu rơi thẳng
xuống so thời gian thì người trả lời ít câu hơn lại xếp trên, ngược hẳn với điều ai cũng nghĩ
là công bằng. Luật tie-break này chỉ áp cho game === 'math' — có test canh để nó không
rò sang game Vẽ.
Điểm chỉ sinh ra ở đúng một thời điểm: lúc bài được NỘP, và mỗi người chỉ nộp được một lần.
- Bấm NỘP BÀI → chấm ngay:
150 − số giây đã dùng; không nhận ra hình thì 0 điểm. - Hết 15 giây → client tự nộp. Ai không gửi được (treo tab, mất mạng) thì server tự nộp hộ bằng nét vẽ cuối cùng nó đã nhận được, không cần client hợp tác.
- Nộp lại lần nữa không chấm lại — server trả về đúng kết quả cũ kèm
already: true, nên bấm đúp hay mạng bắn lại cũng không đổi điểm.
Hai chỗ dễ sửa nhầm:
- Quãng ân hạn 2.5 giây (
SUBMIT_GRACE_MS) sau mốc hết giờ. Bài nộp thật của client luôn tới sauendsAtvài trăm ms, mà đồng hồ nền của server quét mỗi 500ms — không có quãng này thì mọi bài nộp đúng lúc hết giờ đều bị trảTIME_UPvà cả lượt không ai có điểm. Số giây bị kẹp ở độ dài lượt, nên nộp muộn không thể ăn điểm cao hơn nộp đúng mốc. Đường TỰ NỘP của server chạy sau quãng đó nên phải truyềnforce: true, không thì nó bị chính quãng ân hạn chặn lại. - Bỏ qua lượt (admin) KHÔNG tự nộp. Bỏ qua là huỷ lượt, thời gian gần như bằng 0, nên nếu để hook kết thúc lượt tự chấm thì cả 5 người bỗng được ~150 điểm và nhảy lên đầu bảng.
Điểm là cấp cao nhất đã vượt. Lặp sai thì cấp hiện tại về 1 nhưng không lấy đi kỷ lục — giống hệt luật chuỗi của Tính nhanh, và vì cùng lý do: người vừa trượt ở cấp 7 vẫn còn cớ chơi tiếp thay vì ngồi hết 60 giây.
Cấp n dùng n ô đầu của cùng một chuỗi, nên lên cấp chỉ là nối thêm một ô vào thứ vừa nhớ được. Nếu mỗi cấp sinh chuỗi mới thì người chơi phải học lại từ đầu mỗi lần, và trò chơi mất hẳn cảm giác nhớ dài dần — có test canh tính chất này.
Ba chỗ dễ sửa nhầm:
MEMORY_STEP_MS = 600là hằng số CHUNG server ↔ client. Client phát lại chuỗi theo nó, còn server dựa vào chính nó để biết một lượt lặp có kịp xem hay không. Sửa một bên mà quên bên kia thì người chơi thật bị chặn vì nghi gian lận. Bản sao nằm ởapps/web/src/lib/types.ts.- Ngưỡng xem là 80% thời gian phát lý thuyết (
MIN_WATCH_RATIO), không phải 100%. Server đo từ lúc nó gửi chuỗi nên đồng hồ của nó luôn dài hơn thời gian phát thật ở máy người chơi; để nguyên 100% thì chỉ cần client lệch vài ms là người thật bị gắn cờ. - Mọi đường gửi chuỗi ra ngoài phải đi qua
currentSequence()— nó đóng dấu thời gian, tức một nửa của chốt chống bot. Trả chuỗi thẳng từround.sequencelà vô hiệu hoá chốt đó.
- Thêm game mới? Thêm đúng một phần tử vào
GAME_KINDS(store/types.ts) — route/api/rounds/open, zod enum, lưới chọn game ở trang chủ và trang BTC đều đọc mảng đó. TypeScript sẽ chỉ ra nốt những chỗ còn thiếu (DURATION_MS,GAME_LABEL,SCORE_LABEL,GAME_THEME, bảngSCREENStrongPlay.tsx). - Thêm endpoint mới? Tạo/sửa file trong
routes/theo domain, validate body bằng zod, rồi gọiservices/. Không viết luật chơi trong route. - Thêm dữ liệu mới cho một lượt? Thêm field vào
RoundhoặcPlayertrongstore/types.ts. Không thêm database, ORM, hay migration. - Đụng vào model AI? Mọi thay đổi
dtype, kích thước input, hay cách rasterize đều phải chạy lạieval-model.mjsvà đo accuracy trước khi merge. Xem mục Ghi chú kỹ thuật. - Test đặt cạnh code (
*.test.ts). Test chống gian lận là bắt buộc — nó bảo vệ tính công bằng của sự kiện. - Hằng số có số đo phải kèm comment nói rõ đã đo và đo ra sao.
Bốn mốc: cài một lần → dev hằng ngày → chạy bản thật → thử chơi.
Máy mới cài lần đầu: đọc INSTALL.md — hướng dẫn từng bước kèm cách kiểm tra và xử lý sự cố.
| Node.js | >= 20 (đã test trên v24.13.0) |
| Dung lượng trống | ~150MB cho onnxruntime-node, ~20MB cho model |
| Shell | bash (git-bash trên Windows). Các lệnh dưới viết theo bash. |
git clone <repo-url> ClubDay
cd ClubDay
npm installTạo file cấu hình:
cp .env.example .env # Windows không có cp thì dùng: copy .env.example .envMở .env và đổi ADMIN_TOKEN — đây là mật khẩu bảo vệ trang /admin:
ADMIN_TOKEN=ma-cua-ban-dat-o-day
PORT=8787Tải model AI về máy (cần internet, chỉ một lần):
npm run prefetch # tải model ONNX (~20MB) về ./models
npm run check:offline # phải in "✅ OFFLINE OK"Thứ tự ưu tiên: biến môi trường thắng
.env. NênMODEL_OFFLINE=1 npm startghi đè giá trị trong.env— tiện khi cần thử nhanh mà không sửa file.
Cần 2 terminal chạy song song:
# Terminal 1 — server API
npm run dev:server # http://localhost:8787
# Terminal 2 — giao diện web
npm run dev:web # http://localhost:5173Mở http://localhost:5173 — không phải 8787.
Ở chế độ dev, Vite phục vụ giao diện và tự chuyển tiếp /api sang server. Thiếu một trong hai terminal là không chạy được.
Kiểm tra server sống: http://localhost:8787/api/health
npm run build # web -> apps/web/dist, server -> apps/server/dist
npm startLúc này chỉ cần một URL duy nhất: http://localhost:8787 — server phục vụ luôn cả giao diện. Chế độ này không cần chạy Vite nữa.
Tắt server: Ctrl+C — server tự lưu snapshot trước khi thoát.
| Bước | Ai | Làm gì |
|---|---|---|
| 1 | BTC | Mở /admin, dán ADMIN_TOKEN vào ô trên cùng (lưu vào máy, chỉ nhập một lần) |
| 2 | BTC | Bấm + Lượt Tính nhanh / + Lượt Vẽ hình / + Lượt Nhớ nhanh / + Lượt Ô khác màu → trò đó thành trò đang mở, hiện mã 6 ký tự kèm mã QR để quét |
| 3 | Người chơi | Quét QR (hoặc mở http://<IP>:8787) → nhập tên → vào phòng chờ của trò BTC đang mở |
| 4 | Tối đa 5 người. Người thứ 6 bị chặn và báo "chờ lượt sau" | |
| 5 | BTC | Bấm BẮT ĐẦU ở /admin (màn hình phòng chờ KHÔNG có nút này) |
| 6 | Hết giờ tự chuyển sang bảng xếp hạng của đúng 5 người đó |
Vài chi tiết đã cài sẵn:
- Số người mỗi lượt đặt được cho từng trò, và chỉnh được ngay trên
/adminbằng nút −/+ cạnh mỗi trò (1–20). Giá trị khởi đầu lấy từ.env:MAX_PLAYERS=8cho mọi trò,MAX_PLAYERS_DRAW=3đè riêng trò Vẽ; gõ sai thì server cảnh báo rồi dùng mặc định chứ không sập. Chỉnh ở/adminsống trong RAM như mọi state khác — khởi động lại là về giá trị.env. Mỗi lượt chốt sức chứa lúc được tạo, nên đổi số giữa chừng không làm lượt đang chờ đổi luật. - Mỗi thời điểm chỉ MỘT trò được chơi. BTC chọn trò ở
/admin; người chơi không tự chọn — trang chủ chỉ cho vào trò đang mở, trò còn lại hiện mờ "chưa tới lượt". Đổi trò thì mọi lượt đang chờ của trò cũ bị bỏ (lượt đang chơi vẫn chơi hết giờ). Nút Tạm đóng chặn người vào lượt mới trong lúc giải lao. - Chỉ BTC bắt đầu được lượt.
POST /api/rounds/:id/startđòi headerx-admin-token; gọi thẳng từ DevTools cũng nhận 401. Nút BẮT ĐẦU chỉ có ở/admin; phòng chờ/lobby/<mã>là màn hình người chơi nên không có nút nào — nó chỉ báo "đang chờ BTC". - Chạy với
NODE_ENV=productionmà thiếuADMIN_TOKENthì mọi thao tác quản trị bị từ chối (503) thay vì mở toang. Ở chế độ dev vẫn cho qua kèm cảnh báo lúc khởi động. - Bắt đầu được với ≥ 1 người — không có timeout tự động, BTC chủ động về nhịp.
- Bấm Bỏ qua ở
/adminđể kết thúc lượt ngay. - Ai vào sau khi lượt đã bắt đầu sẽ bị từ chối — để không ai bị thiếu giờ so với người khác.
Lấy IP máy BTC:
ipconfig # tìm dòng "IPv4 Address", ví dụ 192.168.1.14Điện thoại cùng Wi-Fi, mở http://192.168.1.14:8787.
Nếu không vào được:
- Server phải bind
0.0.0.0(mặc định), không phải127.0.0.1. - Windows Firewall có thể chặn lần đầu — bấm Allow ở hộp thoại, hoặc mở cổng thủ công:
netsh advfirewall firewall add rule name="ClubDay" dir=in action=allow protocol=TCP localport=8787 - Điện thoại và máy BTC phải cùng một mạng — Wi-Fi khách (guest) thường chặn kết nối nội bộ.
npm run build
MODEL_OFFLINE=1 ADMIN_TOKEN=<mã-bí-mật> npm start- Bắt buộc
npm run prefetchtrước khi còn internet, và xác nhận bằngnpm run check:offline→ phải in✅ OFFLINE OK. Lệnh này chặn hẳn request tải model, nên chạy được nghĩa là sự kiện không phụ thuộc internet. - In QR trỏ
http://<IP>:8787dán ở khu vực chơi — lấy mã ngay ở/admin(ô Cả sự kiện, bấm Phóng to rồi chụp/in). Mã QR sinh ngay trong trình duyệt, KHÔNG gọi dịch vụ ngoài nên vẫn chạy khi offline. - Tắt sleep/hibernate và Windows Update trên máy BTC; cắm sạc.
- Diễn tập trước bằng 5 người thật, cả 2 game.
| Lệnh | Việc |
|---|---|
npm run dev:server |
Server dev, tự nạp lại khi sửa code |
npm run dev:web |
Giao diện dev (Vite) |
npm start |
Chạy bản đã build — server phục vụ cả API lẫn web, 1 cổng |
npm run build |
Build cả web và server |
npm test |
Toàn bộ unit test — cả 2 workspace |
npm run smoke |
71 kiểm tra end-to-end — tự bật server ở cổng 8799 rồi tắt |
npm run loadtest |
Load + stress test đường vẽ. -- --levels 16,32 để đẩy cao hơn |
npm run report:loadtest |
Dựng báo cáo HTML từ JSON của lần đo gần nhất |
npm run screenshots |
Chụp lại bộ ảnh trong README (Playwright, tự bật server riêng) |
npm run prefetch |
Tải model ONNX về ./models |
npm run check:offline |
Xác nhận model vẫn load được khi không có internet |
npm run eval:model |
Đo accuracy THẬT rồi sinh lại allowlist.generated.ts (~35s) |
| Hiện tượng | Cách sửa |
|---|---|
EADDRINUSE :8787 |
Cổng bị chiếm. Đổi PORT trong .env, hoặc tìm và tắt tiến trình: netstat -ano | grep :8787 rồi taskkill /F /PID <pid> |
| Mở 5173 ra trang trắng | Chưa chạy npm run dev:server ở terminal kia |
/admin báo "Sai mã quản trị" |
ADMIN_TOKEN trong .env khác với mã đã nhập |
Vào /admin không cần mã |
.env chưa có ADMIN_TOKEN (chỉ xảy ra ở chế độ dev). Server in cảnh báo lúc khởi động — đọc log |
/admin báo 503 ADMIN_NOT_CONFIGURED |
Chạy production mà thiếu ADMIN_TOKEN — đặt biến rồi khởi động lại |
| Người chơi báo "BTC chưa mở trò nào" | Đang ở trạng thái Tạm đóng — vào /admin tạo lượt cho trò muốn chơi |
| "Lượt này đủ 5 người rồi" | Đúng thiết kế. BTC tạo lượt mới ở /admin |
| Model lỗi khi khởi động | Chưa chạy npm run prefetch |
Sửa .env mà không thấy đổi |
Server chỉ đọc .env lúc khởi động — phải khởi động lại |
Đo thật trên 200 mẫu QuickDraw (20 class):
| dtype | top-1 |
|---|---|
| fp32 | 82.5% ✅ |
| fp16 | load lỗi ❌ |
| q8 | 7.0% ❌ |
Cùng một bộ weights. q8 âm thầm phá accuracy 82.5% → 7.0% mà không báo lỗi gì — nó vẫn load
bình thường, vẫn chạy 4ms, chỉ là trả về kết quả sai một cách rất tự tin. Đừng "tối ưu RAM" bằng q8
ở đây. Xem apps/server/src/model.ts.
Nét vẽ được thu nhỏ về 28×28 trước khi đưa vào model. Độ dày nét ở kích thước đó quyết định
accuracy: 1.5px → 70%, còn 2.5px → 19%. Hằng số đã hiệu chỉnh nằm trong
apps/server/src/services/raster.ts — ĐỔI LÀ PHẢI CHẠY LẠI npm run eval:model.
Rasterizer 28×28 và bảng nhãn chỉ server dùng — client gửi toạ độ nét thô, server tự rasterize.
Không có code nào dùng chung, nên gộp vào apps/server/src/ sẽ tránh được friction của TS project
references mà không mất gì.
Ba tài liệu trong .hermes/plans/, chia vai rõ ràng:
| Tài liệu | Vai trò |
|---|---|
2026-09-11_231500-...-v2-inmemory.md |
Đặc tả kỹ thuật. Kiến trúc, chống gian lận, hằng số đã đo, checklist sự kiện, bảng rủi ro. Đọc trước. |
2026-09-12_201457-clubday-split-2-tracks.md |
Điều phối công việc. Chia 2 track song song, bảng phân quyền file, hợp đồng đóng băng, quy trình git. |
2026-09-17_161327-clubday-recruitment-funnel.md |
Việc đang làm. Phễu đăng ký thành viên (Wave 4), màn hình chiếu + bảng xếp hạng toàn sự kiện (Wave 5), nợ load test (Wave 3). Có bảng theo dõi trạng thái. |
Làm việc nhóm? Đọc file split-2-tracks để biết file nào của ai. Bảng phân quyền file ở mục 3 là thứ quyết định việc 2 người có xung đột hay không.




