A timed CSS debugging game. There is one challenge — CSS Challenge · NovaCloud — which hands the player a finished cloud-platform landing page ("NovaCloud") whose stylesheet shipped broken, and gives them 30 minutes to clear 14 CSS objectives.
Nobody builds NovaCloud, and nobody edits its markup. index.html is
read-only — shown so the player can read the structure and work out which
selectors apply, never editable, and never accepted from the browser. The only
editable file is style.css.
Django 5 + Django Channels (ASGI). No frontend framework.
cd website-fixer
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -r requirements.txt
python manage.py migrate
python manage.py runserverOpen http://127.0.0.1:8000/. daphne is first in INSTALLED_APPS, so
runserver serves ASGI and the WebSocket works without any extra process.
To serve exactly the way production does:
python manage.py collectstatic --no-input
daphne -b 127.0.0.1 -p 8000 games.asgi:application| Area | Where |
|---|---|
| Challenge files (the site itself) | first/challenge/novacloud/ |
| Round metadata + duration + fix table | first/game_config.py |
| Objective checking | first/checks.py |
| Views / API | first/views.py, games/urls.py |
| Presence store | first/presence.py |
| WebSocket consumer | first/consumers.py, first/routing.py, games/asgi.py |
| Game shell UI | static/css/wf-*.css, static/js/wf-*.js, template/*.html |
Flow: / (home) → /signup/ or /login/ → /start/ (launch screen) →
/home/ (arena) → success or timeout.
first/challenge/novacloud/
index.html the finished page -- READ ONLY, never edited or submitted
style.css the broken stylesheet the player repairs
solution.css the organisers' gold standard
index.html is a complete HTML document, not a fragment. The preview
renders it as written and swaps its <link rel="stylesheet"> for the player's
live CSS (previewDocument() in static/js/wf-arena.js).
solution.css is never served to a browser — ArenaPageTests asserts that the
answers do not appear in the arena HTML.
Three independent layers keep the markup fixed:
- the textarea carries
readonlyandaria-readonly, and is never bound to the editor's input/keydown handlers (static/js/wf-arena.js); submission()posts{css}only;_read_submitted_css()infirst/views.pyignores anyhtmlfield, and every check runs againstCHALLENGE_HTMLfrom disk. A forgedhtmlparameter can change neither the page nor the score —ReadOnlyMarkupTestsproves it.
style.css ships with 37 deliberate defects; only 14 are graded. The
rest are visual noise: squashed pricing cards, a four-column footer, cropped
avatars. A player may fix them or ignore them, and the arena says so plainly.
GRADED_FIXES in first/game_config.py is the authoritative list of what is
scored — objective id to the (broken, fixed) edits that clear it. Grouped
edits count as one objective: the stats band needs its column count and its
alignment, the steps row its columns and its padding, the pricing row its gap
and the scale of the featured card. A half-finished group does not score
(test_grouped_objectives_need_all_their_parts). apply_fixes() raises if an
anchor is missing or ambiguous, so a drifted challenge file fails the suite
loudly rather than grading the wrong thing.
The whole round is 17 declarations across 14 objectives — 17 changed lines in a 1278-line stylesheet, about 960 characters of typing.
One defect from the organisers' style.css is deliberately not shipped:
.hero__glow { position: static } puts a 900×900 decorative div into normal
flow, opening a 900px void that pushes the hero headline from 715px to 1615px.
Four of the fourteen objectives live in that hero, so the defect hides the
round rather than decorating it.
To swap in a different site: replace the four files, update the two fix tables,
and point the checks in first/checks.py at the new selectors. Nothing in the
views, templates or JS knows what the challenge is.
14 objectives, all CSS. They are graded on outcome, not on text —
whitespace, property order, comments and equivalent answers (flex vs
inline-flex, 56px vs clamp(...), repeat(3, 1fr) vs auto-fit, a
literal 16px vs the --radius-md token) all pass. Each objective carries a
description plus three hints that the player unlocks one at a time, each
narrowing the search:
| Level | Answers | Never contains |
|---|---|---|
| 1 · the idea | what kind of CSS this is | a selector or a property |
| 2 · where to look | the rule(s) involved | — |
| 3 · which property | the property and which way it is wrong | the finished declaration |
HintQualityTests enforces that shape: hint 1 must not name a selector, hint 2
must point at a rule, hint 3 must name a property, and no hint may contain any
literal declaration from GRADED_FIXES. Backtick spans render as <code>
through the code_spans filter, which escapes before it marks safe.
Two objectives carry an interaction note. Measured at 1120px, while the 860px
breakpoint is still misfiring it overrides .hero__container's column count
and hides .navbar__menu entirely, so fixing css-hero-split or
css-nav-spacing changes nothing on screen. Their hints say so without
revealing how to fix the breakpoint — a test asserts both the note and the
absence of max-width in them.
Two objectives interact, on purpose. The reversed breakpoint
(@media (min-width: 860px)) applies the entire phone stylesheet to desktop,
which overrides the hero's column count — so fixing css-hero-split shows no
visual change until css-responsive is fixed too. Both are graded
independently and the hint for the first one says so.
Neither the page nor the round needs any. The supplied markup referenced a
script.js driving the theme toggle, the FAQ accordion, the mobile menu and a
stat counter; the preview sandbox blocks scripts, so the page was made to stand
on its own:
- the
<script>tag is removed; - the four headline statistics carry their real values in the markup rather
than only in
data-count-toattributes; .faq-item__answeropens tomax-height: 400pxin both stylesheets, so the answers are readable without an accordion — it is not a defect and not an objective;.theme-toggle__icon--moon { display: none }is correct in both stylesheets, so the decorative toggle shows one icon rather than two.
The stylesheet's .reveal rules are dead code: the markup never uses that class.
[ View final preview ] in the preview panel head opens the finished
NovaCloud page — index.html styled by solution.css — so the player can see
what they are aiming at and infer the CSS from the visual difference.
It is a reference, never a grading action. final_preview() in
first/views.py reads nothing from the player and writes nothing back: no
grading, no progress, no autosave, and deliberately no start_challenge()
call, so opening it never starts or extends a clock. The document is composed
server-side and loaded by URL into its own empty-sandbox iframe, so the
arena's own source never carries the answers and the gold-standard CSS is
scoped to that document.
That view is the only one exempted from the site-wide X-Frame-Options: DENY
(to SAMEORIGIN); without it the overlay renders an empty box. A test asserts
both headers.
Note that a participant with devtools can still inspect the rendered reference page and read its computed styles. Anything rendered in the player's own browser is readable there — the feature hides the answers from the game UI, not from a determined inspector.
fitPreview() in static/js/wf-arena.js renders the iframe at a fixed 1120px
virtual width and scales it down to the panel. Sizing the iframe to the panel
instead would put NovaCloud permanently inside its own 860px breakpoint and
hide the desktop-only mistakes. The Phone toggle switches to a 390px
virtual width.
GAME_DURATION_SECONDS = 30 * 60 lives in first/game_config.py.
User.game_start_time is stamped once, the first time the player opens the
arena (User.start_challenge() is a no-op if it is already set). Every
remaining-time value is computed on the server from that timestamp:
- the arena page ships the current
remainingin#wf-arena-data; - the browser only renders a countdown from it and re-syncs with
GET /api/state/every 20 seconds and on window focus; POST /save-css/,POST /api/check/andPOST /api/reset/all re-checkuser.is_lockedand refuse the write when the session is over.
Refreshing, editing timeLeft in devtools, or clearing local storage changes
nothing — the clock is a database timestamp.
The player's page is rendered only inside <iframe id="wf-preview" sandbox>
via srcdoc (static/js/wf-arena.js). An empty sandbox attribute means no
scripts, no forms, no same-origin access and no top-level navigation, so:
- player CSS applies to that document alone and can never reach the game shell;
- game-shell CSS (all
wf-prefixed) never reaches the player's page, because it is a different document; - player JavaScript does not run at all — the challenge is HTML + CSS;
- the preview cannot read cookies, the session, or the parent DOM.
Server side, player HTML/CSS is never rendered with |safe; it only appears
inside auto-escaped <textarea> elements and is parsed (never executed) by
first/checks.py.
/ws/presence/ (first/consumers.py) is a Channels consumer joined to one
broadcast group. Presence is keyed by Django session, so one browser with
five tabs counts as one player. Each client pings every 20 seconds; entries
older than 55 seconds are swept, which covers dropped sockets, instance
restarts and closed laptops. The count is broadcast on every connect and
disconnect, and the browser reconnects with exponential backoff.
The client picks its scheme from the page (https: → wss://, otherwise
ws://) and uses location.host, so nothing about the environment is
hardcoded.
No, not for a single instance — which is what Render's free/starter web
service is. The default InMemoryChannelLayer plus the in-process presence
store are exact when one process serves everybody.
Set REDIS_URL only if you scale to more than one instance. Doing so
switches the channel layer to channels_redis and the presence store to a
Redis sorted set, so the count is shared. Nothing else changes.
-
Build Command:
./build.sh(installs requirements,collectstatic,migrate) -
Start Command:
daphne -b 0.0.0.0 -p $PORT games.asgi:application -
Environment variables:
Key Value PYTHON_VERSION3.11.9DJANGO_SECRET_KEYgenerate a value DJANGO_DEBUGfalseDATABASE_URLPostgres connection string (optional; SQLite otherwise) REDIS_URLonly when running 2+ instances DJANGO_SSL_REDIRECTdefaults to truewhenDJANGO_DEBUG=false; setfalseonly to run a production-shaped server locally over plain HTTP
render.yaml in this folder describes the same thing as a blueprint.
Gunicorn was removed from requirements.txt: it is WSGI-only and would kill
the WebSocket. Daphne serves both HTTP and WS.
Players register themselves at /signup/ with their PC number. Accounts
created before this version already carry a game_start_time, so their clock
looks expired — clear that field in /admin/ (or set completed_at back to
empty) to hand a player a fresh 30 minutes.
python manage.py test runs the regression suite (36 tests). It asserts that
the shipped page fails all 14 objectives, that both the gold standard and a
graded-fixes-only submission pass all 14, that each individual fix clears
exactly one objective and no others, that equivalent beginner answers are
accepted, that the FAQ icon's legitimate rotate(45deg) is never mistaken for
the console's, that the answers never reach the browser, that the clock cannot
be restarted or beaten, and that the presence socket counts one browser session
as one player however many tabs it opens.
- Start the server and open http://127.0.0.1:8000/ — the badge reads
1 player online. - Open a second private/incognito window on the same URL — both windows
move to
2 players onlinewithout a refresh. - Open a second tab in the same window — the count stays at 2 (same session).
- Close the private window — the first window drops back to
1. - Refresh either window — the count settles back to the same number; it does not climb.
Two normal windows of the same browser share a session cookie and therefore count as one player. That is intentional; use a private window to simulate a second person.
Paste this into the style.css tab in the arena:
* { margin: 0 !important; padding: 0 !important; }
body { background: red !important; }
button { background: lime !important; font-size: 50px !important; }
header { display: none !important; }The preview turns red and loses its header. The game bar, timer, buttons and editor must not change at all.