Code as Curriculum

The games are the lesson

No build step, no framework, no minified mystery. Just readable vanilla JavaScript with comments written to be read. Click any file reference on this page to open the real source, right here.

The open-engine philosophy

CEMI Fútbol is written to be opened up. It runs directly in the browser as native ES modules — no bundler, no transpiler, no node_modules. Every file is small, single-purpose, and commented so a curious student can follow it.

Try it now: click cemi/core.js. The real file opens in a viewer with line numbers. Every monospace path on this page works the same way.

The code map: what each file teaches

The engine lives in cemi/; each of the sixteen games lives in its own folder under games/. Click any path to read it.

Engine — cemi/

FileWhat it doesWhat it teaches
cemi/core.jsGame loop, scene manager, responsive canvas, helper math (clamp, lerp, mulberry32, hashStr).Game loops, delta-time updates, seeded RNG, utility functions.
cemi/input.jsUnified pointer/touch/keyboard; swipes carry a full trail so games read direction, speed AND curvature.Event handling, vectors, reading gestures from raw points.
cemi/audio.jsAll-synth WebAudio sound effects — zero audio files.Procedural audio, oscillators, envelopes.
cemi/fx.jsParticles, confetti, screen-shake, floating text.Particle systems, animation, "game juice".
cemi/facts.jsThe Nerd Engine: context-tagged facts that never repeat until the pool is exhausted.Data-driven content, sets, non-repeating random selection.
cemi/i18n.jsEnglish / Español / Français / Português with t('key', {vars}) everywhere.Internationalization, key/value data structures, string templating.
cemi/nations.js32 nations; flags are DRAWN from simple shapes, no image files.Data modeling, procedural vector graphics.
cemi/tournament.jsKnockout brackets: pick a nation, survive 5 rounds to the cup.Arrays, shuffling, tournament trees, powers of two.
cemi/challenge.jsSeeded daily/weekly challenges — the same world for everyone today.Determinism, hashing, date keys, seeds.
cemi/profile.jsOptional, kid-safe local profile — never required to play.localStorage, privacy-by-design, default objects.
cemi/nickname.jsThe nickname filter: a four-language ban list, evasion-proof normalization, whole-word vs. substring matching.String normalization, defense in depth, the Scunthorpe problem.
cemi/backend.jsOne Store interface, two implementations (local + Firebase), offline-first.Interfaces/polymorphism, graceful degradation.
sw.jsThe service worker: ~30 lines that make the whole site installable and playable offline.Caches, stale-while-revalidate, PWAs, network fallbacks.
cemi/ui.jsShared DOM UI and a line-art SVG icon library (never emojis).DOM building, SVG, component thinking.

Golden Boot — games/golden-boot/

FileWhat it doesWhat it teaches
games/golden-boot/main.jsBoots the game, owns the menu/tournament/results screens.App structure, wiring modules together.
games/golden-boot/shootout.jsThe physics scene: gravity + drag + Magnus spin, keeper AI, scoring.Projectile motion, vectors, forces, prediction, energy.
games/golden-boot/render.jsTwo renderers over one state: retro 16-bit and modern broadcast, with a 2.5D projection.3D-to-2D projection, separation of state and view.
games/golden-boot/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback with real numbers.

Gorilla on the Pitch — games/gorilla/

FileWhat it doesWhat it teaches
games/gorilla/main.jsSets up the pitch, teammates, waves, and the survive-and-score loop.Game state, entities, win/lose conditions.
games/gorilla/gorilla.jsThe gorilla: a finite state machine + procedural quadruped animation.State machines, AI targeting, procedural animation, trigonometry.
games/gorilla/strings.jsGame text in four languages + real gorilla-biology facts.Data-driven content, biology as data.

Keepy-Uppy Infinito — games/keepy/

FileWhat it doesWhat it teaches
games/keepy/main.jsThe whole loop: gravity-and-bounce ball physics, a shrinking catch window, foot-alternation combos, and a pre-scheduled hazard timeline.Projectile physics in three lines, easing-based difficulty ramps, simple state tracking, seeded RNG.
games/keepy/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Anaconda Soccer — games/anaconda/

