You will complete a 6×6 memory matching game built on the Observer Pattern. The game is organized into three decoupled layers, and your job is to implement the missing pieces of each layer without breaking the separation between them.
This assignment tests your ability to:
- Apply the Observer Pattern across three real layers (data, event bus, view).
- Write clean functional JavaScript — no classes, no
this, no OOP. - Respect an event contract: publish the right events with the right payloads.
- Keep the DOM and game logic in their proper lanes.
- Use modern, scalable DOM techniques (event delegation, DocumentFragment,
textContent).
Estimated effort: ~4 hours.
┌───────────────────┐ emits events ┌───────────────────┐
│ gameService.js │ ─────────────────────▶ │ eventEmitter.js │
│ (Data / Logic) │ │ (Pub / Sub) │
└───────────────────┘ └─────────┬─────────┘
▲ │ notifies
│ method calls ▼
│ (start, flipCard, ┌───────────────────┐
└──────────────────────────│ ui.js │
│ (View / DOM) │
└───────────────────┘
Three rules that must never be broken:
gameService.jsmust not touch the DOM.ui.jsmust not contain game logic (no match checks, no move counting, no timer state).- The service and UI only communicate through the event bus (plus the UI calling the service's public methods on user input).
| File | Status | Your job |
|---|---|---|
index.html |
Complete | Read only |
styles.css |
Complete | Read only |
js/eventEmitter.js |
Skeleton | Implement on, off, emit |
js/gameService.js |
Skeleton | Implement every TODO (N) body |
js/ui.js |
Skeleton | Implement every TODO (N) body |
js/main.js |
Complete | Read only |
Your service must emit these events with these exact names and payloads. Your UI must subscribe to them and respond correctly.
| Event | Payload | When |
|---|---|---|
game:started |
{ cards, totalPairs } |
A new game starts. |
game:cardFlipped |
{ cardId, symbol } |
A valid click flips a card face-up. |
game:matchFound |
{ firstId, secondId, matchedCount } |
Two flipped cards match. |
game:matchFailed |
{ firstId, secondId } |
Two flipped cards do not match. |
game:moveCountChanged |
{ moves } |
The move counter changes. |
game:timerTick |
{ elapsedSeconds } |
Once per second while playing. |
game:won |
{ moves, elapsedSeconds } |
All 18 pairs matched. |
Your submission is considered complete when all of the following pass:
- Opening
index.htmlrenders a 6×6 grid of 36 face-down cards. - Clicking a card flips it to reveal a symbol.
- Clicking a second card:
- If it matches: both stay face-up and glow.
- If it does not: both flip back after ~900 ms.
- Click ignored during the 900 ms no-match window (the lock).
- A card that is already face-up or matched ignores further clicks.
- Move counter increments by 1 per completed pair attempt (two flips = one move).
- Timer starts at 0:00 on game start and advances each second.
- "New Game" button restarts from scratch (shuffled, counters reset, timer reset).
- When all 18 pairs are found, the win overlay appears with final stats and the timer stops.
- Pressing "Play Again" starts a fresh game.
- Opening DevTools Console → no errors on any interaction.
- Opening DevTools Elements → verify that
.is-flippedand.is-matchedclasses are applied/removed as expected.
- Functional JavaScript only. No
class, nothis, no prototypes. Use factory functions and closures. - ES modules (
import/export) — do not bundle everything into one file. - No external libraries. Vanilla JS only.
- No
innerHTMLfor inserting dynamic content. UsecreateElement+textContent. - Event delegation on the board — one listener, not 36.
- Do not modify
index.html,styles.css, orjs/main.js. - Do not add new event names. Use exactly the names in the contract.
- Do not expose the service's internal state. The only way the UI should learn about state changes is through event payloads.
| Area | Points | What we check |
|---|---|---|
| EventEmitter correctness | 15 | on / off / emit behave correctly; multiple listeners supported; one listener's error does not break others; internal store is not exposed. |
| Service — state & shuffle | 10 | Deck is built correctly (each symbol appears exactly twice), shuffle is pure, state shape matches spec. |
| Service — flipCard logic | 20 | All validation rules honored; match / no-match branches correct; lock behavior correct; win detection correct. |
| Service — timer | 5 | Starts on game start, stops on win, resets on restart, no double-starts. |
| Service — event emission | 10 | Every event fired at the right moment with the correct payload shape. |
| UI — rendering | 10 | Board renders from payload; cards built with correct DOM structure; uses textContent; uses DocumentFragment. |
| UI — event handling | 10 | Event delegation used; handlers only forward intent; no game logic in UI. |
| UI — subscription & cleanup | 10 | All events subscribed; subscriptions tracked; unmount detaches cleanly. |
| Layer discipline | 10 | Service does not touch DOM. UI does not reach into service state. Clean separation. |
Automatic 0 if you use class, this, or OOP patterns anywhere in the JS layers.
Build bottom-up:
- EventEmitter first. Test it in the console:
const bus = createEventEmitter(); bus.on('ping', (x) => console.log('heard', x)); bus.emit('ping', 42); // → "heard 42"
- Then the service. You can test without the UI — listen on the bus and call service methods from the console. You should see events firing with correct payloads.
- Then the UI. Once the first two layers work, the UI is just rendering + forwarding clicks.
Submit a ZIP of the project folder containing:
memory-game/
├── index.html
├── styles.css
├── js/
│ ├── eventEmitter.js
│ ├── gameService.js
│ ├── ui.js
│ └── main.js
└── README.md (this file, unmodified)
Filename: <LastName>_<FirstName>_CMPS2212_MemoryMatch.zip
Deadline: See course schedule.
- The flip animation and all styling are already handled by
styles.css. You only need to toggle.is-flippedand.is-matchedon the card element. - When the service detects a no-match, it schedules an internal
setTimeoutto flip the cards back in state. The UI independently schedules its ownsetTimeoutto toggle the DOM classes after the same delay. Both must agree on the delay (900 ms, constant provided). rootEl.querySelector('[data-card-id="N"]')is the idiomatic way to find a card node by id.Object.freeze(...)on your factory return value prevents accidental API tampering. This is already done in the skeleton — keep it.- If your game seems to "skip" a flip, you probably forgot to emit
game:cardFlippedfor the second pick before the match/no-match event.
Good luck. Keep the layers clean and the pattern will do most of the work for you.