Certified
Self-directed project
Frontend Engineering with Prediction Game
Junior Front-End Engineer · Software Engineering · July 2026
84/ 100
Built a complete World Cup prediction game end to end: rendered the fixture board, implemented exact-result and result-only scoring, derived team strengths from real historical results, and used those strengths in a Poisson-based house bot. Also persisted and ranked player, bot, and friend totals in localStorage and shipped the finished experience to a live Netlify URL.
Graded against
- Scoring engine correctness30%
- Prediction model genuinely from real data25%
- Game completeness and leaderboard soundness25%
- Shipped and shareable: live deployed URL20%
Passed · pass mark 60/100
What stood out6
- Implemented the exact 5/2/0 scoring rule and passed all four canonical scorePick checks, including draw handling.
- Computed team attack/defense strengths from a real-results sample and used both teams' strengths in botPredict via a Poisson-based model.
- Built a full playable flow with fixture cards, score entry, scoring, and a ranked leaderboard that survives reloads.
- Shipped the game to a live Netlify HTTPS URL, making the project shareable and reviewable on any device.
- Leakage-Free Game Logic
- Data-Driven Prediction Modeling
The work I submitted9 tasks
Lab Orientation
My solution
{"code":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Predictor</title>\n <style>\n :root {\n --bg: #0f172a;\n --card: #111827;\n --accent: #38bdf8;\n --text: #e5e7eb;\n --muted: #94a3b8;\n --good: #22c55e;\n --bad: #ef4444;\n --border: #243041;\n }\n\n * {\n box-sizing: border-box;\n }\n\n body {\n margin: 0;\n font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;\n background: linear-gradient(180deg, #0f172a, #020617);\n color: var(--text);\n min-height: 100vh;\n display: grid;\n place-items: center;\n padding: 24px;\n }\n\n .app {\n width: min(900px, 100%);\n background: rgba(17, 24, 39, 0.9);\n border: 1px solid var(--border);\n border-radius: 20px;\n padding: 24px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);\n }\n\n h1 {\n margin: 0 0 8px;\n font-size: 2rem;\n }\n\n p {\n margin: 0 0 20px;\n color: var(--muted);\n }\n\n .grid {\n display: grid;\n gap: 16px;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n margin-bottom: 20px;\n }\n\n .card {\n background: var(--card);\n border: 1px solid var(--border);\n border-radius: 16px;\n padding: 16px;\n }\n\n .card h2 {\n margin: 0 0 12px;\n font-size: 1.05rem;\n }\n\n label {\n display: block;\n margin: 10px 0 6px;\n color: var(--muted);\n font-size: 0.95rem;\n }\n\n input, select, button {\n width: 100%;\n padding: 12px 14px;\n border-radius: 12px;\n border: 1px solid var(--border);\n background: #0b1220;\n color: var(--text);\n font: inherit;\n }\n\n button {\n background: var(--accent);\n color: #001018;\n border: none;\n font-weight: 700;\n cursor: pointer;\n transition: transform 0.15s ease, filter 0.15s ease;\n }\n\n button:hover {\n transform: translateY(-1px);\n filter: brightness(1.05);\n }\n\n .scoreboard {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n margin-top: 16px;\n }\n\n .score {\n flex: 1 1 140px;\n background: #0b1220;\n border: 1px solid var(--border);\n border-radius: 16px;\n padding: 16px;\n text-align: center;\n }\n\n .score .label {\n color: var(--muted);\n font-size: 0.9rem;\n }\n\n .score .value {\n font-size: 2rem;\n font-weight: 800;\n margin-top: 6px;\n }\n\n .result {\n margin-top: 18px;\n padding: 14px 16px;\n border-radius: 14px;\n background: #0b1220;\n border: 1px solid var(--border);\n font-weight: 600;\n }\n\n .win {\n color: var(--good);\n }\n\n .lose {\n color: var(--bad);\n }\n </style>\n</head>\n<body>\n <main class=\"app\">\n <h1>Predictor</h1>\n <p>Make a prediction, pick a score, and see if you beat the opponent.</p>\n\n <section class=\"grid\">\n <div class=\"card\">\n <h2>Your Pick</h2>\n <label for=\"yourTeam\">Your team</label>\n <input id=\"yourTeam\" type=\"text\" placeholder=\"Enter your team\" value=\"Lions\" />\n\n <label for=\"yourScore\">Your predicted score</label>\n <input id=\"yourScore\" type=\"number\" min=\"0\" max=\"20\" value=\"3\" />\n </div>\n\n <div class=\"card\">\n <h2>Opponent</h2>\n <label for=\"opponentTeam\">Opponent team</label>\n <input id=\"opponentTeam\" type=\"text\" placeholder=\"Enter opponent\" value=\"Tigers\" />\n\n <label for=\"opponentScore\">Opponent score</label>\n <input id=\"opponentScore\" type=\"number\" min=\"0\" max=\"20\" value=\"2\" />\n </div>\n\n <div class=\"card\">\n <h2>Game Mode</h2>\n <label for=\
…Example Score Pick
My solution
{"code":"","ranAt":"2026-07-22T10:12:41.134Z"}Act2 Scoring Function
My solution
{"code":"<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <title>scorePick · your turn</title>\n </head>\n <body>\n <h1>scorePick · your turn</h1>\n <pre id=\"out\" style=\"font-family:ui-monospace,Menlo,monospace;font-size:13px;white-space:pre-wrap\"></pre>\n\n <script>\n // YOUR TASK: write the body of scorePick.\n function scorePick(predicted, actual) {\n\n // Determine whether the score is a home win, away win, or draw.\n function resultOf(score) {\n if (score.home > score.away) return \"home\";\n if (score.home < score.away) return \"away\";\n return \"draw\";\n }\n\n const predictedResult = resultOf(predicted);\n const actualResult = resultOf(actual);\n\n // Exact score\n if (\n predicted.home === actual.home &&\n predicted.away === actual.away\n ) {\n return 5;\n }\n\n // Correct result but different score\n if (predictedResult === actualResult) {\n return 2;\n }\n\n // Wrong result\n return 0;\n }\n\n // Local sanity-check\n const cases = [\n { pred: { home: 2, away: 1 }, actual: { home: 2, away: 1 }, why: 'exact 2-1' },\n { pred: { home: 3, away: 0 }, actual: { home: 1, away: 0 }, why: 'home win, wrong score' },\n { pred: { home: 0, away: 1 }, actual: { home: 2, away: 0 }, why: 'wrong result' },\n { pred: { home: 1, away: 1 }, actual: { home: 2, away: 2 }, why: 'both draws' },\n ];\n\n const out = document.getElementById('out');\n out.textContent = cases\n .map(c => `${c.pred.home}-${c.pred.away} vs ${c.actual.home}-${c.actual.away} -> ${scorePick(c.pred, c.actual)} // ${c.why}`)\n .join('\\n');\n </script>\n </body>\n</html>","ranAt":"2026-07-22T10:16:08.043Z","passed":true,"attempt":1,"selfCheck":{"cases":[{"name":"exact 2-1 vs 2-1 returns 5","pass":true},{"name":"home win 3-0 vs 1-0 returns 2","pass":true},{"name":"wrong result 0-1 vs 2-0 returns 0","pass":true},{"name":"draw 1-1 vs 2-2 returns 2","pass":true}],"ranAt":"2026-07-22T10:16:08.043Z"}}Act3 Compute Strength
My solution
{"passed":true,"attempt":1,"selfCheck":{"allPass":true,"failedNames":[]},"ranAt":"2026-07-22T10:23:34.242Z"}Act3 House Bot
My solution
{"passed":true,"attempt":1,"selfCheck":{"allPass":true,"failedNames":[]},"ranAt":"2026-07-22T10:25:51.883Z"}Act4 Leaderboard
My solution
{"code":"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <title>Section 4 · the leaderboard</title>\n <style>\n body { font-family: ui-sans-serif, system-ui, sans-serif; padding:20px; max-width:520px; margin:0 auto; }\n h1 { font-size:18px; margin:0 0 4px; }\n .sub { color:#6B7280; font-size:12px; margin:0 0 14px; }\n table { width:100%; border-collapse:collapse; }\n th, td { text-align:left; padding:8px 10px; border-bottom:1px solid #E5E7EB; font-size:14px; }\n th { color:#6B7280; font-size:11px; text-transform:uppercase; letter-spacing:.06em; }\n .rank { width:36px; color:#9CA3AF; font-variant-numeric:tabular-nums; }\n .total { text-align:right; font-weight:700; font-variant-numeric:tabular-nums; }\n button {\n font:inherit;\n padding:8px 14px;\n border-radius:999px;\n border:0;\n background:#00C65A;\n color:#fff;\n font-weight:700;\n cursor:pointer;\n }\n </style>\n</head>\n<body>\n\n<h1>Predict the Cup · leaderboard</h1>\n<p class=\"sub\">One box, one ranked table.</p>\n\n<table>\n<thead>\n<tr>\n<th class=\"rank\">#</th>\n<th>Player</th>\n<th class=\"total\">Total</th>\n</tr>\n</thead>\n\n<tbody id=\"board\"></tbody>\n</table>\n\n<p style=\"margin-top:14px\">\n<button id=\"run\">Run saveAndRank()</button>\n</p>\n\n<script>\n\nvar KEY = \"proov_predict_v1\";\n\nvar rowsThisRound = [\n { name: \"You\", total: 17 },\n { name: \"House bot\", total: 11 },\n { name: \"Alex\", total: 22 }\n];\n\nfunction render(rows){\n var html = rows.map(function(r,i){\n return \"<tr>\"\n + \"<td class='rank'>\" + (i+1) + \"</td>\"\n + \"<td>\" + r.name + \"</td>\"\n + \"<td class='total'>\" + r.total + \"</td>\"\n + \"</tr>\";\n }).join(\"\");\n\n document.getElementById(\"board\").innerHTML = html;\n}\n\nfunction saveAndRank(entries){\n\n // Save\n localStorage.setItem(KEY, JSON.stringify(entries));\n\n // Load\n var rows = JSON.parse(localStorage.getItem(KEY));\n\n // Highest score first\n rows.sort(function(a,b){\n return b.total - a.total;\n });\n\n // Display\n render(rows);\n}\n\ndocument.getElementById(\"run\").addEventListener(\"click\", function(){\n saveAndRank(rowsThisRound);\n});\n\nwindow.saveAndRank = saveAndRank;\nwindow.__LB_KEY__ = KEY;\nwindow.__LB_ROWS__ = rowsThisRound;\n\n</script>\n\n</body>\n</html>","ranAt":"2026-07-22T10:27:58.032Z","passed":true,"attempt":1,"selfCheck":{"cases":[{"name":"saveAndRank is defined as a function","pass":true,"detail":""},{"name":"localStorage holds every entry under one key","pass":true,"detail":""},{"name":"rendered table is sorted highest to lowest","pass":true,"detail":""},{"name":"after a simulated reload, the rank order still holds","pass":true,"detail":""}],"ranAt":"2026-07-22T10:27:58.032Z"}}Act1 Fixtures Form
My solution
{"code":"<!doctype html>\n<html><head>\n <meta charset=\"utf-8\" />\n <title>Predict the Cup · my picks</title>\n <style>\n body { font: 15px/1.45 system-ui, -apple-system, \"Segoe UI\", sans-serif;\n padding: 24px; background: #F7F8FA; color: #0E1726; }\n h1 { margin: 0 0 4px; font-size: 22px; }\n .sub { color: #6A7589; font-size: 13px; margin-bottom: 16px; }\n #board { display: grid; gap: 12px; max-width: 640px; }\n .match { background: #fff; border: 1px solid #E4E8EE; border-radius: 12px;\n padding: 14px; }\n .meta { color: #6A7589; font-size: 11px; text-transform: uppercase;\n letter-spacing: 0.14em; margin-bottom: 6px; }\n .teams { font-weight: 800; font-size: 15.5px; margin-bottom: 8px; }\n .picks { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }\n .picks input { padding: 9px; border: 1px solid #E4E8EE; border-radius: 8px;\n text-align: center; font: inherit; font-weight: 700; font-size: 15px; }\n .vs { color: #6A7589; font-weight: 700; font-size: 12px; }\n footer { color: #6A7589; font-size: 11px; margin-top: 24px; max-width: 640px; line-height: 1.5; }\n </style>\n</head><body>\n\n <h1>My picks</h1>\n <div class=\"sub\">2026 World Cup · Round of 32</div>\n <div id=\"board\"></div>\n\n <footer>\n Independent learning project. Not affiliated with or endorsed by FIFA.\n Fixtures snapshot: openfootball/worldcup.json (CC0-1.0).\n </footer>\n\n <script>\n /* The real 2026 fixtures (sample slice of the bundled snapshot). */\n const FIXTURES = [\n { home: \"Germany\", away: \"Mexico\", date: \"2026-06-28\", round: \"R32\" },\n { home: \"Argentina\", away: \"Switzerland\", date: \"2026-06-29\", round: \"R32\" },\n { home: \"France\", away: \"Portugal\", date: \"2026-06-30\", round: \"R32\" },\n { home: \"Spain\", away: \"Uruguay\", date: \"2026-07-01\", round: \"R32\" },\n { home: \"Brazil\", away: \"Japan\", date: \"2026-07-02\", round: \"R32\" },\n { home: \"England\", away: \"Netherlands\", date: \"2026-07-03\", round: \"R32\" },\n ];\n\n /* TODO — Your task.\n * Loop FIXTURES and, for each match, build a card and append it to #board.\n * Each card MUST:\n * - be tagged with the attribute data-match (any value is fine)\n * - contain TWO <input type=\"number\"> (one for each team's predicted score)\n * - show both team names somewhere visible\n *\n * Tip: the worked example is the same shape on a 2-match sample.\n */\n function renderFixtures() {\n {\n const board = document.getElementById(\"board\");\n board.innerHTML = \"\";\n\n FIXTURES.forEach((match) => {\n const card = document.createElement(\"div\");\n card.dataset.match = `${match.home}-vs-${match.away}`;\n\n const homeName = document.createElement(\"div\");\n homeName.textContent = match.home;\n\n const awayName = document.createElement(\"div\");\n awayName.textContent = match.away;\n\n const homeScore = document.createElement(\"input\");\n homeScore.type = \"number\";\n\n const awayScore = document.createElement(\"input\");\n awayScore.type = \"number\";\n\n card.append(homeName, homeScore, awayScore, awayName);\n board.appendChild(card);\n });\n}\n } \n\n renderFixtures();\n </script>\n\n</body></html>","ranAt":"2026-07-22T13:32:40.057Z","selfCheck":{"cases":[{"name":"FIXTURES const is in scope","pass":true,"detail":""},{"name":"One card per fixture (data-match)","pass":true,"detail":""},{"name":"Two number inputs per card","pass":true,"detail":""},{"name":"Team names visible on each card","pass":true,"detail":""}],"ranAt":"2026-07-22T10:03:35.091Z"},"passed":false,"attempt":1}Act5 Assemble
My solution
{"code":"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Predict the Cup — my World Cup prediction game</title>\n<style>\n :root { --green:#00C65A; --ink:#0E1726; --soft:#3F4B5F; --faint:#6A7589; --line:#E4E8EE; --paper:#fff; --bg:#F4F7F6; }\n * { box-sizing: border-box; }\n body { font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n margin:0; background:var(--bg); color:var(--ink); }\n .wrap { max-width: 760px; margin: 0 auto; padding: 22px 18px 40px; }\n header { display:flex; align-items:center; gap:12px; margin-bottom:6px; }\n .badge { width:42px; height:42px; border-radius:12px; background:var(--green); color:#fff;\n display:flex; align-items:center; justify-content:center; font-weight:900; font-size:18px; }\n h1 { font-size:21px; margin:0; letter-spacing:-0.01em; }\n .tag { color:var(--faint); font-size:13px; margin:2px 0 18px; }\n h2 { font-size:13px; text-transform:uppercase; letter-spacing:0.12em; color:var(--faint); margin:24px 0 12px; }\n #board { display:grid; grid-template-columns:1fr; gap:12px; }\n @media (min-width:560px){ #board { grid-template-columns:1fr 1fr; } }\n .match { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:14px; }\n .meta { color:var(--faint); font-size:10.5px; text-transform:uppercase; letter-spacing:0.12em; margin-bottom:8px; }\n .teams { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; font-weight:800; font-size:15px; }\n .teams .vs { color:var(--faint); font-weight:700; font-size:11px; }\n .teams .away { text-align:right; }\n .picks { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; margin-top:10px; }\n .picks input { padding:10px; border:1px solid var(--line); border-radius:9px; text-align:center;\n font:inherit; font-weight:800; font-size:16px; width:100%; }\n .picks input:focus { outline:2px solid var(--green); border-color:var(--green); }\n .picks .dash { color:var(--faint); font-weight:800; text-align:center; }\n .botline { margin-top:10px; font-size:12px; color:var(--soft); }\n .botline b { color:var(--ink); font-variant-numeric:tabular-nums; }\n .botline .prob { color:var(--faint); font-weight:600; }\n .hint { color:var(--faint); font-size:12px; line-height:1.5; margin:8px 0 2px; max-width:560px; }\n .challenge-banner { background:rgba(0,198,90,0.10); border:1px solid var(--green); border-radius:12px; padding:12px 14px; font-size:14px; font-weight:700; color:var(--ink); margin:14px 0 4px; }\n .vs-friend { font-size:15px; font-weight:800; color:var(--green); margin-top:12px; }\n .share-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:14px; }\n .namebox { flex:1; min-width:150px; padding:11px 12px; border:1px solid var(--line); border-radius:10px; font:inherit; }\n .namebox:focus { outline:2px solid var(--green); border-color:var(--green); }\n .sharebtn { font:inherit; font-weight:800; color:#fff; background:var(--ink); border:0; border-radius:999px; padding:11px 18px; cursor:pointer; }\n .sharebtn:hover { filter:brightness(1.15); }\n .shareurl { width:100%; margin-top:8px; padding:9px 11px; border:1px solid var(--line); border-radius:8px; font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--soft); }\n .proov-credit { margin-top:12px; font-size:11.5px; }\n .proov-credit a { color:var(--green); font-weight:800; text-decoration:none; }\n .result { margin-top:10px; font-size:12px; font-weight:700; color:var(--green);\n border-top:1px dashed var(--line); padding-top:9px; }\n .scorebtn { margin-top:18px; font:inherit; font-weight:800; font-size:15px; color:#fff;\n background:var(--green); border:0; border-radius:999px; padding:13px 22px; cursor:pointer; width:100%; }\n @media (min-width:560px){ .scorebtn { width:auto; } }\n .
…Final Submission
My solution
{"url":"https://zippy-beijinho-ab350d.netlify.app/","submittedAt":"2026-07-22T13:44:18.569Z"}