FileWhat it doesWhat it teaches
games/anaconda/main.jsThe snake: a growing array of segments that follow each other, steered by an angle-chase AI with a turn-rate limit.Arrays as queues, trigonometric steering (atan2), circle-distance collision, seeded RNG.
games/anaconda/strings.jsGame text in four languages + facts about real anacondas.Data-driven content, biology as data.

Rumble Fútbol — games/rumble/

FileWhat it doesWhat it teaches
games/rumble/main.jsUp to 4-player local hotseat soccer: eight power-ups dispatched from one data table, four keyboard/touch control schemes, and a two-role team AI.Data-driven design (a switch as a dispatch table), local multiplayer input routing, simple steering AI.
games/rumble/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Elemental League — games/elemental/

FileWhat it doesWhat it teaches
games/elemental/main.jsFour elements and their home arenas as two linked data tables; arena hazards (lava, currents, wind, rocks) alter the ball's physics directly.Data modeling with shared keys, environment-as-gameplay, framerate-independent AI decisions.
games/elemental/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Cruce de Tráfico — games/cruce/

FileWhat it doesWhat it teaches
games/cruce/main.jsDeterministic per-row traffic generation, eased tile-to-tile hopping, and hazard rules that branch by lane type (street/train/river).Seeded RNG consumed in strict order, easing curves, state dispatch driven by data.
games/cruce/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Muerto FC — games/muerto/

FileWhat it doesWhat it teaches
games/muerto/main.jsA horde that grows by pushing more zombie objects onto one array after every goal, each one steered by a chase-plus-wobble vector.Data-driven difficulty ramps, steering AI, delta-time-scaled movement.
games/muerto/strings.jsGame text in four languages + its Nerd Engine facts pack (including a fact that names its own data-driven design).i18n data, templated feedback.

Dodgeball FC — games/dodgeball/

FileWhat it doesWhat it teaches
games/dodgeball/main.jsTelegraphed dodgeball throws whose landing spot is fixed at launch, a whole match's throws pre-rolled into per-rival scripts, and speed-gated stun collisions.Honest/telegraphed projectile "juice," deterministic script pre-rolling, state-gated collision.
games/dodgeball/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Gods vs Titans FC — games/gods/

FileWhat it doesWhat it teaches
games/gods/main.jsA data-driven roster of four gods (one shared update/draw function, four stat rows), cooldown-based special powers, and circle push-out physics against the Titan.Data modeling ("one recipe, many characters"), cooldown/resource systems, vector collision response.
games/gods/strings.jsGame text in four languages, god/power names, and mythology-flavored Nerd Engine facts.i18n data, a bridge into etymology and Greek mythology.

FIREBALL FC — games/fireball/

FileWhat it doesWhat it teaches
games/fireball/main.jsBlast-radius explosion physics that shove the ball, a mana/cooldown resource system, and rule-based bot targeting between the ball and its own goal.Radius/falloff physics, resource budgeting, normalized-vector AI.
games/fireball/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Béisbol Gol — games/beisbol/

FileWhat it doesWhat it teaches
games/beisbol/main.jsA whole 54-pitch match pre-rolled from one seeded RNG, timing-window swing quality, parametric trig-based pitch paths, and an adaptive goalkeeper that predicts and remembers.Large-scale RNG determinism, timing mechanics, trigonometric motion, predictive/adaptive AI.
games/beisbol/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Dolphins vs Sharks — games/dolphins/

FileWhat it doesWhat it teaches
games/dolphins/main.jsOne shared momentum/drag swim function driving seven entities, hunt/patrol shark AI with lead-pursuit prediction, and three-way gesture disambiguation (swipe/tap/leap).Physics-function reuse, simple state-machine AI, predictive targeting, gesture input handling.
games/dolphins/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Dungeon Striker — games/dungeon/

