A multiplayer F1 fantasy game where players predict who will finish 10th (P10) in each race. Built on Craft CMS 5 with a custom PHP module, Twig templates, and a JavaScript frontend.
- How the Game Works
- Tech Stack
- Project Structure
- Getting Started (Local Dev)
- PHP Module — Backend Logic
- Templates — Frontend Pages
- JavaScript — Interactive Features
- CSS & Styling
- Database & Content Structure
- API Endpoints Reference
- Caching
- Multi-Site / Multi-Season
- Admin Features
- Environment Variables
- Common Tasks & How-Tos
Each race weekend, every player picks one F1 driver they think will finish exactly 10th. Points are awarded based on how close the picked driver finishes to P10.
| Driver Finishes At | Distance from P10 | Points Earned |
|---|---|---|
| P10 (exact!) | 0 | 25 pts |
| P9 or P11 | 1 | 18 pts |
| P8 or P12 | 2 | 15 pts |
| P7 or P13 | 3 | 12 pts |
| P6 or P14 | 4 | 10 pts |
| P5 or P15 | 5 | 8 pts |
| P4 or P16 | 6 | 6 pts |
| P3 or P17 | 7 | 4 pts |
| P2 or P18 | 8 | 2 pts |
| P1 or P19 | 9 | 1 pt |
| P20 or DNF/DSQ | 10+ | 0 pts |
Every race goes through four statuses in order:
upcoming → selection_open → selection_closed → completed
- Upcoming — Race is on the schedule but voting hasn't started yet
- Selection Open — Players take turns picking their driver (draft-style)
- Selection Closed — All players have picked, waiting for the real race to happen
- Completed — Real race results are in, points have been calculated
Players don't all pick at the same time. Instead, they take turns in reverse standings order — the player in last place picks first, the leader picks last. This balances the game so trailing players get first pick of the best drivers.
Each player gets one booster per season. When activated, it doubles the points earned for that race's prediction. Use it wisely!
If a player takes too long, any other player can skip their turn. The skipped player gets 0 points for that race.
| Layer | Technology |
|---|---|
| CMS | Craft CMS 5.9.14 (PHP) |
| PHP | 8.2 / 8.3 |
| Database | MySQL 8.0 |
| Templates | Twig (Craft's template engine) |
| CSS | Tailwind CSS 4 |
| JavaScript | Vanilla ES6 modules |
| Charts | Chart.js 4 |
| Animations | GSAP 3.13 |
| Build Tool | Vite 7 |
| Local Dev | DDEV (Docker) |
| F1 Data | Jolpica API (free F1 stats API) |
gooat/
│
├── modules/f1gooat/ # 🧠 ALL backend logic lives here
│ ├── Module.php # Bootstrap, routes, global helpers
│ ├── PointsCalculator.php # Scoring formula
│ ├── RaceStatus.php # Status constants (upcoming, selection_open, etc.)
│ ├── CacheService.php # Cache management with tag invalidation
│ ├── SelectionService.php # Draft order logic (who picks when)
│ ├── RaceResultsService.php # Results processing pipeline
│ ├── controllers/ # Web request handlers (6 files)
│ │ ├── AuthController.php
│ │ ├── FrontendController.php
│ │ ├── PredictionController.php
│ │ ├── RaceController.php
│ │ ├── LeaderboardController.php
│ │ └── UpdateController.php
│ ├── console/controllers/ # CLI commands
│ │ ├── ImportController.php
│ │ └── CronController.php
│ └── jobs/ # Background queue jobs
│ └── FetchRaceResultsJob.php
│
├── templates/ # 🎨 ALL page templates (Twig)
│ ├── index.twig # Homepage (standings + next race)
│ ├── f1/ # Game pages
│ │ ├── _layout.twig # Shared layout (header/footer)
│ │ ├── select-driver.twig # Driver voting page
│ │ ├── standings.twig # Season leaderboard
│ │ ├── race-results.twig # Race results breakdown
│ │ ├── race-list.twig # All races schedule
│ │ ├── driver-list.twig # Driver roster
│ │ ├── driver-profile.twig # Individual driver stats
│ │ ├── player-profile.twig # Individual player stats
│ │ ├── login.twig # Email login form
│ │ └── _partials/ # Reusable template pieces
│ │ ├── header.twig
│ │ ├── footer.twig
│ │ ├── playerStanding.twig
│ │ ├── raceHistoryRow.twig
│ │ └── raceCards/ # Race card per status
│ │ ├── _card.twig
│ │ ├── upcoming.twig
│ │ ├── selection_open.twig
│ │ ├── selection_closed.twig
│ │ └── completed.twig
│ └── _layouts/ # Base HTML layouts
│ ├── base.twig
│ └── site.twig
│
├── src/ # 💻 Frontend source files
│ ├── js/
│ │ ├── app.js # Main entry (lazy-loads features)
│ │ └── parts/ # Feature modules
│ │ ├── driverSelection.js # Card grid, voting, confirm modal
│ │ ├── seasonChart.js # Chart.js leaderboard graph
│ │ ├── skipPlayer.js # Skip turn button
│ │ ├── refetchResults.js # Manual results fetch button
│ │ ├── adminActions.js # Admin sync buttons
│ │ ├── playerRaceByRace.js # Expandable race history
│ │ ├── a11y-dialog.js # Accessible modals (login)
│ │ ├── pageHeader.js # Sticky header + site switcher
│ │ ├── formie.js # Craft Formie forms
│ │ ├── design-grid.js # Dev grid overlay
│ │ └── debounce.js # Utility function
│ └── css/
│ ├── app.css # Main CSS entry point
│ └── 3_components/ # Component-specific styles
│ ├── f1/ # F1 game components
│ │ ├── f1-F1DriverCard.css
│ │ ├── f1-cards.css
│ │ ├── f1-badges.css
│ │ ├── f1-login.css
│ │ └── f1-table.css
│ └── globals/ # Global components
│ ├── header.css
│ ├── footer.css
│ ├── buttons.css
│ └── headroom.css
│
├── config/ # ⚙️ Craft & app configuration
│ ├── app.php # Loads the f1gooat module
│ ├── general.php # General Craft settings
│ ├── vite.php # Vite asset integration
│ ├── routes.php # (empty — routes defined in Module.php)
│ └── project/ # Content structure (auto-managed by Craft)
│ ├── project.yaml
│ ├── sections/ # Section definitions
│ ├── entryTypes/ # Entry type definitions
│ └── fields/ # Field definitions
│
├── web/ # 🌐 Public web root
│ └── dist/ # Built assets (Vite output, git-tracked)
│
├── .ddev/ # 🐳 Docker/DDEV configuration
│ ├── config.yaml # PHP 8.3, MySQL 8.0, nginx
│ └── db_snapshots/ # Database snapshots for resets
│
├── vite.config.js # Build tool config
├── postcss.config.js # PostCSS/Tailwind
├── package.json # Node dependencies
├── composer.json # PHP dependencies
└── .env # Environment variables (not in git)
- DDEV installed
- Node.js 18+ and npm
# 1. Start DDEV (starts PHP, MySQL, Nginx containers)
ddev start
# 2. Install PHP dependencies
ddev composer install
# 3. Install Node dependencies
ddev npm install
# 4. Import a database snapshot (if available)
ddev snapshot restore
# 5. Build frontend assets
ddev npm run build
# 6. (Optional) Start Vite dev server for hot-reload
ddev npm run dev- Frontend: The URL shown by
ddev describe(e.g.https://gooat.ddev.site) - Craft Control Panel: Add
/adminto the URL - Vite Dev Server: Port 5173 (auto-configured)
Everything lives in modules/f1gooat/. This is a Craft CMS module — it's registered in config/app.php and bootstraps automatically.
File: modules/f1gooat/Module.php
This is the entry point. When Craft starts, Module::init() runs and does two things:
Every URL in the game maps to a controller action:
/select/123 → FrontendController::actionSelectDriver(123)
/standings → FrontendController::actionStandings()
/results/123 → FrontendController::actionRaceResults(123)
/races → FrontendController::actionRaceList()
/drivers → FrontendController::actionDriverList()
/driver/456 → FrontendController::actionDriverProfile(456)
/player/789 → FrontendController::actionPlayerProfile(789)
/prediction/submit → PredictionController::actionSubmitPrediction()
/prediction/skip → PredictionController::actionSkipPlayer()
/player-login → AuthController::actionLogin()
/player-logout → AuthController::actionLogout()
/update/sync-drivers → UpdateController::actionSyncDrivers()
/update/sync-races → UpdateController::actionSyncRaces()
/race/fetch-results/X → RaceController::actionFetchRaceResults(X)
... etc
Every Twig template automatically has access to:
| Variable | What It Is |
|---|---|
currentPlayer |
The logged-in player entry (or null) |
availableSites |
All season sites (for the season switcher) |
currentSeasonYear |
Year number (e.g. 2026) |
currentSite |
The active Craft site object |
pointsMap |
The points table (used to show scoring rules) |
| Method | What It Does |
|---|---|
getCurrentPlayer() |
Looks up the player entry by the email stored in the session |
calculateSeasonStandings($siteId) |
Computes the full leaderboard (with caching) |
getCurrentSeasonYear() |
Extracts the year from the site handle (e.g. season2026 → 2026) |
getApiBaseUrl() |
Returns the Jolpica API base URL from env |
getAvailableSites() |
Returns all sites in the site group (for season switching) |
These files contain the core game logic. Controllers call these services — they don't contain game logic themselves.
File: modules/f1gooat/PointsCalculator.php
Dead simple: takes a driver's actual finishing position, returns points earned.
PointsCalculator::calculate(10); // 25 (perfect P10)
PointsCalculator::calculate(11); // 18 (1 off)
PointsCalculator::calculate(1); // 1 (9 off)
PointsCalculator::calculate(20); // 0 (10+ off)The POINTS_MAP constant is also exposed to Twig templates as pointsMap for displaying the scoring table.
File: modules/f1gooat/RaceStatus.php
Just four constants so we don't use magic strings everywhere:
RaceStatus::UPCOMING // 'upcoming'
RaceStatus::SELECTION_OPEN // 'selection_open'
RaceStatus::SELECTION_CLOSED // 'selection_closed'
RaceStatus::COMPLETED // 'completed'File: modules/f1gooat/SelectionService.php
Handles the draft order — determines whose turn it is to pick.
| Method | What It Does |
|---|---|
getCurrentSelector($raceId, $siteId) |
Returns the player entry whose turn it is. Uses reversed standings (last place picks first). Appends players not yet in standings. Returns null when all players have picked. |
getSelectedCount($raceId) |
How many predictions exist for this race |
hasUsedBooster($player, $siteId) |
Has this player already used their booster this season? |
How draft order works internally:
- Get the season standings (leaderboard)
- Reverse the order (so last place is first)
- Append any players who aren't in standings yet (no predictions in any race)
- The player at index
[number of predictions already made]is the current selector
File: modules/f1gooat/RaceResultsService.php
Handles everything after a real F1 race finishes. The main method is processRaceResults() which runs the full pipeline:
Fetch API results → Format → Save to race entry → Calculate points for each prediction
→ Apply booster multiplier → Update player standings → Open next race → Clear caches
| Method | What It Does |
|---|---|
formatResults($apiResults) |
Converts Jolpica API response into our internal format |
processRaceResults($race, $results) |
Runs the full pipeline (see above) |
calculatePointsForRace($race, $siteId) |
Calculates and saves points for all predictions of a race |
updatePlayerStandings($siteId) |
Recalculates totalPoints and currentStanding for all players |
openNextRace($completedRace) |
Finds the next upcoming race and sets it to selection_open |
findDriverResult($results, $driverCode) |
Finds a specific driver in the results array |
File: modules/f1gooat/CacheService.php
Manages server-side caching with tag-based invalidation. When data changes, we invalidate specific cache tags instead of clearing everything.
Cache Tags:
| Tag | Used For |
|---|---|
f1.standings |
Leaderboard data |
f1.seasonChart |
Season progress chart |
f1.races |
Race schedule |
f1.drivers |
Driver roster |
f1.predictions |
All predictions |
f1.players |
Player entries |
Cache Durations:
| Duration | Length | Used For |
|---|---|---|
DURATION_SHORT |
5 minutes | Active selection pages |
DURATION_MEDIUM |
1 hour | Standings, charts |
DURATION_LONG |
24 hours | Completed race data, driver profiles |
Invalidation Methods:
| Method | Clears |
|---|---|
invalidateAfterPrediction() |
predictions, standings, seasonChart |
invalidateAfterRaceResults() |
Everything (all tags) |
invalidateAfterRaceSync() |
races |
invalidateAfterDriverSync() |
drivers |
Controllers handle HTTP requests. They validate input, call services, and return responses (JSON or rendered templates).
File: modules/f1gooat/controllers/AuthController.php
Simple email-based login. No passwords — players are trusted.
| Action | URL | Method | What It Does |
|---|---|---|---|
actionLogin() |
/player-login |
GET/POST | Shows login form (GET) or validates email and stores in session (POST) |
actionLogout() |
/player-logout |
GET | Clears session, redirects to homepage |
How login works:
- Player enters their email
- System looks for a player entry with that
playerEmailon the current site - If found, stores the email in the PHP session
Module::getCurrentPlayer()reads this session value on every request
File: modules/f1gooat/controllers/FrontendController.php
Renders all the game pages. Each action prepares data and passes it to a Twig template.
| Action | URL | Template | What It Shows |
|---|---|---|---|
actionSelectDriver($raceId) |
/select/123 |
f1/select-driver |
Driver voting page with grid, selection status, booster toggle |
actionStandings() |
/standings |
f1/standings |
Season leaderboard with chart |
actionRaceResults($raceId) |
/results/123 |
f1/race-results |
Race result breakdown, predictions vs actual |
actionRaceList() |
/races |
f1/race-list |
All races in the schedule |
actionDriverList() |
/drivers |
f1/driver-list |
Active driver roster |
actionDriverProfile($driverId) |
/driver/456 |
f1/driver-profile |
Driver stats (wins, podiums, pick stats) |
actionPlayerProfile($playerId) |
/player/789 |
f1/player-profile |
Player stats (race history, avg points) |
File: modules/f1gooat/controllers/PredictionController.php
Handles the actual voting mechanics. All responses are JSON.
| Action | URL | Method | What It Does |
|---|---|---|---|
actionSubmitPrediction() |
/prediction/submit |
POST | Submit a driver pick. Validates turn, driver availability, booster. Creates prediction entry. Auto-closes selection when all players have picked. |
actionGetAvailableDrivers() |
/prediction/available-drivers |
GET | Returns drivers not yet picked for a race |
actionGetSelectionStatus() |
/prediction/selection-status |
GET | Returns race status, whose turn it is, player count |
actionSkipPlayer() |
/prediction/skip |
POST | Skips current player's turn (creates prediction with driverId=SKIP, 0 points) |
File: modules/f1gooat/controllers/RaceController.php
Fetches real F1 results and calculates points.
| Action | URL | Method | What It Does |
|---|---|---|---|
actionFetchRaceResults($raceId) |
/race/fetch-results/123 |
GET | Fetches results from Jolpica API, runs full processing pipeline |
actionCalculatePoints($raceId) |
/race/calculate-points/123 |
GET | Recalculates points from existing results (without re-fetching API) |
File: modules/f1gooat/controllers/LeaderboardController.php
Public JSON API for leaderboard data (used by Chart.js and can be used externally).
| Action | URL | What It Returns |
|---|---|---|
actionGetStandings() |
/leaderboard/standings |
Full season standings with position changes |
actionGetRaceBreakdown($raceId) |
/leaderboard/race-breakdown/123 |
All predictions for a race with actual P10 |
actionGetSeasonChart() |
/leaderboard/season-chart |
Cumulative points per player per race (for Chart.js) |
actionGetPlayerStats($playerId) |
/leaderboard/player-stats/789 |
Individual player statistics |
File: modules/f1gooat/controllers/UpdateController.php
Admin sync actions — pulls data from the Jolpica F1 API.
| Action | URL | Method | What It Does |
|---|---|---|---|
actionSyncDrivers() |
/update/sync-drivers |
POST | Imports/updates driver roster from API |
actionSyncRaces() |
/update/sync-races |
POST | Imports/updates race schedule from API |
actionFetchAllResults() |
/update/fetch-results |
POST | Batch-fetches results for all selection_closed races |
Run these via ddev craft <command> in the terminal.
File: modules/f1gooat/console/controllers/ImportController.php
| Command | What It Does |
|---|---|
ddev craft f1-gooat/import/drivers --site=season2026 |
Import drivers from Jolpica API |
ddev craft f1-gooat/import/races --site=season2026 --year=2026 |
Import race schedule |
ddev craft f1-gooat/import/update-teams --site=season2026 |
Update driver team names from standings API |
ddev craft f1-gooat/import/clone-players --site=season2026 --from=season2025 |
Copy player roster to a new season |
ddev craft f1-gooat/import/predictions --site=season2025 --file=predictions.json |
Import predictions from a JSON file |
File: modules/f1gooat/console/controllers/CronController.php
| Command | What It Does |
|---|---|
ddev craft f1-gooat/cron/fetch-results |
Checks all sites for races ready for results, queues fetch jobs |
File: modules/f1gooat/jobs/FetchRaceResultsJob.php
A background job that fetches race results from the API. Used by the cron controller to process results asynchronously. Takes a raceId, fetches from Jolpica API, and runs RaceResultsService::processRaceResults().
Templates use Twig (Craft's template engine). They live in templates/ and render the HTML pages.
_layouts/base.twig ← HTML skeleton (<html>, <head>, <body>)
└── _layouts/site.twig ← Site-wide wrapper
└── f1/_layout.twig ← F1 game layout (header + footer + main block)
└── f1/*.twig ← Individual pages
Every page template extends f1/_layout.twig and fills in the {% block main %} block.
| Template | URL | Description |
|---|---|---|
index.twig |
/ |
Homepage. Shows next race card (with voting progress), season standings preview, recent race results. |
f1/select-driver.twig |
/select/123 |
Voting page. The core of the game. Shows: whose turn it is, available driver grid, booster toggle, confirm modal, recently selected list, already-taken drivers. |
f1/standings.twig |
/standings |
Leaderboard. Season standings table with position change indicators, season progress chart (Chart.js), stats summary. |
f1/race-results.twig |
/results/123 |
Race results. Shows the actual P10 driver, each player's prediction with points earned, full race classification grid. |
f1/race-list.twig |
/races |
Schedule. All races listed with status-specific cards (color-coded by status). |
f1/driver-list.twig |
/drivers |
Driver roster. All active drivers grouped by team. |
f1/driver-profile.twig |
/driver/456 |
Driver stats. F1 stats (wins, podiums, DNFs) + game stats (times picked, avg points generated). |
f1/player-profile.twig |
/player/789 |
Player stats. Race history, perfect predictions, average points, favorite drivers. |
f1/login.twig |
/player-login |
Login form. Simple email input. |
| Partial | Used By | What It Renders |
|---|---|---|
f1/_partials/header.twig |
_layout.twig |
Navigation bar with site switcher |
f1/_partials/footer.twig |
_layout.twig |
Footer with admin action buttons |
f1/_partials/playerStanding.twig |
standings.twig, index.twig |
One row in the standings table |
f1/_partials/raceHistoryRow.twig |
player-profile.twig, playerStanding.twig |
One row of race history (prediction + result) |
f1/_partials/raceCards/_card.twig |
raceCards/*.twig |
Unified race card with status-driven styling |
f1/_partials/raceCards/upcoming.twig |
race-list.twig |
Thin wrapper for upcoming race card |
f1/_partials/raceCards/selection_open.twig |
race-list.twig |
Thin wrapper for open selection card |
f1/_partials/raceCards/selection_closed.twig |
race-list.twig |
Thin wrapper for closed selection card |
f1/_partials/raceCards/completed.twig |
race-list.twig |
Cached wrapper for completed race card |
| Macro File | What It Contains |
|---|---|
f1/_macros/icons.twig |
SVG icon macros (calendar, clock, trophy, flag, users, check, etc.) — call with {{ icons.trophy() }} |
f1/_macros/ui.twig |
UI component macros (backButton, statusBadge, raceDateTime, statCard, linkButton, pendingBadge, pointsDisplay) |
How to use macros in a template:
{% import "f1/_macros/icons.twig" as icons %}
{% import "f1/_macros/ui.twig" as ui %}
{{ icons.trophy('w-5 h-5 text-amber-500') }}
{{ ui.statusBadge(race.raceStatus) }}
{{ ui.statCard('Total Points', player.totalPoints, icons.star()) }}JavaScript lives in src/js/. The entry point app.js lazy-loads feature modules only when their DOM elements exist on the page.
app.js runs on DOMContentLoaded and checks for specific elements:
// Only loads driverSelection.js if #driverGrid exists on the page
const driverGrid = document.querySelector('#driverGrid');
if (driverGrid) {
import('@js/parts/driverSelection').then(m => m.driverSelection(driverGrid));
}This means JavaScript is only loaded for pages that need it.
| File | Trigger Element | What It Does |
|---|---|---|
parts/driverSelection.js |
#driverGrid |
Driver card click → confirm modal → POST prediction → reload. Also handles booster toggle. |
parts/seasonChart.js |
#seasonChart |
Fetches /leaderboard/season-chart, renders Chart.js line chart with cumulative points per player. |
parts/skipPlayer.js |
#skipPlayerBtn |
Confirm modal → POST skip → toast notification → reload. |
parts/refetchResults.js |
#refetchResultsBtn |
POST to /race/fetch-results/<id> → shows loading spinner → toast. |
parts/adminActions.js |
.js-admin-action |
Generic handler for admin footer buttons (sync drivers, sync races, fetch results). |
parts/playerRaceByRace.js |
.js-player-race-by-race |
Accordion-style expandable race history rows. |
parts/a11y-dialog.js |
[data-a11y-dialog] |
Accessible modal dialogs (login modal). |
parts/pageHeader.js |
— | Headroom.js sticky header + season site switcher. |
parts/formie.js |
— | Craft Formie form integration. |
# Development (with hot reload)
ddev npm run dev
# Production build (outputs to web/dist/)
ddev npm run buildThe built files go to web/dist/ and are git-tracked so the production server doesn't need Node.js.
Uses Tailwind CSS 4 with component-specific CSS files.
src/css/
├── app.css ← Main entry (imports Tailwind + components)
└── 3_components/
├── f1/ ← F1 game-specific styles
│ ├── f1-F1DriverCard.css ← Driver card states (hover, selected, confirming)
│ ├── f1-cards.css ← Generic card styling
│ ├── f1-badges.css ← Status badges, points displays
│ ├── f1-login.css ← Login form
│ └── f1-table.css ← Results/standings tables
├── globals/ ← Site-wide styles
│ ├── header.css
│ ├── footer.css
│ ├── buttons.css
│ └── headroom.css
└── general.css ← Typography, spacing, utilities
Tailwind CSS 4 uses a different syntax than v3:
- Important modifier goes after:
cursor-pointer!(not!cursor-pointer) - Some utilities have changed names
The database is managed by Craft CMS. Content is organized into Sections (like database tables) with Fields (like columns).
| Section | What It Stores | Key Fields |
|---|---|---|
| drivers | F1 driver roster | driverId, driverCode, driverFirstName, driverLastName, driverNumber, teamName, isActive, driverPhoto |
| races | Race schedule | raceDate, raceRound, season, raceStatus, raceResults (table field) |
| players | Player accounts | playerEmail, totalPoints, currentStanding, previousStanding |
| predictions | Individual predictions | driverId, driverCode, driverName, selectionOrder, actualPosition, pointsEarned, boosterUsed |
Predictions link to both a player and a race using Craft's Entries relation fields:
prediction.predictionPlayer → player entry
prediction.predictionRace → race entry
The raceResults field on races stores the full race classification as a table field (array of rows). Each row has:
position, driverCode, driverId, status (Finished/DNF/DSQ)
This is populated when results are fetched from the Jolpica API.
All return JSON. Endpoints marked "Auth: player" require a logged-in player. "Auth: admin" also works for CP-logged-in admins.
| Endpoint | Method | Auth | Request Body / Query | Response |
|---|---|---|---|---|
/prediction/submit |
POST | player/admin | raceId, driverId, boosterUsed |
{ success, prediction: { id, driverCode, driverName, selectionOrder, boosterUsed } } |
/prediction/available-drivers |
GET | any | ?raceId=123 |
{ success, drivers: [{ id, driverId, driverCode, firstName, lastName, teamName, photo }] } |
/prediction/selection-status |
GET | any | ?raceId=123 |
{ success, status, totalPlayers, selectedCount, currentSelector, isPlayerTurn } |
/prediction/skip |
POST | player/admin | raceId |
{ success, skippedPlayer, selectionOrder, totalPlayers } |
| Endpoint | Method | Auth | Response |
|---|---|---|---|
/race/fetch-results/<raceId> |
GET | any | { success, message } |
/race/calculate-points/<raceId> |
GET | any | { success, message } |
| Endpoint | Method | Auth | Response |
|---|---|---|---|
/leaderboard/standings |
GET | any | { success, season, standings: [{ position, name, totalPoints, positionChange, lastRace }] } |
/leaderboard/race-breakdown/<raceId> |
GET | any | { success, race, breakdown, actualP10, perfectCount } |
/leaderboard/season-chart |
GET | any | { success, labels, datasets } |
| Endpoint | Method | Auth | Response |
|---|---|---|---|
/update/sync-drivers |
POST | player | { success, message, imported, updated } |
/update/sync-races |
POST | player | { success, message, imported, updated } |
/update/fetch-results |
POST | player | { success, message, processed, raceNames } |
Uses Craft's cache system with tag-based invalidation via CacheService.
How it works: When you cache something, you tag it (e.g., TAG_STANDINGS). When standings change, you invalidate that tag, which clears all caches with that tag.
// Caching data:
CacheService::getOrSet('cacheKey', [CacheService::TAG_STANDINGS], CacheService::DURATION_MEDIUM, function() {
return expensiveCalculation();
});
// Invalidating after a prediction:
CacheService::invalidateAfterPrediction(); // Clears predictions + standings + chart cachesSome templates use Craft's {% cache %} tag for expensive template blocks:
{% cache using key "standings-" ~ currentSite.id for 1 hour %}
{# Expensive template rendering here #}
{% endcache %}API endpoints set Cache-Control headers for browser/CDN caching:
- Active data (standings): 5 min cache, 1 hour stale-while-revalidate
- Completed data (race results): 1 hour cache, 1 week stale-while-revalidate
The game supports multiple F1 seasons using Craft's multi-site feature. Each season is a separate Craft "site" within a site group.
- Sites are named by handle:
season2024,season2025,season2026 - Each site has its own set of drivers, races, players, and predictions
- Players are cloned across seasons (same email, separate entries)
- The year is extracted from the site handle:
season2026→2026
The header includes a season switcher dropdown that lets users navigate between seasons. This is powered by Module::getAvailableSites().
# 1. Create a new site in Craft CP (Settings → Sites) with handle "season2027"
# 2. Clone players from previous season
ddev craft f1-gooat/import/clone-players --site=season2027 --from=season2026
# 3. Import drivers for the new year
ddev craft f1-gooat/import/drivers --site=season2027
# 4. Import race schedule
ddev craft f1-gooat/import/races --site=season2027 --year=2027
# 5. Update team info
ddev craft f1-gooat/import/update-teams --site=season2027When a Craft CMS admin is logged into the Control Panel (/admin), they get extra powers on the frontend:
Admins can vote on behalf of any player without logging in as that player. When an admin visits the voting page:
- A blue "Admin Mode — Voting for [Player Name]" banner appears
- The driver grid is fully interactive
- Predictions are submitted on behalf of the current selector
- Boosters can be toggled for the player being voted for
- Skipping works without needing a player login
This is useful when a player can't access the site themselves (e.g., they tell you their pick over text).
The footer shows admin action buttons:
- Sync Drivers — Pulls latest driver roster from the Jolpica API
- Sync Races — Pulls latest race schedule
- Fetch Results — Fetches results for all races waiting for results
Admin status is checked via:
$isAdmin = Craft::$app->getUser()->getIdentity() && Craft::$app->getUser()->getIdentity()->admin;This checks the Craft CP login, not the player email login. They are separate authentication systems.
Set these in your .env file (not committed to git):
| Variable | Required | Description | Example |
|---|---|---|---|
JOLPICA_API_URL |
No | F1 data API base URL | https://api.jolpi.ca/ergast/f1 (default) |
CRAFT_APP_ID |
Yes | Unique Craft app identifier | gooat |
CRAFT_DEV_MODE |
No | Enable dev features | true |
PRIMARY_SITE_URL |
Yes | Base site URL | https://gooat.ddev.site |
CRAFT_DB_SERVER |
Yes | Database host | db (DDEV default) |
CRAFT_DB_DATABASE |
Yes | Database name | db (DDEV default) |
CRAFT_DB_USER |
Yes | Database user | db (DDEV default) |
CRAFT_DB_PASSWORD |
Yes | Database password | db (DDEV default) |
SMTP_HOST |
No | Mail server host | localhost |
SMTP_PORT |
No | Mail server port | 1025 |
Edit modules/f1gooat/PointsCalculator.php. Change the POINTS_MAP array. The key is the distance from P10, the value is the points awarded.
- Create a Twig template in
templates/f1/your-page.twig(extendf1/_layout) - Add a controller action in the appropriate controller (or
FrontendController.php) - Register the route in
Module.php→init()→EVENT_REGISTER_SITE_URL_RULES
Edit modules/f1gooat/SelectionService.php → getCurrentSelector(). Currently it reverses the standings. You could change it to random, alphabetical, or any custom order.
- Add the field in Craft CP → Settings → Fields
- Add it to the appropriate entry type in Settings → Sections → Entry Types
- The field will automatically be available in templates as
entry.fieldHandle
- Layout/structure: Edit templates in
templates/f1/ - Colors/spacing: Most styling is Tailwind utility classes directly in templates
- Component styles: Edit CSS files in
src/css/3_components/ - After CSS changes: Run
ddev npm run build(orddev npm run devfor live reload)
Results are fetched via the Jolpica API. The race must be in selection_closed status. You can:
- Click "Fetch Results" in the admin footer
- Visit
/race/fetch-results/<raceId>directly - Run
ddev craft f1-gooat/cron/fetch-resultsfrom the terminal
Visit /race/calculate-points/<raceId> — this recalculates all points from the stored race results without re-fetching from the API.
- Go to Craft CP → Entries → Players
- Create a new entry on the correct site (season)
- Set their
playerEmail— this is what they'll use to log in
- Go to Craft CP → Entries → Races
- Find the race, change
raceStatustoselection_open - Save
Free F1 statistics API (successor to Ergast). Used for:
- Driver roster and team info
- Race schedule with dates/times
- Race results (finishing positions)
Base URL: https://api.jolpi.ca/ergast/f1
Endpoints we use:
| Endpoint | What It Returns |
|---|---|
/{year}/drivers/ |
All drivers for a season |
/{year}/driverStandings/ |
Driver standings (includes team info) |
/{year}/ |
Full race schedule |
/{year}/{round}/results/ |
Race results for a specific round |
No API key required. Rate limiting applies (be gentle with bulk requests).