Developer · Game

GITCRAFT

Learn Git with a live animated commit graph. Every command — commit, branch, merge, rebase — plays out visually. 12 levels, real terminal, real explanations.

← LEVELS
LEVEL 1
Basics
HINT
Working Tree
Staged
Stash
0
entries
$
Suggestions
🎉
LEVEL COMPLETE
Hints (click to reveal)
INTERACTIVE REBASE
Edit each commit's action. Drag to reorder is not supported — focus on pick/squash/fixup/drop/reword.
pick — keep commit reword — edit msg squash — merge + combine msgs fixup — merge, discard msg drop — remove commit
" > index.html',desc:'Create HTML'},{cmd:'git add .',desc:'Stage all'},{cmd:'git commit -m "Add index.html"',desc:'Commit'},{cmd:'git log --oneline',desc:'View history'}] }, { id:3, title:'BRANCH OUT', tag:'Branching', objective:'Create a branch called feat/dark-mode, switch to it, and make a commit on it.', story:'You want to experiment with a dark mode — but don\'t risk breaking the main site. Create a feature branch.', hints:['Create: git branch feat/dark-mode','Switch: git switch feat/dark-mode','Or combine: git checkout -b feat/dark-mode','Make a change and commit it','Notice the branch diverge in the graph!'], setup(r) { r.init(); r.workdir.set('index.html','

DevBlog