FileWhat it doesWhat it teaches
games/dungeon/main.jsA ten-floor data table, a two-state keeper AI with a telegraphed hunt transition, a whole-run seeded pre-roll of loot and floor "quirks," stacking inventory multipliers, and a baked-tile lighting optimization.Procedural level design, finite-state AI with fair telegraphs, large-scale determinism, inventory systems, render performance.
games/dungeon/strings.jsGame text in four languages + its Nerd Engine facts pack.i18n data, templated feedback.

Endless Striker — games/striker/

FileWhat it doesWhat it teaches
games/striker/main.jsEased lane-switching movement, three independent seeded RNG streams (obstacles/rivals/curve), and state-gated obstacle collision.Easing (lerp), multi-stream determinism, state-gated collision, distance-based difficulty ramps.
games/striker/shared.jsThe single source of truth for constants and helpers reused by both this 2D game and its 3D twin, games/striker3d/.Code reuse across two very different renderers — the DRY principle in practice.
games/striker/strings.jsGame text in four languages + its Nerd Engine facts pack, also imported directly by games/striker3d/.i18n data, shared content across renderers.

Endless Striker 3D — games/striker3d/

FileWhat it doesWhat it teaches
games/striker3d/main.jsThe same ruleset as Endless Striker (imports its constants straight from games/striker/shared.js), driven by a custom render loop instead of the shared 2D Game/FX classes."Same game, two renderers," reusing a shared 2D engine's input/audio/i18n/profile modules from a 3D game.
games/striker3d/scene.jsBuilds the Three.js scene graph: Scene, PerspectiveCamera, DirectionalLight, fog.3D scene graphs, cameras, lighting — the diorama-and-photo model of 3D rendering.
games/striker3d/runner.jsThe 3D runner's mesh and its procedural animation.3D meshes and procedural animation in three dimensions.
games/striker3d/obstacles.jsDefender and obstacle meshes managed with object pooling (acquireDefender/releaseDefender) instead of create-and-destroy.Object pooling, memory and performance in real-time 3D.

Guided code-reading activities

Each activity opens a real file in the source viewer. No editor and no setup needed — reading comes before writing. The first four use Golden Boot and Gorilla; the rest tour the other fourteen games, roughly simplest first.

1 The gravity constant — and Moon ball

Objective
Locate a physical constant in code and reason about a change without running it.
Instructions
Open games/golden-boot/shootout.js. Search for 9.81 — Earth's gravity in m/s². Find where it pulls the ball down each frame (B.vy -= g * dt). Now imagine replacing it with the Moon's gravity, 1.62.
Expected result
Students predict that on the Moon the ball would barely arc down — shots would sail long and float, keepers would have far more time, and "over the bar" would happen constantly. They can point to the exact line responsible.
Extension / Teacher's note
Extension: The game also has a "beach ball" effect that uses g = 3.6. Find it and explain why a beach ball floats. Note: reasoning about a change in your head ("what-if") is a real debugging skill — no need to edit anything.

2 How the keeper reads a shot

Objective
See how simple AI can look smart by predicting the future.
Instructions
In games/golden-boot/shootout.js, find predictCrossing. Read how it fast-forwards the ball's flight in a loop to guess where it will cross the goal line, then look at _keeperDecide to see how difficulty makes the keeper "read" the shot more often.
Expected result
Students discover the keeper isn't cheating — it runs a cheap copy of the same physics to predict the crossing point, then dives there (with some deliberate error so it's beatable). Higher difficulty simply lowers the error.
Extension / Teacher's note
Extension: Discuss the trade-off: a perfect predictor would be unbeatable and no fun. Where is the randomness that keeps it fair? Note: the same forward-simulation trick powers real game and robotics AI.

3 Walking without a sprite sheet

