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.
- Zero-build. The browser loads the modules straight from disk. What you read is exactly what runs.
- Vanilla JS on HTML5 canvas. No hidden magic — the game is drawn one frame at a time with plain
<canvas>calls. - Comments as curriculum. The code explains its own physics, biology and structure in the comments, in plain language.
- A shared engine. Both games sit on the same
cemi/engine, so a lesson learned in one game transfers to the other.
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.
Jump to a game's code map: Golden Boot · Gorilla · Keepy-Uppy · Anaconda · Rumble · Elemental · Cruce · Muerto · Dodgeball · Gods vs Titans · Fireball · Béisbol · Dolphins · Dungeon · Endless Striker · Endless Striker 3D
Engine — cemi/
| File | What it does | What it teaches |
|---|---|---|
cemi/core.js | Game loop, scene manager, responsive canvas, helper math (clamp, lerp, mulberry32, hashStr). | Game loops, delta-time updates, seeded RNG, utility functions. |
cemi/input.js | Unified 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.js | All-synth WebAudio sound effects — zero audio files. | Procedural audio, oscillators, envelopes. |
cemi/fx.js | Particles, confetti, screen-shake, floating text. | Particle systems, animation, "game juice". |
cemi/facts.js | The Nerd Engine: context-tagged facts that never repeat until the pool is exhausted. | Data-driven content, sets, non-repeating random selection. |
cemi/i18n.js | English / Español / Français / Português with t('key', {vars}) everywhere. | Internationalization, key/value data structures, string templating. |
cemi/nations.js | 32 nations; flags are DRAWN from simple shapes, no image files. | Data modeling, procedural vector graphics. |
cemi/tournament.js | Knockout brackets: pick a nation, survive 5 rounds to the cup. | Arrays, shuffling, tournament trees, powers of two. |
cemi/challenge.js | Seeded daily/weekly challenges — the same world for everyone today. | Determinism, hashing, date keys, seeds. |
cemi/profile.js | Optional, kid-safe local profile — never required to play. | localStorage, privacy-by-design, default objects. |
cemi/nickname.js | The 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.js | One Store interface, two implementations (local + Firebase), offline-first. | Interfaces/polymorphism, graceful degradation. |
sw.js | The service worker: ~30 lines that make the whole site installable and playable offline. | Caches, stale-while-revalidate, PWAs, network fallbacks. |
cemi/ui.js | Shared DOM UI and a line-art SVG icon library (never emojis). | DOM building, SVG, component thinking. |
Golden Boot — games/golden-boot/
| File | What it does | What it teaches |
|---|---|---|
games/golden-boot/main.js | Boots the game, owns the menu/tournament/results screens. | App structure, wiring modules together. |
games/golden-boot/shootout.js | The physics scene: gravity + drag + Magnus spin, keeper AI, scoring. | Projectile motion, vectors, forces, prediction, energy. |
games/golden-boot/render.js | Two 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback with real numbers. |
Gorilla on the Pitch — games/gorilla/
| File | What it does | What it teaches |
|---|---|---|
games/gorilla/main.js | Sets up the pitch, teammates, waves, and the survive-and-score loop. | Game state, entities, win/lose conditions. |
games/gorilla/gorilla.js | The gorilla: a finite state machine + procedural quadruped animation. | State machines, AI targeting, procedural animation, trigonometry. |
games/gorilla/strings.js | Game text in four languages + real gorilla-biology facts. | Data-driven content, biology as data. |
Keepy-Uppy Infinito — games/keepy/
| File | What it does | What it teaches |
|---|---|---|
games/keepy/main.js | The 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Anaconda Soccer — games/anaconda/
| File | What it does | What it teaches |
|---|---|---|
games/anaconda/main.js | The 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.js | Game text in four languages + facts about real anacondas. | Data-driven content, biology as data. |
Rumble Fútbol — games/rumble/
| File | What it does | What it teaches |
|---|---|---|
games/rumble/main.js | Up 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Elemental League — games/elemental/
| File | What it does | What it teaches |
|---|---|---|
games/elemental/main.js | Four 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Cruce de Tráfico — games/cruce/
| File | What it does | What it teaches |
|---|---|---|
games/cruce/main.js | Deterministic 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Muerto FC — games/muerto/
| File | What it does | What it teaches |
|---|---|---|
games/muerto/main.js | A 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.js | Game 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/
| File | What it does | What it teaches |
|---|---|---|
games/dodgeball/main.js | Telegraphed 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Gods vs Titans FC — games/gods/
| File | What it does | What it teaches |
|---|---|---|
games/gods/main.js | A 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.js | Game 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/
| File | What it does | What it teaches |
|---|---|---|
games/fireball/main.js | Blast-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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Béisbol Gol — games/beisbol/
| File | What it does | What it teaches |
|---|---|---|
games/beisbol/main.js | A 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Dolphins vs Sharks — games/dolphins/
| File | What it does | What it teaches |
|---|---|---|
games/dolphins/main.js | One 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Dungeon Striker — games/dungeon/
| File | What it does | What it teaches |
|---|---|---|
games/dungeon/main.js | A 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.js | Game text in four languages + its Nerd Engine facts pack. | i18n data, templated feedback. |
Endless Striker — games/striker/
| File | What it does | What it teaches |
|---|---|---|
games/striker/main.js | Eased 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.js | The 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.js | Game 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/
| File | What it does | What it teaches |
|---|---|---|
games/striker3d/main.js | The 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.js | Builds 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.js | The 3D runner's mesh and its procedural animation. | 3D meshes and procedural animation in three dimensions. |
games/striker3d/obstacles.js | Defender 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 for9.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, findpredictCrossing. Read how it fast-forwards the ball's flight in a loop to guess where it will cross the goal line, then look at_keeperDecideto 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_gaitand 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 theLIMBStable 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
_renderProceduraland thebob/swayvalues — 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.jsand read theSTRtable: each key maps to{ en, es, fr, pt }. Then opencemi/facts.jsand see the exact same shape used for the Nerd Engine's facts. Find thet()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 ingames/golden-boot/strings.jsuse 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.jsand find the per-frame update:ball.vy += G * dt; ball.y += ball.vy * dt; ball.x += ball.vx * dt;. Then findrelaunch()and see how a kick is justb.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, howlerpshrinks the catch window as the combo grows. Note: this exact three-line pattern (velocity, then position, both scaled bydt) 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.jsand find the segment-following loop insideupdateSnake, then the growth code:for (let i = 0; i < GROW; i++) snake.pts.push({ x: tail.x, y: tail.y });. Also findconst TURN = 2.6 + snake.speed / 60;and theclampthat 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
wigsine 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
switchreplace eight separate features. - Instructions
- Open
games/rumble/main.js, findconst PU = ['giant', 'freeze', 'speed', 'tele', 'bumper', 'goals', 'ice', 'fire'];and its matchingswitch (pu.type)inapplyPow. Then findKEYSETSand see howhumanVec(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
caseline a ninth power-up would need. Note: the "ice" effect is not new physics — it just lowers the existinggripvariable 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.jsand compareconst ELEMENTS = {...}withconst ARENAS = {...}just below it — both keyed bywater/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
ballobject'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. FindmakeRow(idx), where a row's direction and speed are rolled once withrng. Then findmovingItems(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.jsand findonGoal():for (let i = 0; i < SPAWN_PER_GOAL; i++) zombies.push(makeZombie(false));. Then find the chase-plus-wobble vector inupdateZombies. - 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_GOALand 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 abovethrowPlaninstartMatch(), then find how each rival reads down its own pre-rolled list inupdateRival(). - 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.jsand read theGODStable (color/speed/cd/nameKey/powerKey per god). Then findusePower()and see how a power only fires oncegod.powerfills up togod.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.cdfrom 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.jsand findexplode(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.jsand findstartMatch(), where all 54 pitches are built into aplanarray before play begins. Then findpitchPath(p)and see howMath.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.jsand find the hunting shark's target:const tx = ball.x + ball.vx * 0.25;. Then findswimStep()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.25lead factor to0or1and predict how passing difficulty changes. Note: patrol-state sharks circle usingMath.cos/Math.sinon 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.jsand findstartRun(daily), where loot offers and every floor's "quirk" are all drawn from onerngbefore floor one begins. Then findupdateHunt()and itswindupT = 0.5warning 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.3tohunt: 1and 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.jsand findrng,rivalRngandcurveRng, each built with its ownmakeRng(...)call instartRun(). Then find the collision block inupdatePlay()and trace its chain of state checks (fireT, jumped/slid,invulT,shieldOn) before it ever callscrash(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.jscalls 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.jsand read its header comment. Then opengames/striker/shared.js, find a constant such asROCKET_RANGE_M, and confirm the 3D file imports that exact same constant. Finally opengames/striker3d/scene.jsand findbuildWorld(), where aScene, aPerspectiveCameraand aDirectionalLightare 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
lerpto ease toward the player's lane — the same easing trick as the 2D game's lane movement. Note: old defenders are never destroyed —acquireDefender/releaseDefenderingames/striker3d/obstacles.jswipe 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.
| Layer | Where | What it does |
|---|---|---|
| 1 — Validate at input | cemi/ui.js | When 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 write | cemi/profile.js | displayName() 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 display | cemi/backend.js | Every 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 server | functions/index.js | A 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 (0→o, 3→e, $→s...), and every non-letter removed — dots, underscores and spaces simply vanish. A second pass collapses repeated letters (baaad → bad), 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.
- One Store interface. Games call
submitScore()andtopScores()and nothing else. Incemi/backend.js,getStore()hands back one of two implementations depending on the profile's mode — the textbook shape of polymorphism: same calls, different behavior. - LocalStore. Keeps boards in
localStorage, top 100 per board, on this device only. Erasing the browser erases it — that is the promise of the mode, not a defect. An opt-in toggle can additionally read the world boards: a pure lookup where nothing of the player's is written. - FirebaseStore extends LocalStore. Every score is written locally first, then pushed to the cloud. If the network fails mid-write, the local copy already exists — offline never loses data, and the class inheritance makes "cloud = local + push" literal.
- The honest merge. On login,
syncProfile()reconciles two histories without trusting either: each counter keeps the maximum of the two sides and achievements become a union. Signing in from a new device can only ever add progress, never erase it. - Local mode never even loads the network SDK. "Nothing is sent" is enforced by code, not by a promise in a privacy policy:
init()incemi/backend.jsreturns aLocalStorebefore the network module is ever imported. The cloud library is dynamically loaded only when a cloud feature is actually used.
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).
- Clone the repository:
git clone https://github.com/<owner>/futbol.gitcd futbol - Start the tiny dev server (needed because ES modules require HTTP, not
file://):python3 scripts/dev-server.py 8321 - 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.