'); r.workdir.set('style.css','body{font-family:sans-serif;background:#fff}'); r.add(['.']); r.commit('Initial setup'); r.workdir.set('app.js','const blog={version:"1.0"};'); r.add(['app.js']); r.commit('Add app.js'); }, check(r) { if (!r.branches.has('feat/dark-mode')) return {pass:false,hint:"Create branch named 'feat/dark-mode'"}; if (r.branches.get('feat/dark-mode')===r.branches.get('main')) return {pass:false,hint:'Commit something on feat/dark-mode'}; return {pass:true}; }, winMessage:'Branch created! You can now develop features in total isolation from main.', suggestions:[{cmd:'git checkout -b feat/dark-mode',desc:'Create + switch'},{cmd:'echo "body{background:#111;color:#eee}" > style.css',desc:'Edit file'},{cmd:'git add .',desc:'Stage'},{cmd:'git commit -m "Add dark mode styles"',desc:'Commit'}] }, { id:4, title:'FAST-FORWARD', tag:'Merging', objective:'Merge feat/dark-mode back into main (a clean fast-forward — no conflicts).', story:'Dark mode is done and tested. Main has no new commits since you branched, so this merge is a simple pointer move.', hints:['Switch back: git switch main','Run: git merge feat/dark-mode','No merge commit appears — the pointer just advances!','This is a fast-forward: main had no new commits','The graph stays linear'], setup(r) { r.init(); r.workdir.set('index.html','

DevBlog

'); r.add(['.']); r.commit('Initial setup'); r.checkout('feat/dark-mode',true); r.workdir.set('style.css','body{background:#111;color:#eee}'); r.add(['style.css']); r.commit('Add dark mode'); r.workdir.set('style.css','body{background:#0a0a0a;color:#e8e0d5;font-family:monospace}'); r.add(['style.css']); r.commit('Polish dark mode'); r.checkout('main'); }, check(r) { return r.branches.get('main')===r.branches.get('feat/dark-mode')?{pass:true}:{pass:false,hint:'Merge feat/dark-mode into main'}; }, winMessage:'Fast-forward! No merge commit needed when histories don\'t diverge.', suggestions:[{cmd:'git merge feat/dark-mode',desc:'Fast-forward merge'},{cmd:'git log --oneline',desc:'See linear history'},{cmd:'git branch',desc:'List branches'}] }, { id:5, title:'TRUE MERGE', tag:'Merging', objective:'Both main and feat/contact have new commits. Merge feat/contact into main to create a merge commit.', story:'While you built the contact page, a hotfix landed on main. Now both branches have diverged — Git needs a real merge commit.', hints:['Be on main: git switch main','Run: git merge feat/contact','Git creates a merge commit automatically','The commit has TWO parents — watch the graph!','The "diamond" pattern appears'], setup(r) { r.init(); r.workdir.set('index.html','

DevBlog

'); r.add(['.']); r.commit('Initial setup'); r.checkout('feat/contact',true); r.workdir.set('contact.html','

Contact

'); r.add(['contact.html']); r.commit('Add contact page'); r.checkout('main'); r.workdir.set('index.html','

DevBlog

Now Open!

'); r.add(['index.html']); r.commit('Add opening notice'); }, check(r) { const h=r.branches.get('main'); const c=r.commits.get(h); return c?.parents.length===2?{pass:true}:{pass:false,hint:'Create a merge commit on main'}; }, winMessage:'True merge! The diamond pattern in the graph shows two histories converging.', suggestions:[{cmd:'git merge feat/contact',desc:'Create merge commit'},{cmd:'git log --oneline',desc:'See merge commit'},{cmd:'git log',desc:'See two parents'}] }, { id:6, title:'TAG A RELEASE', tag:'Tagging', objective:'Tag the current HEAD as v1.0 with an annotated tag.', story:'DevBlog is ready to launch. Mark this moment permanently — v1.0 will always point to exactly this commit, forever.', hints:['Lightweight tag: git tag v1.0','Annotated (preferred): git tag -a v1.0 -m "First release"','Annotated tags store who, when, and why','See tags: git tag','Watch the gold bookmark appear on the commit!'], setup(r) { r.init(); r.workdir.set('index.html','…'); r.workdir.set('style.css','body{}'); r.workdir.set('app.js','const v=1;'); r.add(['.']); r.commit('Release prep: final polish'); }, check(r) { return r.tags.has('v1.0')?{pass:true}:{pass:false,hint:"Create a tag called v1.0"}; }, winMessage:'Tagged! v1.0 is immortalized — you can always git checkout v1.0 to return here.', suggestions:[{cmd:'git tag -a v1.0 -m "Version 1.0 launch"',desc:'Annotated tag'},{cmd:'git tag',desc:'List tags'},{cmd:'git log --oneline',desc:'See tag in log'}] }, { id:7, title:'RESET: UNDO', tag:'Undo', objective:'The latest commit has debug code. Use git reset --hard HEAD~1 to remove it.', story:'You accidentally committed console.log debugging. Nobody\'s seen it yet — safe to rewrite local history.', hints:['git log --oneline shows the bad commit','HEAD~1 means "one before HEAD"','git reset --hard HEAD~1 moves branch AND reverts files','git reset --soft keeps your changes staged','git reset --mixed keeps changes but unstaged'], setup(r) { r.init(); r.workdir.set('app.js','const blog={};'); r.add(['.']); r.commit('Add app.js'); r.workdir.set('app.js','const blog={}; console.log("DEBUG",JSON.stringify(blog));'); r.add(['.']); r.commit('debug: temp logging (remove!)'); }, check(r) { const h=r.headCommit(); if(!h) return {pass:false,hint:'Reset to before the debug commit'}; if(h.message.toLowerCase().includes('debug')) return {pass:false,hint:'That is still the bad commit. Reset it!'}; return {pass:true}; }, winMessage:'Reset! The bad commit is gone. Your history is clean.', suggestions:[{cmd:'git log --oneline',desc:'See commits'},{cmd:'git reset --hard HEAD~1',desc:'Hard reset'},{cmd:'git reset --soft HEAD~1',desc:'Soft reset'},{cmd:'git reset --mixed HEAD~1',desc:'Mixed reset'}] }, { id:8, title:'REVERT: SAFE UNDO', tag:'Undo', objective:'The debug commit was already pushed. Use git revert HEAD to undo it safely.', story:'The bad commit is on the shared remote now. Resetting would cause conflicts. Revert creates a new commit that undoes it — history preserved.', hints:['git revert HEAD targets the latest commit','It creates a NEW commit that undoes the old one','The original commit stays in history — no rewriting','This is safe for shared/pushed branches','git log shows both the original and the revert commit'], setup(r) { r.init(); r.workdir.set('app.js','const blog={};'); r.add(['.']); r.commit('Add app.js'); r.workdir.set('debug.js','module.exports={verbose:true,logAll:true};'); r.add(['.']); r.commit('Enable debug mode (pushed by mistake)'); }, check(r) { if(r.commits.size<3) return {pass:false,hint:'Use git revert HEAD'}; const h=r.headCommit(); return h?.message.toLowerCase().includes('revert')?{pass:true}:{pass:false,hint:'The latest commit should be a revert'}; }, winMessage:'Reverted safely! History preserved — teammates can still pull without conflicts.', suggestions:[{cmd:'git revert HEAD',desc:'Safe undo commit'},{cmd:'git log --oneline',desc:'See revert in history'}] }, { id:9, title:'CHERRY-PICK', tag:'Advanced', objective:'Cherry-pick the critical bugfix commit from the hotfix branch onto main.', story:'A null-check fix on hotfix is critical for production. You need just that one commit on main — not the whole branch.', hints:['First find the hash: git log hotfix --oneline','You\'re on main: git switch main','Run: git cherry-pick ','A NEW commit is created on main (different hash)','The hotfix branch is unchanged'], setup(r) { r.init(); r.workdir.set('app.js','function get(x){return x.value;}'); r.add(['.']); r.commit('Add get function'); r.branch('hotfix'); r.checkout('hotfix'); r.workdir.set('app.js','function get(x){return x&&x.value;}'); r.add(['.']); r.commit('Fix: null check in get()'); r.checkout('main'); r.workdir.set('ui.js','function render(){}'); r.add(['ui.js']); r.commit('Add UI module'); }, check(r) { const mainTree=r.treeAt(r.branches.get('main')); return mainTree.get('app.js')==='function get(x){return x&&x.value;}'?{pass:true}:{pass:false,hint:'Cherry-pick the fix from hotfix branch'}; }, winMessage:'Cherry-picked! The fix is on main without importing the whole hotfix branch.', suggestions:[{cmd:'git log hotfix --oneline',desc:'Find commit hash'},{cmd:'git cherry-pick ',desc:'Copy commit'},{cmd:'git log --oneline',desc:'See result'}] }, { id:10, title:'REBASE', tag:'Advanced', objective:'Rebase feat/api onto main to create a linear, clean history without a merge commit.', story:'Your API feature diverged from main. Rather than a messy merge commit, rebase replays your commits on top of the latest main.', hints:['Switch to feat/api first','Run: git rebase main','Your commits "move" on top of main\'s latest commit','Commits get new hashes (parent changed)','Result: perfectly linear history'], setup(r) { r.init(); r.workdir.set('index.html',''); r.add(['.']); r.commit('Initial'); r.checkout('feat/api',true); r.workdir.set('api.js','const api={};'); r.add(['.']); r.commit('Add API module'); r.workdir.set('api.js','const api={version:"1"};'); r.add(['.']); r.commit('Add API version'); r.checkout('main'); r.workdir.set('index.html','Home'); r.add(['.']); r.commit('Update homepage'); r.checkout('feat/api'); }, check(r) { const mainH=r.branches.get('main'), featH=r.branches.get('feat/api'); return r.isAncestor(mainH,featH)?{pass:true}:{pass:false,hint:'Rebase feat/api onto main'}; }, winMessage:'Rebased! Linear history — your feature commits now sit cleanly after main.', suggestions:[{cmd:'git rebase main',desc:'Replay on top of main'},{cmd:'git log --oneline',desc:'See linear result'},{cmd:'git log',desc:'Check parent chain'}] }, { id:11, title:'STASH IT', tag:'Advanced', objective:'Stash your half-done work, switch to main, then restore your changes.', story:'Mid-feature, a bug is reported on main. You can\'t commit half-done work. Stash it, fix the bug on main, then return.', hints:['Check work in progress: git status','git stash saves and cleans up','Switch: git switch main','Do work there (or just look around)','Come back: git switch feat/stash-test','Restore: git stash pop'], setup(r) { r.init(); r.workdir.set('app.js','const x=1;'); r.add(['.']); r.commit('Initial'); r.checkout('feat/stash-test',true); r.workdir.set('app.js','const x=1; // WIP: adding feature…'); // unstaged change }, check(r) { const content=r.workdir.get('app.js'); if (r.stash.length>0) return {pass:false,hint:'Pop the stash to restore: git stash pop'}; return content?.includes('WIP')?{pass:true}:{pass:false,hint:'Stash, switch to main, then come back and pop the stash'}; }, winMessage:'Stash mastered! Context-switching without losing work.', suggestions:[{cmd:'git stash',desc:'Save + clean'},{cmd:'git switch main',desc:'Switch context'},{cmd:'git switch feat/stash-test',desc:'Return'},{cmd:'git stash pop',desc:'Restore work'}] }, { id:12, title:'TIME TRAVEL', tag:'Navigation', objective:'Checkout the very first commit to inspect it, then safely return to main.', story:'Something changed between v0 and today. Travel back to the first commit to investigate, then return to the present.', hints:['git log --oneline shows all commits','Copy the oldest hash from the bottom of the log','git checkout → Detached HEAD state','Look around — you\'re in the past!','Return: git switch main'], setup(r) { r.init(); r.workdir.set('app.js','const v=1;'); r.add(['.']); r.commit('v1: initial'); r.workdir.set('app.js','const v=2;'); r.add(['.']); r.commit('v2: improvements'); r.workdir.set('app.js','const v=3;const features=["dark","search"];'); r.add(['.']); r.commit('v3: latest with features'); }, check(r) { return r.HEAD.type==='branch'&&r.HEAD.name==='main'?{pass:true}:{pass:false,hint:'Return to main: git switch main'}; }, winMessage:'Time travel complete! Detached HEAD lets you explore the past safely.', suggestions:[{cmd:'git log --oneline',desc:'See all hashes'},{cmd:'git checkout ',desc:'Detach to past'},{cmd:'git switch main',desc:'Return to present'}] } ]; /* ══════════════════════════════════════════════════════════════ GAME CONTROLLER ══════════════════════════════════════════════════════════════ */ class Game { constructor() { this.repo = null; this.level = null; this.levelIdx = 0; this.history = []; this.histIdx = -1; this.completed = JSON.parse(localStorage.getItem('gitcraft_done')||'[]'); this.renderer = new GraphRenderer( document.getElementById('commit-graph'), document.getElementById('panel-graph') ); this._buildLanding(); this._bindTerminal(); } _buildLanding() { const grid = document.getElementById('level-grid'); grid.innerHTML = ''; LEVELS.forEach((lvl,i) => { const done = this.completed.includes(lvl.id); const locked = i > 0 && !this.completed.includes(LEVELS[i-1].id) && !done; const card = document.createElement('div'); card.className = 'level-card' + (locked?' locked':'') + (done?' done':''); card.innerHTML = `
LEVEL ${String(lvl.id).padStart(2,'0')}
${lvl.title}
${lvl.tag}
${lvl.objective.slice(0,60)}…
${done?'✅':locked?'🔒':''}
`; if (!locked) card.addEventListener('click', () => this.startLevel(i)); grid.appendChild(card); }); } startLevel(idx) { this.levelIdx = idx; this.level = LEVELS[idx]; this.repo = new GitRepo(); this.level.setup(this.repo); this.history = []; this.histIdx = -1; document.getElementById('landing').style.display = 'none'; const gameEl = document.getElementById('game'); gameEl.style.display = 'flex'; gameEl.classList.add('active'); document.getElementById('gh-level-title').textContent = `LEVEL ${this.level.id}: ${this.level.title}`; document.getElementById('gh-level-tag').textContent = this.level.tag; document.getElementById('objective-text').textContent = this.level.objective; document.getElementById('check-result').textContent = ''; document.getElementById('check-result').className = ''; document.getElementById('win-overlay').classList.remove('active'); document.getElementById('hint-popup').classList.remove('active'); this._buildProgDots(); this._clearTerminal(); this._printWelcome(); this._updateFilePanel(); this._updateSuggestions(); this.renderer.render(this.repo); showExplanation({ concept:'repository', title:this.level.title, body: this.level.story, change: `Objective: ${this.level.objective}` }); document.getElementById('term-input').focus(); } _buildProgDots() { const dots = document.getElementById('prog-dots'); dots.innerHTML = ''; LEVELS.forEach((l,i) => { const d = document.createElement('div'); d.className = 'prog-dot' + (this.completed.includes(l.id)?' done':'') + (i===this.levelIdx?' current':''); dots.appendChild(d); }); } exitToLanding() { document.getElementById('game').classList.remove('active'); document.getElementById('game').style.display = 'none'; document.getElementById('landing').style.display = ''; document.getElementById('hint-popup').classList.remove('active'); this._buildLanding(); } replayLevel() { document.getElementById('win-overlay').classList.remove('active'); this.startLevel(this.levelIdx); } nextLevel() { document.getElementById('win-overlay').classList.remove('active'); const next = this.levelIdx + 1; if (next < LEVELS.length) this.startLevel(next); else this.exitToLanding(); } _bindTerminal() { const input = document.getElementById('term-input'); input.addEventListener('keydown', e => { if (e.key==='Enter') { this._runCommand(input.value); input.value=''; this._hideAC(); return; } if (e.key==='ArrowUp') { e.preventDefault(); this._histNav(-1); return; } if (e.key==='ArrowDown') { e.preventDefault(); this._histNav(1); return; } if (e.key==='Tab') { e.preventDefault(); this._tabComplete(input.value); return; } }); input.addEventListener('input', () => this._showAC(input.value)); } _histNav(dir) { const input = document.getElementById('term-input'); const newIdx = this.histIdx - dir; if (newIdx < 0) { this.histIdx=-1; input.value=''; return; } if (newIdx >= this.history.length) return; this.histIdx = newIdx; input.value = this.history[this.history.length-1-newIdx]; } _tabComplete(val) { const cmds = ['git init','git status','git add .','git add','git commit -m ""','git branch','git checkout','git checkout -b','git switch','git switch -c','git merge','git rebase','git cherry-pick','git reset --hard','git reset --soft','git reset --mixed','git revert','git tag','git stash','git stash pop','git stash list','git log','git log --oneline','git diff','touch','echo','cat','ls','clear','help']; const matches = cmds.filter(c=>c.startsWith(val)); const input = document.getElementById('term-input'); if (matches.length===1) { input.value=matches[0]; this._hideAC(); } else if (matches.length>1) { this._showACItems(matches); } } _showAC(val) { if (!val) { this._hideAC(); return; } const cmds=['git init','git status','git add .','git commit -m','git branch','git checkout','git switch','git merge','git rebase','git cherry-pick','git reset','git revert','git tag','git stash','git log','git diff']; const matches = cmds.filter(c=>c.startsWith(val) && c!==val); if (matches.length) this._showACItems(matches.slice(0,4)); else this._hideAC(); } _showACItems(items) { const ac = document.getElementById('autocomplete'); ac.style.display = 'block'; ac.innerHTML = items.map(c=>`${c}`).join(''); } _hideAC() { const ac=document.getElementById('autocomplete'); ac.style.display='none'; ac.innerHTML=''; } async _runCommand(raw) { if (!raw.trim()) return; if (!this.level) return; this.history.push(raw); this.histIdx=-1; this._printLine({t:'cmd', s:`$ ${raw}`}); const result = execCommand(this.repo, raw); if (result.clear) { this._clearTerminal(); return; } (result.lines||[]).forEach(l => this._printLine(l)); if (result.explanation) showExplanation(result.explanation); // Interactive rebase: show editor modal and stop here if (result.irebase) { this._showIRebase(result.commits); return; } // Log to reflog any state-changing command if (result.ok && result.animDesc?.length) this.repo._logRef(raw.trim().slice(0,60)); this._updateFilePanel(); this._updateSuggestions(); document.getElementById('stash-count').textContent = this.repo.stash.length; if (result.ok && result.animDesc?.length) { await this.renderer.animateAndRender(result.animDesc, this.repo); } else { this.renderer.render(this.repo); } this._checkLevel(); } _showIRebase(commits) { const list = document.getElementById('ir-commit-list'); list.innerHTML = commits.map(c => `
${c.hash}
`).join(''); document.getElementById('irebase-modal').classList.add('active'); } _cancelIRebase() { this.repo._iRebaseState = null; document.getElementById('irebase-modal').classList.remove('active'); this._printLine({t:'warning', s:'Interactive rebase cancelled.'}); } async _executeIRebase() { document.getElementById('irebase-modal').classList.remove('active'); const rows = document.querySelectorAll('#ir-commit-list .ir-row'); const actionsMap = {}; rows.forEach(row => { const h = row.dataset.hash; actionsMap[h] = row.querySelector('.ir-action').value; actionsMap[h+'_msg'] = row.querySelector('.ir-msg').value; }); const result = this.repo.iRebaseExec(actionsMap); this._printLine({t:'cmd', s:'$ (interactive rebase executed)'}); (result.lines||[]).forEach(l => this._printLine(l)); if (result.explanation) showExplanation(result.explanation); if (result.ok && result.animDesc?.length) this.repo._logRef('rebase -i'); this._updateFilePanel(); this._updateSuggestions(); document.getElementById('stash-count').textContent = this.repo.stash.length; if (result.ok && result.animDesc?.length) { await this.renderer.animateAndRender(result.animDesc, this.repo); } else { this.renderer.render(this.repo); } this._checkLevel(); } _checkLevel() { if (!this.level) return; const result = this.level.check(this.repo); const el = document.getElementById('check-result'); if (result.pass) { el.textContent = '✓ COMPLETE'; el.className = 'pass'; setTimeout(() => this._showWin(), 600); } else { el.textContent = result.hint||''; el.className = ''; } } _showWin() { if (!this.completed.includes(this.level.id)) { this.completed.push(this.level.id); localStorage.setItem('gitcraft_done', JSON.stringify(this.completed)); } document.getElementById('win-msg').textContent = this.level.winMessage; const nextBtn = document.getElementById('win-next-btn'); if (this.levelIdx >= LEVELS.length-1) { nextBtn.textContent='BACK TO LEVELS'; } else { nextBtn.textContent='NEXT →'; } document.getElementById('win-overlay').classList.add('active'); } _printLine(line) { const out = document.getElementById('term-output'); const div = document.createElement('div'); div.className = `to-line ${line.t}`; div.textContent = line.s.replace(/\x1b/g,''); out.appendChild(div); out.scrollTop = out.scrollHeight; } _clearTerminal() { document.getElementById('term-output').innerHTML = ''; } _printWelcome() { [{t:'info',s:`Level ${this.level.id}: ${this.level.title}`}, {t:'muted',s:this.level.story}, {t:'muted',s:'─'.repeat(38)}, {t:'muted',s:`Goal: ${this.level.objective}`}, {t:'muted',s:`Type 'help' for all commands. Click HINT for tips.`} ].forEach(l => this._printLine(l)); } _updateFilePanel() { const headTree = this.repo.treeAt(this.repo.headHash()); const wdEl = document.getElementById('pf-workdir'); const stEl = document.getElementById('pf-staged'); wdEl.innerHTML=''; stEl.innerHTML=''; document.getElementById('stash-count').textContent = this.repo.stash.length; // Working dir files const allFiles = new Set([...this.repo.workdir.keys(), ...headTree.keys()]); for (const p of allFiles) { const wdContent = this.repo.workdir.get(p); const htContent = headTree.get(p); const staged = this.repo.index.has(p); let status, statusClass; if (staged) { status='S'; statusClass='staged'; } else if (wdContent===undefined) { status='D'; statusClass='deleted'; } else if (htContent===undefined) { status='?'; statusClass='untracked'; } else if (wdContent!==htContent) { status='M'; statusClass='modified'; } else { status='✓'; statusClass='clean'; } wdEl.innerHTML += `
${status}${p}
`; } if (!allFiles.size) wdEl.innerHTML='
(empty)
'; // Staged for (const [p] of this.repo.index) { stEl.innerHTML += `
+${p}
`; } if (!this.repo.index.size) stEl.innerHTML='
(none)
'; } _updateSuggestions() { const lvlSugs = this.level.suggestions || []; const dynamic = []; if (this.repo.index.size) dynamic.push({cmd:'git commit -m "message"', desc:'Save staged'}); else if ([...this.repo.workdir.entries()].some(([p,c])=>this.repo.treeAt(this.repo.headHash()).get(p)!==c)) { dynamic.push({cmd:'git add .', desc:'Stage changes'}); } if (this.repo.stash.length) dynamic.push({cmd:'git stash pop', desc:'Restore stash'}); const all = [...dynamic, ...lvlSugs].slice(0,4); const chips = document.getElementById('sug-chips'); chips.innerHTML = all.map(s=>`
${s.cmd} ${s.desc?`${s.desc}`:''}
`).join(''); } toggleHint() { const popup = document.getElementById('hint-popup'); if (popup.classList.contains('active')) { popup.classList.remove('active'); return; } const list = document.getElementById('hint-list'); list.innerHTML = (this.level.hints||[]).map((h,i)=>`
HINT ${i+1} — click to reveal
${h}
`).join(''); popup.classList.add('active'); } } /* ══════════════════════════════════════════════════════════════ INIT ══════════════════════════════════════════════════════════════ */ let game; window.addEventListener('DOMContentLoaded', () => { game = new Game(); });