Objective
Understand procedural animation — motion generated by math, not drawn frames.
Instructions
Open games/gorilla/gorilla.js. Find _gait and read how the phase advances with distance travelled, not time (this.phase += (sp * dt) / this.stride), so the gorilla's legs plant and step realistically. Then find the LIMBS table and read the comment about diagonal limb pairs.
Expected result
Students see that a convincing knuckle-walk is produced entirely by trigonometry and timing — the four limbs step in diagonal pairs, the body bobs twice per stride and sways once. No animation frames exist at all.
Extension / Teacher's note
Extension: Find _renderProcedural and the bob/sway values — change one in your head and predict the effect. Note: the file supports an optional sprite sheet too; the procedural path is the fallback and the teaching case.

4 One data structure, four languages

Objective
See how a single data shape can hold every translation of the game.
Instructions
Open cemi/i18n.js and read the STR table: each key maps to { en, es, fr, pt }. Then open cemi/facts.js and see the exact same shape used for the Nerd Engine's facts. Find the t() function and read how {vars} get substituted into a string.
Expected result
Students recognize one reusable pattern — a key with four language values — powering the entire multilingual interface and the trivia. They understand why adding a language means adding one field, not rewriting the game.
Extension / Teacher's note
Extension: Trace how t('gb_nerd_goal', {kmh, j, ms}) turns into the sentence you read after a goal. Note: the physics feedback strings in games/golden-boot/strings.js use the same templating with real computed numbers.

5 The three-line physics loop

Objective
Find the smallest complete physics simulation in the whole codebase.
Instructions
Open games/keepy/main.js and find the per-frame update: ball.vy += G * dt; ball.y += ball.vy * dt; ball.x += ball.vx * dt;. Then find relaunch() and see how a kick is just b.vy = -kickV(c).
Expected result
Students see that gravity, motion, and a kick are three short lines total — no physics library, no hidden engine.
Extension / Teacher's note
Extension: Find band(n) and explain, in your own words, how lerp shrinks the catch window as the combo grows. Note: this exact three-line pattern (velocity, then position, both scaled by dt) recurs, with decorations, in every other game in this catalog.

6 A queue that grows

Objective
See a snake body implemented as a plain JavaScript array.
Instructions
Open games/anaconda/main.js and find the segment-following loop inside updateSnake, then the growth code: for (let i = 0; i < GROW; i++) snake.pts.push({ x: tail.x, y: tail.y });. Also find const TURN = 2.6 + snake.speed / 60; and the clamp that limits how fast the heading can turn.
Expected result
Students see the whole snake is one array of points, each one following the point ahead of it, and that "eating" is nothing more than .push().
Extension / Teacher's note
Extension: Explain why a limited turn rate turns "chasing" into a fair, escapable challenge instead of an inevitable catch. Note: the wig sine term added to the heading is the same "not a straight line" trick used by the gorilla's gait and the muerto zombies' wobble.

7 A switch that plays eight roles

Objective
See a small data table and a switch replace eight separate features.
Instructions
Open games/rumble/main.js, find const PU = ['giant', 'freeze', 'speed', 'tele', 'bumper', 'goals', 'ice', 'fire']; and its matching switch (pu.type) in applyPow. Then find KEYSETS and see how humanVec(p) only ever reads one player's own keys.
Expected result
Students see one object shape ({type, x, y, ttl}) driving eight different effects, and one small lookup table cleanly splitting a keyboard between up to four players.
Extension / Teacher's note
Extension: Write the one new case line a ninth power-up would need. Note: the "ice" effect is not new physics — it just lowers the existing grip variable already used every frame.

8 Two tables, one key

Objective
See data modeling where two tables stay in sync through a shared identifier.
Instructions
Open games/elemental/main.js and compare const ELEMENTS = {...} with const ARENAS = {...} just below it — both keyed by water/earth/air/fire. Then find the fire arena's lava-pool code: ball.vx *= 1.4; ball.vy *= 1.4;.
Expected result
Students see a character's stats and its home arena's look-and-feel are two separate lookups sharing one id, and that stepping into a hazard directly rewrites the shared ball object's velocity.
Extension / Teacher's note
Extension: Sketch the two data rows a fifth element would need. Note: the rival's power-use decision is rolled only once every 0.25 real seconds, not once per frame — keeping the daily challenge fair across different device speeds.

9 Decide once, draw forever

Objective
Separate a one-time random decision from an ongoing pure calculation.
Instructions
Open games/cruce/main.js. Find makeRow(idx), where a row's direction and speed are rolled once with rng. Then find movingItems(row), which computes every car's current position using only elapsed time (tGlob) — no randomness at all.
Expected result
Students see that once a row exists, exactly where its traffic sits at any instant is pure math, not a fresh coin flip — which is exactly what keeps a daily challenge identical for a fast player and a slow one.
Extension / Teacher's note
Extension: Find posOf(e) and identify the "fast start, slow finish" easing formula used for each hop. Note: the ball's bounce direction after being punted loose is chosen from a list shuffled once per row, not re-rolled at the moment of impact.

10 Same object, more copies

Objective
See a horde's growing difficulty implemented as array growth, not new code.
Instructions
Open games/muerto/main.js and find onGoal(): for (let i = 0; i < SPAWN_PER_GOAL; i++) zombies.push(makeZombie(false));. Then find the chase-plus-wobble vector in updateZombies.
Expected result
Students see every zombie is the same object shape with different numbers, and that the horde "growing" is simply more entries in one array.
Extension / Teacher's note
Extension: Change SPAWN_PER_GOAL and predict the new horde size after five goals. Note: the game's own Nerd Engine facts name this exact pattern "data-driven design" out loud.

11 A script instead of live dice

Objective
Understand why some games pre-roll their randomness instead of rolling it during play.
Instructions
Open games/dodgeball/main.js, read the comment above throwPlan in startMatch(), then find how each rival reads down its own pre-rolled list in updateRival().
Expected result
Students see that live rng() calls during play would let a fast-moving player and a cautious one consume the shared daily seed in a different order, breaking fairness — pre-rolling the whole script avoids that.
Extension / Teacher's note
Extension: Compare this solution with Endless Striker's "three separate live dice" solution to the same fairness problem. Note: a hit only stuns a rival above a speed threshold (speed > 170) — the same collision code, two different outcomes.

12 One function, four gods

Objective
Recognize a data table driving a whole character roster from one function.
Instructions
Open games/gods/main.js and read the GODS table (color/speed/cd/nameKey/powerKey per god). Then find usePower() and see how a power only fires once god.power fills up to god.cd.
Expected result
Students see one shared update/draw function reading a different stat row per god — a "trading card" pattern — and a recharge-bar cooldown reused for all four.
Extension / Teacher's note
Extension: Change zeus.cd from 7 to 2 and predict how much more often lightning could strike. Note: this "one recipe, many characters" idea reappears — doubled — in Elemental League's linked element/arena tables.

13 Blast falloff in one line

Objective
Read radius-based explosion physics built from distance and a clamp.
Instructions
Open games/fireball/main.js and find explode(b): const power = (b.big ? 460 : 330) * clamp(1 - d / (R + 60), 0.35, 1);.
Expected result
Students see that the closer the ball is to the blast, the harder the push — a readable falloff model using the same dist() helper as every other collision check in the catalog.
Extension / Teacher's note
Extension: Find cast() and explain how a fixed mana cost plus a fixed regeneration rate sets a "shots per minute" ceiling. Note: the bot's aim uses the same normalized-vector trick you will meet again in Elemental League and Dodgeball FC.

14 A match scripted before it starts

Objective
See seeded-RNG determinism applied to a whole match at once.
Instructions
Open games/beisbol/main.js and find startMatch(), where all 54 pitches are built into a plan array before play begins. Then find pitchPath(p) and see how Math.sin(p * Math.PI) fakes a curveball.
Expected result
Students see the strongest example yet of "same seed, same world for everyone" — an entire match's pitches decided in advance — and a complex-looking flight path built from one extra trig term.
Extension / Teacher's note
Extension: Compare this whole-match pre-roll with Dodgeball's pre-rolled per-rival lists and Endless Striker's three live streams. Note: the keeper predicts your shot's landing spot with the real formula 0.5 * GRAVITY * tc * tc — the same trick as Golden Boot's keeper.

15 Aim ahead of a moving target

Objective
Understand predictive ("lead") pursuit AI as distinct from naive chasing.
Instructions
Open games/dolphins/main.js and find the hunting shark's target: const tx = ball.x + ball.vx * 0.25;. Then find swimStep() and see how it drives all seven swimming entities with one shared momentum/drag function.
Expected result
Students see the hunter aims a little ahead of the ball's current position, not at it — real predator behavior — and that one physics function serves every swimmer in the game.
Extension / Teacher's note
Extension: Change the 0.25 lead factor to 0 or 1 and predict how passing difficulty changes. Note: patrol-state sharks circle using Math.cos/Math.sin on a slowly advancing angle.

16 A run decided at floor one

Objective
See seeded-RNG determinism scaled up to an entire multi-floor run, plus a fair telegraphed AI transition.
Instructions
Open games/dungeon/main.js and find startRun(daily), where loot offers and every floor's "quirk" are all drawn from one rng before floor one begins. Then find updateHunt() and its windupT = 0.5 warning shake.
Expected result
Students see the biggest pre-roll in the catalog — a ten-floor run decided at the very start — and recognize the half-second warning shake as the same honest-telegraph pattern as the gorilla's chest-beat.
Extension / Teacher's note
Extension: Change floor one's hunt: 0.3 to hunt: 1 and predict the new difficulty. Note: loot is just a counter multiplied into a stat wherever it matters, so picking the same item twice keeps compounding with no new code.

17 Three dice instead of one

Objective
Understand why one fair game sometimes needs several independent seeded RNGs.
Instructions
Open games/striker/main.js and find rng, rivalRng and curveRng, each built with its own makeRng(...) call in startRun(). Then find the collision block in updatePlay() and trace its chain of state checks (fireT, jumped/slid, invulT, shieldOn) before it ever calls crash(o).
Expected result
Students see that sharing one die would let picking a country accidentally reroll the obstacle course, and that touching an obstacle is really an ordered checklist of "was I in a state that saves me?" questions.
Extension / Teacher's note
Extension: Imagine merging the three RNGs into one and predict what would break. Note: games/striker/shared.js calls itself the single source of truth for both this game and its 3D twin — read on to see exactly what that means.

18 Find the shared file

Objective
Prove "same game, different renderer" by finding literally shared code.
Instructions
Open games/striker3d/main.js and read its header comment. Then open games/striker/shared.js, find a constant such as ROCKET_RANGE_M, and confirm the 3D file imports that exact same constant. Finally open games/striker3d/scene.js and find buildWorld(), where a Scene, a PerspectiveCamera and a DirectionalLight are created and added to the scene.
Expected result
Students see the same numbers imported into two very different-looking games, and understand that 3D rendering means arranging real objects in a scene graph and photographing them with a camera every frame, instead of drawing pixels directly.
Extension / Teacher's note
Extension: Find where the 3D camera uses lerp to ease toward the player's lane — the same easing trick as the 2D game's lane movement. Note: old defenders are never destroyed — acquireDefender/releaseDefender in games/striker3d/obstacles.js wipe and reuse them from a pool, a real lesson in why game engines recycle objects instead of constantly creating new ones.

Defense in depth: four layers against a bad nickname

Players pick their own nicknames, and sooner or later somebody will try to sneak in something offensive. The engine treats that as a small security problem and answers it the way security engineers answer big ones: with defense in depth — several independent layers, each written as if every layer before it had already failed. The whole lesson lives in one small file, cemi/nickname.js, and the four places that use it.

LayerWhereWhat it does
1 — Validate at inputcemi/ui.jsWhen the player saves a nickname in the profile dialog, isNameAllowed() checks it first. A bad name is rejected on the spot with a friendly "pick another name" message — nothing is stored. This layer exists for the honest player: instant, polite feedback.
2 — Sanitize at writecemi/profile.jsdisplayName() re-checks the stored name every time it is about to be used. If a banned name got in anyway (an old save, hand-edited storage), what goes out — to the screen and to the cloud — is the neutral house name instead.
3 — Mask at displaycemi/backend.jsEvery name that arrives from the cloud passes through safeName() before it is rendered. Even if some other writer bypassed the first two layers completely, no player ever sees the bad name on a leaderboard.
4 — Enforce on the serverfunctions/index.jsA small trigger runs on the server itself: any banned name that touches the database is rewritten immediately to the neutral house name. This is the only layer a cheater armed with curl cannot skip.

Why one layer is never enough

Layers 1–3 run in the browser — on the player's machine, under the player's control. Anyone can open the developer tools, edit the JavaScript, or skip the app entirely and write to the network with curl. That is why client-side checks are for experience (catch honest mistakes instantly and kindly), while the real rule has to live where nobody can reach it. The one-line summary worth memorizing: client code is a suggestion; the server is the law.

The neutral house name: a blocked player is never left nameless. anonName() in cemi/profile.js derives a stable, identity-free football nickname (like Golazo#417) from the player's random id — deterministic, so every layer computes the same replacement.

Normalization: seeing through the disguises

A ban list that only matches exact spellings loses on day one to B.A.D.W.O.R.D, b_a_d_w_o_r_d, baaadword and b4dw0rd. So before comparing anything, normalize() in cemi/nickname.js reduces every name to its plainest possible form: lowercase, accents stripped (é becomes e), a leet map applied (0o, 3e, $s...), and every non-letter removed — dots, underscores and spaces simply vanish. A second pass collapses repeated letters (baaadbad), and the list is checked against both versions. The disguise is undone before the comparison even starts.

The Scunthorpe problem

Now the opposite danger: ban ass as a substring and you have also banned bass, class and passion. This classic false-positive trap is named after the English town of Scunthorpe, whose residents kept getting blocked by early internet filters (the town's name hides a four-letter word). The file's answer is two lists: long, unambiguous terms are matched as substrings anywhere in the name, while short, ambiguous terms only count as a whole word or the whole name.

And when the two goals collide, the filter makes a deliberate, documented choice: strict by default. In a school space a false positive costs one player a "pick another name"; a false negative puts a slur on a classroom leaderboard. Those costs are not symmetrical, so the trade-off isn't either. (Yes, this means the town of Scunthorpe itself is banned as a nickname. The file's header comment apologizes.)

Note: nothing above depends on any particular vendor. Our implementation happens to use Firebase — layer 4 is a ~40-line Cloud Function trigger in functions/index.js watching Firestore writes — but the four-layer pattern applies to any app that accepts text from strangers.

Local-first accounts: one interface, two stores

The other privacy decision is architectural. The player chooses between two modes: local (the default: a nickname on this device, nothing ever sent anywhere) and cloud (sign in, sync progress, join the world leaderboards). The games themselves never know which one is active — and that is the lesson.

Read it yourself: open cemi/backend.js and find init() at the bottom — the entire mode decision is six lines. Then find syncProfile() and check: where is the Math.max? Where is the union?

Clone it and run it

Because there is no build step, running the whole project locally takes three commands. You only need Git and Python 3 (which ships with macOS and Linux, and is a quick install on Windows).

  1. Clone the repository:
    git clone https://github.com/<owner>/futbol.git
    cd futbol
  2. Start the tiny dev server (needed because ES modules require HTTP, not file://):
    python3 scripts/dev-server.py 8321
  3. Open it in a browser:
    http://localhost:8321

The dev server (scripts/dev-server.py) is a ~20-line Python script that serves the files with no-cache headers, so every edit shows up on reload. Change a number in games/golden-boot/shootout.js, refresh, and watch the game change. That immediate loop — edit, save, refresh, see — is the whole point.

Note: the source viewer on these pages fetches files over HTTP too, so it works best when the docs are opened through that same local server (or wherever the site is hosted), not from a bare file:// path.